From 93743a0b871e417ee2c4a45d7c62b7bb6f2955e5 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 23:18:02 +0700 Subject: [PATCH] test: replace sleeps with barriers, isolate global state, migrate XCTest --- .swiftlint.yml | 9 + Packages/TableProCore/Package.swift | 8 +- .../TableProDatabase/ConnectionManager.swift | 15 +- .../CancellableBlockingWorkTests.swift | 59 +- .../ConnectionManagerTestSupport.swift | 58 +- .../ConnectionManagerTests.swift | 70 +- .../GeoJSONGeometryReaderTests.swift | 92 +- .../SpatialGeometryTests.swift | 28 +- .../SpatialNestingDepthTests.swift | 51 +- .../SpatialProjectionTests.swift | 123 +- .../SpatialValueReaderTests.swift | 182 ++- .../WKBGeometryReaderTests.swift | 161 ++- .../WKTGeometryReaderTests.swift | 213 ++- .../GoogleOAuthLoopbackFlowTests.swift | 14 +- ...ConnectionExportCryptoIsolationTests.swift | 30 +- .../ConnectionExportCryptoTests.swift | 28 +- .../ShareableAdditionalFieldsTests.swift | 19 +- .../MSSQLColumnTypeTests.swift | 69 +- .../MSSQLDatetimeFormatterTests.swift | 82 +- .../MSSQLFreeTDSConfigFileTests.swift | 22 +- .../MSSQLModuleHeaderTests.swift | 98 +- .../MSSQLSchemaQueriesTests.swift | 319 +++-- .../MSSQLStringLiteralTests.swift | 120 +- .../SSHConfigurationTests.swift | 11 +- .../MySQLSocketTimeoutTests.swift | 11 +- .../JSONWriterEquivalenceTests.swift | 11 +- .../NumberTextTests.swift | 232 ++-- .../AsyncTimeoutTests.swift | 33 +- .../CSVTypeInferrerTests.swift | 81 +- .../HttpQueryTimeoutTests.swift | 12 +- .../OracleConnectErrorClassifierTests.swift | 48 +- .../PluginSSLClassifierTests.swift | 17 +- .../LibPQByteaDecoderTests.swift | 49 +- .../RedisArgumentCodecTests.swift | 14 +- .../RedisClusterRedirectTests.swift | 19 +- .../RedisConnectProbeTests.swift | 14 +- .../FieldLevelMergeTests.swift | 4 +- .../TableProSyncTests/ScratchDefaults.swift | 14 + .../SyncMetadataStorageTests.swift | 20 +- .../SyncZoneStateTests.swift | 10 +- .../DelimitedParityTests.swift | 28 +- .../JSONDifferentialTests.swift | 28 +- .../TableProTabularIOTests/JSONFixtures.swift | 4 +- .../JSONSourceTests.swift | 157 ++- .../JSONTableIndexerTests.swift | 239 ++-- .../JSONTableWriterTests.swift | 185 ++- .../JSONValueTypingTests.swift | 63 +- .../TabularEncodingDetectorTests.swift | 110 +- .../TabularTextTranscoderTests.swift | 108 +- .../XLSXCellStorageTests.swift | 99 +- .../XLSXSheetSourceTests.swift | 160 +-- .../XLSXWorkbookTests.swift | 72 +- .../ZipArchiveTests.swift | 138 +- .../TabularEditingTests.swift | 128 +- .../TabularQueryTests.swift | 41 +- .../TabularSaveTests.swift | 146 +-- .../TabularTableJSONOutputTests.swift | 52 +- .../TabularTableJSONSourceTests.swift | 18 +- .../TabularTableTests.swift | 42 +- .../Tests/TableProTestSupport/AsyncGate.swift | 31 + .../TableProTestSupport/ManualClock.swift | 117 ++ .../Tests/TableProTestSupport/WaitUntil.swift | 23 + Packages/TableProEditor/Package.swift | 4 +- .../CaptureModifierSetTests.swift | 36 +- .../CodeEditSourceEditorTests.swift | 32 +- .../CodeSuggestion/SuggestionApplyTests.swift | 53 +- .../SuggestionCursorsUpdatedTests.swift | 29 +- .../SuggestionPanelFocusTests.swift | 81 +- .../SuggestionPanelNativeColorTests.swift | 6 +- .../SuggestionSessionLifetimeTests.swift | 109 +- .../SuggestionShowCompletionsGuardTests.swift | 19 +- .../SuggestionWindowPlacementTests.swift | 45 +- .../PrecedingKeyDownClaimTests.swift | 3 +- .../TextViewController+IndentTests.swift | 40 +- .../TextViewController+MoveLinesTests.swift | 24 +- .../FindPanelReplaceTests.swift | 6 +- .../Highlighting/HighlighterTests.swift | 87 +- .../PasteHighlightCancelTests.swift | 27 +- .../StyledRangeContainerTests.swift | 87 +- .../VisibleRangeProviderTests.swift | 20 +- .../LineFoldChunkBoundaryTests.swift | 10 +- .../RepresentableSyncPhaseTests.swift | 42 +- .../SourceEditorBindingSyncTests.swift | 91 +- .../TrailingEdgeClickTests.swift | 32 + .../TreeSitterClientTests.swift | 30 +- .../TreeSitterExecutorTests.swift | 21 +- .../AccessibilityTests.swift | 12 +- .../CmdShiftLeftAtEndOfDocumentTests.swift | 23 +- .../CmdUpAtEndOfDocumentTests.swift | 20 +- .../CopyPasteTests.swift | 15 +- .../KillRingTests.swift | 44 +- .../TextLayoutManagerAttachmentsTests.swift | 47 +- .../LineEndingTests.swift | 40 +- .../MarkedTextTests.swift | 99 +- .../TextSelectionManagerTests.swift | 136 +- .../TableProSSHConfig/SSHMatchExecutor.swift | 13 +- .../UI/SplitViewAutosaveKey.swift | 11 + .../DerivedTableParserTests.swift | 14 +- .../KeywordUppercaseHelperTests.swift | 27 +- ...LContextAnalyzerCaseInsensitiveTests.swift | 242 +--- .../SQLContextAnalyzerTests.swift | 217 +-- .../SQLSnippetMarkerTests.swift | 11 +- .../TableProMCPTestSupport/MCPTestClock.swift | 2 - .../Protocol/MCPCancellationTokenTests.swift | 62 +- .../Protocol/MCPInflightRegistryTests.swift | 88 +- .../Protocol/MCPProgressEmitterTests.swift | 94 +- .../Transport/MCPProtocolErrorTests.swift | 238 ++-- ...CPStreamableHttpClientTransportTests.swift | 58 +- .../Wire/HttpRequestParserTests.swift | 207 ++- .../Wire/HttpRequestStreamParserTests.swift | 22 +- .../Wire/JsonRpcIdTests.swift | 71 +- .../Wire/JsonRpcMessageTests.swift | 160 ++- .../Wire/SseEncoderDecoderTests.swift | 123 +- .../SSHMatchExecutorTests.swift | 9 +- .../SSHUnsupportedDirectiveTests.swift | 11 +- .../TOTP/Base32Tests.swift | 94 +- .../TOTP/TOTPGeneratorTests.swift | 117 +- .../JavaScriptStatementScannerTests.swift | 98 +- .../Process/DescriptorReadTests.swift | 6 +- .../QuickSwitcherFrecencyStoreTests.swift | 8 +- .../SplitViewAutosaveKeyTests.swift | 41 + .../OracleAsyncTimeoutTests.swift | 55 +- .../OracleBindValueTests.swift | 31 +- .../OracleCellDecodingTests.swift | 67 +- .../OracleCellFormattingTests.swift | 264 ++-- .../OracleCloseRecordTests.swift | 55 +- .../OracleColumnDefaultTests.swift | 123 +- .../OracleColumnTypeNameTests.swift | 47 +- .../OracleColumnTypeSpellingTests.swift | 115 +- .../OracleConnectDeadlineTests.swift | 28 +- .../OracleConnectErrorClassifierTests.swift | 116 +- .../OracleConnectionOptionsTests.swift | 69 +- .../OracleCoreErrorMessageTests.swift | 31 +- .../OracleDictionaryQualificationTests.swift | 45 +- .../OracleDisconnectReasonTests.swift | 45 +- .../OracleListenerRefusalTests.swift | 45 +- .../OracleNetworkEncryptionTests.swift | 60 +- .../OraclePLSQLUnitTests.swift | 47 +- .../OraclePingDecisionTests.swift | 25 +- .../OracleQueryGateTests.swift | 32 +- .../OracleQueryTimeoutTests.swift | 21 +- .../OracleRoleTests.swift | 50 +- .../OracleSchemaQueriesTests.swift | 337 +++-- .../OracleServerOutputTests.swift | 59 +- .../OracleServerReleaseTests.swift | 41 +- .../OracleSessionTransactionTests.swift | 107 +- .../OracleTLSMapperTests.swift | 78 +- .../OracleTransactionRoleTests.swift | 35 +- .../OracleViewDefinitionTests.swift | 27 +- ...seGeneratedColumnClassificationTests.swift | 11 +- .../JSONImportSkipTests.swift | 9 +- .../SQLExportDDLRewriterTests.swift | 14 +- .../MySQLCompareCapabilityTests.swift | 20 + .../MySQLForeignKeyClauseTests.swift | 14 +- ...QLGeneratedColumnClassificationTests.swift | 11 +- .../MySQLStatementClassificationTests.swift | 14 +- .../PostgreSQLCompareCapabilityTests.swift | 20 + .../RedisBinaryValueTests.swift | 12 +- .../RedisCommandParserTests.swift | 105 +- .../SQLiteCompareCapabilityTests.swift | 20 + .../SQLiteTypelessColumnTests.swift | 37 + .../DatabaseManager+OpenTransactions.swift | 15 +- TablePro/Core/Plugins/PluginManager.swift | 9 +- TablePro/Core/SOCKS/SOCKSProxyManager.swift | 40 +- .../Infrastructure/AppActivationPolicy.swift | 1 - .../Infrastructure/LaunchIntent.swift | 4 +- .../Infrastructure/LaunchIntentRouter.swift | 8 +- .../TabPersistenceCoordinator.swift | 2 +- .../OperationCompletionReporter.swift | 7 +- TablePro/Core/Storage/KeychainHelper.swift | 6 +- .../Core/Storage/SplitViewAutosaveSweep.swift | 10 +- .../Testing/UITestLaunchEnvironment.swift | 30 +- .../Connection/PasswordSourceResolver.swift | 19 +- TablePro/ViewModels/ERDiagramViewModel.swift | 14 +- .../ViewModels/HistoryPanelViewModel.swift | 11 +- TablePro/ViewModels/SidebarViewModel.swift | 42 +- .../Editor/QueryDiagnosticsController.swift | 7 +- .../Views/Editor/SQLEditorCoordinator.swift | 8 +- .../Plugins/InstalledPluginsView.swift | 6 +- .../Settings/SettingsWindowController.swift | 23 +- .../AWS/AWSDiscoverySessionTests.swift | 40 + .../CloudSQL/CloudSQLProxyManagerTests.swift | 19 +- .../CloudflareTunnelManagerTests.swift | 19 +- .../Core/AI/AIConnectionAccessGateTests.swift | 17 +- .../AI/Chat/ToolApprovalOrderingTests.swift | 21 +- .../AI/CopilotIdleStopControllerTests.swift | 118 +- ...ineSuggestionManagerCompositionTests.swift | 44 +- .../Core/AI/ToolApprovalCenterTests.swift | 27 +- .../CompletionEngineFilterTests.swift | 1 - .../Autocomplete/CompletionEngineTests.swift | 1 - .../SQLSchemaProviderFallbackTests.swift | 1 - .../Autocomplete/SQLSchemaProviderTests.swift | 17 +- .../SQLStatementGeneratorSchemaTests.swift | 41 +- .../SQLWriteBatchBudgetTests.swift | 110 +- .../CompareDataPlanGroupingTests.swift | 110 +- .../Core/Compare/CompareDataScopeTests.swift | 630 ++++----- .../CompareMetadataReadPlanTests.swift | 12 +- .../Compare/CompareObjectScopeTests.swift | 86 +- .../Compare/CompareResultGroupingTests.swift | 78 +- .../Compare/CompareReviewFindingTests.swift | 117 +- .../CompareRowServiceRefusalTests.swift | 33 +- .../Core/Compare/CompareRunClaimTests.swift | 17 +- .../Core/Compare/CompareSQLLiteralTests.swift | 68 +- .../CompareSourceDefinitionReadTests.swift | 116 +- .../CompareSyncExecutorScopeTests.swift | 20 +- .../Compare/CompareSyncExecutorTests.swift | 175 +-- .../CompareSyncSessionDataScopeTests.swift | 14 +- .../CompareSyncSessionScriptTextTests.swift | 19 +- .../CompareSyncSetupRestoreTests.swift | 17 +- .../Compare/CompareSyncStartStateTests.swift | 53 +- .../ConcurrentRefreshIndexRuleTests.swift | 34 +- .../Compare/DataSyncScriptBuilderTests.swift | 17 +- .../Core/Compare/KeyOrderingTests.swift | 169 +-- .../MaterializedViewIndexCompareTests.swift | 124 +- .../SchemaSyncScriptBuilderTests.swift | 28 +- .../Compare/SourceObjectDiffEngineTests.swift | 210 +-- .../SourceObjectIndexCarriageTests.swift | 34 +- .../Compare/StreamingRowProviderTests.swift | 86 +- .../Compare/StructureChangeGuardTests.swift | 216 +-- .../Compare/StructureDiffEngineTests.swift | 168 +-- .../TableDefinitionRendererTests.swift | 15 +- .../Core/Compare/TimestampValueTests.swift | 82 +- .../Core/Concurrency/OnceTaskTests.swift | 155 +-- .../Concurrency/SessionDriverGateTests.swift | 30 +- .../TaskCancellationShieldTests.swift | 67 +- .../ConnectionLibraryStorageTests.swift | 8 +- .../ConnectionListPreferencesTests.swift | 10 +- .../RecentConnectionsRecorderTests.swift | 6 +- .../BrowseSearchExactCountTests.swift | 18 +- .../Coordinators/ExactRowCounterTests.swift | 18 +- .../Core/Coordinators/FindMatcherTests.swift | 29 + .../RowEditingCoordinatorCopyTests.swift | 376 +++--- .../RowEditingCoordinatorJsonModeTests.swift | 11 +- ...owEditingCoordinatorValueFilterTests.swift | 11 +- .../CrossEngineKeyTranslationTests.swift | 161 ++- .../CrossEngineStructureTranslatorTests.swift | 247 ++-- ...ssEngineTypeModifierTranslationTests.swift | 146 ++- .../CrossEngineValueCoercerTests.swift | 144 +- .../CrossEngine/MySQLStorageWidthTests.swift | 160 +-- .../SQLTypeParserModifierTests.swift | 106 +- .../Core/CrossEngine/SQLTypeParserTests.swift | 201 +-- .../SQLTypeRendererModifierTests.swift | 189 +-- .../CrossEngine/SQLTypeRendererTests.swift | 189 +-- .../DataFiles/DataFileControllerTests.swift | 26 + .../DataFileSelectionSummaryTests.swift | 9 +- .../DataWrite/DataWriteExecutorTests.swift | 19 +- .../Core/DataWrite/RewindCipherTests.swift | 25 +- ...angeStatementFactoryServerOwnedTests.swift | 17 +- .../Core/Database/BoundedQueryTests.swift | 57 +- .../CancelledConnectionCleanupTests.swift | 196 +-- .../Database/ConnectionDeadlineTests.swift | 39 +- .../Database/ConnectionEndReasonTests.swift | 1 - .../ConnectionVerificationTests.swift | 171 +-- .../DatabaseEndpointPickerModelTests.swift | 44 +- .../Core/Database/DatabaseEndpointTests.swift | 46 +- .../DatabaseManagerDisconnectTests.swift | 246 ++-- ...abaseManagerSchemaChangeRoutingTests.swift | 1158 +++++++++-------- .../Core/Database/DatabaseManagerTests.swift | 10 +- .../DatabaseManagerVersionTests.swift | 1 - .../DatabaseSwitchLeaseOrderingTests.swift | 163 +-- .../Core/Database/ExecuteUserQueryTests.swift | 19 +- .../HealthMonitorReconnectTests.swift | 12 +- .../Database/LoadableExtensionGateTests.swift | 7 +- .../Core/Database/MultiConnectionTests.swift | 1 - .../Database/PluginStreamAbortTests.swift | 39 +- .../PreConnectScriptConsentTests.swift | 8 +- .../ProtectedWritePingSuppressionTests.swift | 1 - .../ReconnectCredentialRecoveryTests.swift | 1 - .../Database/ResultSetBatchAdapterTests.swift | 19 +- .../ScopedDriverCancellationTests.swift | 30 +- .../Database/ScopedDriverPinningTests.swift | 18 +- .../Database/ScopedDriverRoutingTests.swift | 1 - .../Core/Database/SessionLanesTests.swift | 114 +- .../StatementTextValidatorTests.swift | 19 +- .../SwitchDatabasePooledConnectionTests.swift | 24 +- .../SwitchDatabaseReconnectFailureTests.swift | 30 +- .../TableOperationSQLBuilderTests.swift | 38 +- .../Database/TriggerInfoMappingTests.swift | 18 +- .../Core/Diagnostics/LaunchTracerTests.swift | 38 +- .../KeyWindowCommandSubscriptionTests.swift | 26 +- .../Export/TableTransferServiceTests.swift | 22 +- .../Core/Git/GitIntegrationTests.swift | 12 +- .../Core/MCP/MCPConnectionApprovalTests.swift | 37 +- .../Core/MCP/MCPTokenStoreTests.swift | 6 +- .../MCP/Outside/MCPRemoteToolScopeTests.swift | 28 +- .../MCP/Outside/MCPServerStoreTests.swift | 11 +- .../Handlers/PromptsListHandlerTests.swift | 113 +- .../Handlers/ResourcesListHandlerTests.swift | 82 +- .../Handlers/ResourcesReadHandlerTests.swift | 96 +- .../Protocol/MCPProtocolDispatcherTests.swift | 361 ++--- .../MCP/Transport/MCPHttpKeepAliveTests.swift | 1 - .../MCPHttpServerConfigurationTests.swift | 1 - .../MCPHttpServerTransportTests.swift | 1 - .../Core/Menu/MainMenuBuilderTests.swift | 54 + ...ObjectCopyConcurrentReadRefusalTests.swift | 36 +- .../ObjectCopyEligibilityTests.swift | 186 +-- .../Core/ObjectCopy/ObjectCopyPlanTests.swift | 81 +- .../ObjectCopyPlannerOrderingTests.swift | 160 ++- .../ObjectCopy/ObjectCopyRowCopierTests.swift | 124 +- .../ObjectCopySelectQueryTests.swift | 206 ++- .../ObjectCopySequenceStatementTests.swift | 35 +- .../ObjectCopyServerSideInsertTests.swift | 120 +- .../ObjectCopyTransactionTests.swift | 20 +- .../OperationCompletionPolicyTests.swift | 5 +- .../OperationCompletionReporterTests.swift | 27 +- .../Plugins/DriverUnavailabilityTests.swift | 14 +- ...DataSourceAdapterImplicitSchemaTests.swift | 38 +- ...ExportDataSourceAdapterRowLimitTests.swift | 18 +- ...ExportDataSourceAdapterRowScopeTests.swift | 18 +- ...portDataSourceAdapterScriptTextTests.swift | 19 +- .../Plugins/ImportFieldDetectionTests.swift | 5 +- .../Plugins/MySQLVariantSupportTests.swift | 1 - .../Plugins/PluginBundleVersionTests.swift | 1 - .../PluginCreateTableStatementsTests.swift | 19 +- .../PluginDriverAdapterConcurrencyTests.swift | 22 +- ...inDriverAdapterPartitionDefaultTests.swift | 38 +- .../PluginDriverAdapterSchemaSetTests.swift | 38 +- ...inDriverAdapterStatementContextTests.swift | 20 +- ...inDriverAdapterStructureMappingTests.swift | 11 +- ...uginDriverAdapterSystemDatabaseTests.swift | 14 +- .../PluginDriverAdapterTableOpsTests.swift | 19 +- ...inDriverAdapterTableTypeMappingTests.swift | 17 +- .../PluginInstallTrackerStagedTests.swift | 1 - .../Plugins/PluginInstallerHelpersTests.swift | 1 - ...luginLazyActivationVerificationTests.swift | 11 +- .../PluginManagerInitialLoadTests.swift | 75 ++ .../PluginManagerStagingCleanupTests.swift | 1 - .../PluginResultColumnHintsTests.swift | 18 +- .../Core/Plugins/PluginRowLocatorTests.swift | 20 +- .../Plugins/PluginSettingsSnapshotTests.swift | 1 - .../Core/Plugins/PluginSettingsTests.swift | 8 +- .../Plugins/PluginSignatureSweepTests.swift | 5 +- .../RegistryClientFreshnessTests.swift | 11 +- .../Core/Plugins/RegistryClientURLTests.swift | 19 +- .../Plugins/SQLDialectDescriptorTests.swift | 73 +- .../Core/Process/PipeReaderTests.swift | 15 - .../SupervisedProcessRunnerTests.swift | 21 +- .../Core/RecentTabs/RecentTabOrderTests.swift | 17 +- .../RecentTabSwitcherControllerTests.swift | 10 +- .../AgentAuthenticationReportingTests.swift | 1 - .../CheckConstraintStatementTests.swift | 17 +- .../CreateTableCommentCompositionTests.swift | 17 +- .../IndexDecliningDriverTests.swift | 17 +- .../SchemaOperationRefusalTests.swift | 17 +- .../SchemaStatementGeneratorPluginTests.swift | 17 +- .../TableCommentStatementTests.swift | 17 +- .../Services/ClipboardInstallGuardTests.swift | 61 - .../Services/ColumnTypeClassifierTests.swift | 155 +-- .../Services/DateFormattingServiceTests.swift | 7 +- .../BeekeeperStudioImporterTests.swift | 1 - .../ForeignApp/DBeaverImporterTests.swift | 1 - .../ForeignApp/DataGripImporterTests.swift | 1 - .../JetBrainsCredentialStoreTests.swift | 1 - .../ForeignApp/NavicatImporterTests.swift | 1 - .../ForeignApp/SequelAceImporterTests.swift | 1 - .../ForeignApp/TablePlusImporterTests.swift | 13 +- .../Formatting/CellImageRendererTests.swift | 1 - .../Import/MacImportLibraryStoreTests.swift | 3 +- .../Infrastructure/AgentModeWindowTests.swift | 713 +++++----- .../AppLaunchCoordinatorTests.swift | 170 +-- .../ConnectionFormDraftStoreTests.swift | 1 - .../ConnectionLivenessTests.swift | 1 - .../ConnectionStageObserverTests.swift | 1 - .../ConnectionWindowChromeTests.swift | 39 +- .../ConnectionWindowInitialFocusTests.swift | 1 - .../ConnectionWindowToolbarTests.swift | 1 - .../Infrastructure/ContentModeTests.swift | 88 +- .../ExternalConnectionGateTests.swift | 11 +- .../InspectorToolbarPlacementTests.swift | 1 - .../Infrastructure/LaunchPhaseTests.swift | 30 +- ...ewControllerPaneSynchronizationTests.swift | 15 +- .../TrailingPaneRevealTests.swift | 288 ++-- .../Infrastructure/URLClassifierTests.swift | 1 - .../Infrastructure/WelcomeRouterTests.swift | 55 +- .../WorkspacePanesFirewallTests.swift | 1 - .../WorkspaceSwitchKeyStateTests.swift | 1 - .../Core/Services/ManagedPolicyTests.swift | 11 +- .../CatalogEditFavoriteAdoptionTests.swift | 10 +- ...CatalogEditSchemaFilterAdoptionTests.swift | 10 +- .../MetadataConnectionPoolCapacityTests.swift | 18 +- .../Query/MetadataConnectionPoolTests.swift | 26 +- .../Query/SQLTableEditAdoptionTests.swift | 15 +- ...SavedConnectionDatabaseAdoptionTests.swift | 17 +- .../SchemaColumnStoreCancellationTests.swift | 1 - .../Query/SchemaRefreshAfterWriteTests.swift | 4 +- .../Query/SidebarSchemaFilterTests.swift | 17 +- .../RowOperationsFieldAbsenceTests.swift | 270 ++-- .../RowOperationsManagerBinaryCopyTests.swift | 127 +- .../RowOperationsManagerCopyTests.swift | 568 ++++---- .../RowOperationsManagerPasteTests.swift | 61 +- .../Services/SQLFormatterServiceTests.swift | 9 + .../TabPersistenceCoordinatorTests.swift | 67 +- .../TableQueryBuilderFilterTests.swift | 19 +- .../TableQueryBuilderSortScopeTests.swift | 19 +- ...dowLifecycleMonitorRegistrationTests.swift | 1 - .../AppSettingsStorageMigrationTests.swift | 9 +- .../AppSettingsStorageResetTests.swift | 6 +- .../Storage/AppStorageEnvironmentTests.swift | 10 +- .../CodableListPreferenceStoreTests.swift | 5 +- .../Core/Storage/ColumnLayoutSyncTests.swift | 6 +- .../ConnectionLocalStatePurgeTests.swift | 24 +- .../ConnectionRecordCoverageTests.swift | 5 +- .../ConnectionStorageAIFieldsTests.swift | 11 +- ...nnectionStorageAdditionalFieldsTests.swift | 9 +- ...ConnectionStorageExternalAccessTests.swift | 11 +- .../ConnectionStorageImportTests.swift | 10 +- .../ConnectionStoragePersistenceTests.swift | 11 +- .../ConnectionStorageRemoveTagTests.swift | 11 +- .../ConnectionStorageSyncDeleteTests.swift | 7 +- .../CredentialProfileImportTests.swift | 5 +- .../CredentialProfileStorageTests.swift | 11 +- .../CustomSlashCommandStorageTests.swift | 21 +- .../DatabaseTreeFilterStorageTests.swift | 7 +- .../ExternalConnectionTrustStoreTests.swift | 19 +- .../FavoriteDatabasesStorageTests.swift | 9 +- .../Storage/FavoriteTablesStorageTests.swift | 9 +- .../Storage/FilterSettingsStorageTests.swift | 33 +- .../Storage/GroupStorageImportPathTests.swift | 7 +- .../Core/Storage/GroupStorageTests.swift | 18 +- .../Core/Storage/KeychainHelperTests.swift | 10 +- .../Core/Storage/LinkedFolderStoreTests.swift | 6 +- .../LoadableExtensionApprovalStoreTests.swift | 6 +- .../Storage/QueryHistoryInsightsTests.swift | 20 + .../RecentTablesStoreMigrationTests.swift | 8 +- .../RecentTablesStoreSchemaClearTests.swift | 8 +- .../Storage/RewindSnapshotStorageTests.swift | 31 +- .../SQLFavoriteDeletionSyncTests.swift | 5 +- .../SQLFavoriteManagerImportTests.swift | 6 +- .../Storage/SQLFavoriteRemoteApplyTests.swift | 5 +- .../Storage/SQLFavoriteVersionTests.swift | 4 +- .../Core/Storage/SSHProfileStorageTests.swift | 21 +- .../Core/Storage/SafeModeMigrationTests.swift | 19 +- .../Core/Storage/StoredSecretStateTests.swift | 28 +- .../Core/Storage/SyncDirtyMarkingTests.swift | 9 +- .../Storage/TableFolderStorageTests.swift | 10 +- .../Core/Storage/TagStorageImportTests.swift | 12 +- .../Core/Storage/TagStorageTests.swift | 19 +- .../Storage/TransferDialogStorageTests.swift | 9 +- .../ValueDisplayFormatStorageTests.swift | 4 +- .../WorkspaceRailOrderStoreTests.swift | 7 +- .../Core/Sync/SyncChangeTrackerTests.swift | 6 +- .../Core/Sync/SyncCoordinatorEchoTests.swift | 6 +- .../SyncCoordinatorSQLFavoritePullTests.swift | 6 +- .../SyncMetadataStorageEnvironmentTests.swift | 5 +- TableProTests/Core/Sync/SyncScopeTests.swift | 3 +- .../PrincipalChangeManagerTests.swift | 1 - .../PasswordSourceResolverTests.swift | 30 +- .../SQLParameterExtractorTests.swift | 250 ++-- .../Utilities/SQLStatementScannerTests.swift | 350 ++--- .../Core/Vim/VimEngineCaseChangeTests.swift | 95 +- .../Core/Vim/VimEngineCommandLineTests.swift | 137 +- .../Core/Vim/VimEngineControlKeyTests.swift | 152 ++- .../Core/Vim/VimEngineCountPrefixTests.swift | 103 +- .../Core/Vim/VimEngineEdgeCasesTests.swift | 153 ++- .../Core/Vim/VimEngineFindCharTests.swift | 183 +-- .../Core/Vim/VimEngineIndentTests.swift | 69 +- .../Core/Vim/VimEngineInsertEntryTests.swift | 213 +-- .../Vim/VimEngineInsertModeEditTests.swift | 86 +- .../Core/Vim/VimEngineJoinTests.swift | 168 ++- .../Core/Vim/VimEngineMacroTests.swift | 74 +- .../Vim/VimEngineMarksAndRegistersTests.swift | 98 +- .../Vim/VimEngineNormalMotionsTests.swift | 347 +++-- .../Core/Vim/VimEngineNumberAdjustTests.swift | 83 +- .../Core/Vim/VimEngineOperatorsTests.swift | 269 ++-- .../Core/Vim/VimEnginePasteTests.swift | 104 +- .../Core/Vim/VimEngineReplaceTests.swift | 119 +- .../Core/Vim/VimEngineScrollTests.swift | 71 +- .../Vim/VimEngineSentenceParagraphTests.swift | 78 +- .../Core/Vim/VimEngineTextObjectsTests.swift | 153 ++- .../Core/Vim/VimEngineUndoRepeatTests.swift | 79 +- .../Core/Vim/VimEngineVisualModeTests.swift | 327 +++-- .../Vim/VimEngineVisualReselectionTests.swift | 58 +- .../Database/NativeDumpBatchTests.swift | 1 - .../Database/NativeDumpServiceTests.swift | 1 - .../Extensions/ObservedValueChangeTests.swift | 78 +- .../OnValueChangeReparentTests.swift | 52 +- .../Extensions/StringHexDumpTests.swift | 40 +- .../Extensions/StringJsonTests.swift | 17 +- TableProTests/Helpers/AIFeatureScope.swift | 38 - TableProTests/Helpers/AppSettingsTrait.swift | 79 ++ .../Helpers/CoordinatorFixture.swift | 46 + TableProTests/Helpers/EphemeralDefaults.swift | 37 + TableProTests/Helpers/FakeMSSQLPlugin.swift | 30 +- TableProTests/Helpers/GlobalStateSuites.swift | 7 + .../Helpers/ImportLibraryFixture.swift | 23 +- .../Helpers/InMemoryColumnLayoutStore.swift | 13 + .../Helpers/IndexStatementStubDriver.swift | 32 +- TableProTests/Helpers/OfflineRegistry.swift | 67 + TableProTests/Helpers/PluginDriverStub.swift | 30 + .../Helpers/RecordingClipboard.swift | 65 + .../Helpers/RowWriteStubDrivers.swift | 53 +- TableProTests/Helpers/SharedHelperTests.swift | 128 ++ .../Helpers/SourceDefinitionStubDriver.swift | 29 +- .../Helpers/SyncTestEnvironment.swift | 14 +- .../PortableConnectionMappingTests.swift | 13 +- .../Models/ConnectionSessionTests.swift | 15 +- TableProTests/Models/DatabaseScopeTests.swift | 1 - .../Models/ERDiagram/ERDiagramDragTests.swift | 32 +- .../Models/JSON/JSONRowFilterTests.swift | 18 +- .../Models/LoadingRevealPolicyTests.swift | 53 +- .../Query/QueryTabManagerRecencyTests.swift | 12 +- .../Models/Query/StatementAnchorTests.swift | 21 +- .../TabScopeIsWindowIndependentTests.swift | 47 +- .../Models/Query/TableRowsTests.swift | 21 +- .../Models/QuickSwitcherKeyCommandTests.swift | 13 +- .../Models/RestoredHiddenColumnsTests.swift | 58 +- .../Settings/LegacyThemeFontsTests.swift | 3 +- .../Settings/SettingsFieldDecodingTests.swift | 3 +- .../Models/SystemContainerNamesTests.swift | 31 +- .../Models/TrailingPaneHeaderModelTests.swift | 255 ++-- .../Models/TrailingPaneStateTests.swift | 40 +- .../Models/UI/FilterPresetStorageTests.swift | 1 - .../UI/WindowSidebarStateSeedingTests.swift | 17 +- .../SOCKS/SOCKSProxyManagerTests.swift | 59 +- .../Services/ContentWindowBackdropTests.swift | 1 - .../EditorWindowInitialFrameTests.swift | 1 - .../Services/MacAnalyticsProviderTests.swift | 79 +- .../MainWindowToolbarLayoutTests.swift | 14 +- .../MainWindowToolbarShortcutHintTests.swift | 221 ++-- .../MainWindowToolbarValidationTests.swift | 123 +- .../SchemaRefreshCommitCostTests.swift | 21 +- .../Services/SchemaServiceRefreshTests.swift | 16 +- .../Services/SchemaServiceRoutinesTests.swift | 59 +- .../SparkleUpdatePreferencesTests.swift | 10 +- .../Services/ToolbarHiddenSetTests.swift | 97 +- .../CustomSlashCommandStorageSyncTests.swift | 27 +- .../FileColumnLayoutPersisterTests.swift | 3 +- .../ForeignKeyLabelColumnStoreTests.swift | 45 +- .../ImportColumnMappingStoreTests.swift | 32 +- .../Storage/RecentTablesStoreTests.swift | 39 +- .../Storage/SplitViewAutosaveSweepTests.swift | 47 +- .../TeamLibrarySyncCoordinatorTests.swift | 23 +- .../Theme/ThemeTableChromeTests.swift | 1 - .../Theme/ThemeTextViewStyleTests.swift | 1 - .../Theme/ThemeWindowBackdropTests.swift | 1 - .../TunnelCommandManagerTests.swift | 21 +- ...AIChatViewModelStreamingCadenceTests.swift | 1 - .../AIChatViewModelToolLoopTests.swift | 10 +- .../ERDiagramViewModelLoadTests.swift | 10 +- .../FavoritesExpansionStateTests.swift | 24 +- .../FavoritesSidebarViewModelTests.swift | 18 +- .../HistoryPanelViewModelTests.swift | 39 +- .../JSONRowInspectorViewModelTests.swift | 67 +- .../QuickSwitcherCrossSchemaTests.swift | 22 +- .../QuickSwitcherListingDemandTests.swift | 34 +- .../QuickSwitcherRecentIdentityTests.swift | 13 +- .../QuickSwitcherViewModelTests.swift | 70 +- .../RedisKeyTreeViewModelLoadTests.swift | 60 +- .../ViewModels/SidebarViewModelTests.swift | 218 ++-- ...sRolesViewModelChangeForwardingTests.swift | 17 +- ...UsersRolesViewModelReappearanceTests.swift | 56 +- .../ViewModels/WelcomeViewModelTests.swift | 471 +++---- .../ViewModels/WindowSidebarStateTests.swift | 23 +- .../Compare/CompareRowGridModelTests.swift | 335 +++-- .../CollapsingSplitViewControllerTests.swift | 22 +- .../ClearedSecretSaveTests.swift | 13 +- .../CreateDatabaseFormRulesTests.swift | 68 +- .../Views/DetailSurfaceThemeTests.swift | 1 - .../ERDiagram/ERDiagramInitialFitTests.swift | 33 +- .../ERDiagramPointerInputTests.swift | 27 +- .../Views/ERDiagram/ERDiagramThemeTests.swift | 1 - .../Editor/EditorLifecycleTeardownTests.swift | 30 +- .../Editor/FoldCommandBehaviourTests.swift | 12 +- .../Editor/FoldPreviewHitTestTests.swift | 11 +- ...QueryCompletionAdapterLifecycleTests.swift | 559 ++++---- .../QueryCompletionProfileRegistryTests.swift | 50 +- .../Editor/QueryDiagnosticsRefreshTests.swift | 52 +- .../SQLCompletionAdapterFuzzyTests.swift | 40 +- .../SQLEditorCursorPropagationTests.swift | 22 +- .../Editor/StatementRunControllerTests.swift | 21 + .../Filter/CellFilterMenuBuilderTests.swift | 23 + .../Views/Import/RowImportMappingTests.swift | 14 +- .../Views/Main/CatalogChangeWindowTests.swift | 88 +- .../Main/CommandActionsBulkCloseTests.swift | 17 +- .../Main/CommandActionsDispatchTests.swift | 550 ++++---- .../Main/CoordinatorActivationTests.swift | 46 +- .../Views/Main/CoordinatorFixtures.swift | 16 + .../Main/CoordinatorQueryRoutingTests.swift | 43 + .../Views/Main/DataRefreshScopeTests.swift | 35 +- .../Main/DefaultSortInitialQueryTests.swift | 526 ++++---- .../Views/Main/DetailPaneBackdropTests.swift | 1 - .../DocumentEditingAvailabilityTests.swift | 12 +- .../Views/Main/EditorTabActivationTests.swift | 33 +- .../Main/EditorTabStripTrackThemeTests.swift | 1 - .../Main/EditorTabStripWindowDragTests.swift | 1 - TableProTests/Views/Main/EvictionTests.swift | 15 +- .../Views/Main/ExtractTableNameTests.swift | 34 +- .../Views/Main/FetchAllQueryTaskTests.swift | 34 +- .../FilterPersistenceRoundTripTests.swift | 13 +- .../FilterTypingBeforeFirstLoadTests.swift | 317 +++-- .../Main/GridStandInBackgroundTests.swift | 1 - .../MainContentCoordinatorAddRowTests.swift | 22 +- ...nContentCoordinatorDisplayStateTests.swift | 12 +- ...ContentCoordinatorGridSelectionTests.swift | 12 +- .../MainContentCoordinatorLazyLoadTests.swift | 12 +- .../MainContentCoordinatorRefreshTests.swift | 29 +- ...ontentCoordinatorSelectionResetTests.swift | 12 +- .../MainContentCoordinatorSortTests.swift | 25 +- .../Main/MaterializedViewRowWriteTests.swift | 475 ++++--- .../Main/MultiConnectionNavigationTests.swift | 40 +- .../Main/PaginationCoordinatorTests.swift | 12 +- .../Views/Main/Phase2RowCountGuardTests.swift | 17 +- ...luginBrowsedQueryTabEditabilityTests.swift | 18 +- .../Main/QueryFailureReportingTests.swift | 37 +- .../Main/QueryParameterDetectionTests.swift | 85 +- .../Main/QuickHighlightOrderingTests.swift | 12 +- .../RedisDatabaseSelectionGateTests.swift | 97 +- .../Main/ResultStatusBarLayoutTests.swift | 14 +- .../Main/ResultSwitchIdentityTests.swift | 12 +- .../Views/Main/SaveCompletionTests.swift | 19 +- .../Main/SeedBufferFromErrorResultTests.swift | 39 +- .../Views/Main/SidebarSaveCoverageTests.swift | 12 +- .../Main/TabExecutionObservationTests.swift | 9 +- .../Main/TableLoadConfirmationTests.swift | 27 +- .../TableLoadForeignKeyPrefetchTests.swift | 10 +- .../Views/QueryPlan/QueryPlanThemeTests.swift | 20 +- .../CellOverlayAccessibilityTests.swift | 1 - .../Results/CellOverlayAppearanceTests.swift | 1 - .../DataGridEmptyResultColumnsTests.swift | 59 +- .../Results/DataGridFieldRemovalTests.swift | 211 ++- .../DataGridFilterConfigurationTests.swift | 1 - .../Results/DataGridRowIdentityTests.swift | 1 - .../DataGridRowNumberRenderingTests.swift | 1 - .../Results/DataGridRowTintThemeTests.swift | 1 - .../Results/DataGridRowViewCopyTests.swift | 1050 +++++++-------- .../DataGridRowViewSetValueTests.swift | 17 +- .../DataGridSelectionSummaryMenuTests.swift | 303 ++--- .../Results/DataGridThemeDrawingTests.swift | 1 - .../Results/DataGridTrailingSpaceTests.swift | 1 - .../DataGridViewportPlacementTests.swift | 1 - .../Results/DrawnCellReachabilityTests.swift | 1 - .../Extensions/CellPasteRoutingTests.swift | 294 ++--- .../Results/GridColumnDisplayOrderTests.swift | 187 +-- .../GridSelectionCopyTruncationTests.swift | 116 +- .../KeyHandlingTableViewOverlayTests.swift | 10 +- .../SelectionSummaryTrackerTests.swift | 29 +- .../Results/SizeAllColumnsToFitTests.swift | 1 - .../SortableHeaderResizeZoneTests.swift | 1 - .../Views/Results/StatusBarChromeTests.swift | 1 - .../TreeOutlineCoordinatorTests.swift | 958 +++++++------- .../TreeOutline/TreeOutlineFixture.swift | 29 +- .../Views/Results/ValueTextThemeTests.swift | 1 - .../ResizableFieldMetricsTests.swift | 57 +- .../Views/SQLCodeFieldThemeTests.swift | 1 - .../ServerDashboardSplitViewTests.swift | 21 +- .../SettingsPluginsPaneRegistryTests.swift | 40 + .../Settings/SettingsWindowTitleTests.swift | 20 +- .../Sidebar/DatabaseTreeFolderNodeTests.swift | 10 +- .../DatabaseTreeSearchReadsTests.swift | 16 +- .../MaintenancePreviewSyntaxTests.swift | 1 - .../Sidebar/SidebarOutlineScaffoldTests.swift | 102 +- .../Sidebar/SidebarScopeControlTests.swift | 16 +- .../CreateTableDraftCompositionTests.swift | 69 +- .../ForeignKeyReferenceMenusTests.swift | 50 +- ...terializedViewConcurrentRefreshTests.swift | 1 - .../StructureEditingSessionTests.swift | 90 +- .../StructureListGridAccessibilityTests.swift | 1 - .../StructureListGridModelTests.swift | 330 +++-- .../StructureListGridSelectionTests.swift | 81 +- .../StructureSaveConfirmationTests.swift | 17 +- .../Structure/StructureSavePlanTests.swift | 33 +- .../StructureServerSupportTests.swift | 38 +- TableProTests/Views/SwitchSchemaTests.swift | 45 +- .../Views/TransferFailureReportTests.swift | 407 +++--- .../PrivilegeScopeOutlineThemeTests.swift | 1 - .../Views/Welcome/WelcomeToolbarTests.swift | 12 +- .../AppearanceSettingsUITests.swift | 13 +- TableProUITests/EditorTabDetachUITests.swift | 2 +- TableProUITests/EditorTabReorderUITests.swift | 4 +- .../ForeignKeyNavigationUITests.swift | 8 +- .../OpenQuicklyCommandUITests.swift | 6 +- .../SQLiteFirstTableLoadUITests.swift | 2 +- TableProUITests/SidebarAddMenuUITests.swift | 4 +- TableProUITests/SidebarTableTabUITests.swift | 22 +- TableProUITests/Support/AppShortcut.swift | 2 +- .../Support/SeededSQLiteSession.swift | 30 +- .../Support/UITestCase+EditorTabs.swift | 26 +- .../Support/UITestCase+FixtureGrid.swift | 10 - .../Support/UITestCase+RowImport.swift | 5 +- TableProUITests/Support/UITestCase.swift | 42 +- .../Support/XCUIElementWaiting.swift | 24 +- .../TableChangeReloadUITests.swift | 4 +- 682 files changed, 19444 insertions(+), 21872 deletions(-) create mode 100644 Packages/TableProCore/Tests/TableProSyncTests/ScratchDefaults.swift create mode 100644 Packages/TableProCore/Tests/TableProTestSupport/AsyncGate.swift create mode 100644 Packages/TableProCore/Tests/TableProTestSupport/ManualClock.swift create mode 100644 Packages/TableProCore/Tests/TableProTestSupport/WaitUntil.swift create mode 100644 Packages/TableProEditor/Tests/TableProEditorKitTests/TrailingEdgeClickTests.swift create mode 100644 Packages/TableProMacCore/Sources/TableProUtilities/UI/SplitViewAutosaveKey.swift create mode 100644 Packages/TableProMacCore/Tests/TableProUtilitiesTests/SplitViewAutosaveKeyTests.swift create mode 100644 Plugins/MySQLDriverTests/MySQLCompareCapabilityTests.swift create mode 100644 Plugins/PostgreSQLDriverTests/PostgreSQLCompareCapabilityTests.swift create mode 100644 Plugins/SQLiteDriverTests/SQLiteCompareCapabilityTests.swift create mode 100644 Plugins/SQLiteDriverTests/SQLiteTypelessColumnTests.swift create mode 100644 TableProTests/AWS/AWSDiscoverySessionTests.swift create mode 100644 TableProTests/Core/Plugins/PluginManagerInitialLoadTests.swift delete mode 100644 TableProTests/Core/Services/ClipboardInstallGuardTests.swift delete mode 100644 TableProTests/Helpers/AIFeatureScope.swift create mode 100644 TableProTests/Helpers/AppSettingsTrait.swift create mode 100644 TableProTests/Helpers/CoordinatorFixture.swift create mode 100644 TableProTests/Helpers/EphemeralDefaults.swift create mode 100644 TableProTests/Helpers/GlobalStateSuites.swift create mode 100644 TableProTests/Helpers/InMemoryColumnLayoutStore.swift create mode 100644 TableProTests/Helpers/OfflineRegistry.swift create mode 100644 TableProTests/Helpers/PluginDriverStub.swift create mode 100644 TableProTests/Helpers/RecordingClipboard.swift create mode 100644 TableProTests/Helpers/SharedHelperTests.swift create mode 100644 TableProTests/Views/Main/CoordinatorFixtures.swift create mode 100644 TableProTests/Views/Settings/SettingsPluginsPaneRegistryTests.swift diff --git a/.swiftlint.yml b/.swiftlint.yml index 231055e695..6ee1e2e8e1 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -189,6 +189,15 @@ custom_rules: excluded: ".*/(AppStorageEnvironment|GeneralSettings|SoftwareUpdater|WorkspaceRailViewController|SplitViewAutosaveSweep)\\.swift" message: "Resolve preferences through AppStorageEnvironment.shared.defaults. Only a macOS-owned system preference reads the standard domain." severity: error + # Package code cannot reach AppStorageEnvironment and the iOS app has no sandbox, so `.standard` is + # their only default. ManagedPolicy reads MDM-managed keys, which live in the standard domain. + storage_environment_defaults_argument: + name: "Storage Environment Defaults Argument" + regex: '\bUserDefaults[?!]?\s*=\s*\.standard\b' + excluded: ['.*/(Packages|Plugins|TableProMobile)/', '.*/ManagedPolicy\.swift'] + excluded_match_kinds: [comment, doccomment] + message: "Default a UserDefaults parameter to AppStorageEnvironment.shared.defaults, or a sandboxed run writes the developer's own com.TablePro." + severity: error public_error_text_in_log: name: "Error Text In The System Log" regex: '(\.(localizedDescription|errorDescription|failureReason)(\.prefix\(\d+\))?(\s*\?\?\s*"[^"]*")?|String\(describing:\s*\w*[eE]rr(or)?\)|\\\((?!(has|is|did|was|should)[A-Z])(\w*[eE]rr(or)?|e)(\.(message|description|debugDescription))?)\s*,\s*privacy:\s*\.public' diff --git a/Packages/TableProCore/Package.swift b/Packages/TableProCore/Package.swift index 9607a68465..ed70b9b600 100644 --- a/Packages/TableProCore/Package.swift +++ b/Packages/TableProCore/Package.swift @@ -249,7 +249,7 @@ let package = Package( ), .testTarget( name: "TableProCoreTypesTests", - dependencies: ["TableProCoreTypes"], + dependencies: ["TableProCoreTypes", "TableProTestSupport"], path: "Tests/TableProCoreTypesTests" ), .testTarget( @@ -289,7 +289,7 @@ let package = Package( ), .testTarget( name: "TableProDatabaseTests", - dependencies: ["TableProDatabase", "TableProModels", "TableProPluginKit"], + dependencies: ["TableProDatabase", "TableProModels", "TableProPluginKit", "TableProTestSupport"], path: "Tests/TableProDatabaseTests" ), .testTarget( @@ -309,7 +309,7 @@ let package = Package( ), .testTarget( name: "TableProMSSQLCoreTests", - dependencies: ["TableProMSSQLCore"], + dependencies: ["TableProMSSQLCore", "TableProTestSupport"], path: "Tests/TableProMSSQLCoreTests" ), .testTarget( @@ -319,7 +319,7 @@ let package = Package( ), .testTarget( name: "TableProGoogleCloudTests", - dependencies: ["TableProGoogleCloud"], + dependencies: ["TableProGoogleCloud", "TableProTestSupport"], path: "Tests/TableProGoogleCloudTests" ), .testTarget( diff --git a/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift b/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift index 60264dc130..4a2dc13a3b 100644 --- a/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift +++ b/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift @@ -8,6 +8,7 @@ public final class ConnectionManager: @unchecked Sendable { private let secureStore: SecureStore private let sshProvider: SSHProvider? private let teardownWaitLimit: Duration + private let clock: any Clock private let lock = NSLock() private var sessions: [UUID: ConnectionSession] = [:] @@ -20,12 +21,14 @@ public final class ConnectionManager: @unchecked Sendable { driverFactory: DriverFactory, secureStore: SecureStore, sshProvider: SSHProvider? = nil, - teardownWaitLimit: Duration = ConnectionManager.defaultTeardownWaitLimit + teardownWaitLimit: Duration = ConnectionManager.defaultTeardownWaitLimit, + clock: any Clock = ContinuousClock() ) { self.driverFactory = driverFactory self.secureStore = secureStore self.sshProvider = sshProvider self.teardownWaitLimit = teardownWaitLimit + self.clock = clock } /// Opens a session, waiting out a teardown still running for the same connection first. @@ -152,18 +155,22 @@ public final class ConnectionManager: @unchecked Sendable { case .untilFinished: return await teardown.waitUntilFinished() ? .cleared : .cancelled case .after(let limit): - return await Self.wait(on: teardown, within: limit) + return await Self.wait(on: teardown, within: limit, clock: clock) } } - private static func wait(on teardown: ConnectionTeardown, within limit: Duration) async -> TeardownWait { + private static func wait( + on teardown: ConnectionTeardown, + within limit: Duration, + clock: any Clock + ) async -> TeardownWait { await withTaskGroup(of: TeardownWait.self) { group in group.addTask { await teardown.waitUntilFinished() ? .cleared : .cancelled } group.addTask { do { - try await Task.sleep(for: limit) + try await clock.sleep(for: limit) } catch { return .cancelled } diff --git a/Packages/TableProCore/Tests/TableProCoreTypesTests/CancellableBlockingWorkTests.swift b/Packages/TableProCore/Tests/TableProCoreTypesTests/CancellableBlockingWorkTests.swift index 2b7f1c42e1..d0ce378606 100644 --- a/Packages/TableProCore/Tests/TableProCoreTypesTests/CancellableBlockingWorkTests.swift +++ b/Packages/TableProCore/Tests/TableProCoreTypesTests/CancellableBlockingWorkTests.swift @@ -1,5 +1,6 @@ import Foundation @testable import TableProCoreTypes +import TableProTestSupport import Testing private final class FlagBox: @unchecked Sendable { @@ -14,56 +15,8 @@ private final class FlagBox: @unchecked Sendable { } } -private func waitUntil( - timeout: Duration = .seconds(2), - _ condition: @Sendable () -> Bool -) async -> Bool { - let deadline = ContinuousClock.now.advanced(by: timeout) - while ContinuousClock.now < deadline { - if condition() { return true } - try? await Task.sleep(for: .milliseconds(5)) - } - return condition() -} - private struct TimeoutError: Error {} -private enum TestWatchdog { - private final class FirstArrival: @unchecked Sendable { - private let lock = NSLock() - private var continuation: CheckedContinuation? - - init(_ continuation: CheckedContinuation) { - self.continuation = continuation - } - - func claim() -> CheckedContinuation? { - lock.withLock { - defer { continuation = nil } - return continuation - } - } - } - - static func result( - within timeout: Duration = .seconds(2), - of work: @escaping @Sendable () async -> Value - ) async -> Value? { - await withCheckedContinuation { continuation in - let arrival = FirstArrival(continuation) - let timer = Task { - try? await Task.sleep(for: timeout) - arrival.claim()?.resume(returning: nil) - } - Task { - let value = await work() - arrival.claim()?.resume(returning: value) - timer.cancel() - } - } - } -} - @Suite("Cancellable blocking work") struct CancellableBlockingWorkTests { @Test("Work that completes normally returns its value") @@ -97,10 +50,10 @@ struct CancellableBlockingWorkTests { if !didRelease { release.signal() } } - try #require(await waitUntil { workStarted.value }) + try await waitUntil { workStarted.value } task.cancel() - let result = try #require(await TestWatchdog.result { await task.result }) + let result = try #require(await BoundedCall.result(within: .seconds(2)) { await task.result }) switch result { case .failure(let error): #expect(error is CancellationError) @@ -111,7 +64,7 @@ struct CancellableBlockingWorkTests { didRelease = true release.signal() - #expect(await waitUntil { discarded.value }) + try await waitUntil { discarded.value } } @Test("A deadline fails the caller and discards the late result") @@ -138,7 +91,7 @@ struct CancellableBlockingWorkTests { if !didRelease { release.signal() } } - let result = try #require(await TestWatchdog.result { await task.result }) + let result = try #require(await BoundedCall.result(within: .seconds(2)) { await task.result }) switch result { case .failure(let error): @@ -149,7 +102,7 @@ struct CancellableBlockingWorkTests { didRelease = true release.signal() - #expect(await waitUntil { discarded.value }) + try await waitUntil { discarded.value } } @Test("Work that completes before any cancel adopts the result") diff --git a/Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerTestSupport.swift b/Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerTestSupport.swift index 88904d3b84..8948e701b3 100644 --- a/Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerTestSupport.swift +++ b/Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerTestSupport.swift @@ -1,6 +1,7 @@ import Foundation @testable import TableProDatabase @testable import TableProModels +import TableProTestSupport import Testing // MARK: - Mock Types @@ -81,32 +82,6 @@ final class MockSecureStore: SecureStore, Sendable { // MARK: - Synchronisation Helpers -actor Gate { - private var isOpen = false - private var hasEntered = false - private var blocked: [CheckedContinuation] = [] - private var observers: [CheckedContinuation] = [] - - func enter() async { - hasEntered = true - for observer in observers { observer.resume() } - observers.removeAll() - guard !isOpen else { return } - await withCheckedContinuation { blocked.append($0) } - } - - func open() { - isOpen = true - for waiter in blocked { waiter.resume() } - blocked.removeAll() - } - - func waitUntilEntered() async { - guard !hasEntered else { return } - await withCheckedContinuation { observers.append($0) } - } -} - actor Flag { private(set) var isRaised = false @@ -128,17 +103,9 @@ actor OrderLog { } } -/// How an attempt run on its own task ended, for a test that has to be able to stop waiting for it. -/// -/// A test must never end on `await task.value`: that ignores the awaiting task's own cancellation, so -/// `.timeLimit` cannot break it, and a task group never leaves scope while one of its children is -/// awaiting one. Work that never lands then hangs the whole run instead of failing the one test that -/// asked for it. Polling a recorded outcome is a wait the test can give up on, which is what turns -/// that hang back into a failure. +// Polled, never awaited: `await task.value` ignores the waiter's own cancellation, so `.timeLimit` cannot end it. actor AttemptOutcome { - private static var pollInterval: Duration { .milliseconds(10) } - - private var result: Result? + private(set) var result: Result? func record(_ work: @Sendable () async throws -> Success) async { do { @@ -148,20 +115,10 @@ actor AttemptOutcome { result = .failure(error) } } - - func settled(within limit: Duration) async -> Result? { - let deadline = ContinuousClock.now + limit - while result == nil, ContinuousClock.now < deadline, !Task.isCancelled { - try? await Task.sleep(for: Self.pollInterval) - } - return result - } } +// Bounded, so teardowns run one at a time fail the overlap count instead of hanging. actor Barrier { - private static let pollInterval: UInt64 = 20_000_000 - private static let maxPolls = 250 - private let expected: Int private var arrived = 0 private(set) var overlapped = 0 @@ -172,12 +129,7 @@ actor Barrier { func arriveAndWait() async { arrived += 1 - var polls = 0 - while arrived < expected, polls < Self.maxPolls { - try? await Task.sleep(nanoseconds: Self.pollInterval) - polls += 1 - } - guard arrived >= expected else { return } + guard (try? await waitUntil(timeout: .seconds(5)) { self.arrived >= self.expected }) != nil else { return } overlapped += 1 } } diff --git a/Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerTests.swift b/Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerTests.swift index 39d0c2f5f7..c50ac803ce 100644 --- a/Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerTests.swift +++ b/Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerTests.swift @@ -1,9 +1,9 @@ import Foundation @testable import TableProDatabase @testable import TableProModels +import TableProTestSupport import Testing - @Suite("ConnectionManager Tests", .timeLimit(.minutes(1))) struct ConnectionManagerTests { @Test("Connect creates a session") @@ -169,14 +169,14 @@ struct ConnectionManagerTests { sshEnabled: true, sshConfiguration: SSHConfiguration(host: "jump.example.com") ) - let gate = Gate() + let gate = AsyncGate() let slow = MockDatabaseDriver() - slow.beforeConnect = { await gate.enter() } + slow.beforeConnect = { await gate.wait() } factory.drivers["mock"] = slow let losing = Task { _ = try await manager.connect(connection) } - await gate.waitUntilEntered() + await gate.arrived(1) factory.drivers["mock"] = MockDatabaseDriver() _ = try await manager.connect(connection) @@ -249,16 +249,16 @@ struct ConnectionManagerTests { let store = MockSecureStore() let manager = ConnectionManager(driverFactory: factory, secureStore: store) let connection = DatabaseConnection(name: "File", type: DatabaseType(rawValue: "mock")) - let gate = Gate() + let gate = AsyncGate() let driver = MockDatabaseDriver() driver.holdsSuspensionBlockingResource = true - driver.beforeDisconnect = { await gate.enter() } + driver.beforeDisconnect = { await gate.wait() } factory.drivers["mock"] = driver _ = try await manager.connect(connection) let teardown = Task { await manager.disconnect(connection.id) } - await gate.waitUntilEntered() + await gate.arrived(1) #expect(manager.session(for: connection.id) == nil) #expect(manager.hasSuspensionBlockingResources) @@ -273,23 +273,24 @@ struct ConnectionManagerTests { func connectWaitsForInFlightTeardown() async throws { let factory = MockDriverFactory() let store = MockSecureStore() - let manager = ConnectionManager(driverFactory: factory, secureStore: store) + let clock = ManualClock() + let manager = ConnectionManager(driverFactory: factory, secureStore: store, clock: clock) let connection = DatabaseConnection(name: "Test", type: DatabaseType(rawValue: "mock")) - let gate = Gate() + let gate = AsyncGate() let first = MockDatabaseDriver() - first.beforeDisconnect = { await gate.enter() } + first.beforeDisconnect = { await gate.wait() } factory.drivers["mock"] = first _ = try await manager.connect(connection) let teardown = Task { await manager.disconnect(connection.id) } - await gate.waitUntilEntered() + await gate.arrived(1) let second = MockDatabaseDriver() factory.drivers["mock"] = second let reconnect = Task { try await manager.connect(connection) } - try await Task.sleep(nanoseconds: 100_000_000) + await clock.waitUntilSleeping() #expect(!second.isConnected) await gate.open() @@ -305,14 +306,14 @@ struct ConnectionManagerTests { let factory = MockDriverFactory() let manager = ConnectionManager(driverFactory: factory, secureStore: MockSecureStore()) let connection = DatabaseConnection(name: "Test", type: DatabaseType(rawValue: "mock")) - let gate = Gate() + let gate = AsyncGate() let driver = MockDatabaseDriver() - driver.beforeConnect = { await gate.enter() } + driver.beforeConnect = { await gate.wait() } factory.drivers["mock"] = driver let attempt = Task { try await manager.connect(connection) } - await gate.waitUntilEntered() + await gate.arrived(1) manager.invalidateAttempt(for: connection.id) await gate.open() @@ -327,14 +328,14 @@ struct ConnectionManagerTests { let factory = MockDriverFactory() let manager = ConnectionManager(driverFactory: factory, secureStore: MockSecureStore()) let connection = DatabaseConnection(name: "Test", type: DatabaseType(rawValue: "mock")) - let gate = Gate() + let gate = AsyncGate() let slow = MockDatabaseDriver() - slow.beforeConnect = { await gate.enter() } + slow.beforeConnect = { await gate.wait() } factory.drivers["mock"] = slow let first = Task { try await manager.connect(connection) } - await gate.waitUntilEntered() + await gate.arrived(1) let fast = MockDatabaseDriver() factory.drivers["mock"] = fast @@ -390,10 +391,10 @@ struct ConnectionManagerTests { sshEnabled: true, sshConfiguration: SSHConfiguration(host: "jump.example.com") ) - let unblocked = Gate() + let unblocked = AsyncGate() let driver = MockDatabaseDriver() - driver.beforeDisconnect = { await unblocked.enter() } + driver.beforeDisconnect = { await unblocked.wait() } ssh.beforeCloseTunnel = { _ in await unblocked.open() } factory.drivers["mock"] = driver @@ -423,16 +424,16 @@ struct ConnectionManagerTests { teardownWaitLimit: .milliseconds(200) ) let connection = DatabaseConnection(name: "Ledger", type: DatabaseType(rawValue: "mock")) - let stuck = Gate() + let stuck = AsyncGate() let first = MockDatabaseDriver() first.holdsSuspensionBlockingResource = true - first.beforeDisconnect = { await stuck.enter() } + first.beforeDisconnect = { await stuck.wait() } factory.drivers["mock"] = first _ = try await manager.connect(connection) let release = Task { await manager.disconnect(connection.id) } - await stuck.waitUntilEntered() + await stuck.arrived(1) await #expect(throws: ConnectionError.previousSessionStillClosing) { _ = try await manager.connect(connection) @@ -459,12 +460,12 @@ struct ConnectionManagerTests { teardownWaitLimit: .milliseconds(50) ) let connection = DatabaseConnection(name: "Ledger", type: DatabaseType(rawValue: "mock")) - let checkpointing = Gate() + let checkpointing = AsyncGate() let hasReturned = Flag() let driver = MockDatabaseDriver() driver.holdsSuspensionBlockingResource = true - driver.beforeDisconnect = { await checkpointing.enter() } + driver.beforeDisconnect = { await checkpointing.wait() } factory.drivers["mock"] = driver _ = try await manager.connect(connection) @@ -472,7 +473,7 @@ struct ConnectionManagerTests { await manager.releaseSuspensionBlockingResources() await hasReturned.raise() } - await checkpointing.waitUntilEntered() + await checkpointing.arrived(1) try await Task.sleep(for: .milliseconds(250)) #expect(await hasReturned.isRaised == false) @@ -489,36 +490,39 @@ struct ConnectionManagerTests { @Test("A connect cancelled while it waits for a teardown gives up instead of queuing behind it") func cancelledConnectStopsWaitingForTheTeardown() async throws { let factory = MockDriverFactory() + let clock = ManualClock() let manager = ConnectionManager( driverFactory: factory, secureStore: MockSecureStore(), - teardownWaitLimit: .seconds(30) + teardownWaitLimit: .seconds(30), + clock: clock ) let connection = DatabaseConnection(name: "Ledger", type: DatabaseType(rawValue: "mock")) - let stuck = Gate() + let stuck = AsyncGate() let first = MockDatabaseDriver() - first.beforeDisconnect = { await stuck.enter() } + first.beforeDisconnect = { await stuck.wait() } factory.drivers["mock"] = first _ = try await manager.connect(connection) let release = Task { await manager.disconnect(connection.id) } - await stuck.waitUntilEntered() + await stuck.arrived(1) let second = MockDatabaseDriver() factory.drivers["mock"] = second let outcome = AttemptOutcome() let attempt = Task { await outcome.record { try await manager.connect(connection) } } - try await Task.sleep(for: .milliseconds(100)) + await clock.waitUntilSleeping() attempt.cancel() - let settled = await outcome.settled(within: .seconds(10)) + let gaveUp = (try? await waitUntil { await outcome.result != nil }) != nil #expect(!second.isConnected) await stuck.open() await release.value - let result = try #require(settled, "the cancelled connect never gave up") + #expect(gaveUp, "the cancelled connect never gave up") + let result = try #require(await outcome.result) #expect(throws: CancellationError.self) { try result.get() } } diff --git a/Packages/TableProCore/Tests/TableProGeometryTests/GeoJSONGeometryReaderTests.swift b/Packages/TableProCore/Tests/TableProGeometryTests/GeoJSONGeometryReaderTests.swift index 8b005558fe..6a306b3903 100644 --- a/Packages/TableProCore/Tests/TableProGeometryTests/GeoJSONGeometryReaderTests.swift +++ b/Packages/TableProCore/Tests/TableProGeometryTests/GeoJSONGeometryReaderTests.swift @@ -1,111 +1,83 @@ +import Foundation @testable import TableProGeometry -import XCTest +import Testing /// RFC 7946 fixes the capitalisation of a type name. Elasticsearch documents its `geo_shape` types /// in lowercase and hands a document back as it was indexed, so the reader takes any case. -final class GeoJSONGeometryReaderTests: XCTestCase { +struct GeoJSONGeometryReaderTests { private func geometry( _ text: String, - file: StaticString = #filePath, - line: UInt = #line + sourceLocation: SourceLocation = #_sourceLocation ) -> SpatialGeometry? { switch GeoJSONGeometryReader.read(text) { case .success(let value): return value.geometry case .failure(let failure): - XCTFail("expected a geometry, got \(failure) for \(text)", file: file, line: line) + Issue.record("expected a geometry, got \(failure) for \(text)", sourceLocation: sourceLocation) return nil } } - func testPointReadsInAnyCase() { + @Test func testPointReadsInAnyCase() { for type in ["Point", "point", "POINT", "pOiNt"] { - XCTAssertEqual( - geometry(#"{"type":"\#(type)","coordinates":[-73.97,40.77]}"#), - .point(SpatialPoint(x: -73.97, y: 40.77)) - ) + #expect(geometry(#"{"type":"\#(type)","coordinates":[-73.97,40.77]}"#) == .point(SpatialPoint(x: -73.97, y: 40.77))) } } - func testPolygonReadsInAnyCase() { + @Test func testPolygonReadsInAnyCase() { for type in ["Polygon", "polygon", "POLYGON"] { guard case .polygon(let rings)? = geometry( #"{"type":"\#(type)","coordinates":[[[0,0],[10,0],[10,10],[0,0]]]}"# ) else { - return XCTFail("expected a polygon for \(type)") + Issue.record("expected a polygon for \(type)") + return } - XCTAssertEqual(rings.count, 1) - XCTAssertEqual(rings[0].count, 4) + #expect(rings.count == 1) + #expect(rings[0].count == 4) } } - func testEveryLowercaseTypeKeepsItsOwnShape() { + @Test func testEveryLowercaseTypeKeepsItsOwnShape() { let first = SpatialPoint(x: 0, y: 0) let second = SpatialPoint(x: 1, y: 1) - XCTAssertEqual( - geometry(#"{"type":"multipoint","coordinates":[[0,0],[1,1]]}"#), - .multiPoint([first, second]) - ) - XCTAssertEqual( - geometry(#"{"type":"linestring","coordinates":[[0,0],[1,1]]}"#), - .lineString([first, second]) - ) - XCTAssertEqual( - geometry(#"{"type":"multilinestring","coordinates":[[[0,0],[1,1]]]}"#), - .multiLineString([[first, second]]) - ) - XCTAssertEqual( - geometry(#"{"type":"multipolygon","coordinates":[[[[0,0],[1,1],[1,0],[0,0]]]]}"#), - .multiPolygon([[[first, second, SpatialPoint(x: 1, y: 0), first]]]) - ) + #expect(geometry(#"{"type":"multipoint","coordinates":[[0,0],[1,1]]}"#) == .multiPoint([first, second])) + #expect(geometry(#"{"type":"linestring","coordinates":[[0,0],[1,1]]}"#) == .lineString([first, second])) + #expect(geometry(#"{"type":"multilinestring","coordinates":[[[0,0],[1,1]]]}"#) == .multiLineString([[first, second]])) + #expect(geometry(#"{"type":"multipolygon","coordinates":[[[[0,0],[1,1],[1,0],[0,0]]]]}"#) == .multiPolygon([[[first, second, SpatialPoint(x: 1, y: 0), first]]])) } - func testCollectionAndItsMembersReadInLowercase() { + @Test func testCollectionAndItsMembersReadInLowercase() { let json = #"{"type":"geometrycollection","geometries":[{"type":"point","coordinates":[1,2]},{"type":"LINESTRING","coordinates":[[3,4],[5,6]]}]}"# - XCTAssertEqual( - geometry(json), - .collection([ - .point(SpatialPoint(x: 1, y: 2)), - .lineString([SpatialPoint(x: 3, y: 4), SpatialPoint(x: 5, y: 6)]), - ]) - ) + #expect(geometry(json) == .collection([ + .point(SpatialPoint(x: 1, y: 2)), + .lineString([SpatialPoint(x: 3, y: 4), SpatialPoint(x: 5, y: 6)]), + ])) } - func testFeatureWrappersReadInLowercase() { - XCTAssertEqual( - geometry(#"{"type":"feature","geometry":{"type":"point","coordinates":[1,2]},"properties":{}}"#), - .point(SpatialPoint(x: 1, y: 2)) - ) - XCTAssertEqual( - geometry(#"{"type":"FEATURECOLLECTION","features":[{"type":"FEATURE","geometry":{"type":"POINT","coordinates":[1,2]}}]}"#), - .point(SpatialPoint(x: 1, y: 2)) - ) + @Test func testFeatureWrappersReadInLowercase() { + #expect(geometry(#"{"type":"feature","geometry":{"type":"point","coordinates":[1,2]},"properties":{}}"#) == .point(SpatialPoint(x: 1, y: 2))) + #expect(geometry(#"{"type":"FEATURECOLLECTION","features":[{"type":"FEATURE","geometry":{"type":"POINT","coordinates":[1,2]}}]}"#) == .point(SpatialPoint(x: 1, y: 2))) } - func testLowercaseTypeStillCarriesWGS84() { - XCTAssertEqual( - GeoJSONGeometryReader.read(#"{"type":"point","coordinates":[1,2]}"#), - .success(SpatialValue(srid: 4_326, geometry: .point(SpatialPoint(x: 1, y: 2)))) - ) + @Test func testLowercaseTypeStillCarriesWGS84() { + #expect(GeoJSONGeometryReader.read(#"{"type":"point","coordinates":[1,2]}"#) == .success(SpatialValue(srid: 4_326, geometry: .point(SpatialPoint(x: 1, y: 2))))) } - func testLowercaseTypeSurvivesTheSniff() { - XCTAssertEqual( - SpatialValueReader.read(#"{"type":"polygon","coordinates":[[[0,0],[10,0],[10,10],[0,0]]]}"#), - GeoJSONGeometryReader.read(#"{"type":"Polygon","coordinates":[[[0,0],[10,0],[10,10],[0,0]]]}"#) - ) + @Test func testLowercaseTypeSurvivesTheSniff() { + let lowercase = SpatialValueReader.read(#"{"type":"polygon","coordinates":[[[0,0],[10,0],[10,10],[0,0]]]}"#) + #expect(lowercase == GeoJSONGeometryReader.read(#"{"type":"Polygon","coordinates":[[[0,0],[10,0],[10,10],[0,0]]]}"#)) } /// Elasticsearch also writes `envelope` and `circle`, which have no GeoJSON shape. Folding case /// must not turn them, or any other name, into one that reads. - func testNamesWithNoShapeStayRefused() { + @Test func testNamesWithNoShapeStayRefused() { for json in [ #"{"type":"envelope","coordinates":[[-10,10],[10,-10]]}"#, #"{"type":"circle","coordinates":[-10,10],"radius":"10m"}"#, #"{"type":"points","coordinates":[1,2]}"#, #"{"type":"","coordinates":[1,2]}"#, ] { - XCTAssertEqual(GeoJSONGeometryReader.read(json), .failure(.notGeometry), json) + #expect(GeoJSONGeometryReader.read(json) == .failure(.notGeometry), "\(json)") } } } diff --git a/Packages/TableProCore/Tests/TableProGeometryTests/SpatialGeometryTests.swift b/Packages/TableProCore/Tests/TableProGeometryTests/SpatialGeometryTests.swift index ffb2a6fa3a..0ce4120465 100644 --- a/Packages/TableProCore/Tests/TableProGeometryTests/SpatialGeometryTests.swift +++ b/Packages/TableProCore/Tests/TableProGeometryTests/SpatialGeometryTests.swift @@ -1,12 +1,13 @@ +import Foundation import TableProGeometry -import XCTest +import Testing /// Imported without `@testable`: the app reads `typeName` from another module, so this file stops /// compiling if the accessor loses `public`. -final class SpatialGeometryTests: XCTestCase { +struct SpatialGeometryTests { private let point = SpatialPoint(x: 1, y: 2) - func testTypeNameForEveryCase() { + @Test func testTypeNameForEveryCase() { let ring = [point, SpatialPoint(x: 3, y: 4), SpatialPoint(x: 5, y: 0), point] let expected: [(geometry: SpatialGeometry, name: String)] = [ (.point(point), "Point"), @@ -19,23 +20,20 @@ final class SpatialGeometryTests: XCTestCase { (.empty, "Empty"), ] for entry in expected { - XCTAssertEqual(entry.geometry.typeName, entry.name) + #expect(entry.geometry.typeName == entry.name) } } /// The name is the type the value declared, whatever is left inside it. - func testTypeNameIgnoresContents() { - XCTAssertEqual(SpatialGeometry.polygon(rings: []).typeName, "Polygon") - XCTAssertEqual(SpatialGeometry.multiPoint([]).typeName, "MultiPoint") - XCTAssertEqual(SpatialGeometry.collection([]).typeName, "GeometryCollection") - XCTAssertEqual(SpatialGeometry.collection([.polygon(rings: [])]).typeName, "GeometryCollection") + @Test func testTypeNameIgnoresContents() { + #expect(SpatialGeometry.polygon(rings: []).typeName == "Polygon") + #expect(SpatialGeometry.multiPoint([]).typeName == "MultiPoint") + #expect(SpatialGeometry.collection([]).typeName == "GeometryCollection") + #expect(SpatialGeometry.collection([.polygon(rings: [])]).typeName == "GeometryCollection") } - func testTypeNameOfAReadValue() { - XCTAssertEqual( - try? WKTGeometryReader.read("MULTIPOLYGON(((0 0,1 0,1 1,0 0)))").get().geometry.typeName, - "MultiPolygon" - ) - XCTAssertEqual(try? WKTGeometryReader.read("POINT EMPTY").get().geometry.typeName, "Empty") + @Test func testTypeNameOfAReadValue() { + #expect((try? WKTGeometryReader.read("MULTIPOLYGON(((0 0,1 0,1 1,0 0)))").get().geometry.typeName) == "MultiPolygon") + #expect((try? WKTGeometryReader.read("POINT EMPTY").get().geometry.typeName) == "Empty") } } diff --git a/Packages/TableProCore/Tests/TableProGeometryTests/SpatialNestingDepthTests.swift b/Packages/TableProCore/Tests/TableProGeometryTests/SpatialNestingDepthTests.swift index e3c483b3b8..201788d153 100644 --- a/Packages/TableProCore/Tests/TableProGeometryTests/SpatialNestingDepthTests.swift +++ b/Packages/TableProCore/Tests/TableProGeometryTests/SpatialNestingDepthTests.swift @@ -1,5 +1,6 @@ +import Foundation @testable import TableProGeometry -import XCTest +import Testing /// Every wire format lets a geometry collection nest without limit, and each level is one more frame /// on the reader's stack, so a few hundred bytes of nothing but collection headers would exhaust it @@ -8,68 +9,66 @@ import XCTest /// The limit counts the reader's own recursion, so a value whose innermost member is a point spends /// one level on that point: the deepest value a reader accepts is `maximumNestingDepth - 1` /// collections around it. -final class SpatialNestingDepthTests: XCTestCase { - func testWKTAcceptsNestingUpToTheLimit() { +struct SpatialNestingDepthTests { + @Test func testWKTAcceptsNestingUpToTheLimit() { let depth = SpatialLimits.maximumNestingDepth - 1 - XCTAssertNotNil(try? WKTGeometryReader.read(Self.nestedWKT(depth: depth)).get()) + #expect((try? WKTGeometryReader.read(Self.nestedWKT(depth: depth)).get()) != nil) } - func testWKTRefusesNestingPastTheLimit() { + @Test func testWKTRefusesNestingPastTheLimit() { let text = Self.nestedWKT(depth: SpatialLimits.maximumNestingDepth) - XCTAssertEqual(WKTGeometryReader.read(text), .failure(.malformed)) + #expect(WKTGeometryReader.read(text) == .failure(.malformed)) } /// The shape a crafted value takes: thousands of levels, and no stack to hold them. - func testWKTRefusesDeepNestingWithoutRecursingIntoIt() { + @Test func testWKTRefusesDeepNestingWithoutRecursingIntoIt() { let text = Self.nestedWKT(depth: 5_000) - XCTAssertEqual(WKTGeometryReader.read(text), .failure(.malformed)) + #expect(WKTGeometryReader.read(text) == .failure(.malformed)) } - func testGeoJSONRefusesNestingPastTheLimit() { - XCTAssertEqual( - GeoJSONGeometryReader.read(Self.nestedGeoJSON(depth: 5_000)), - .failure(.notGeometry) - ) + @Test func testGeoJSONRefusesNestingPastTheLimit() { + #expect(GeoJSONGeometryReader.read(Self.nestedGeoJSON(depth: 5_000)) == .failure(.notGeometry)) } - func testGeoJSONAcceptsNestingUpToTheLimit() { + @Test func testGeoJSONAcceptsNestingUpToTheLimit() { let text = Self.nestedGeoJSON(depth: SpatialLimits.maximumNestingDepth - 1) - XCTAssertNotNil(try? GeoJSONGeometryReader.read(text).get()) + #expect((try? GeoJSONGeometryReader.read(text).get()) != nil) } /// WKB nests the same way, a counted run of children each re-declaring its own byte order. - func testWKBRefusesNestingPastTheLimit() { + @Test func testWKBRefusesNestingPastTheLimit() { var bytes: [UInt8] = [] for _ in 0 ..< 5_000 { bytes += [0x01, 0x07, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00] } bytes += [0x01, 0x01, 0x00, 0x00, 0x00] + [UInt8](repeating: 0, count: 16) - XCTAssertEqual(WKBGeometryReader.read(bytes: bytes), .failure(.malformed)) + #expect(WKBGeometryReader.read(bytes: bytes) == .failure(.malformed)) } /// A ClickHouse tuple nests by bracket alone, so 200 KB of brackets is all it takes. Far past /// the bound on purpose: a reader that recursed into this would overflow the stack. - func testTupleRefusesDeepNestingWithoutRecursingIntoIt() { + @Test func testTupleRefusesDeepNestingWithoutRecursingIntoIt() { for (open, close) in [("[", "]"), ("(", ")")] { let text = Self.nestedTuple(depth: 100_000, open: open, close: close) - XCTAssertNil(SpatialValueReader.readClickHouseTuple(text)) - XCTAssertEqual(SpatialValueReader.read(text), .failure(.notGeometry)) + #expect(SpatialValueReader.readClickHouseTuple(text) == nil) + #expect(SpatialValueReader.read(text) == .failure(.notGeometry)) } } - func testTupleRefusesDeepNestingAroundAPoint() { + @Test func testTupleRefusesDeepNestingAroundAPoint() { let text = String(repeating: "[", count: 100_000) + "(1,2)" + String(repeating: "]", count: 100_000) - XCTAssertEqual(SpatialValueReader.read(text), .failure(.notGeometry)) + #expect(SpatialValueReader.read(text) == .failure(.notGeometry)) } /// The deepest value ClickHouse writes is a MultiPolygon, five levels with its numbers. - func testTupleStillReadsTheDeepestRealValue() { + @Test func testTupleStillReadsTheDeepestRealValue() { let text = "[[[(0,0),(1,0),(1,1),(0,0)],[(0.2,0.2),(0.4,0.2),(0.4,0.4),(0.2,0.2)]]]" guard case .multiPolygon(let polygons)? = SpatialValueReader.readClickHouseTuple(text)?.geometry else { - return XCTFail("expected a multipolygon") + Issue.record("expected a multipolygon") + return } - XCTAssertEqual(polygons.count, 1) - XCTAssertEqual(polygons[0].count, 2) + #expect(polygons.count == 1) + #expect(polygons[0].count == 2) } private static func nestedWKT(depth: Int) -> String { diff --git a/Packages/TableProCore/Tests/TableProGeometryTests/SpatialProjectionTests.swift b/Packages/TableProCore/Tests/TableProGeometryTests/SpatialProjectionTests.swift index 1eaef02ea6..12036f5f9c 100644 --- a/Packages/TableProCore/Tests/TableProGeometryTests/SpatialProjectionTests.swift +++ b/Packages/TableProCore/Tests/TableProGeometryTests/SpatialProjectionTests.swift @@ -1,143 +1,118 @@ +import Foundation @testable import TableProGeometry -import XCTest +import Testing -final class SpatialProjectionTests: XCTestCase { +struct SpatialProjectionTests { private let sanFrancisco = SpatialPoint(x: -122.4194, y: 37.7749) - func testGeographicSRIDsDrawDirectly() { + @Test func testGeographicSRIDsDrawDirectly() { for srid: Int32 in [4_326, 4_269, 4_979] { - XCTAssertEqual( - SpatialProjection.projectability(srid: srid, geometry: .point(sanFrancisco)), - .geographic, - "SRID \(srid) should draw directly" - ) + #expect(SpatialProjection.projectability(srid: srid, geometry: .point(sanFrancisco)) == .geographic, "SRID \(srid) should draw directly") } } /// ETRS89, GDA94, GDA2020, CGCS2000, SIRGAS 2000, NAD83(2011) and JGD2011: degrees on a datum /// within a few metres of WGS84, so they draw as they are stored. - func testRegionalGeographicDatumsDrawDirectly() { + @Test func testRegionalGeographicDatumsDrawDirectly() { for srid: Int32 in [4_258, 4_283, 7_844, 4_490, 4_674, 6_318, 6_668] { - XCTAssertEqual( - SpatialProjection.projectability(srid: srid, geometry: .point(sanFrancisco)), - .geographic, - "SRID \(srid) should draw directly" - ) + #expect(SpatialProjection.projectability(srid: srid, geometry: .point(sanFrancisco)) == .geographic, "SRID \(srid) should draw directly") } } - func testRegionalGeographicDatumReadsThroughEWKT() { + @Test func testRegionalGeographicDatumReadsThroughEWKT() { guard case .success(let value) = WKTGeometryReader.read("SRID=4258;POINT(10.5 52.3)") else { - return XCTFail("expected a geometry") + Issue.record("expected a geometry") + return } let projectability = SpatialProjection.projectability(srid: value.srid, geometry: value.geometry) - XCTAssertEqual(projectability, .geographic) - XCTAssertEqual( - SpatialProjection.project(SpatialPoint(x: 10.5, y: 52.3), using: projectability), - GeographicCoordinate(longitude: 10.5, latitude: 52.3) - ) + #expect(projectability == .geographic) + #expect(SpatialProjection.project(SpatialPoint(x: 10.5, y: 52.3), using: projectability) == GeographicCoordinate(longitude: 10.5, latitude: 52.3)) } - func testWebMercatorAliases() { + @Test func testWebMercatorAliases() { for srid: Int32 in [3_857, 900_913, 102_100, 102_113, 3_785] { - XCTAssertEqual( - SpatialProjection.projectability(srid: srid, geometry: .point(sanFrancisco)), - .webMercator, - "SRID \(srid) should invert as spherical Mercator" - ) + #expect(SpatialProjection.projectability(srid: srid, geometry: .point(sanFrancisco)) == .webMercator, "SRID \(srid) should invert as spherical Mercator") } } /// Verified against PROJ 9.8.1 and re-derived independently: the closed form round-trips to /// zero error at double precision, so no projection library is needed for this family. - func testWebMercatorInverseMatchesPROJ() { + @Test func testWebMercatorInverseMatchesPROJ() { let projected = SpatialPoint(x: -13_627_665.271218073, y: 4_547_675.354340557) guard let coordinate = SpatialProjection.project(projected, using: .webMercator) else { - return XCTFail("expected a coordinate") + Issue.record("expected a coordinate") + return } - XCTAssertEqual(coordinate.longitude, -122.4194, accuracy: 1e-9) - XCTAssertEqual(coordinate.latitude, 37.7749, accuracy: 1e-9) + #expect(abs(coordinate.longitude - (-122.4194)) <= 1e-9) + #expect(abs(coordinate.latitude - (37.7749)) <= 1e-9) } - func testWebMercatorOriginIsNullIsland() { + @Test func testWebMercatorOriginIsNullIsland() { guard let coordinate = SpatialProjection.project( SpatialPoint(x: 0, y: 0), using: .webMercator ) else { - return XCTFail("expected a coordinate") + Issue.record("expected a coordinate") + return } - XCTAssertEqual(coordinate.longitude, 0, accuracy: 1e-12) - XCTAssertEqual(coordinate.latitude, 0, accuracy: 1e-12) + #expect(abs(coordinate.longitude - (0)) <= 1e-12) + #expect(abs(coordinate.latitude - (0)) <= 1e-12) } /// PostGIS writes no prefix for SRID 0 and MySQL stores a literal 0. Degrees are the common /// case for such a column, so values inside the envelope are drawn and the pane says so. - func testAbsentSRIDInsideTheEnvelopeIsAssumedGeographic() { - XCTAssertEqual( - SpatialProjection.projectability(srid: nil, geometry: .point(sanFrancisco)), - .assumedGeographic - ) + @Test func testAbsentSRIDInsideTheEnvelopeIsAssumedGeographic() { + #expect(SpatialProjection.projectability(srid: nil, geometry: .point(sanFrancisco)) == .assumedGeographic) } - func testAbsentSRIDOutsideTheEnvelopeIsRefused() { + @Test func testAbsentSRIDOutsideTheEnvelopeIsRefused() { let projectedMetres = SpatialPoint(x: -13_627_665.27, y: 4_547_675.35) - XCTAssertEqual( - SpatialProjection.projectability(srid: nil, geometry: .point(projectedMetres)), - .unsupported(srid: nil) - ) + #expect(SpatialProjection.projectability(srid: nil, geometry: .point(projectedMetres)) == .unsupported(srid: nil)) } /// One coordinate outside the envelope disqualifies the whole geometry: half a shape drawn in /// the wrong place is worse than a shape reported as undrawable. - func testOneStrayCoordinateDisqualifiesTheGeometry() { + @Test func testOneStrayCoordinateDisqualifiesTheGeometry() { let ring = [ SpatialPoint(x: 0, y: 0), SpatialPoint(x: 1, y: 1), SpatialPoint(x: 500_000, y: 1), ] - XCTAssertEqual( - SpatialProjection.projectability(srid: nil, geometry: .lineString(ring)), - .unsupported(srid: nil) - ) + #expect(SpatialProjection.projectability(srid: nil, geometry: .lineString(ring)) == .unsupported(srid: nil)) } - func testEmptyGeometryIsNotAssumedGeographic() { - XCTAssertEqual( - SpatialProjection.projectability(srid: nil, geometry: .empty), - .unsupported(srid: nil) - ) + @Test func testEmptyGeometryIsNotAssumedGeographic() { + #expect(SpatialProjection.projectability(srid: nil, geometry: .empty) == .unsupported(srid: nil)) } - func testProjectedSRIDIsRefusedByNumber() { - XCTAssertEqual( - SpatialProjection.projectability(srid: 32_633, geometry: .point(sanFrancisco)), - .unsupported(srid: 32_633) - ) - XCTAssertNil(SpatialProjection.project(sanFrancisco, using: .unsupported(srid: 32_633))) + @Test func testProjectedSRIDIsRefusedByNumber() { + #expect(SpatialProjection.projectability(srid: 32_633, geometry: .point(sanFrancisco)) == .unsupported(srid: 32_633)) + #expect(SpatialProjection.project(sanFrancisco, using: .unsupported(srid: 32_633)) == nil) } /// Outside the valid range `MKMapPoint(coordinate)` returns the sentinel (-1, -1) rather than /// wrapping, which is garbage outside `MKMapRectWorld` and destroys an extent fold. Nothing /// invalid may reach a shape. - func testOutOfRangeCoordinatesAreRejectedBeforeAShapeIsBuilt() { - XCTAssertNil(SpatialProjection.project(SpatialPoint(x: 180.0000001, y: 0), using: .geographic)) - XCTAssertNil(SpatialProjection.project(SpatialPoint(x: 0, y: 91), using: .geographic)) - XCTAssertNil(SpatialProjection.project(SpatialPoint(x: -181, y: 0), using: .geographic)) - XCTAssertNil(SpatialProjection.project(SpatialPoint(x: .nan, y: 0), using: .geographic)) - XCTAssertNil(SpatialProjection.project(SpatialPoint(x: .infinity, y: 0), using: .geographic)) + @Test func testOutOfRangeCoordinatesAreRejectedBeforeAShapeIsBuilt() { + #expect(SpatialProjection.project(SpatialPoint(x: 180.0000001, y: 0), using: .geographic) == nil) + #expect(SpatialProjection.project(SpatialPoint(x: 0, y: 91), using: .geographic) == nil) + #expect(SpatialProjection.project(SpatialPoint(x: -181, y: 0), using: .geographic) == nil) + #expect(SpatialProjection.project(SpatialPoint(x: .nan, y: 0), using: .geographic) == nil) + #expect(SpatialProjection.project(SpatialPoint(x: .infinity, y: 0), using: .geographic) == nil) } - func testExactEnvelopeEdgesAreValid() { - XCTAssertNotNil(SpatialProjection.project(SpatialPoint(x: 180, y: 90), using: .geographic)) - XCTAssertNotNil(SpatialProjection.project(SpatialPoint(x: -180, y: -90), using: .geographic)) + @Test func testExactEnvelopeEdgesAreValid() { + #expect(SpatialProjection.project(SpatialPoint(x: 180, y: 90), using: .geographic) != nil) + #expect(SpatialProjection.project(SpatialPoint(x: -180, y: -90), using: .geographic) != nil) } /// The stored order is (x, y) = (longitude, latitude) for every dialect the app reads. - func testProjectionKeepsLongitudeInX() { + @Test func testProjectionKeepsLongitudeInX() { guard let coordinate = SpatialProjection.project(sanFrancisco, using: .geographic) else { - return XCTFail("expected a coordinate") + Issue.record("expected a coordinate") + return } - XCTAssertEqual(coordinate.longitude, -122.4194) - XCTAssertEqual(coordinate.latitude, 37.7749) + #expect(coordinate.longitude == -122.4194) + #expect(coordinate.latitude == 37.7749) } } diff --git a/Packages/TableProCore/Tests/TableProGeometryTests/SpatialValueReaderTests.swift b/Packages/TableProCore/Tests/TableProGeometryTests/SpatialValueReaderTests.swift index 6345b27943..73091fea8b 100644 --- a/Packages/TableProCore/Tests/TableProGeometryTests/SpatialValueReaderTests.swift +++ b/Packages/TableProCore/Tests/TableProGeometryTests/SpatialValueReaderTests.swift @@ -1,201 +1,179 @@ +import Foundation @testable import TableProGeometry -import XCTest +import Testing /// The sniffer is what makes a `.spatial` column work without the app knowing which engine it came /// from. A PostGIS column reports `geometry` whether the value arrived as EWKT or, when the /// `ST_AsEWKT` rewrite failed, as raw EWKB hex, so the value has to decide. -final class SpatialValueReaderTests: XCTestCase { +struct SpatialValueReaderTests { private func geometry( _ text: String, - file: StaticString = #filePath, - line: UInt = #line + sourceLocation: SourceLocation = #_sourceLocation ) -> SpatialGeometry? { switch SpatialValueReader.read(text) { case .success(let value): return value.geometry case .failure(let failure): - XCTFail("expected a geometry, got \(failure)", file: file, line: line) + Issue.record("expected a geometry, got \(failure)", sourceLocation: sourceLocation) return nil } } - func testEWKTIsRouted() { - XCTAssertEqual(geometry("SRID=4326;POINT(-122.4194 37.7749)"), - .point(SpatialPoint(x: -122.4194, y: 37.7749))) + @Test func testEWKTIsRouted() { + #expect(geometry("SRID=4326;POINT(-122.4194 37.7749)") == .point(SpatialPoint(x: -122.4194, y: 37.7749))) } /// This is the PostGIS fallback path: the column still says `geometry` but the rewrite failed /// and the cell holds raw hex. - func testEWKBHexIsRouted() { - XCTAssertEqual(geometry("0101000020E610000050FC1873D79A5EC0D0D556EC2FE34240"), - .point(SpatialPoint(x: -122.4194, y: 37.7749))) + @Test func testEWKBHexIsRouted() { + #expect(geometry("0101000020E610000050FC1873D79A5EC0D0D556EC2FE34240") == .point(SpatialPoint(x: -122.4194, y: 37.7749))) } - func testGeoJSONIsRouted() { - XCTAssertEqual( - geometry(#"{"type":"Point","coordinates":[-122.4194,37.7749]}"#), - .point(SpatialPoint(x: -122.4194, y: 37.7749)) - ) + @Test func testGeoJSONIsRouted() { + #expect(geometry(#"{"type":"Point","coordinates":[-122.4194,37.7749]}"#) == .point(SpatialPoint(x: -122.4194, y: 37.7749))) } - func testGeoJSONCarriesWGS84() { + @Test func testGeoJSONCarriesWGS84() { guard case .success(let value) = SpatialValueReader.read( #"{"type":"Point","coordinates":[1,2]}"# ) else { - return XCTFail("expected a geometry") + Issue.record("expected a geometry") + return } - XCTAssertEqual(value.srid, 4_326) + #expect(value.srid == 4_326) } - func testGeoJSONPolygonWithHole() { + @Test func testGeoJSONPolygonWithHole() { let json = #"{"type":"Polygon","coordinates":[[[0,0],[0,10],[10,10],[0,0]],[[2,2],[2,4],[4,4],[2,2]]]}"# guard case .polygon(let rings)? = geometry(json) else { - return XCTFail("expected a polygon") + Issue.record("expected a polygon") + return } - XCTAssertEqual(rings.count, 2) + #expect(rings.count == 2) } - func testGeoJSONFeatureUnwraps() { + @Test func testGeoJSONFeatureUnwraps() { let json = #"{"type":"Feature","geometry":{"type":"Point","coordinates":[1,2]},"properties":{"a":1}}"# - XCTAssertEqual(geometry(json), .point(SpatialPoint(x: 1, y: 2))) + #expect(geometry(json) == .point(SpatialPoint(x: 1, y: 2))) } /// A GeoJSON altitude is dropped, which is what `MKGeoJSONDecoder` does with the same input. - func testGeoJSONThirdOrdinateIsDropped() { - XCTAssertEqual( - geometry(#"{"type":"Point","coordinates":[1,2,3]}"#), - .point(SpatialPoint(x: 1, y: 2)) - ) + @Test func testGeoJSONThirdOrdinateIsDropped() { + #expect(geometry(#"{"type":"Point","coordinates":[1,2,3]}"#) == .point(SpatialPoint(x: 1, y: 2))) } /// ClickHouse streams TabSeparatedWithNamesAndTypes, so a Point is a bare tuple literal. - func testClickHousePointTuple() { - XCTAssertEqual(geometry("(-122.4194,37.7749)"), - .point(SpatialPoint(x: -122.4194, y: 37.7749))) + @Test func testClickHousePointTuple() { + #expect(geometry("(-122.4194,37.7749)") == .point(SpatialPoint(x: -122.4194, y: 37.7749))) } - func testClickHouseRingBecomesAPolygon() { + @Test func testClickHouseRingBecomesAPolygon() { guard case .polygon(let rings)? = geometry("((0,0),(1,0),(1,1),(0,0))") else { - return XCTFail("expected a polygon") + Issue.record("expected a polygon") + return } - XCTAssertEqual(rings.count, 1) - XCTAssertEqual(rings[0].count, 4) + #expect(rings.count == 1) + #expect(rings[0].count == 4) } - func testClickHousePolygonWithHole() { + @Test func testClickHousePolygonWithHole() { guard case .polygon(let rings)? = geometry("(((0,0),(4,0),(4,4),(0,0)),((1,1),(2,1),(2,2),(1,1)))") else { - return XCTFail("expected a polygon") + Issue.record("expected a polygon") + return } - XCTAssertEqual(rings.count, 2) + #expect(rings.count == 2) } /// Elasticsearch's `"lat,lon"` string is the one spelling in the whole reader that is /// latitude-first, and Elasticsearch documents it that way. - func testElasticsearchLatLonStringIsLatitudeFirst() { - XCTAssertEqual( - geometry("37.7749,-122.4194"), - .point(SpatialPoint(x: -122.4194, y: 37.7749)) - ) + @Test func testElasticsearchLatLonStringIsLatitudeFirst() { + #expect(geometry("37.7749,-122.4194") == .point(SpatialPoint(x: -122.4194, y: 37.7749))) } - func testElasticsearchStringRejectsOutOfRangePairs() { - XCTAssertNil(SpatialValueReader.readElasticsearchGeoPoint("200,100")) - XCTAssertNil(SpatialValueReader.readElasticsearchGeoPoint("hello,world")) - XCTAssertNil(SpatialValueReader.readElasticsearchGeoPoint("1,2,3")) + @Test func testElasticsearchStringRejectsOutOfRangePairs() { + #expect(SpatialValueReader.readElasticsearchGeoPoint("200,100") == nil) + #expect(SpatialValueReader.readElasticsearchGeoPoint("hello,world") == nil) + #expect(SpatialValueReader.readElasticsearchGeoPoint("1,2,3") == nil) } /// ClickHouse writes a `Tuple` in parentheses and an `Array` in square brackets, which is what /// `ClickHouseTabSeparatedRowDecoderTests` already pins for ordinary arrays. Requiring /// parentheses at every level rejected every `Ring`, `Polygon` and `MultiPolygon` the driver /// hands over. - func testClickHouseRingUsesSquareBrackets() { + @Test func testClickHouseRingUsesSquareBrackets() { guard case .polygon(let rings)? = geometry("[(0,0),(4,0),(4,4),(0,0)]") else { - return XCTFail("expected a ring") + Issue.record("expected a ring") + return } - XCTAssertEqual(rings.count, 1) - XCTAssertEqual(rings[0].count, 4) - XCTAssertEqual(rings[0][1], SpatialPoint(x: 4, y: 0)) + #expect(rings.count == 1) + #expect(rings[0].count == 4) + #expect(rings[0][1] == SpatialPoint(x: 4, y: 0)) } - func testClickHousePolygonUsesSquareBracketsAtEveryLevel() { + @Test func testClickHousePolygonUsesSquareBracketsAtEveryLevel() { guard case .polygon(let rings)? = geometry("[[(0,0),(4,0),(4,4),(0,0)],[(1,1),(2,1),(2,2),(1,1)]]") else { - return XCTFail("expected a polygon with a hole") + Issue.record("expected a polygon with a hole") + return } - XCTAssertEqual(rings.count, 2) + #expect(rings.count == 2) } - func testClickHouseMultiPolygonUsesSquareBrackets() { + @Test func testClickHouseMultiPolygonUsesSquareBrackets() { let text = "[[[(0,0),(1,0),(1,1),(0,0)]],[[(5,5),(6,5),(6,6),(5,5)]]]" guard case .multiPolygon(let polygons)? = geometry(text) else { - return XCTFail("expected a multipolygon") + Issue.record("expected a multipolygon") + return } - XCTAssertEqual(polygons.count, 2) + #expect(polygons.count == 2) } /// Elasticsearch's object form is latitude-first, as Elasticsearch documents it, while its array /// form is longitude-first to match GeoJSON. Reading one as the other puts the point in the /// wrong hemisphere, so both are pinned. - func testElasticsearchObjectFormIsLatitudeFirst() { - XCTAssertEqual( - geometry(#"{"lat":37.7749,"lon":-122.4194}"#), - .point(SpatialPoint(x: -122.4194, y: 37.7749)) - ) - XCTAssertEqual( - geometry(#"{"lat": 37.7749, "long": -122.4194}"#), - .point(SpatialPoint(x: -122.4194, y: 37.7749)) - ) + @Test func testElasticsearchObjectFormIsLatitudeFirst() { + #expect(geometry(#"{"lat":37.7749,"lon":-122.4194}"#) == .point(SpatialPoint(x: -122.4194, y: 37.7749))) + #expect(geometry(#"{"lat": 37.7749, "long": -122.4194}"#) == .point(SpatialPoint(x: -122.4194, y: 37.7749))) } - func testElasticsearchArrayFormIsLongitudeFirst() { - XCTAssertEqual( - geometry("[-122.4194,37.7749]"), - .point(SpatialPoint(x: -122.4194, y: 37.7749)) - ) + @Test func testElasticsearchArrayFormIsLongitudeFirst() { + #expect(geometry("[-122.4194,37.7749]") == .point(SpatialPoint(x: -122.4194, y: 37.7749))) } /// A geohash resolves to the centre of its cell, which is what Elasticsearch itself does. /// `9q8yyk8ytpxr` is San Francisco; twelve characters places it within a metre. - func testElasticsearchGeohashResolvesToTheCellCentre() { + @Test func testElasticsearchGeohashResolvesToTheCellCentre() { guard case .point(let point)? = geometry("9q8yyk8ytpxr") else { - return XCTFail("expected a point") + Issue.record("expected a point") + return } - XCTAssertEqual(point.y, 37.7749, accuracy: 0.0001) - XCTAssertEqual(point.x, -122.4194, accuracy: 0.0001) + #expect(abs(point.y - (37.7749)) <= 0.0001) + #expect(abs(point.x - (-122.4194)) <= 0.0001) } - func testElasticsearchGeohashRejectsLettersOutsideItsAlphabet() { - XCTAssertNil(SpatialValueReader.readElasticsearchGeoPoint("aio1")) - XCTAssertNil(SpatialValueReader.readElasticsearchGeoPoint("9q8yyk8ytpxr9q8yy")) + @Test func testElasticsearchGeohashRejectsLettersOutsideItsAlphabet() { + #expect(SpatialValueReader.readElasticsearchGeoPoint("aio1") == nil) + #expect(SpatialValueReader.readElasticsearchGeoPoint("9q8yyk8ytpxr9q8yy") == nil) } - func testOrdinaryTextIsRefused() { - XCTAssertEqual(SpatialValueReader.read("hello"), .failure(.notGeometry)) - XCTAssertEqual(SpatialValueReader.read(" "), .failure(.notGeometry)) - XCTAssertEqual(SpatialValueReader.read("2024-01-01"), .failure(.notGeometry)) + @Test func testOrdinaryTextIsRefused() { + #expect(SpatialValueReader.read("hello") == .failure(.notGeometry)) + #expect(SpatialValueReader.read(" ") == .failure(.notGeometry)) + #expect(SpatialValueReader.read("2024-01-01") == .failure(.notGeometry)) } /// A refused keyword keeps its name all the way through the sniffer, so the pane can print it. - func testUnsupportedKeywordSurvivesTheSniff() { - XCTAssertEqual( - SpatialValueReader.read("CIRCULARSTRING(0 0,1 1,2 0)"), - .failure(.unsupportedGeometryType("CIRCULARSTRING")) - ) + @Test func testUnsupportedKeywordSurvivesTheSniff() { + #expect(SpatialValueReader.read("CIRCULARSTRING(0 0,1 1,2 0)") == .failure(.unsupportedGeometryType("CIRCULARSTRING"))) } /// `CIRCULARSTRINGM` does not open like a type the WKT reader takes, so it reaches the last read /// by falling through every other reader, and has to come out named. - func testUnsupportedKeywordWithAFusedTagSurvivesTheSniff() { - XCTAssertEqual( - SpatialValueReader.read("CIRCULARSTRINGM(0 0 1,1 1 1,2 0 1)"), - .failure(.unsupportedGeometryType("CIRCULARSTRING")) - ) - XCTAssertEqual( - SpatialValueReader.read("SRID=4326;TINZM(((0 0 0 1,0 0 1 1,0 1 0 1,0 0 0 1)))"), - .failure(.unsupportedGeometryType("TIN")) - ) - } - - func testSurroundingWhitespaceIsIgnored() { - XCTAssertEqual(geometry(" POINT(1 2)\n"), .point(SpatialPoint(x: 1, y: 2))) + @Test func testUnsupportedKeywordWithAFusedTagSurvivesTheSniff() { + #expect(SpatialValueReader.read("CIRCULARSTRINGM(0 0 1,1 1 1,2 0 1)") == .failure(.unsupportedGeometryType("CIRCULARSTRING"))) + #expect(SpatialValueReader.read("SRID=4326;TINZM(((0 0 0 1,0 0 1 1,0 1 0 1,0 0 0 1)))") == .failure(.unsupportedGeometryType("TIN"))) + } + + @Test func testSurroundingWhitespaceIsIgnored() { + #expect(geometry(" POINT(1 2)\n") == .point(SpatialPoint(x: 1, y: 2))) } } diff --git a/Packages/TableProCore/Tests/TableProGeometryTests/WKBGeometryReaderTests.swift b/Packages/TableProCore/Tests/TableProGeometryTests/WKBGeometryReaderTests.swift index 9fb59389f4..8b6fad4335 100644 --- a/Packages/TableProCore/Tests/TableProGeometryTests/WKBGeometryReaderTests.swift +++ b/Packages/TableProCore/Tests/TableProGeometryTests/WKBGeometryReaderTests.swift @@ -1,20 +1,20 @@ +import Foundation @testable import TableProGeometry -import XCTest +import Testing /// Every hex fixture here came off a live server during the investigation of #2532. The PostGIS /// ones are the wire bytes libpq hands over before the `ST_AsEWKT` rewrite runs; the MySQL and /// MariaDB ones are what `libmariadb` returns for a geometry column. -final class WKBGeometryReaderTests: XCTestCase { +struct WKBGeometryReaderTests { private func value( hex: String, - file: StaticString = #filePath, - line: UInt = #line + sourceLocation: SourceLocation = #_sourceLocation ) -> SpatialValue? { switch WKBGeometryReader.read(hex: hex) { case .success(let value): return value case .failure(let failure): - XCTFail("expected a geometry, got \(failure)", file: file, line: line) + Issue.record("expected a geometry, got \(failure)", sourceLocation: sourceLocation) return nil } } @@ -25,88 +25,83 @@ final class WKBGeometryReaderTests: XCTestCase { /// Francisco. MySQL's `ST_AsText` prints this value latitude-first, so the plausible-looking /// "make the parser agree with ST_AsText" change would swap these two numbers and move every /// MySQL point into the Southern Ocean. The storage is longitude-first and this test says so. - func testMySQLInternalIsLongitudeFirst() { + @Test func testMySQLInternalIsLongitudeFirst() { let hex = "E6100000010100000050FC1873D79A5EC0D0D556EC2FE34240" - guard let bytes = Self.bytes(hex) else { return XCTFail("bad fixture") } + guard let bytes = Self.bytes(hex) else { Issue.record("bad fixture"); return } guard case .success(let parsed) = WKBGeometryReader.read(mysqlInternal: bytes) else { - return XCTFail("expected a geometry") + Issue.record("expected a geometry") + return } - XCTAssertEqual(parsed.srid, 4_326) - guard case .point(let point) = parsed.geometry else { return XCTFail("expected a point") } - XCTAssertEqual(point.x, -122.4194, accuracy: 1e-9, "x must be LONGITUDE") - XCTAssertEqual(point.y, 37.7749, accuracy: 1e-9, "y must be LATITUDE") + #expect(parsed.srid == 4_326) + guard case .point(let point) = parsed.geometry else { Issue.record("expected a point"); return } + #expect(abs(point.x - (-122.4194)) <= 1e-9, "x must be LONGITUDE") + #expect(abs(point.y - (37.7749)) <= 1e-9, "y must be LATITUDE") } /// MySQL writes a literal 0 for "no SRID", which means unknown rather than a system numbered 0. - func testMySQLInternalZeroSRIDReadsAsAbsent() { + @Test func testMySQLInternalZeroSRIDReadsAsAbsent() { let hex = "00000000010100000050FC1873D79A5EC0D0D556EC2FE34240" - guard let bytes = Self.bytes(hex) else { return XCTFail("bad fixture") } + guard let bytes = Self.bytes(hex) else { Issue.record("bad fixture"); return } guard case .success(let parsed) = WKBGeometryReader.read(mysqlInternal: bytes) else { - return XCTFail("expected a geometry") + Issue.record("expected a geometry") + return } - XCTAssertNil(parsed.srid) + #expect(parsed.srid == nil) } - func testPostGISEWKBSRIDFlag() { + @Test func testPostGISEWKBSRIDFlag() { let parsed = value(hex: "0101000020E610000050FC1873D79A5EC0D0D556EC2FE34240") - XCTAssertEqual(parsed?.srid, 4_326) - guard case .point(let point)? = parsed?.geometry else { return XCTFail("expected a point") } - XCTAssertEqual(point.x, -122.4194, accuracy: 1e-9) + #expect(parsed?.srid == 4_326) + guard case .point(let point)? = parsed?.geometry else { Issue.record("expected a point"); return } + #expect(abs(point.x - (-122.4194)) <= 1e-9) } - func testPlainWKBWithoutSRID() { + @Test func testPlainWKBWithoutSRID() { let parsed = value(hex: "0101000000000000000000F03F0000000000000040") - XCTAssertNil(parsed?.srid) - XCTAssertEqual(parsed?.geometry, .point(SpatialPoint(x: 1, y: 2))) + #expect(parsed?.srid == nil) + #expect(parsed?.geometry == .point(SpatialPoint(x: 1, y: 2))) } - func testBigEndianByteOrder() { + @Test func testBigEndianByteOrder() { let parsed = value(hex: "00000000013FF00000000000004000000000000000") - XCTAssertEqual(parsed?.geometry, .point(SpatialPoint(x: 1, y: 2))) + #expect(parsed?.geometry == .point(SpatialPoint(x: 1, y: 2))) } /// `POINT EMPTY` has no zero-length body in the format; every producer writes two NaN doubles. - func testNaNPointReadsAsEmpty() { + @Test func testNaNPointReadsAsEmpty() { let parsed = value(hex: "0101000000000000000000F87F000000000000F87F") - XCTAssertEqual(parsed?.geometry, .empty) + #expect(parsed?.geometry == .empty) } /// ISO WKB adds 1000/2000/3000 to the type for Z, M and ZM; PostGIS EWKB sets high bits. Both /// dialects reach the app from the same column. - func testISOAndEWKBDimensionEncodings() { - XCTAssertEqual( - value(hex: "01E9030000000000000000F03F00000000000000400000000000000840")?.geometry, - .point(SpatialPoint(x: 1, y: 2)) - ) - XCTAssertEqual( - value(hex: "0101000080000000000000F03F00000000000000400000000000000840")?.geometry, - .point(SpatialPoint(x: 1, y: 2)) - ) - XCTAssertEqual( - value(hex: "0101000040000000000000F03F00000000000000400000000000000840")?.geometry, - .point(SpatialPoint(x: 1, y: 2)) - ) + @Test func testISOAndEWKBDimensionEncodings() { + #expect(value(hex: "01E9030000000000000000F03F00000000000000400000000000000840")?.geometry == .point(SpatialPoint(x: 1, y: 2))) + #expect(value(hex: "0101000080000000000000F03F00000000000000400000000000000840")?.geometry == .point(SpatialPoint(x: 1, y: 2))) + #expect(value(hex: "0101000040000000000000F03F00000000000000400000000000000840")?.geometry == .point(SpatialPoint(x: 1, y: 2))) } /// Verbatim from MySQL 8.4.11: /// `HEX(ST_GeomFromText('POLYGON((0 0,4 0,4 4,0 4,0 0),(1 1,2 1,2 2,1 2,1 1))',0))`. /// It carries the 4-byte SRID prefix, so it reads through the MySQL entry point. - func testPolygonWithInteriorRing() { + @Test func testPolygonWithInteriorRing() { let hex = "00000000010300000002000000050000000000000000000000000000000000000000000000" + "0000104000000000000000000000000000001040000000000000104000000000000000000000" + "0000000010400000000000000000000000000000000005000000000000000000F03F00000000" + "0000F03F0000000000000040000000000000F03F000000000000004000000000000000400000" + "00000000F03F0000000000000040000000000000F03F000000000000F03F" - guard let bytes = Self.bytes(hex) else { return XCTFail("bad fixture") } + guard let bytes = Self.bytes(hex) else { Issue.record("bad fixture"); return } guard case .success(let parsed) = WKBGeometryReader.read(mysqlInternal: bytes) else { - return XCTFail("expected a geometry") + Issue.record("expected a geometry") + return } guard case .polygon(let rings) = parsed.geometry else { - return XCTFail("expected a polygon") + Issue.record("expected a polygon") + return } - XCTAssertEqual(rings.count, 2) - XCTAssertEqual(rings[0].count, 5) - XCTAssertEqual(rings[1][0], SpatialPoint(x: 1, y: 1)) + #expect(rings.count == 2) + #expect(rings[0].count == 5) + #expect(rings[1][0] == SpatialPoint(x: 1, y: 1)) } /// A nested geometry re-declares its own byte order. A reader that inherits the parent's is @@ -114,59 +109,49 @@ final class WKBGeometryReaderTests: XCTestCase { /// /// Verbatim from MySQL 8.4.11: /// `HEX(ST_GeomFromText('GEOMETRYCOLLECTION(POINT(1 2),LINESTRING(3 4,5 6))',0))`. - func testCollectionMembersDeclareTheirOwnByteOrder() { + @Test func testCollectionMembersDeclareTheirOwnByteOrder() { let hex = "000000000107000000020000000101000000000000000000F03F000000000000004001020000" + "00020000000000000000000840000000000000104000000000000014400000000000001840" - guard let bytes = Self.bytes(hex) else { return XCTFail("bad fixture") } + guard let bytes = Self.bytes(hex) else { Issue.record("bad fixture"); return } guard case .success(let parsed) = WKBGeometryReader.read(mysqlInternal: bytes) else { - return XCTFail("expected a geometry") + Issue.record("expected a geometry") + return } guard case .collection(let children) = parsed.geometry else { - return XCTFail("expected a collection") + Issue.record("expected a collection") + return } - XCTAssertEqual(children.count, 2) - XCTAssertEqual(children[0], .point(SpatialPoint(x: 1, y: 2))) - XCTAssertEqual( - children[1], - .lineString([SpatialPoint(x: 3, y: 4), SpatialPoint(x: 5, y: 6)]) - ) + #expect(children.count == 2) + #expect(children[0] == .point(SpatialPoint(x: 1, y: 2))) + #expect(children[1] == .lineString([SpatialPoint(x: 3, y: 4), SpatialPoint(x: 5, y: 6)])) } - func testTruncatedBufferIsMalformedRatherThanACrash() { - XCTAssertEqual(WKBGeometryReader.read(hex: "0101000000000000000000F03F"), .failure(.malformed)) - XCTAssertEqual(WKBGeometryReader.read(hex: "0101"), .failure(.malformed)) + @Test func testTruncatedBufferIsMalformedRatherThanACrash() { + #expect(WKBGeometryReader.read(hex: "0101000000000000000000F03F") == .failure(.malformed)) + #expect(WKBGeometryReader.read(hex: "0101") == .failure(.malformed)) } /// A declared count far larger than the bytes remaining must be refused before it is used to /// reserve capacity. - func testAbsurdPointCountIsRefused() { - XCTAssertEqual( - WKBGeometryReader.read(hex: "0102000000FFFFFF7F0000000000000000"), - .failure(.malformed) - ) - } - - func testCurvedTypesAreNamed() { - XCTAssertEqual( - WKBGeometryReader.read(hex: "010800000000000000"), - .failure(.unsupportedGeometryType("CIRCULARSTRING")) - ) - XCTAssertEqual( - WKBGeometryReader.read(hex: "011000000000000000"), - .failure(.unsupportedGeometryType("TIN")) - ) - } - - func testNonHexIsNotAGeometry() { - XCTAssertEqual(WKBGeometryReader.read(hex: "not hex at all"), .failure(.notGeometry)) - XCTAssertEqual(WKBGeometryReader.read(hex: "0101000"), .failure(.notGeometry)) - } - - func testLooksLikeHexGatesTheSniffer() { - XCTAssertTrue(WKBGeometryReader.looksLikeHex("0101000020E6100000")) - XCTAssertFalse(WKBGeometryReader.looksLikeHex("POINT(1 2)")) - XCTAssertFalse(WKBGeometryReader.looksLikeHex("DEADBE")) - XCTAssertFalse(WKBGeometryReader.looksLikeHex("0101000020E610000")) + @Test func testAbsurdPointCountIsRefused() { + #expect(WKBGeometryReader.read(hex: "0102000000FFFFFF7F0000000000000000") == .failure(.malformed)) + } + + @Test func testCurvedTypesAreNamed() { + #expect(WKBGeometryReader.read(hex: "010800000000000000") == .failure(.unsupportedGeometryType("CIRCULARSTRING"))) + #expect(WKBGeometryReader.read(hex: "011000000000000000") == .failure(.unsupportedGeometryType("TIN"))) + } + + @Test func testNonHexIsNotAGeometry() { + #expect(WKBGeometryReader.read(hex: "not hex at all") == .failure(.notGeometry)) + #expect(WKBGeometryReader.read(hex: "0101000") == .failure(.notGeometry)) + } + + @Test func testLooksLikeHexGatesTheSniffer() { + #expect(WKBGeometryReader.looksLikeHex("0101000020E6100000")) + #expect(!(WKBGeometryReader.looksLikeHex("POINT(1 2)"))) + #expect(!WKBGeometryReader.looksLikeHex("DEADBE")) + #expect(!WKBGeometryReader.looksLikeHex("0101000020E610000")) } private static func bytes(_ hex: String) -> [UInt8]? { diff --git a/Packages/TableProCore/Tests/TableProGeometryTests/WKTGeometryReaderTests.swift b/Packages/TableProCore/Tests/TableProGeometryTests/WKTGeometryReaderTests.swift index d674e9c4e5..9fef663ba0 100644 --- a/Packages/TableProCore/Tests/TableProGeometryTests/WKTGeometryReaderTests.swift +++ b/Packages/TableProCore/Tests/TableProGeometryTests/WKTGeometryReaderTests.swift @@ -1,236 +1,211 @@ +import Foundation @testable import TableProGeometry -import XCTest +import Testing /// Every spelling here was produced by a live server in this repository's investigation of #2532: /// PostGIS 3.6.4, MySQL 8.4.11, MariaDB 12.3.3 and DuckDB 1.5.4. They disagree with each other, so /// the table is the specification. -final class WKTGeometryReaderTests: XCTestCase { - private func value(_ text: String, file: StaticString = #filePath, line: UInt = #line) -> SpatialValue? { +struct WKTGeometryReaderTests { + private func value(_ text: String, sourceLocation: SourceLocation = #_sourceLocation) -> SpatialValue? { switch WKTGeometryReader.read(text) { case .success(let value): return value case .failure(let failure): - XCTFail("expected a geometry, got \(failure)", file: file, line: line) + Issue.record("expected a geometry, got \(failure)", sourceLocation: sourceLocation) return nil } } private func failure( _ text: String, - file: StaticString = #filePath, - line: UInt = #line + sourceLocation: SourceLocation = #_sourceLocation ) -> SpatialReadFailure? { switch WKTGeometryReader.read(text) { case .success(let value): - XCTFail("expected a failure, got \(value)", file: file, line: line) + Issue.record("expected a failure, got \(value)", sourceLocation: sourceLocation) return nil case .failure(let failure): return failure } } - func testPostGISEWKTCarriesItsSRID() { + @Test func testPostGISEWKTCarriesItsSRID() { let parsed = value("SRID=4326;POINT(-122.4194 37.7749)") - XCTAssertEqual(parsed?.srid, 4_326) - XCTAssertEqual(parsed?.geometry, .point(SpatialPoint(x: -122.4194, y: 37.7749))) + #expect(parsed?.srid == 4_326) + #expect(parsed?.geometry == .point(SpatialPoint(x: -122.4194, y: 37.7749))) } /// PostGIS writes the prefix only when the SRID is non-zero, so a bare geometry is the normal /// shape for an unconstrained column rather than a malformed one. - func testBareWKTHasNoSRID() { + @Test func testBareWKTHasNoSRID() { let parsed = value("POINT(1 2)") - XCTAssertNil(parsed?.srid) - XCTAssertEqual(parsed?.geometry, .point(SpatialPoint(x: 1, y: 2))) + #expect(parsed?.srid == nil) + #expect(parsed?.geometry == .point(SpatialPoint(x: 1, y: 2))) } - func testNegativeAndScientificOrdinates() { - XCTAssertEqual( - value("POINT(-1.5e2 +2.25)")?.geometry, - .point(SpatialPoint(x: -150, y: 2.25)) - ) + @Test func testNegativeAndScientificOrdinates() { + #expect(value("POINT(-1.5e2 +2.25)")?.geometry == .point(SpatialPoint(x: -150, y: 2.25))) } /// PostGIS writes XYZ with no tag at all, so the dimensionality is only visible in the count. - func testBareThreeOrdinatesDropTheThird() { - XCTAssertEqual(value("POINT(1 2 3)")?.geometry, .point(SpatialPoint(x: 1, y: 2))) + @Test func testBareThreeOrdinatesDropTheThird() { + #expect(value("POINT(1 2 3)")?.geometry == .point(SpatialPoint(x: 1, y: 2))) } /// PostGIS writes XYM as POINTM, not as the OGC "POINT M (". - func testPostGISPointM() { - XCTAssertEqual(value("POINTM(1 2 3)")?.geometry, .point(SpatialPoint(x: 1, y: 2))) + @Test func testPostGISPointM() { + #expect(value("POINTM(1 2 3)")?.geometry == .point(SpatialPoint(x: 1, y: 2))) } - func testOGCSeparatedDimensionTags() { - XCTAssertEqual(value("POINT Z (1 2 3)")?.geometry, .point(SpatialPoint(x: 1, y: 2))) - XCTAssertEqual(value("POINT ZM (1 2 3 4)")?.geometry, .point(SpatialPoint(x: 1, y: 2))) - XCTAssertEqual(value("POINT M (1 2 3)")?.geometry, .point(SpatialPoint(x: 1, y: 2))) + @Test func testOGCSeparatedDimensionTags() { + #expect(value("POINT Z (1 2 3)")?.geometry == .point(SpatialPoint(x: 1, y: 2))) + #expect(value("POINT ZM (1 2 3 4)")?.geometry == .point(SpatialPoint(x: 1, y: 2))) + #expect(value("POINT M (1 2 3)")?.geometry == .point(SpatialPoint(x: 1, y: 2))) } - func testFourOrdinates() { - XCTAssertEqual(value("POINT(1 2 3 4)")?.geometry, .point(SpatialPoint(x: 1, y: 2))) + @Test func testFourOrdinates() { + #expect(value("POINT(1 2 3 4)")?.geometry == .point(SpatialPoint(x: 1, y: 2))) } /// DuckDB 1.5.4 puts a space before the paren. Nothing else does. - func testDuckDBSpaceBeforeParen() { - XCTAssertEqual(value("POINT (1 2)")?.geometry, .point(SpatialPoint(x: 1, y: 2))) - XCTAssertEqual(value("POINT Z (1 2 3)")?.geometry, .point(SpatialPoint(x: 1, y: 2))) + @Test func testDuckDBSpaceBeforeParen() { + #expect(value("POINT (1 2)")?.geometry == .point(SpatialPoint(x: 1, y: 2))) + #expect(value("POINT Z (1 2 3)")?.geometry == .point(SpatialPoint(x: 1, y: 2))) } /// Both MULTIPOINT spellings are legal and both ship: PostGIS writes the bare form, MySQL the /// parenthesised one. - func testBothMultiPointSpellings() { + @Test func testBothMultiPointSpellings() { let expected = SpatialGeometry.multiPoint([SpatialPoint(x: 1, y: 2), SpatialPoint(x: 3, y: 4)]) - XCTAssertEqual(value("MULTIPOINT(1 2,3 4)")?.geometry, expected) - XCTAssertEqual(value("MULTIPOINT((1 2),(3 4))")?.geometry, expected) - XCTAssertEqual(value("MULTIPOINT (1 2, 3 4)")?.geometry, expected) + #expect(value("MULTIPOINT(1 2,3 4)")?.geometry == expected) + #expect(value("MULTIPOINT((1 2),(3 4))")?.geometry == expected) + #expect(value("MULTIPOINT (1 2, 3 4)")?.geometry == expected) } - func testEmptyAtEveryLevel() { - XCTAssertEqual(value("POINT EMPTY")?.geometry, .empty) - XCTAssertEqual(value("GEOMETRYCOLLECTION EMPTY")?.geometry, .empty) - XCTAssertEqual(value("POLYGON EMPTY")?.geometry, .empty) - XCTAssertEqual(value("MULTIPOLYGON EMPTY")?.geometry, .empty) + @Test func testEmptyAtEveryLevel() { + #expect(value("POINT EMPTY")?.geometry == .empty) + #expect(value("GEOMETRYCOLLECTION EMPTY")?.geometry == .empty) + #expect(value("POLYGON EMPTY")?.geometry == .empty) + #expect(value("MULTIPOLYGON EMPTY")?.geometry == .empty) } /// MySQL 8.4 emits this for an empty collection. No grammar allows it, and it still has to read. - func testMySQLInvalidEmptyCollection() { - XCTAssertEqual(value("GEOMETRYCOLLECTION()")?.geometry, .empty) + @Test func testMySQLInvalidEmptyCollection() { + #expect(value("GEOMETRYCOLLECTION()")?.geometry == .empty) } /// MySQL 8.0.11 renamed the type; its catalog and its WKT both say GEOMCOLLECTION. - func testMySQLGeomCollectionSpelling() { - XCTAssertEqual( - value("GEOMCOLLECTION(POINT(1 2))")?.geometry, - .collection([.point(SpatialPoint(x: 1, y: 2))]) - ) + @Test func testMySQLGeomCollectionSpelling() { + #expect(value("GEOMCOLLECTION(POINT(1 2))")?.geometry == .collection([.point(SpatialPoint(x: 1, y: 2))])) } - func testPolygonWithHole() { + @Test func testPolygonWithHole() { let parsed = value("POLYGON((0 0,4 0,4 4,0 4,0 0),(1 1,2 1,2 2,1 2,1 1))") guard case .polygon(let rings)? = parsed?.geometry else { - return XCTFail("expected a polygon") + Issue.record("expected a polygon") + return } - XCTAssertEqual(rings.count, 2) - XCTAssertEqual(rings[0].count, 5) - XCTAssertEqual(rings[1].count, 5) - XCTAssertEqual(rings[1][0], SpatialPoint(x: 1, y: 1)) + #expect(rings.count == 2) + #expect(rings[0].count == 5) + #expect(rings[1].count == 5) + #expect(rings[1][0] == SpatialPoint(x: 1, y: 1)) } - func testMultiPolygon() { + @Test func testMultiPolygon() { let parsed = value("MULTIPOLYGON(((0 0,1 0,1 1,0 0)),((5 5,6 5,6 6,5 5)))") guard case .multiPolygon(let polygons)? = parsed?.geometry else { - return XCTFail("expected a multipolygon") + Issue.record("expected a multipolygon") + return } - XCTAssertEqual(polygons.count, 2) - XCTAssertEqual(polygons[1][0][0], SpatialPoint(x: 5, y: 5)) + #expect(polygons.count == 2) + #expect(polygons[1][0][0] == SpatialPoint(x: 5, y: 5)) } - func testNestedCollection() { + @Test func testNestedCollection() { let parsed = value("GEOMETRYCOLLECTION(POINT(1 2),LINESTRING(3 4,5 6))") - XCTAssertEqual( - parsed?.geometry, - .collection([ - .point(SpatialPoint(x: 1, y: 2)), - .lineString([SpatialPoint(x: 3, y: 4), SpatialPoint(x: 5, y: 6)]), - ]) - ) + #expect(parsed?.geometry == .collection([ + .point(SpatialPoint(x: 1, y: 2)), + .lineString([SpatialPoint(x: 3, y: 4), SpatialPoint(x: 5, y: 6)]), + ])) } - func testSRIDPrefixOnACollection() { - XCTAssertEqual(value("SRID=3857;GEOMETRYCOLLECTION(POINT(1 2))")?.srid, 3_857) + @Test func testSRIDPrefixOnACollection() { + #expect(value("SRID=3857;GEOMETRYCOLLECTION(POINT(1 2))")?.srid == 3_857) } /// A curved or polyhedral type is named rather than dropped, because the pane has to say which /// keyword it refused. - func testUnsupportedTypesAreNamed() { + @Test func testUnsupportedTypesAreNamed() { for keyword in [ "CIRCULARSTRING", "COMPOUNDCURVE", "CURVEPOLYGON", "MULTICURVE", "MULTISURFACE", "POLYHEDRALSURFACE", "TIN", "TRIANGLE", ] { - XCTAssertEqual( - failure("\(keyword)(1 2,3 4,5 6)"), - .unsupportedGeometryType(keyword), - "expected \(keyword) to be refused by name" - ) + #expect(failure("\(keyword)(1 2,3 4,5 6)") == .unsupportedGeometryType(keyword), "expected \(keyword) to be refused by name") } } /// PostGIS writes XYM with the tag fused onto the type name, for the types that cannot be drawn /// as much as for `POINTM`, so the name has to survive the tag. - func testUnsupportedTypesAreNamedThroughAFusedDimensionTag() { + @Test func testUnsupportedTypesAreNamedThroughAFusedDimensionTag() { for keyword in [ "CIRCULARSTRING", "COMPOUNDCURVE", "CURVEPOLYGON", "MULTICURVE", "MULTISURFACE", "POLYHEDRALSURFACE", "TIN", "TRIANGLE", ] { for tag in ["M", "Z", "ZM"] { - XCTAssertEqual( - failure("\(keyword)\(tag)(0 0 1,1 1 1,2 0 1)"), - .unsupportedGeometryType(keyword), - "expected \(keyword)\(tag) to be refused as \(keyword)" - ) + #expect(failure("\(keyword)\(tag)(0 0 1,1 1 1,2 0 1)") == .unsupportedGeometryType(keyword), "expected \(keyword)\(tag) to be refused as \(keyword)") } } } - func testFusedTagOnAnUnsupportedTypeInPostGISSpellings() { - XCTAssertEqual( - failure("SRID=4326;CURVEPOLYGONM(CIRCULARSTRINGM(0 0 1,4 0 1,4 4 1,0 4 1,0 0 1))"), - .unsupportedGeometryType("CURVEPOLYGON") - ) - XCTAssertEqual(failure("compoundcurvem((0 0 1,1 1 1))"), .unsupportedGeometryType("COMPOUNDCURVE")) - XCTAssertEqual( - failure("GEOMETRYCOLLECTION(POINT(1 2),TRIANGLEM((0 0 1,1 0 1,1 1 1,0 0 1)))"), - .unsupportedGeometryType("TRIANGLE") - ) + @Test func testFusedTagOnAnUnsupportedTypeInPostGISSpellings() { + #expect(failure("SRID=4326;CURVEPOLYGONM(CIRCULARSTRINGM(0 0 1,4 0 1,4 4 1,0 4 1,0 0 1))") == .unsupportedGeometryType("CURVEPOLYGON")) + #expect(failure("compoundcurvem((0 0 1,1 1 1))") == .unsupportedGeometryType("COMPOUNDCURVE")) + #expect(failure("GEOMETRYCOLLECTION(POINT(1 2),TRIANGLEM((0 0 1,1 0 1,1 1 1,0 0 1)))") == .unsupportedGeometryType("TRIANGLE")) } /// Only a refused type's own name is stripped: a word that merely ends in a tag letter is text. - func testFusedTagLookupLeavesOrdinaryWordsAlone() { - XCTAssertEqual(failure("PROGRAM(1 2)"), .notGeometry) - XCTAssertEqual(failure("QUIZ"), .notGeometry) - XCTAssertEqual(value("MULTIPOINTM(1 2 3,4 5 6)")?.geometry, - .multiPoint([SpatialPoint(x: 1, y: 2), SpatialPoint(x: 4, y: 5)])) + @Test func testFusedTagLookupLeavesOrdinaryWordsAlone() { + #expect(failure("PROGRAM(1 2)") == .notGeometry) + #expect(failure("QUIZ") == .notGeometry) + #expect(value("MULTIPOINTM(1 2 3,4 5 6)")?.geometry == .multiPoint([SpatialPoint(x: 1, y: 2), SpatialPoint(x: 4, y: 5)])) } /// Zero is how a value says nobody named a system. EWKB reads it as no SRID, and the same value /// spelled as EWKT has to agree or it draws in one spelling and is refused in the other. - func testZeroSRIDPrefixReadsAsNoSRID() { + @Test func testZeroSRIDPrefixReadsAsNoSRID() { let expected = SpatialValue(srid: nil, geometry: .point(SpatialPoint(x: 1, y: 2))) - XCTAssertEqual(value("SRID=0;POINT(1 2)"), expected) - XCTAssertEqual(value("srid = 0 ; POINT(1 2)"), expected) + #expect(value("SRID=0;POINT(1 2)") == expected) + #expect(value("srid = 0 ; POINT(1 2)") == expected) let ewkbWithZeroSRID = "0101000020" + "00000000" + "000000000000F03F" + "0000000000000040" - XCTAssertEqual(WKBGeometryReader.read(hex: ewkbWithZeroSRID), .success(expected)) + #expect(WKBGeometryReader.read(hex: ewkbWithZeroSRID) == .success(expected)) } - func testZeroSRIDPrefixIsDrawnLikeABareValue() { + @Test func testZeroSRIDPrefixIsDrawnLikeABareValue() { guard let parsed = value("SRID=0;POINT(1 2)") else { return } - XCTAssertEqual( - SpatialProjection.projectability(srid: parsed.srid, geometry: parsed.geometry), - .assumedGeographic - ) + #expect(SpatialProjection.projectability(srid: parsed.srid, geometry: parsed.geometry) == .assumedGeographic) } - func testOrdinaryTextIsNotAGeometry() { - XCTAssertEqual(failure("hello world"), .notGeometry) - XCTAssertEqual(failure(""), .notGeometry) - XCTAssertEqual(failure("12345"), .notGeometry) + @Test func testOrdinaryTextIsNotAGeometry() { + #expect(failure("hello world") == .notGeometry) + #expect(failure("") == .notGeometry) + #expect(failure("12345") == .notGeometry) } - func testTruncatedInputIsMalformed() { - XCTAssertEqual(failure("POINT(1 2"), .malformed) - XCTAssertEqual(failure("POLYGON((0 0,1 1)"), .malformed) - XCTAssertEqual(failure("POINT(1)"), .malformed) + @Test func testTruncatedInputIsMalformed() { + #expect(failure("POINT(1 2") == .malformed) + #expect(failure("POLYGON((0 0,1 1)") == .malformed) + #expect(failure("POINT(1)") == .malformed) } - func testTrailingTextIsRejected() { - XCTAssertEqual(failure("POINT(1 2) extra"), .malformed) + @Test func testTrailingTextIsRejected() { + #expect(failure("POINT(1 2) extra") == .malformed) } - func testLooksLikeWKTGatesTheSniffer() { - XCTAssertTrue(WKTGeometryReader.looksLikeWKT("POINT(1 2)")) - XCTAssertTrue(WKTGeometryReader.looksLikeWKT("SRID=4326;MULTIPOLYGON EMPTY")) - XCTAssertTrue(WKTGeometryReader.looksLikeWKT("point (1 2)")) - XCTAssertFalse(WKTGeometryReader.looksLikeWKT("hello")) - XCTAssertFalse(WKTGeometryReader.looksLikeWKT("{\"type\":\"Point\"}")) + @Test func testLooksLikeWKTGatesTheSniffer() { + #expect(WKTGeometryReader.looksLikeWKT("POINT(1 2)")) + #expect(WKTGeometryReader.looksLikeWKT("SRID=4326;MULTIPOLYGON EMPTY")) + #expect(WKTGeometryReader.looksLikeWKT("point (1 2)")) + #expect(!WKTGeometryReader.looksLikeWKT("hello")) + #expect(!WKTGeometryReader.looksLikeWKT("{\"type\":\"Point\"}")) } } diff --git a/Packages/TableProCore/Tests/TableProGoogleCloudTests/GoogleOAuthLoopbackFlowTests.swift b/Packages/TableProCore/Tests/TableProGoogleCloudTests/GoogleOAuthLoopbackFlowTests.swift index cf6a1a2f86..e0fb0c4405 100644 --- a/Packages/TableProCore/Tests/TableProGoogleCloudTests/GoogleOAuthLoopbackFlowTests.swift +++ b/Packages/TableProCore/Tests/TableProGoogleCloudTests/GoogleOAuthLoopbackFlowTests.swift @@ -1,5 +1,6 @@ import Foundation @testable import TableProGoogleCloud +import TableProTestSupport import Testing private enum LoopbackBrowser { @@ -30,17 +31,10 @@ private enum LoopbackBrowser { static func listenerStopped(for authorizationURL: URL) async -> Bool { let probe = callbackURL(for: authorizationURL, query: [(name: "probe", value: "1")]) - for _ in 0..<20 { - if await status(of: probe) == nil { - return true - } - try? await Task.sleep(for: .milliseconds(50)) - } - return false + return (try? await waitUntil { await status(of: probe) == nil }) != nil } } -@Suite("OAuth loopback flow", .serialized) struct GoogleOAuthLoopbackFlowTests { private let client = GoogleOAuthClient(clientId: "desktop.apps.googleusercontent.com", clientSecret: "desktop-secret") private let tokenJSON = #"{"access_token":"access","refresh_token":"1//refresh","expires_in":3600}"# @@ -161,9 +155,7 @@ struct GoogleOAuthLoopbackFlowTests { } let task = Task { try await flow.run() } - for _ in 0..<250 where authorizationURL.value == nil { - try await Task.sleep(for: .milliseconds(20)) - } + try await waitUntil { authorizationURL.value != nil } let url = try #require(authorizationURL.value) task.cancel() diff --git a/Packages/TableProCore/Tests/TableProImportTests/ConnectionExportCryptoIsolationTests.swift b/Packages/TableProCore/Tests/TableProImportTests/ConnectionExportCryptoIsolationTests.swift index cbc520ac8f..bc96ad89fa 100644 --- a/Packages/TableProCore/Tests/TableProImportTests/ConnectionExportCryptoIsolationTests.swift +++ b/Packages/TableProCore/Tests/TableProImportTests/ConnectionExportCryptoIsolationTests.swift @@ -1,11 +1,12 @@ +import Foundation @testable import TableProImport -import XCTest +import Testing @MainActor -final class ConnectionExportCryptoIsolationTests: XCTestCase { +struct ConnectionExportCryptoIsolationTests { private let passphrase = "correct horse battery" - func testEncryptingLeavesTheMainActorFree() async throws { + @Test func testEncryptingLeavesTheMainActorFree() async throws { let payload = Data("payload".utf8) let passphrase = passphrase try await assertLeavesMainActorFree { @@ -13,7 +14,7 @@ final class ConnectionExportCryptoIsolationTests: XCTestCase { } } - func testDecryptingLeavesTheMainActorFree() async throws { + @Test func testDecryptingLeavesTheMainActorFree() async throws { let passphrase = passphrase let sealed = try await ConnectionExportCrypto.encrypt(data: Data("payload".utf8), passphrase: passphrase) try await assertLeavesMainActorFree { @@ -21,7 +22,7 @@ final class ConnectionExportCryptoIsolationTests: XCTestCase { } } - func testEncodingAnEncryptedFileLeavesTheMainActorFree() async throws { + @Test func testEncodingAnEncryptedFileLeavesTheMainActorFree() async throws { let passphrase = passphrase let bundle = try makeBundle() try await assertLeavesMainActorFree { @@ -29,7 +30,7 @@ final class ConnectionExportCryptoIsolationTests: XCTestCase { } } - func testDecodingAnEncryptedFileLeavesTheMainActorFree() async throws { + @Test func testDecodingAnEncryptedFileLeavesTheMainActorFree() async throws { let passphrase = passphrase let sealed = try await ConnectionBundleCodec.encode(makeBundle(), passphrase: passphrase) try await assertLeavesMainActorFree { @@ -51,26 +52,25 @@ final class ConnectionExportCryptoIsolationTests: XCTestCase { private func assertLeavesMainActorFree( _ work: @escaping @MainActor () async throws -> Void, - file: StaticString = #filePath, - line: UInt = #line + sourceLocation: SourceLocation = #_sourceLocation ) async throws { let probe = DerivationProbe() + let (started, signalStart) = AsyncStream.makeStream() let task = Task { @MainActor in - probe.hasStarted = true + signalStart.yield() + signalStart.finish() try await work() probe.hasFinished = true } - while !probe.hasStarted { - await Task.yield() - } - XCTAssertFalse(probe.hasFinished, "The derivation held the main actor until it finished", file: file, line: line) + // This resumes on the main actor, so it runs before the work finishes only if the work let go of it. + for await _ in started { break } + #expect(!probe.hasFinished, "The derivation held the main actor until it finished", sourceLocation: sourceLocation) try await task.value - XCTAssertTrue(probe.hasFinished, file: file, line: line) + #expect(probe.hasFinished, sourceLocation: sourceLocation) } } @MainActor private final class DerivationProbe { - var hasStarted = false var hasFinished = false } diff --git a/Packages/TableProCore/Tests/TableProImportTests/ConnectionExportCryptoTests.swift b/Packages/TableProCore/Tests/TableProImportTests/ConnectionExportCryptoTests.swift index e7b3be20c1..05ae915646 100644 --- a/Packages/TableProCore/Tests/TableProImportTests/ConnectionExportCryptoTests.swift +++ b/Packages/TableProCore/Tests/TableProImportTests/ConnectionExportCryptoTests.swift @@ -1,31 +1,32 @@ +import Foundation @testable import TableProImport -import XCTest +import Testing -final class ConnectionExportCryptoTests: XCTestCase { - func testEncryptDecryptRoundTripRecoversOriginal() async throws { +struct ConnectionExportCryptoTests { + @Test func testEncryptDecryptRoundTripRecoversOriginal() async throws { let original = Data("the quick brown fox".utf8) let encrypted = try await ConnectionExportCrypto.encrypt(data: original, passphrase: "correct horse battery") let decrypted = try await ConnectionExportCrypto.decrypt(data: encrypted, passphrase: "correct horse battery") - XCTAssertEqual(decrypted, original) + #expect(decrypted == original) } - func testEncryptedBlobIsDetectedAndPlainJSONIsNot() async throws { + @Test func testEncryptedBlobIsDetectedAndPlainJSONIsNot() async throws { let encrypted = try await ConnectionExportCrypto.encrypt(data: Data("x".utf8), passphrase: "pw") - XCTAssertTrue(ConnectionExportCrypto.isEncrypted(encrypted)) - XCTAssertFalse(ConnectionExportCrypto.isEncrypted(Data("{\"a\":1}".utf8))) + #expect(ConnectionExportCrypto.isEncrypted(encrypted)) + #expect(!(ConnectionExportCrypto.isEncrypted(Data("{\"a\":1}".utf8)))) } - func testWrongPassphraseThrowsInvalidPassphrase() async throws { + @Test func testWrongPassphraseThrowsInvalidPassphrase() async throws { let encrypted = try await ConnectionExportCrypto.encrypt(data: Data("secret".utf8), passphrase: "right") await assertDecryptFails(encrypted, passphrase: "wrong", with: .invalidPassphrase) } - func testTruncatedHeaderThrowsCorruptData() async { + @Test func testTruncatedHeaderThrowsCorruptData() async { let tooShort = Data([0x54, 0x50, 0x52, 0x4F, 0x01]) await assertDecryptFails(tooShort, passphrase: "pw", with: .corruptData) } - func testNonMagicPrefixThrowsCorruptData() async throws { + @Test func testNonMagicPrefixThrowsCorruptData() async throws { var blob = try await ConnectionExportCrypto.encrypt(data: Data("hello world data".utf8), passphrase: "pw") blob[0] = 0x00 await assertDecryptFails(blob, passphrase: "pw", with: .corruptData) @@ -35,14 +36,13 @@ final class ConnectionExportCryptoTests: XCTestCase { _ data: Data, passphrase: String, with expected: ConnectionExportCryptoError, - file: StaticString = #filePath, - line: UInt = #line + sourceLocation: SourceLocation = #_sourceLocation ) async { do { _ = try await ConnectionExportCrypto.decrypt(data: data, passphrase: passphrase) - XCTFail("Decrypting was expected to throw \(expected)", file: file, line: line) + Issue.record("Decrypting was expected to throw \(expected)", sourceLocation: sourceLocation) } catch { - XCTAssertEqual(error as? ConnectionExportCryptoError, expected, file: file, line: line) + #expect(error as? ConnectionExportCryptoError == expected, sourceLocation: sourceLocation) } } } diff --git a/Packages/TableProCore/Tests/TableProImportTests/ShareableAdditionalFieldsTests.swift b/Packages/TableProCore/Tests/TableProImportTests/ShareableAdditionalFieldsTests.swift index 54abc2f9a2..4af96fdd03 100644 --- a/Packages/TableProCore/Tests/TableProImportTests/ShareableAdditionalFieldsTests.swift +++ b/Packages/TableProCore/Tests/TableProImportTests/ShareableAdditionalFieldsTests.swift @@ -1,8 +1,9 @@ +import Foundation import TableProImport -import XCTest +import Testing -final class ShareableAdditionalFieldsTests: XCTestCase { - func testLeavesOutEveryImportBlockedKeyInAnyCase() { +struct ShareableAdditionalFieldsTests { + @Test func testLeavesOutEveryImportBlockedKeyInAnyCase() { let fields = [ "preConnectScript": "export PGTOKEN=secret-token", "usePgpass": "true", @@ -14,10 +15,10 @@ final class ShareableAdditionalFieldsTests: XCTestCase { let shareable = ExportableConnection.shareableAdditionalFields(fields) - XCTAssertEqual(shareable, ["connectionOptions": "-c search_path=app"]) + #expect(shareable == ["connectionOptions": "-c search_path=app"]) } - func testLeavesOutTheExcludedKeys() { + @Test func testLeavesOutTheExcludedKeys() { let fields = ["mssqlKerberosPassword": "secret", "mssqlAuthMethod": "kerberos"] let shareable = ExportableConnection.shareableAdditionalFields( @@ -25,13 +26,13 @@ final class ShareableAdditionalFieldsTests: XCTestCase { excluding: ["mssqlKerberosPassword"] ) - XCTAssertEqual(shareable, ["mssqlAuthMethod": "kerberos"]) + #expect(shareable == ["mssqlAuthMethod": "kerberos"]) } - func testReturnsNilWhenNothingIsLeftToShare() { + @Test func testReturnsNilWhenNothingIsLeftToShare() { let fields = ["preConnectScript": "echo hi", "usePgpass": "true"] - XCTAssertNil(ExportableConnection.shareableAdditionalFields(fields)) - XCTAssertNil(ExportableConnection.shareableAdditionalFields([:])) + #expect(ExportableConnection.shareableAdditionalFields(fields) == nil) + #expect(ExportableConnection.shareableAdditionalFields([:]) == nil) } } diff --git a/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLColumnTypeTests.swift b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLColumnTypeTests.swift index 3ead003548..a6e5620ca8 100644 --- a/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLColumnTypeTests.swift +++ b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLColumnTypeTests.swift @@ -1,58 +1,59 @@ +import Foundation @testable import TableProMSSQLCore -import XCTest +import Testing -final class MSSQLColumnTypeTests: XCTestCase { - func testIsBinaryCoversBinaryFamily() { - XCTAssertTrue(MSSQLColumnType.binary.isBinary) - XCTAssertTrue(MSSQLColumnType.varbinary.isBinary) - XCTAssertTrue(MSSQLColumnType.image.isBinary) - XCTAssertFalse(MSSQLColumnType.varchar.isBinary) - XCTAssertFalse(MSSQLColumnType.int.isBinary) +struct MSSQLColumnTypeTests { + @Test func testIsBinaryCoversBinaryFamily() { + #expect(MSSQLColumnType.binary.isBinary) + #expect(MSSQLColumnType.varbinary.isBinary) + #expect(MSSQLColumnType.image.isBinary) + #expect(!MSSQLColumnType.varchar.isBinary) + #expect(!MSSQLColumnType.int.isBinary) } - func testIsDateOrTimeCoversAllDateTimeVariants() { + @Test func testIsDateOrTimeCoversAllDateTimeVariants() { let dateTypes: [MSSQLColumnType] = [ .dateTime, .smallDateTime, .dateTimeN, .date, .time, .dateTime2, .dateTimeOffset ] for type in dateTypes { - XCTAssertTrue(type.isDateOrTime, "\(type.canonicalName) should be date/time") + #expect(type.isDateOrTime, "\(type.canonicalName) should be date/time") } - XCTAssertFalse(MSSQLColumnType.int.isDateOrTime) - XCTAssertFalse(MSSQLColumnType.varchar.isDateOrTime) + #expect(!MSSQLColumnType.int.isDateOrTime) + #expect(!MSSQLColumnType.varchar.isDateOrTime) } - func testIsUnicodeStringOnlyForNTypes() { - XCTAssertTrue(MSSQLColumnType.nchar.isUnicodeString) - XCTAssertTrue(MSSQLColumnType.nvarchar.isUnicodeString) - XCTAssertTrue(MSSQLColumnType.ntext.isUnicodeString) - XCTAssertFalse(MSSQLColumnType.char.isUnicodeString) - XCTAssertFalse(MSSQLColumnType.varchar.isUnicodeString) + @Test func testIsUnicodeStringOnlyForNTypes() { + #expect(MSSQLColumnType.nchar.isUnicodeString) + #expect(MSSQLColumnType.nvarchar.isUnicodeString) + #expect(MSSQLColumnType.ntext.isUnicodeString) + #expect(!MSSQLColumnType.char.isUnicodeString) + #expect(!MSSQLColumnType.varchar.isUnicodeString) } - func testIsNarrowStringOnlyForNonUnicodeStrings() { - XCTAssertTrue(MSSQLColumnType.char.isNarrowString) - XCTAssertTrue(MSSQLColumnType.varchar.isNarrowString) - XCTAssertTrue(MSSQLColumnType.text.isNarrowString) - XCTAssertFalse(MSSQLColumnType.nvarchar.isNarrowString) - XCTAssertFalse(MSSQLColumnType.int.isNarrowString) + @Test func testIsNarrowStringOnlyForNonUnicodeStrings() { + #expect(MSSQLColumnType.char.isNarrowString) + #expect(MSSQLColumnType.varchar.isNarrowString) + #expect(MSSQLColumnType.text.isNarrowString) + #expect(!MSSQLColumnType.nvarchar.isNarrowString) + #expect(!MSSQLColumnType.int.isNarrowString) } - func testCanonicalNameForDateTimeFamily() { - XCTAssertEqual(MSSQLColumnType.dateTime.canonicalName, "datetime") - XCTAssertEqual(MSSQLColumnType.dateTimeN.canonicalName, "datetime") - XCTAssertEqual(MSSQLColumnType.smallDateTime.canonicalName, "smalldatetime") - XCTAssertEqual(MSSQLColumnType.dateTime2.canonicalName, "datetime2") - XCTAssertEqual(MSSQLColumnType.dateTimeOffset.canonicalName, "datetimeoffset") + @Test func testCanonicalNameForDateTimeFamily() { + #expect(MSSQLColumnType.dateTime.canonicalName == "datetime") + #expect(MSSQLColumnType.dateTimeN.canonicalName == "datetime") + #expect(MSSQLColumnType.smallDateTime.canonicalName == "smalldatetime") + #expect(MSSQLColumnType.dateTime2.canonicalName == "datetime2") + #expect(MSSQLColumnType.dateTimeOffset.canonicalName == "datetimeoffset") } - func testUnknownTypePreservesToken() { + @Test func testUnknownTypePreservesToken() { let unknown = MSSQLColumnType.unknown(99) - XCTAssertEqual(unknown.canonicalName, "unknown") + #expect(unknown.canonicalName == "unknown") if case .unknown(let token) = unknown { - XCTAssertEqual(token, 99) + #expect(token == 99) } else { - XCTFail("expected .unknown case") + Issue.record("expected .unknown case") } } } diff --git a/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLDatetimeFormatterTests.swift b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLDatetimeFormatterTests.swift index cf7280e5c3..e67478d617 100644 --- a/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLDatetimeFormatterTests.swift +++ b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLDatetimeFormatterTests.swift @@ -1,83 +1,71 @@ +import Foundation @testable import TableProMSSQLCore -import XCTest +import Testing -final class MSSQLDatetimeFormatterTests: XCTestCase { - func testDatetimeIsReformatted() { +struct MSSQLDatetimeFormatterTests { + @Test func testDatetimeIsReformatted() { let result = MSSQLDatetimeFormatter.reformat("Jan 15 2024 10:30:00:123AM", type: .dateTime) - XCTAssertEqual(result, "2024-01-15 10:30:00.123") + #expect(result == "2024-01-15 10:30:00.123") } - func testPMHoursAreAdjusted() { + @Test func testPMHoursAreAdjusted() { let result = MSSQLDatetimeFormatter.reformat("Mar 5 2024 2:45:30PM", type: .dateTime) - XCTAssertEqual(result, "2024-03-05 14:45:30") + #expect(result == "2024-03-05 14:45:30") } - func testNoonHandledCorrectly() { - XCTAssertEqual(MSSQLDatetimeFormatter.parse("Jun 1 2024 12:00:00PM"), "2024-06-01 12:00:00") + @Test func testNoonHandledCorrectly() { + #expect(MSSQLDatetimeFormatter.parse("Jun 1 2024 12:00:00PM") == "2024-06-01 12:00:00") } - func testMidnightHandledCorrectly() { - XCTAssertEqual(MSSQLDatetimeFormatter.parse("Jun 1 2024 12:00:00AM"), "2024-06-01 00:00:00") + @Test func testMidnightHandledCorrectly() { + #expect(MSSQLDatetimeFormatter.parse("Jun 1 2024 12:00:00AM") == "2024-06-01 00:00:00") } - func testAlreadyISOPassesThrough() { + @Test func testAlreadyISOPassesThrough() { let raw = "2024-01-15 10:30:00.123" - XCTAssertEqual(MSSQLDatetimeFormatter.parse(raw), raw) + #expect(MSSQLDatetimeFormatter.parse(raw) == raw) } - func testReformatReturnsNilForNonDatetimeType() { - XCTAssertNil(MSSQLDatetimeFormatter.reformat("Jan 15 2024 10:30:00AM", type: .int)) - XCTAssertNil(MSSQLDatetimeFormatter.reformat("Jan 15 2024 10:30:00AM", type: .nvarchar)) + @Test func testReformatReturnsNilForNonDatetimeType() { + #expect(MSSQLDatetimeFormatter.reformat("Jan 15 2024 10:30:00AM", type: .int) == nil) + #expect(MSSQLDatetimeFormatter.reformat("Jan 15 2024 10:30:00AM", type: .nvarchar) == nil) } - func testEmptyInputReturnsNil() { - XCTAssertNil(MSSQLDatetimeFormatter.parse("")) - XCTAssertNil(MSSQLDatetimeFormatter.parse(" ")) + @Test func testEmptyInputReturnsNil() { + #expect(MSSQLDatetimeFormatter.parse("") == nil) + #expect(MSSQLDatetimeFormatter.parse(" ") == nil) } - func testInvalidMonthReturnsNil() { - XCTAssertNil(MSSQLDatetimeFormatter.parse("Xyz 1 2024 10:00AM")) + @Test func testInvalidMonthReturnsNil() { + #expect(MSSQLDatetimeFormatter.parse("Xyz 1 2024 10:00AM") == nil) } - func testFractionalSecondsPreserved() { + @Test func testFractionalSecondsPreserved() { let result = MSSQLDatetimeFormatter.parse("Jan 1 2024 1:00:00:1234567AM") - XCTAssertEqual(result, "2024-01-01 01:00:00.1234567") + #expect(result == "2024-01-01 01:00:00.1234567") } - func testDate2025Handled() { - XCTAssertEqual(MSSQLDatetimeFormatter.reformat("Dec 31 2025 11:59:59:999PM", type: .dateTime2), - "2025-12-31 23:59:59.999") + @Test func testDate2025Handled() { + #expect(MSSQLDatetimeFormatter.reformat("Dec 31 2025 11:59:59:999PM", type: .dateTime2) == "2025-12-31 23:59:59.999") } - func testDateTimeOffsetKeepsAPositiveOffset() { - XCTAssertEqual( - MSSQLDatetimeFormatter.dateTimeOffset("Jan 2 2024 3:04:05:1234567AM", offsetMinutes: 330), - "2024-01-02T03:04:05.1234567+05:30" - ) + @Test func testDateTimeOffsetKeepsAPositiveOffset() { + #expect(MSSQLDatetimeFormatter.dateTimeOffset("Jan 2 2024 3:04:05:1234567AM", offsetMinutes: 330) == "2024-01-02T03:04:05.1234567+05:30") } - func testDateTimeOffsetKeepsANegativeOffset() { - XCTAssertEqual( - MSSQLDatetimeFormatter.dateTimeOffset("Jan 2 2024 3:04:05:PM", offsetMinutes: -480), - "2024-01-02T15:04:05-08:00" - ) + @Test func testDateTimeOffsetKeepsANegativeOffset() { + #expect(MSSQLDatetimeFormatter.dateTimeOffset("Jan 2 2024 3:04:05:PM", offsetMinutes: -480) == "2024-01-02T15:04:05-08:00") } - func testDateTimeOffsetAtUTCSaysSo() { - XCTAssertEqual( - MSSQLDatetimeFormatter.dateTimeOffset("Jan 15 2024 10:30:45:1234567AM", offsetMinutes: 0), - "2024-01-15T10:30:45.1234567+00:00" - ) + @Test func testDateTimeOffsetAtUTCSaysSo() { + #expect(MSSQLDatetimeFormatter.dateTimeOffset("Jan 15 2024 10:30:45:1234567AM", offsetMinutes: 0) == "2024-01-15T10:30:45.1234567+00:00") } - func testDateTimeOffsetKeepsAnOffsetWithMinutesBehindUTC() { - XCTAssertEqual( - MSSQLDatetimeFormatter.dateTimeOffset("Jan 15 2024 10:30:45AM", offsetMinutes: -210), - "2024-01-15T10:30:45-03:30" - ) + @Test func testDateTimeOffsetKeepsAnOffsetWithMinutesBehindUTC() { + #expect(MSSQLDatetimeFormatter.dateTimeOffset("Jan 15 2024 10:30:45AM", offsetMinutes: -210) == "2024-01-15T10:30:45-03:30") } - func testDateTimeOffsetOfUnreadableTextIsNil() { - XCTAssertNil(MSSQLDatetimeFormatter.dateTimeOffset("not a date", offsetMinutes: 60)) + @Test func testDateTimeOffsetOfUnreadableTextIsNil() { + #expect(MSSQLDatetimeFormatter.dateTimeOffset("not a date", offsetMinutes: 60) == nil) } } diff --git a/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLFreeTDSConfigFileTests.swift b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLFreeTDSConfigFileTests.swift index 884219d7cc..14905cbd79 100644 --- a/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLFreeTDSConfigFileTests.swift +++ b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLFreeTDSConfigFileTests.swift @@ -1,5 +1,6 @@ import Foundation @testable import TableProMSSQLCore +import TableProTestSupport import Testing @Suite("MSSQL FreeTDS config file") @@ -96,7 +97,7 @@ struct MSSQLFreeTDSConfigFileTests { let waiter = runOnOwnThread { try? file.withEntry(upper, waitingAtMost: 10) { order.append("upper in") } } - try await Task.sleep(for: .milliseconds(200)) + try await waitUntil { file.waitingConnections(named: "db.example.com") == 1 } released.signal() await holder.wait() await waiter.wait() @@ -129,7 +130,7 @@ struct MSSQLFreeTDSConfigFileTests { seenByEncrypted.value = contents() } } - try await Task.sleep(for: .milliseconds(200)) + try await waitUntil { file.waitingConnections(named: "db.example.com") == 1 } #expect(order.entries == ["plain in"]) released.signal() await holder.wait() @@ -211,11 +212,11 @@ struct MSSQLFreeTDSConfigFileTests { let encryptedConnect = runOnOwnThread { try? file.withEntry(encrypted, waitingAtMost: 10) { order.append("encrypted in") } } - #expect(await eventually { file.waitingConnections(named: "db.example.com") == 1 }) + try await waitUntil { file.waitingConnections(named: "db.example.com") == 1 } let secondPlainConnect = runOnOwnThread { try? file.withEntry(plain, waitingAtMost: 10) { order.append("second plain in") } } - #expect(await eventually { file.waitingConnections(named: "db.example.com") == 2 }) + try await waitUntil { file.waitingConnections(named: "db.example.com") == 2 } #expect(order.entries == ["first plain in"]) released.signal() await holder.wait() @@ -277,7 +278,7 @@ struct MSSQLFreeTDSConfigFileTests { waiterError.value = error } } - #expect(await eventually { file.waitingConnections(named: "db.example.com") == 1 }) + try await waitUntil { file.waitingConnections(named: "db.example.com") == 1 } abandoned.value = true file.interruptWaits() let leftInTime = await waiter.opens(within: .seconds(2)) @@ -303,15 +304,6 @@ struct MSSQLFreeTDSConfigFileTests { } } -private func eventually(within limit: Duration = .seconds(5), _ condition: () -> Bool) async -> Bool { - let deadline = ContinuousClock.now + limit - while !condition() { - guard ContinuousClock.now < deadline else { return false } - try? await Task.sleep(for: .milliseconds(10)) - } - return true -} - private func runOnOwnThread(_ work: @escaping @Sendable () -> Void) -> Latch { let finished = Latch() let thread = Thread { @@ -358,7 +350,7 @@ private final class Latch: @unchecked Sendable { } func opens(within limit: Duration) async -> Bool { - await eventually(within: limit) { opened } + (try? await waitUntil(timeout: limit) { opened }) != nil } } diff --git a/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLModuleHeaderTests.swift b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLModuleHeaderTests.swift index d6672b1f05..5c619a6f60 100644 --- a/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLModuleHeaderTests.swift +++ b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLModuleHeaderTests.swift @@ -1,123 +1,121 @@ +import Foundation @testable import TableProMSSQLCore -import XCTest +import Testing -final class MSSQLModuleHeaderTests: XCTestCase { - func testReadsATwoPartName() throws { - let header = try XCTUnwrap(MSSQLModuleHeader.parse("CREATE VIEW dbo.v AS SELECT 1 AS a", objectKeyword: "VIEW")) - XCTAssertEqual(header.nameParts, ["dbo", "v"]) - XCTAssertEqual(header.nameRange, NSRange(location: 12, length: 5)) +struct MSSQLModuleHeaderTests { + @Test func testReadsATwoPartName() throws { + let header = try #require(MSSQLModuleHeader.parse("CREATE VIEW dbo.v AS SELECT 1 AS a", objectKeyword: "VIEW")) + #expect(header.nameParts == ["dbo", "v"]) + #expect(header.nameRange == NSRange(location: 12, length: 5)) } /// The stored batch keeps the comments before the header, and a comment can name another object. - func testSkipsLeadingCommentsThatReadLikeAHeader() throws { + @Test func testSkipsLeadingCommentsThatReadLikeAHeader() throws { let definition = "-- CREATE VIEW dbo.other AS\r\n/* outer /* nested */ CREATE VIEW x */\ncreate view [dbo].[v_dscp1]\r\nAS\r\nSELECT 1" - let header = try XCTUnwrap(MSSQLModuleHeader.parse(definition, objectKeyword: "VIEW")) - XCTAssertEqual(header.nameParts, ["dbo", "v_dscp1"]) - XCTAssertEqual((definition as NSString).substring(with: header.nameRange), "[dbo].[v_dscp1]") + let header = try #require(MSSQLModuleHeader.parse(definition, objectKeyword: "VIEW")) + #expect(header.nameParts == ["dbo", "v_dscp1"]) + #expect((definition as NSString).substring(with: header.nameRange) == "[dbo].[v_dscp1]") } - func testUnquotesBracketedAndDoubleQuotedParts() throws { - let header = try XCTUnwrap(MSSQLModuleHeader.parse("CREATE VIEW [we]]ird].\"a\"\"b\" AS SELECT 1", objectKeyword: "VIEW")) - XCTAssertEqual(header.nameParts, ["we]ird", "a\"b"]) + @Test func testUnquotesBracketedAndDoubleQuotedParts() throws { + let header = try #require(MSSQLModuleHeader.parse("CREATE VIEW [we]]ird].\"a\"\"b\" AS SELECT 1", objectKeyword: "VIEW")) + #expect(header.nameParts == ["we]ird", "a\"b"]) } /// A period inside brackets is part of the name, not a separator. - func testKeepsAPeriodInsideBrackets() throws { - let header = try XCTUnwrap(MSSQLModuleHeader.parse("CREATE VIEW sales.[v.1] AS SELECT 1", objectKeyword: "VIEW")) - XCTAssertEqual(header.nameParts, ["sales", "v.1"]) + @Test func testKeepsAPeriodInsideBrackets() throws { + let header = try #require(MSSQLModuleHeader.parse("CREATE VIEW sales.[v.1] AS SELECT 1", objectKeyword: "VIEW")) + #expect(header.nameParts == ["sales", "v.1"]) } - func testAllowsCommentsAndSpacesAroundTheParts() throws { + @Test func testAllowsCommentsAndSpacesAroundTheParts() throws { let definition = "/*X*/ CrEaTe /*Y*/ VIEW dbo . v(a) AS SELECT 1 AS a;" - let header = try XCTUnwrap(MSSQLModuleHeader.parse(definition, objectKeyword: "VIEW")) - XCTAssertEqual(header.nameParts, ["dbo", "v"]) - XCTAssertEqual((definition as NSString).substring(with: header.nameRange), "dbo . v") + let header = try #require(MSSQLModuleHeader.parse(definition, objectKeyword: "VIEW")) + #expect(header.nameParts == ["dbo", "v"]) + #expect((definition as NSString).substring(with: header.nameRange) == "dbo . v") } - func testAcceptsCreateOrAlterAndAlter() { - XCTAssertEqual(MSSQLModuleHeader.parse("CREATE OR ALTER VIEW v AS SELECT 1", objectKeyword: "VIEW")?.nameParts, ["v"]) - XCTAssertEqual(MSSQLModuleHeader.parse("ALTER VIEW s.v AS SELECT 1", objectKeyword: "VIEW")?.nameParts, ["s", "v"]) + @Test func testAcceptsCreateOrAlterAndAlter() { + #expect(MSSQLModuleHeader.parse("CREATE OR ALTER VIEW v AS SELECT 1", objectKeyword: "VIEW")?.nameParts == ["v"]) + #expect(MSSQLModuleHeader.parse("ALTER VIEW s.v AS SELECT 1", objectKeyword: "VIEW")?.nameParts == ["s", "v"]) } - func testRefusesTextThatIsNotThatHeader() { - XCTAssertNil(MSSQLModuleHeader.parse("SELECT 1", objectKeyword: "VIEW")) - XCTAssertNil(MSSQLModuleHeader.parse("CREATE TABLE t (a int)", objectKeyword: "VIEW")) - XCTAssertNil(MSSQLModuleHeader.parse("CREATE VIEWS v AS SELECT 1", objectKeyword: "VIEW")) - XCTAssertNil(MSSQLModuleHeader.parse("CREATE VIEW db.dbo.v AS SELECT 1", objectKeyword: "VIEW")) - XCTAssertNil(MSSQLModuleHeader.parse("CREATE VIEW [unterminated AS SELECT 1", objectKeyword: "VIEW")) - XCTAssertNil(MSSQLModuleHeader.parse("", objectKeyword: "VIEW")) + @Test func testRefusesTextThatIsNotThatHeader() { + #expect(MSSQLModuleHeader.parse("SELECT 1", objectKeyword: "VIEW") == nil) + #expect(MSSQLModuleHeader.parse("CREATE TABLE t (a int)", objectKeyword: "VIEW") == nil) + #expect(MSSQLModuleHeader.parse("CREATE VIEWS v AS SELECT 1", objectKeyword: "VIEW") == nil) + #expect(MSSQLModuleHeader.parse("CREATE VIEW db.dbo.v AS SELECT 1", objectKeyword: "VIEW") == nil) + #expect(MSSQLModuleHeader.parse("CREATE VIEW [unterminated AS SELECT 1", objectKeyword: "VIEW") == nil) + #expect(MSSQLModuleHeader.parse("", objectKeyword: "VIEW") == nil) } /// Measured: after `sp_rename '[dbo].[v_old]', 'v_new'` the stored text still reads `CREATE VIEW dbo.v_old`. - func testRenamesAStaleName() { + @Test func testRenamesAStaleName() { let renamed = MSSQLModuleHeader.namingObject( in: "CREATE VIEW dbo.v_old AS SELECT 2 AS y\n", objectKeyword: "VIEW", schema: "dbo", name: "v_new" ) - XCTAssertEqual(renamed, "CREATE VIEW dbo.[v_new] AS SELECT 2 AS y\n") + #expect(renamed == "CREATE VIEW dbo.[v_new] AS SELECT 2 AS y\n") } - func testRenamesAStaleSchema() { + @Test func testRenamesAStaleSchema() { let moved = MSSQLModuleHeader.namingObject( in: "CREATE VIEW dbo.v AS SELECT 1", objectKeyword: "VIEW", schema: "sales", name: "v" ) - XCTAssertEqual(moved, "CREATE VIEW [sales].v AS SELECT 1") + #expect(moved == "CREATE VIEW [sales].v AS SELECT 1") } - func testKeepsWhatLiesBetweenTheParts() { + @Test func testKeepsWhatLiesBetweenTheParts() { let renamed = MSSQLModuleHeader.namingObject( in: "CREATE VIEW dbo /* keep this */ . old_name AS SELECT 1 AS n", objectKeyword: "VIEW", schema: "dbo", name: "new_name" ) - XCTAssertEqual(renamed, "CREATE VIEW dbo /* keep this */ . [new_name] AS SELECT 1 AS n") + #expect(renamed == "CREATE VIEW dbo /* keep this */ . [new_name] AS SELECT 1 AS n") let both = MSSQLModuleHeader.namingObject( in: "CREATE VIEW dbo/*a*/./*b*/old AS SELECT 1", objectKeyword: "VIEW", schema: "sales", name: "new" ) - XCTAssertEqual(both, "CREATE VIEW [sales]/*a*/./*b*/[new] AS SELECT 1") + #expect(both == "CREATE VIEW [sales]/*a*/./*b*/[new] AS SELECT 1") } - func testAnUnqualifiedStaleNameGainsItsSchema() { + @Test func testAnUnqualifiedStaleNameGainsItsSchema() { let renamed = MSSQLModuleHeader.namingObject( in: "CREATE VIEW v_old AS SELECT 1", objectKeyword: "VIEW", schema: "dbo", name: "v_new" ) - XCTAssertEqual(renamed, "CREATE VIEW [dbo].[v_new] AS SELECT 1") + #expect(renamed == "CREATE VIEW [dbo].[v_new] AS SELECT 1") } - func testLeavesACurrentHeaderAsWritten() { + @Test func testLeavesACurrentHeaderAsWritten() { let definitions = [ "-- owner: finance\r\ncreate view [dbo].[v] AS SELECT 1 -- trailing note\r\n", "CREATE VIEW v AS SELECT 1", "CREATE VIEW dbo.v AS SELECT 1", ] for definition in definitions { - XCTAssertEqual( - MSSQLModuleHeader.namingObject(in: definition, objectKeyword: "VIEW", schema: "dbo", name: "v"), - definition - ) + #expect(MSSQLModuleHeader.namingObject(in: definition, objectKeyword: "VIEW", schema: "dbo", name: "v") == definition) } } - func testEscapesAClosingBracketInTheNewName() { + @Test func testEscapesAClosingBracketInTheNewName() { let renamed = MSSQLModuleHeader.namingObject( in: "CREATE VIEW dbo.v AS SELECT 1", objectKeyword: "VIEW", schema: "dbo", name: "a]b" ) - XCTAssertEqual(renamed, "CREATE VIEW dbo.[a]]b] AS SELECT 1") + #expect(renamed == "CREATE VIEW dbo.[a]]b] AS SELECT 1") } - func testANewNameCannotCloseItsBracketsEarly() { + @Test func testANewNameCannotCloseItsBracketsEarly() { let renamed = MSSQLModuleHeader.namingObject( in: "CREATE VIEW dbo.a AS SELECT 1", objectKeyword: "VIEW", schema: "dbo", name: "v]\u{0301}\nGO\nDROP TABLE t" ) - XCTAssertEqual(renamed, "CREATE VIEW dbo.[v]]\u{0301}\nGO\nDROP TABLE t] AS SELECT 1") + #expect(renamed == "CREATE VIEW dbo.[v]]\u{0301}\nGO\nDROP TABLE t] AS SELECT 1") } - func testLeavesUnreadableTextAlone() { + @Test func testLeavesUnreadableTextAlone() { let text = "SELECT 1" - XCTAssertEqual(MSSQLModuleHeader.namingObject(in: text, objectKeyword: "VIEW", schema: "dbo", name: "v"), text) + #expect(MSSQLModuleHeader.namingObject(in: text, objectKeyword: "VIEW", schema: "dbo", name: "v") == text) } } diff --git a/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLSchemaQueriesTests.swift b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLSchemaQueriesTests.swift index a1c19ea5f1..b3985d55e6 100644 --- a/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLSchemaQueriesTests.swift +++ b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLSchemaQueriesTests.swift @@ -1,320 +1,303 @@ +import Foundation @testable import TableProMSSQLCore -import XCTest +import Testing -final class MSSQLSchemaQueriesTests: XCTestCase { - func testEscapeHandlesSingleQuote() { - XCTAssertEqual(MSSQLSchemaQueries.escape("O'Brien"), "O''Brien") - XCTAssertEqual(MSSQLSchemaQueries.escape("plain"), "plain") +struct MSSQLSchemaQueriesTests { + @Test func testEscapeHandlesSingleQuote() { + #expect(MSSQLSchemaQueries.escape("O'Brien") == "O''Brien") + #expect(MSSQLSchemaQueries.escape("plain") == "plain") } - func testEscapeBracketHandlesClosingBracket() { - XCTAssertEqual(MSSQLSchemaQueries.escapeBracket("weird]name"), "weird]]name") + @Test func testEscapeBracketHandlesClosingBracket() { + #expect(MSSQLSchemaQueries.escapeBracket("weird]name") == "weird]]name") } /// A name `sp_rename` accepted verbatim must not close its brackets early and start a new batch. - func testEscapeBracketDoublesABracketBeforeACombiningMark() { - XCTAssertEqual(MSSQLSchemaQueries.escapeBracket("v]\u{0301}\nGO"), "v]]\u{0301}\nGO") - XCTAssertEqual( - MSSQLSchemaQueries.bracketed(schema: "dbo", table: "v]\u{0301}"), - "[dbo].[v]]\u{0301}]" - ) + @Test func testEscapeBracketDoublesABracketBeforeACombiningMark() { + #expect(MSSQLSchemaQueries.escapeBracket("v]\u{0301}\nGO") == "v]]\u{0301}\nGO") + #expect(MSSQLSchemaQueries.bracketed(schema: "dbo", table: "v]\u{0301}") == "[dbo].[v]]\u{0301}]") } - func testBracketedComposesIdentifier() { - XCTAssertEqual(MSSQLSchemaQueries.bracketed(schema: "dbo", table: "Users"), "[dbo].[Users]") - XCTAssertEqual(MSSQLSchemaQueries.bracketed(schema: "weird]", table: "x"), "[weird]]].[x]") + @Test func testBracketedComposesIdentifier() { + #expect(MSSQLSchemaQueries.bracketed(schema: "dbo", table: "Users") == "[dbo].[Users]") + #expect(MSSQLSchemaQueries.bracketed(schema: "weird]", table: "x") == "[weird]]].[x]") } /// Unqualified, both views describe the database the connection is using, so every name got that database's /// numbers. Measured on SQL Server 2022 connected to AppDb: msdb reported 16 MB and 2 tables instead of 145. - func testDatabaseMetadataReadsTheNamedDatabasesCatalog() { + @Test func testDatabaseMetadataReadsTheNamedDatabasesCatalog() { let sql = MSSQLSchemaQueries.databaseMetadata(database: "msdb") - XCTAssertTrue(sql.contains("FROM [msdb].sys.database_files")) - XCTAssertTrue(sql.contains("FROM [msdb].sys.tables")) - XCTAssertFalse(sql.contains(" sys.database_files")) - XCTAssertFalse(sql.contains(" sys.tables")) + #expect(sql.contains("FROM [msdb].sys.database_files")) + #expect(sql.contains("FROM [msdb].sys.tables")) + #expect(!sql.contains(" sys.database_files")) + #expect(!sql.contains(" sys.tables")) } - func testDatabaseMetadataEscapesAClosingBracketInTheName() { + @Test func testDatabaseMetadataEscapesAClosingBracketInTheName() { let sql = MSSQLSchemaQueries.databaseMetadata(database: "we]ird") - XCTAssertTrue(sql.contains("FROM [we]]ird].sys.database_files")) - XCTAssertTrue(sql.contains("FROM [we]]ird].sys.tables")) + #expect(sql.contains("FROM [we]]ird].sys.database_files")) + #expect(sql.contains("FROM [we]]ird].sys.tables")) } /// The session runs with ANSI_WARNINGS off, so an int sum over a 2 GB database overflowed to NULL. - func testSizesAreSummedAsBigintBytes() { - XCTAssertTrue(MSSQLSchemaQueries.databaseMetadata(database: "AppDb").contains("SUM(CAST(size AS bigint)) * 8192")) - XCTAssertTrue(MSSQLSchemaQueries.allDatabaseSizes.contains("SUM(CAST(mf.size AS bigint)) * 8192")) - XCTAssertTrue(MSSQLSchemaQueries.allDatabaseSizes.contains("LEFT JOIN sys.master_files")) + @Test func testSizesAreSummedAsBigintBytes() { + #expect(MSSQLSchemaQueries.databaseMetadata(database: "AppDb").contains("SUM(CAST(size AS bigint)) * 8192")) + #expect(MSSQLSchemaQueries.allDatabaseSizes.contains("SUM(CAST(mf.size AS bigint)) * 8192")) + #expect(MSSQLSchemaQueries.allDatabaseSizes.contains("LEFT JOIN sys.master_files")) } - func testTablesQueryEscapesSchema() { + @Test func testTablesQueryEscapesSchema() { let sql = MSSQLSchemaQueries.tables(schema: "O'Brien") - XCTAssertTrue(sql.contains("N'O''Brien'")) - XCTAssertTrue(sql.contains("INFORMATION_SCHEMA.TABLES")) - XCTAssertTrue(sql.contains("'BASE TABLE'")) - XCTAssertTrue(sql.contains("'VIEW'")) + #expect(sql.contains("N'O''Brien'")) + #expect(sql.contains("INFORMATION_SCHEMA.TABLES")) + #expect(sql.contains("'BASE TABLE'")) + #expect(sql.contains("'VIEW'")) } - func testOneSchemaListingIsFilteredByTheSchemaAlone() { + @Test func testOneSchemaListingIsFilteredByTheSchemaAlone() { let sql = MSSQLSchemaQueries.tables(in: .schema("sales")) - XCTAssertEqual(sql, MSSQLSchemaQueries.tables(schema: "sales")) - XCTAssertTrue(sql.contains("WHERE t.TABLE_SCHEMA = N'sales'")) - XCTAssertTrue(sql.hasPrefix("SELECT t.TABLE_NAME, t.TABLE_TYPE\n")) - XCTAssertTrue(sql.hasSuffix("ORDER BY t.TABLE_NAME")) - XCTAssertFalse(sql.contains("INFORMATION_SCHEMA.SCHEMATA")) + #expect(sql == MSSQLSchemaQueries.tables(schema: "sales")) + #expect(sql.contains("WHERE t.TABLE_SCHEMA = N'sales'")) + #expect(sql.hasPrefix("SELECT t.TABLE_NAME, t.TABLE_TYPE\n")) + #expect(sql.hasSuffix("ORDER BY t.TABLE_NAME")) + #expect(!sql.contains("INFORMATION_SCHEMA.SCHEMATA")) } /// The all-schema listing is filtered by the schema list query itself, so a table is listed exactly when its schema /// is one `fetchSchemas()` returns. SQL Server rejects an `ORDER BY` inside that subquery, which is why the list is /// split from its ordering. - func testAllSchemaListingIsFilteredByTheSchemaListQuery() { + @Test func testAllSchemaListingIsFilteredByTheSchemaListQuery() { let sql = MSSQLSchemaQueries.tables(in: .allSchemas) - XCTAssertTrue(sql.contains("WHERE t.TABLE_SCHEMA IN (\n\(MSSQLSchemaQueries.listedSchemaNames)\n)")) - XCTAssertFalse(sql.contains("ORDER BY SCHEMA_NAME")) - XCTAssertTrue(sql.hasPrefix("SELECT t.TABLE_NAME, t.TABLE_TYPE, t.TABLE_SCHEMA\n")) - XCTAssertTrue(sql.contains("AND t.TABLE_TYPE IN ('BASE TABLE', 'VIEW')")) - XCTAssertTrue(sql.hasSuffix("ORDER BY t.TABLE_SCHEMA, t.TABLE_NAME")) + #expect(sql.contains("WHERE t.TABLE_SCHEMA IN (\n\(MSSQLSchemaQueries.listedSchemaNames)\n)")) + #expect(!sql.contains("ORDER BY SCHEMA_NAME")) + #expect(sql.hasPrefix("SELECT t.TABLE_NAME, t.TABLE_TYPE, t.TABLE_SCHEMA\n")) + #expect(sql.contains("AND t.TABLE_TYPE IN ('BASE TABLE', 'VIEW')")) + #expect(sql.hasSuffix("ORDER BY t.TABLE_SCHEMA, t.TABLE_NAME")) } - func testSchemaSetListingIsFilteredByTheNamedSchemasAlone() { + @Test func testSchemaSetListingIsFilteredByTheNamedSchemasAlone() { let sql = MSSQLSchemaQueries.tables(in: .schemas(["sales", "dbo"])) - XCTAssertTrue(sql.contains("WHERE t.TABLE_SCHEMA IN (N'sales', N'dbo')\n")) - XCTAssertFalse(sql.contains("INFORMATION_SCHEMA.SCHEMATA")) - XCTAssertTrue(sql.hasPrefix("SELECT t.TABLE_NAME, t.TABLE_TYPE, t.TABLE_SCHEMA\n")) - XCTAssertTrue(sql.contains("AND t.TABLE_TYPE IN ('BASE TABLE', 'VIEW')")) - XCTAssertTrue(sql.hasSuffix("ORDER BY t.TABLE_SCHEMA, t.TABLE_NAME")) + #expect(sql.contains("WHERE t.TABLE_SCHEMA IN (N'sales', N'dbo')\n")) + #expect(!sql.contains("INFORMATION_SCHEMA.SCHEMATA")) + #expect(sql.hasPrefix("SELECT t.TABLE_NAME, t.TABLE_TYPE, t.TABLE_SCHEMA\n")) + #expect(sql.contains("AND t.TABLE_TYPE IN ('BASE TABLE', 'VIEW')")) + #expect(sql.hasSuffix("ORDER BY t.TABLE_SCHEMA, t.TABLE_NAME")) } - func testSchemaSetListingQuotesEachName() { + @Test func testSchemaSetListingQuotesEachName() { let sql = MSSQLSchemaQueries.tables(in: .schemas(["O'Brien", "日本"])) - XCTAssertTrue(sql.contains("t.TABLE_SCHEMA IN (N'O''Brien', N'日本')")) - XCTAssertFalse(sql.contains("N'O'Brien'")) + #expect(sql.contains("t.TABLE_SCHEMA IN (N'O''Brien', N'日本')")) + #expect(!sql.contains("N'O'Brien'")) } - func testEmptySchemaSetListingMatchesNothing() { + @Test func testEmptySchemaSetListingMatchesNothing() { let sql = MSSQLSchemaQueries.tables(in: .schemas([])) - XCTAssertTrue(sql.contains("WHERE 1 = 0\n")) - XCTAssertFalse(sql.contains("IN ()")) + #expect(sql.contains("WHERE 1 = 0\n")) + #expect(!(sql.contains("IN ()"))) } - func testSchemaListIsTheListedSchemasInOrder() { - XCTAssertEqual(MSSQLSchemaQueries.schemas, MSSQLSchemaQueries.listedSchemaNames + "\nORDER BY SCHEMA_NAME") - XCTAssertFalse(MSSQLSchemaQueries.listedSchemaNames.contains("ORDER BY")) + @Test func testSchemaListIsTheListedSchemasInOrder() { + #expect(MSSQLSchemaQueries.schemas == MSSQLSchemaQueries.listedSchemaNames + "\nORDER BY SCHEMA_NAME") + #expect(!MSSQLSchemaQueries.listedSchemaNames.contains("ORDER BY")) } - func testParseTableRowReadsTheSchemaColumnWhenPresent() { - XCTAssertEqual( - MSSQLSchemaQueries.parseTableRow(["orders", "BASE TABLE", "sales"]), - MSSQLTableRow(name: "orders", isView: false, schema: "sales") - ) - XCTAssertNil(MSSQLSchemaQueries.parseTableRow(["orders", "BASE TABLE"])?.schema) + @Test func testParseTableRowReadsTheSchemaColumnWhenPresent() { + #expect(MSSQLSchemaQueries.parseTableRow(["orders", "BASE TABLE", "sales"]) == MSSQLTableRow(name: "orders", isView: false, schema: "sales")) + #expect(MSSQLSchemaQueries.parseTableRow(["orders", "BASE TABLE"])?.schema == nil) } - func testColumnsQueryIncludesIdentityAndPrimaryKey() { + @Test func testColumnsQueryIncludesIdentityAndPrimaryKey() { let sql = MSSQLSchemaQueries.columns(schema: "dbo", table: "Users") - XCTAssertTrue(sql.contains("IsIdentity")) - XCTAssertTrue(sql.contains("PRIMARY KEY")) - XCTAssertTrue(sql.contains("N'Users'")) - XCTAssertTrue(sql.contains("N'dbo'")) + #expect(sql.contains("IsIdentity")) + #expect(sql.contains("PRIMARY KEY")) + #expect(sql.contains("N'Users'")) + #expect(sql.contains("N'dbo'")) } - func testColumnsQueryQuotesBothNamesBeforeResolvingTheObject() { + @Test func testColumnsQueryQuotesBothNamesBeforeResolvingTheObject() { let sql = MSSQLSchemaQueries.columns(schema: "x.y", table: "a.b") - XCTAssertTrue(sql.contains("OBJECT_ID(QUOTENAME(c.TABLE_SCHEMA) + '.' + QUOTENAME(c.TABLE_NAME))")) - XCTAssertFalse(sql.contains("OBJECT_ID(c.TABLE_SCHEMA + '.' + c.TABLE_NAME)")) + #expect(sql.contains("OBJECT_ID(QUOTENAME(c.TABLE_SCHEMA) + '.' + QUOTENAME(c.TABLE_NAME))")) + #expect(!(sql.contains("OBJECT_ID(c.TABLE_SCHEMA + '.' + c.TABLE_NAME)"))) } - func testColumnsQueryReportsRowversionAndPeriodColumnsAsServerGenerated() { + @Test func testColumnsQueryReportsRowversionAndPeriodColumnsAsServerGenerated() { let sql = MSSQLSchemaQueries.columns(schema: "dbo", table: "Users") - XCTAssertTrue(sql.contains("WHEN c.DATA_TYPE = 'timestamp' THEN 1")) - XCTAssertTrue(sql.contains("'GeneratedAlwaysType') > 0 THEN 1")) - XCTAssertTrue(sql.contains("AS IS_SERVER_GENERATED")) + #expect(sql.contains("WHEN c.DATA_TYPE = 'timestamp' THEN 1")) + #expect(sql.contains("'GeneratedAlwaysType') > 0 THEN 1")) + #expect(sql.contains("AS IS_SERVER_GENERATED")) } - func testAllColumnsQueryCarriesTheTableNameAfterTheParsedColumns() { + @Test func testAllColumnsQueryCarriesTheTableNameAfterTheParsedColumns() { let sql = MSSQLSchemaQueries.allColumns(schema: "dbo") - XCTAssertTrue(sql.contains("AS IS_SERVER_GENERATED,\n c.TABLE_NAME")) - XCTAssertTrue(sql.contains("c.TABLE_SCHEMA = N'dbo'")) - XCTAssertEqual(MSSQLSchemaQueries.allColumnsTableNameIndex, 11) + #expect(sql.contains("AS IS_SERVER_GENERATED,\n c.TABLE_NAME")) + #expect(sql.contains("c.TABLE_SCHEMA = N'dbo'")) + #expect(MSSQLSchemaQueries.allColumnsTableNameIndex == 11) } - func testParseColumnRowReadsServerGeneratedColumns() { + @Test func testParseColumnRowReadsServerGeneratedColumns() { let rowversion: [String?] = ["RV", "timestamp", nil, nil, nil, "NO", nil, "0", "0", "0", "1"] - XCTAssertEqual(MSSQLSchemaQueries.parseColumnRow(rowversion)?.isServerGenerated, true) - XCTAssertEqual(MSSQLSchemaQueries.parseColumnRow(rowversion)?.isGenerated, true) + #expect(MSSQLSchemaQueries.parseColumnRow(rowversion)?.isServerGenerated == true) + #expect(MSSQLSchemaQueries.parseColumnRow(rowversion)?.isGenerated == true) let computed: [String?] = ["Doubled", "int", nil, "10", "0", "YES", nil, "0", "0", "1", "0"] - XCTAssertEqual(MSSQLSchemaQueries.parseColumnRow(computed)?.isGenerated, true) - XCTAssertEqual(MSSQLSchemaQueries.parseColumnRow(computed)?.isServerGenerated, false) + #expect(MSSQLSchemaQueries.parseColumnRow(computed)?.isGenerated == true) + #expect(MSSQLSchemaQueries.parseColumnRow(computed)?.isServerGenerated == false) let plain: [String?] = ["Name", "nvarchar", "50", nil, nil, "YES", nil, "0", "0", "0", "0"] - XCTAssertEqual(MSSQLSchemaQueries.parseColumnRow(plain)?.isGenerated, false) + #expect(MSSQLSchemaQueries.parseColumnRow(plain)?.isGenerated == false) } - func testIndexesQueryUsesBracketedIdentifier() { + @Test func testIndexesQueryUsesBracketedIdentifier() { let sql = MSSQLSchemaQueries.indexes(schema: "dbo", table: "Users") - XCTAssertTrue(sql.contains("OBJECT_ID(N'[dbo].[Users]')")) - XCTAssertTrue(sql.contains("sys.indexes")) + #expect(sql.contains("OBJECT_ID(N'[dbo].[Users]')")) + #expect(sql.contains("sys.indexes")) } - func testForeignKeysQueryFiltersByTableAndSchema() { + @Test func testForeignKeysQueryFiltersByTableAndSchema() { let sql = MSSQLSchemaQueries.foreignKeys(schema: "dbo", table: "Orders") - XCTAssertTrue(sql.contains("sys.foreign_keys")) - XCTAssertTrue(sql.contains("N'Orders'")) - XCTAssertTrue(sql.contains("N'dbo'")) + #expect(sql.contains("sys.foreign_keys")) + #expect(sql.contains("N'Orders'")) + #expect(sql.contains("N'dbo'")) } - func testForeignKeysQuerySelectsReferencedSchema() { + @Test func testForeignKeysQuerySelectsReferencedSchema() { let sql = MSSQLSchemaQueries.foreignKeys(schema: "dbo", table: "Orders") - XCTAssertTrue(sql.contains("sr.name AS ref_schema")) - XCTAssertTrue(sql.contains("JOIN sys.schemas sr ON tr.schema_id = sr.schema_id")) + #expect(sql.contains("sr.name AS ref_schema")) + #expect(sql.contains("JOIN sys.schemas sr ON tr.schema_id = sr.schema_id")) } - func testParseTableRowDetectsView() { + @Test func testParseTableRowDetectsView() { let row: [String?] = ["v_active_users", "VIEW"] - XCTAssertEqual(MSSQLSchemaQueries.parseTableRow(row), MSSQLTableRow(name: "v_active_users", isView: true)) + #expect(MSSQLSchemaQueries.parseTableRow(row) == MSSQLTableRow(name: "v_active_users", isView: true)) } - func testParseTableRowDefaultsToTable() { + @Test func testParseTableRowDefaultsToTable() { let row: [String?] = ["users", "BASE TABLE"] - XCTAssertEqual(MSSQLSchemaQueries.parseTableRow(row), MSSQLTableRow(name: "users", isView: false)) + #expect(MSSQLSchemaQueries.parseTableRow(row) == MSSQLTableRow(name: "users", isView: false)) } - func testParseTableRowRejectsMissingName() { - XCTAssertNil(MSSQLSchemaQueries.parseTableRow([nil, "BASE TABLE"])) + @Test func testParseTableRowRejectsMissingName() { + #expect(MSSQLSchemaQueries.parseTableRow([nil, "BASE TABLE"]) == nil) } - func testParseColumnRowExtractsAllFields() { + @Test func testParseColumnRowExtractsAllFields() { let row: [String?] = ["id", "int", nil, "10", "0", "NO", nil, "1", "1"] let parsed = MSSQLSchemaQueries.parseColumnRow(row) - XCTAssertEqual(parsed?.name, "id") - XCTAssertEqual(parsed?.dataType, "int") - XCTAssertEqual(parsed?.isNullable, false) - XCTAssertEqual(parsed?.isIdentity, true) - XCTAssertEqual(parsed?.isPrimaryKey, true) + #expect(parsed?.name == "id") + #expect(parsed?.dataType == "int") + #expect(parsed?.isNullable == false) + #expect(parsed?.isIdentity == true) + #expect(parsed?.isPrimaryKey == true) } - func testColumnDisplayTypeForFixedSizeOmitsLength() { + @Test func testColumnDisplayTypeForFixedSizeOmitsLength() { let row: [String?] = ["id", "int", "4", nil, nil, "NO", nil, "0", "0"] - XCTAssertEqual(MSSQLSchemaQueries.parseColumnRow(row)?.displayType, "int") + #expect(MSSQLSchemaQueries.parseColumnRow(row)?.displayType == "int") } - func testColumnDisplayTypeForVarcharIncludesLength() { + @Test func testColumnDisplayTypeForVarcharIncludesLength() { let row: [String?] = ["name", "nvarchar", "100", nil, nil, "YES", nil, "0", "0"] - XCTAssertEqual(MSSQLSchemaQueries.parseColumnRow(row)?.displayType, "nvarchar(100)") + #expect(MSSQLSchemaQueries.parseColumnRow(row)?.displayType == "nvarchar(100)") } - func testColumnDisplayTypeForMaxLengthRendersMax() { + @Test func testColumnDisplayTypeForMaxLengthRendersMax() { let row: [String?] = ["body", "varchar", "-1", nil, nil, "YES", nil, "0", "0"] - XCTAssertEqual(MSSQLSchemaQueries.parseColumnRow(row)?.displayType, "varchar(max)") + #expect(MSSQLSchemaQueries.parseColumnRow(row)?.displayType == "varchar(max)") } - func testColumnDisplayTypeForDecimalIncludesPrecisionScale() { + @Test func testColumnDisplayTypeForDecimalIncludesPrecisionScale() { let row: [String?] = ["amount", "decimal", nil, "18", "2", "NO", nil, "0", "0"] - XCTAssertEqual(MSSQLSchemaQueries.parseColumnRow(row)?.displayType, "decimal(18,2)") + #expect(MSSQLSchemaQueries.parseColumnRow(row)?.displayType == "decimal(18,2)") } - func testParseIndexRowExtractsFlags() { + @Test func testParseIndexRowExtractsFlags() { let row: [String?] = ["IX_users_email", "1", "0", "email"] let parsed = MSSQLSchemaQueries.parseIndexRow(row) - XCTAssertEqual(parsed?.name, "IX_users_email") - XCTAssertEqual(parsed?.isUnique, true) - XCTAssertEqual(parsed?.isPrimary, false) - XCTAssertEqual(parsed?.columnName, "email") + #expect(parsed?.name == "IX_users_email") + #expect(parsed?.isUnique == true) + #expect(parsed?.isPrimary == false) + #expect(parsed?.columnName == "email") } - func testParseIndexRowRejectsMissingColumn() { - XCTAssertNil(MSSQLSchemaQueries.parseIndexRow(["IX_x", "0", "0", nil])) + @Test func testParseIndexRowRejectsMissingColumn() { + #expect(MSSQLSchemaQueries.parseIndexRow(["IX_x", "0", "0", nil]) == nil) } - func testParseForeignKeyRowExtractsAll() { + @Test func testParseForeignKeyRowExtractsAll() { let row: [String?] = ["FK_orders_users", "user_id", "users", "id", "sales"] let parsed = MSSQLSchemaQueries.parseForeignKeyRow(row) - XCTAssertEqual(parsed?.constraintName, "FK_orders_users") - XCTAssertEqual(parsed?.columnName, "user_id") - XCTAssertEqual(parsed?.referencedTable, "users") - XCTAssertEqual(parsed?.referencedColumn, "id") - XCTAssertEqual(parsed?.referencedSchema, "sales") + #expect(parsed?.constraintName == "FK_orders_users") + #expect(parsed?.columnName == "user_id") + #expect(parsed?.referencedTable == "users") + #expect(parsed?.referencedColumn == "id") + #expect(parsed?.referencedSchema == "sales") } - func testParseForeignKeyRowWithoutReferencedSchema() { + @Test func testParseForeignKeyRowWithoutReferencedSchema() { let row: [String?] = ["FK_orders_users", "user_id", "users", "id"] - XCTAssertNil(MSSQLSchemaQueries.parseForeignKeyRow(row)?.referencedSchema) + #expect(MSSQLSchemaQueries.parseForeignKeyRow(row)?.referencedSchema == nil) } - func testQualifiedNamePrefixesSchema() { - XCTAssertEqual(MSSQLSchemaQueries.qualifiedName(schema: "sales", table: "routeCache"), "[sales].[routeCache]") + @Test func testQualifiedNamePrefixesSchema() { + #expect(MSSQLSchemaQueries.qualifiedName(schema: "sales", table: "routeCache") == "[sales].[routeCache]") } - func testQualifiedNameOmitsSchemaWhenNilOrEmpty() { - XCTAssertEqual(MSSQLSchemaQueries.qualifiedName(schema: nil, table: "routeCache"), "[routeCache]") - XCTAssertEqual(MSSQLSchemaQueries.qualifiedName(schema: "", table: "routeCache"), "[routeCache]") + @Test func testQualifiedNameOmitsSchemaWhenNilOrEmpty() { + #expect(MSSQLSchemaQueries.qualifiedName(schema: nil, table: "routeCache") == "[routeCache]") + #expect(MSSQLSchemaQueries.qualifiedName(schema: "", table: "routeCache") == "[routeCache]") } - func testBrowseQualifiesNonDefaultSchema() { + @Test func testBrowseQualifiesNonDefaultSchema() { let sql = MSSQLSchemaQueries.browse( schema: "sales", table: "routeCache", orderByClause: "ORDER BY (SELECT NULL)", offset: 0, limit: 200 ) - XCTAssertEqual( - sql, - "SELECT * FROM [sales].[routeCache] ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT 200 ROWS ONLY" - ) + #expect(sql == "SELECT * FROM [sales].[routeCache] ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT 200 ROWS ONLY") } - func testBrowseWithoutSchemaStaysUnqualified() { + @Test func testBrowseWithoutSchemaStaysUnqualified() { let sql = MSSQLSchemaQueries.browse( schema: nil, table: "routeCache", orderByClause: "ORDER BY (SELECT NULL)", offset: 10, limit: 50 ) - XCTAssertEqual( - sql, - "SELECT * FROM [routeCache] ORDER BY (SELECT NULL) OFFSET 10 ROWS FETCH NEXT 50 ROWS ONLY" - ) + #expect(sql == "SELECT * FROM [routeCache] ORDER BY (SELECT NULL) OFFSET 10 ROWS FETCH NEXT 50 ROWS ONLY") } - func testFilteredQualifiesSchemaAndAppendsWhere() { + @Test func testFilteredQualifiesSchemaAndAppendsWhere() { let sql = MSSQLSchemaQueries.filtered( schema: "sales", table: "routeCache", whereClause: "[id] = 1", orderByClause: "ORDER BY (SELECT NULL)", offset: 0, limit: 200 ) - XCTAssertEqual( - sql, - "SELECT * FROM [sales].[routeCache] WHERE [id] = 1 ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT 200 ROWS ONLY" - ) + #expect(sql == "SELECT * FROM [sales].[routeCache] WHERE [id] = 1 ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT 200 ROWS ONLY") } - func testFilteredOmitsWhenWhereClauseEmpty() { + @Test func testFilteredOmitsWhenWhereClauseEmpty() { let sql = MSSQLSchemaQueries.filtered( schema: "sales", table: "routeCache", whereClause: "", orderByClause: "ORDER BY (SELECT NULL)", offset: 0, limit: 200 ) - XCTAssertEqual( - sql, - "SELECT * FROM [sales].[routeCache] ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT 200 ROWS ONLY" - ) + #expect(sql == "SELECT * FROM [sales].[routeCache] ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT 200 ROWS ONLY") } - func testCatalogPredicatesSurviveANonAsciiName() { + @Test func testCatalogPredicatesSurviveANonAsciiName() { let columns = MSSQLSchemaQueries.columns(schema: "dbo", table: "顧客テーブル") - XCTAssertTrue(columns.contains("N'顧客テーブル'")) - XCTAssertFalse(columns.contains("= '顧客テーブル'")) + #expect(columns.contains("N'顧客テーブル'")) + #expect(!columns.contains("= '顧客テーブル'")) let indexes = MSSQLSchemaQueries.indexes(schema: "dbo", table: "顧客テーブル") - XCTAssertTrue(indexes.contains("OBJECT_ID(N'[dbo].[顧客テーブル]')")) + #expect(indexes.contains("OBJECT_ID(N'[dbo].[顧客テーブル]')")) let foreignKeys = MSSQLSchemaQueries.foreignKeys(schema: "スキーマ", table: "顧客テーブル") - XCTAssertTrue(foreignKeys.contains("N'顧客テーブル'")) - XCTAssertTrue(foreignKeys.contains("N'スキーマ'")) + #expect(foreignKeys.contains("N'顧客テーブル'")) + #expect(foreignKeys.contains("N'スキーマ'")) } - func testFixedCatalogConstantsStayUnprefixed() { + @Test func testFixedCatalogConstantsStayUnprefixed() { let tables = MSSQLSchemaQueries.tables(schema: "dbo") - XCTAssertTrue(tables.contains("IN ('BASE TABLE', 'VIEW')")) - XCTAssertTrue(MSSQLSchemaQueries.columns(schema: "dbo", table: "t").contains("= 'PRIMARY KEY'")) - XCTAssertTrue(MSSQLSchemaQueries.schemas.contains("'information_schema'")) + #expect(tables.contains("IN ('BASE TABLE', 'VIEW')")) + #expect(MSSQLSchemaQueries.columns(schema: "dbo", table: "t").contains("= 'PRIMARY KEY'")) + #expect(MSSQLSchemaQueries.schemas.contains("'information_schema'")) } } diff --git a/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLStringLiteralTests.swift b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLStringLiteralTests.swift index 6cbddb50ae..efccc5ce98 100644 --- a/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLStringLiteralTests.swift +++ b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLStringLiteralTests.swift @@ -1,112 +1,80 @@ +import Foundation @testable import TableProMSSQLCore -import XCTest +import Testing -final class MSSQLStringLiteralTests: XCTestCase { - func testQuotedPrefixesWithN() { - XCTAssertEqual(MSSQLStringLiteral.quoted("plain"), "N'plain'") +struct MSSQLStringLiteralTests { + @Test func testQuotedPrefixesWithN() { + #expect(MSSQLStringLiteral.quoted("plain") == "N'plain'") } - func testQuotedKeepsNonAsciiText() { - XCTAssertEqual(MSSQLStringLiteral.quoted("日本語メール"), "N'日本語メール'") + @Test func testQuotedKeepsNonAsciiText() { + #expect(MSSQLStringLiteral.quoted("日本語メール") == "N'日本語メール'") } - func testQuotedDoublesSingleQuotes() { - XCTAssertEqual(MSSQLStringLiteral.quoted("O'Brien"), "N'O''Brien'") - XCTAssertEqual(MSSQLStringLiteral.quoted("'; DROP TABLE t --"), "N'''; DROP TABLE t --'") + @Test func testQuotedDoublesSingleQuotes() { + #expect(MSSQLStringLiteral.quoted("O'Brien") == "N'O''Brien'") + #expect(MSSQLStringLiteral.quoted("'; DROP TABLE t --") == "N'''; DROP TABLE t --'") } - func testQuotedHandlesEmptyString() { - XCTAssertEqual(MSSQLStringLiteral.quoted(""), "N''") + @Test func testQuotedHandlesEmptyString() { + #expect(MSSQLStringLiteral.quoted("") == "N''") } - func testEscapedReturnsBodyWithoutQuotes() { - XCTAssertEqual(MSSQLStringLiteral.escaped("O'Brien"), "O''Brien") - XCTAssertEqual(MSSQLStringLiteral.escaped("plain"), "plain") + @Test func testEscapedReturnsBodyWithoutQuotes() { + #expect(MSSQLStringLiteral.escaped("O'Brien") == "O''Brien") + #expect(MSSQLStringLiteral.escaped("plain") == "plain") } - func testLikePatternWrapsWildcardsInsideTheLiteral() { - XCTAssertEqual( - MSSQLStringLiteral.likePattern("メール", prefixWildcard: true, suffixWildcard: true), - "N'%メール%' ESCAPE '\\'" - ) - XCTAssertEqual( - MSSQLStringLiteral.likePattern("abc", prefixWildcard: false, suffixWildcard: true), - "N'abc%' ESCAPE '\\'" - ) - XCTAssertEqual( - MSSQLStringLiteral.likePattern("abc", prefixWildcard: true, suffixWildcard: false), - "N'%abc' ESCAPE '\\'" - ) + @Test func testLikePatternWrapsWildcardsInsideTheLiteral() { + #expect(MSSQLStringLiteral.likePattern("メール", prefixWildcard: true, suffixWildcard: true) == "N'%メール%' ESCAPE '\\'") + #expect(MSSQLStringLiteral.likePattern("abc", prefixWildcard: false, suffixWildcard: true) == "N'abc%' ESCAPE '\\'") + #expect(MSSQLStringLiteral.likePattern("abc", prefixWildcard: true, suffixWildcard: false) == "N'%abc' ESCAPE '\\'") } - func testLikePatternEscapesUserWildcards() { - XCTAssertEqual( - MSSQLStringLiteral.likePattern("50%_off", prefixWildcard: true, suffixWildcard: true), - "N'%50\\%\\_off%' ESCAPE '\\'" - ) + @Test func testLikePatternEscapesUserWildcards() { + #expect(MSSQLStringLiteral.likePattern("50%_off", prefixWildcard: true, suffixWildcard: true) == "N'%50\\%\\_off%' ESCAPE '\\'") } /// `[` opens a character class in T-SQL and in no other engine this app speaks to, so a /// filter for `a[bc]` matched `ab` and `ac` and never the text the user typed. - func testLikePatternEscapesBracketWildcards() { - XCTAssertEqual( - MSSQLStringLiteral.likePattern("a[bc]", prefixWildcard: true, suffixWildcard: true), - "N'%a\\[bc]%' ESCAPE '\\'" - ) + @Test func testLikePatternEscapesBracketWildcards() { + #expect(MSSQLStringLiteral.likePattern("a[bc]", prefixWildcard: true, suffixWildcard: true) == "N'%a\\[bc]%' ESCAPE '\\'") } - func testLikePatternEscapesBackslashBeforeWildcards() { - XCTAssertEqual( - MSSQLStringLiteral.likePattern("a\\b", prefixWildcard: false, suffixWildcard: false), - "N'a\\\\b' ESCAPE '\\'" - ) + @Test func testLikePatternEscapesBackslashBeforeWildcards() { + #expect(MSSQLStringLiteral.likePattern("a\\b", prefixWildcard: false, suffixWildcard: false) == "N'a\\\\b' ESCAPE '\\'") } - func testLikePatternDoublesSingleQuotes() { - XCTAssertEqual( - MSSQLStringLiteral.likePattern("O'Brien", prefixWildcard: true, suffixWildcard: true), - "N'%O''Brien%' ESCAPE '\\'" - ) + @Test func testLikePatternDoublesSingleQuotes() { + #expect(MSSQLStringLiteral.likePattern("O'Brien", prefixWildcard: true, suffixWildcard: true) == "N'%O''Brien%' ESCAPE '\\'") } - func testLikeConditionCoversTheFourPatternOperators() { - XCTAssertEqual( - MSSQLStringLiteral.likeCondition(quotedColumn: "[n]", op: "CONTAINS", value: "日本"), - "[n] LIKE N'%日本%' ESCAPE '\\'" - ) - XCTAssertEqual( - MSSQLStringLiteral.likeCondition(quotedColumn: "[n]", op: "NOT CONTAINS", value: "日本"), - "[n] NOT LIKE N'%日本%' ESCAPE '\\'" - ) - XCTAssertEqual( - MSSQLStringLiteral.likeCondition(quotedColumn: "[n]", op: "STARTS WITH", value: "日本"), - "[n] LIKE N'日本%' ESCAPE '\\'" - ) - XCTAssertEqual( - MSSQLStringLiteral.likeCondition(quotedColumn: "[n]", op: "ENDS WITH", value: "日本"), - "[n] LIKE N'%日本' ESCAPE '\\'" - ) + @Test func testLikeConditionCoversTheFourPatternOperators() { + #expect(MSSQLStringLiteral.likeCondition(quotedColumn: "[n]", op: "CONTAINS", value: "日本") == "[n] LIKE N'%日本%' ESCAPE '\\'") + #expect(MSSQLStringLiteral.likeCondition(quotedColumn: "[n]", op: "NOT CONTAINS", value: "日本") == "[n] NOT LIKE N'%日本%' ESCAPE '\\'") + #expect(MSSQLStringLiteral.likeCondition(quotedColumn: "[n]", op: "STARTS WITH", value: "日本") == "[n] LIKE N'日本%' ESCAPE '\\'") + #expect(MSSQLStringLiteral.likeCondition(quotedColumn: "[n]", op: "ENDS WITH", value: "日本") == "[n] LIKE N'%日本' ESCAPE '\\'") } - func testLikeConditionLeavesOtherOperatorsToTheSharedBuilder() { - XCTAssertNil(MSSQLStringLiteral.likeCondition(quotedColumn: "[n]", op: "=", value: "x")) - XCTAssertNil(MSSQLStringLiteral.likeCondition(quotedColumn: "[n]", op: "IN", value: "a,b")) - XCTAssertNil(MSSQLStringLiteral.likeCondition(quotedColumn: "[n]", op: "IS NULL", value: "")) - XCTAssertNil(MSSQLStringLiteral.likeCondition(quotedColumn: "[n]", op: "REGEX", value: "x")) + @Test func testLikeConditionLeavesOtherOperatorsToTheSharedBuilder() { + #expect(MSSQLStringLiteral.likeCondition(quotedColumn: "[n]", op: "=", value: "x") == nil) + #expect(MSSQLStringLiteral.likeCondition(quotedColumn: "[n]", op: "IN", value: "a,b") == nil) + #expect(MSSQLStringLiteral.likeCondition(quotedColumn: "[n]", op: "IS NULL", value: "") == nil) + #expect(MSSQLStringLiteral.likeCondition(quotedColumn: "[n]", op: "REGEX", value: "x") == nil) } /// A default `replacingOccurrences` matches whole grapheme clusters, so an apostrophe followed by a combining or /// format mark was left single and ended the literal early. - func testApostropheBeforeCombiningMarkIsDoubled() { - XCTAssertEqual(MSSQLStringLiteral.escaped("a'\u{301}b"), "a''\u{301}b") + @Test func testApostropheBeforeCombiningMarkIsDoubled() { + #expect(MSSQLStringLiteral.escaped("a'\u{301}b") == "a''\u{301}b") } - func testApostropheBeforeZeroWidthJoinerIsDoubled() { - XCTAssertEqual(MSSQLStringLiteral.quoted("a'\u{200D}b"), "N'a''\u{200D}b'") + @Test func testApostropheBeforeZeroWidthJoinerIsDoubled() { + #expect(MSSQLStringLiteral.quoted("a'\u{200D}b") == "N'a''\u{200D}b'") } - func testLikeWildcardBeforeCombiningMarkIsEscaped() { - XCTAssertEqual(MSSQLStringLiteral.escapeForLike("5%\u{301}"), "5\\%\u{301}") - XCTAssertEqual(MSSQLStringLiteral.escapeForLike("a_\u{200D}[\u{301}"), "a\\_\u{200D}\\[\u{301}") + @Test func testLikeWildcardBeforeCombiningMarkIsEscaped() { + #expect(MSSQLStringLiteral.escapeForLike("5%\u{301}") == "5\\%\u{301}") + #expect(MSSQLStringLiteral.escapeForLike("a_\u{200D}[\u{301}") == "a\\_\u{200D}\\[\u{301}") } } diff --git a/Packages/TableProCore/Tests/TableProModelsTests/SSHConfigurationTests.swift b/Packages/TableProCore/Tests/TableProModelsTests/SSHConfigurationTests.swift index 1487d341d4..196971d8f3 100644 --- a/Packages/TableProCore/Tests/TableProModelsTests/SSHConfigurationTests.swift +++ b/Packages/TableProCore/Tests/TableProModelsTests/SSHConfigurationTests.swift @@ -12,14 +12,9 @@ struct SSHConfigurationTests { return try JSONDecoder().decode(SSHConfiguration.self, from: Data(json.utf8)) } - @Test("decodes the macOS None raw value") - func decodesMacOSNone() throws { - #expect(try decode(authMethod: "None").authMethod == .none) - } - - @Test("decodes the lowercase none raw value") - func decodesLowercaseNone() throws { - #expect(try decode(authMethod: "none").authMethod == .none) + @Test("decodes the macOS None raw value and the lowercase none", arguments: ["None", "none"]) + func decodesNone(_ raw: String) throws { + #expect(try decode(authMethod: raw).authMethod == .none) } @Test("None survives an encode and decode round trip") diff --git a/Packages/TableProCore/Tests/TableProMySQLCoreTests/MySQLSocketTimeoutTests.swift b/Packages/TableProCore/Tests/TableProMySQLCoreTests/MySQLSocketTimeoutTests.swift index fb0cc640a4..471417482c 100644 --- a/Packages/TableProCore/Tests/TableProMySQLCoreTests/MySQLSocketTimeoutTests.swift +++ b/Packages/TableProCore/Tests/TableProMySQLCoreTests/MySQLSocketTimeoutTests.swift @@ -8,14 +8,9 @@ import Testing @testable import TableProMySQLCore struct MySQLSocketTimeoutTests { - @Test("No limit maps to an infinite socket timeout") - func noLimitIsInfinite() { - #expect(mysqlSocketTimeoutSeconds(forQueryTimeout: 0) == 0) - } - - @Test("A negative query timeout maps to an infinite socket timeout") - func negativeIsInfinite() { - #expect(mysqlSocketTimeoutSeconds(forQueryTimeout: -5) == 0) + @Test("No limit, or a negative query timeout, maps to an infinite socket timeout", arguments: [0, -5]) + func noLimitIsInfinite(_ queryTimeout: Int) { + #expect(mysqlSocketTimeoutSeconds(forQueryTimeout: queryTimeout) == 0) } @Test("A finite query timeout adds the grace period") diff --git a/Packages/TableProCore/Tests/TableProNumberFormattingTests/JSONWriterEquivalenceTests.swift b/Packages/TableProCore/Tests/TableProNumberFormattingTests/JSONWriterEquivalenceTests.swift index 84ddcfd8f9..3e0ce162b5 100644 --- a/Packages/TableProCore/Tests/TableProNumberFormattingTests/JSONWriterEquivalenceTests.swift +++ b/Packages/TableProCore/Tests/TableProNumberFormattingTests/JSONWriterEquivalenceTests.swift @@ -1,5 +1,6 @@ +import Foundation @testable import TableProNumberFormatting -import XCTest +import Testing private struct Seeded: RandomNumberGenerator { private var state: UInt64 @@ -13,7 +14,7 @@ private struct Seeded: RandomNumberGenerator { } } -final class JSONWriterEquivalenceTests: XCTestCase { +struct JSONWriterEquivalenceTests { private let alphabet: [String] = [ "", "a", "a/b", "q\"x", "back\\slash", "tab\there", "nl\nhere", "cr\rhere", "\u{08}\u{0C}", "\u{01}\u{1F}", "café → 日本", "\u{7F}", "emoji 🎉", "key.with.dots", @@ -50,7 +51,7 @@ final class JSONWriterEquivalenceTests: XCTestCase { /// with one deliberate exception: escaping `/` is optional in JSON and the writer does not do /// it, so the reference encoder is asked not to either. A path, URL or MQTT topic reads and /// copies as it was stored instead of arriving as `device\/state\/up`. - func testWriterMatchesJSONEncoderByteForByte() throws { + @Test func testWriterMatchesJSONEncoderByteForByte() throws { var g = Seeded(seed: 0x5EED_1501_0000_0001) var compared = 0 for _ in 0 ..< 4_000 { @@ -58,9 +59,9 @@ final class JSONWriterEquivalenceTests: XCTestCase { guard let mine = NumberText.json(from: value) else { continue } guard let reference = referenceJSON(value) else { continue } compared += 1 - XCTAssertEqual(mine, reference, "diverged for \(value)") + #expect(mine == reference, "diverged for \(value)") } - XCTAssertGreaterThan(compared, 1_000) + #expect(compared > 1_000) } private func referenceJSON(_ value: Any) -> String? { diff --git a/Packages/TableProCore/Tests/TableProNumberFormattingTests/NumberTextTests.swift b/Packages/TableProCore/Tests/TableProNumberFormattingTests/NumberTextTests.swift index ad72f1897c..2b2d8d9237 100644 --- a/Packages/TableProCore/Tests/TableProNumberFormattingTests/NumberTextTests.swift +++ b/Packages/TableProCore/Tests/TableProNumberFormattingTests/NumberTextTests.swift @@ -1,5 +1,6 @@ +import Foundation @testable import TableProNumberFormatting -import XCTest +import Testing private struct SplitMix64: RandomNumberGenerator { private var state: UInt64 @@ -17,236 +18,221 @@ private struct SplitMix64: RandomNumberGenerator { } } -final class NumberTextTests: XCTestCase { - func testGoldenDoubleStrings() { - XCTAssertEqual(NumberText.text(for: 1_847.27), "1847.27") - XCTAssertEqual(NumberText.text(for: 0.1), "0.1") - XCTAssertEqual(NumberText.text(for: 1.0), "1") - XCTAssertEqual(NumberText.text(for: 100.0), "100") - XCTAssertEqual(NumberText.text(for: 1.0 / 3.0), "0.3333333333333333") - XCTAssertEqual(NumberText.text(for: 2.6243074014352636e-25), "2.6243074014352636e-25") - XCTAssertEqual(NumberText.text(for: -3.9192320754595876e-07), "-3.9192320754595876e-07") +struct NumberTextTests { + @Test func testGoldenDoubleStrings() { + #expect(NumberText.text(for: 1_847.27) == "1847.27") + #expect(NumberText.text(for: 0.1) == "0.1") + #expect(NumberText.text(for: 1.0) == "1") + #expect(NumberText.text(for: 100.0) == "100") + #expect(NumberText.text(for: 1.0 / 3.0) == "0.3333333333333333") + #expect(NumberText.text(for: 2.6243074014352636e-25) == "2.6243074014352636e-25") + #expect(NumberText.text(for: -3.9192320754595876e-07) == "-3.9192320754595876e-07") } - func testShortestRoundTripBeatsNSNumberStringValue() { + @Test func testShortestRoundTripBeatsNSNumberStringValue() { let value = -3.9192320754595876e-07 - XCTAssertNotEqual(Double(NSNumber(value: value).stringValue), value) - XCTAssertEqual(Double(NumberText.text(for: value)), value) + #expect(Double(NSNumber(value: value).stringValue) != value) + #expect(Double(NumberText.text(for: value)) == value) } - func testDecimalNumbersKeepTheirExactDigits() throws { + @Test func testDecimalNumbersKeepTheirExactDigits() throws { for literal in ["99999999999999999999", "0.12345678901234567890123"] { let parsed = try JSONSerialization.jsonObject(with: Data(#"{"v": \#(literal)}"#.utf8)) - let number = try XCTUnwrap((parsed as? [String: Any])?["v"] as? NSNumber) - XCTAssertTrue(number is NSDecimalNumber, "expected NSDecimalNumber for \(literal)") - XCTAssertEqual(NumberText.text(for: number), literal) + let number = try #require((parsed as? [String: Any])?["v"] as? NSNumber) + #expect(number is NSDecimalNumber, "expected NSDecimalNumber for \(literal)") + #expect(NumberText.text(for: number) == literal) } } - func testDecimalNumbersKeepTheirExactDigitsInNestedJson() throws { + @Test func testDecimalNumbersKeepTheirExactDigitsInNestedJson() throws { let parsed = try JSONSerialization.jsonObject(with: Data(#"{"big": 99999999999999999999}"#.utf8)) - let value = try XCTUnwrap(parsed as? [String: Any]) - XCTAssertEqual(NumberText.json(from: value), #"{"big":99999999999999999999}"#) + let value = try #require(parsed as? [String: Any]) + #expect(NumberText.json(from: value) == #"{"big":99999999999999999999}"#) } - func testBooleanNumbersKeepTheNumericFormAtThisLayer() { - XCTAssertEqual(NumberText.text(for: NSNumber(value: true)), "1") - XCTAssertEqual(NumberText.text(for: NSNumber(value: false)), "0") + @Test func testBooleanNumbersKeepTheNumericFormAtThisLayer() { + #expect(NumberText.text(for: NSNumber(value: true)) == "1") + #expect(NumberText.text(for: NSNumber(value: false)) == "0") } - func testBooleansStayTrueAndFalseInsideJson() { - XCTAssertEqual(NumberText.json(from: ["flag": true]), #"{"flag":true}"#) + @Test func testBooleansStayTrueAndFalseInsideJson() { + #expect(NumberText.json(from: ["flag": true]) == #"{"flag":true}"#) } - func testForwardSlashesAreNotEscaped() { - XCTAssertEqual( - NumberText.json(from: ["topic": "device/state/up/122"]), - #"{"topic":"device/state/up/122"}"# - ) + @Test func testForwardSlashesAreNotEscaped() { + #expect(NumberText.json(from: ["topic": "device/state/up/122"]) == #"{"topic":"device/state/up/122"}"#) } - func testForwardSlashesStayUnescapedInsideNestedValues() { + @Test func testForwardSlashesStayUnescapedInsideNestedValues() { let value: [String: Any] = ["destinations": [["topic": "device/state/up/122"]]] - XCTAssertEqual( - NumberText.json(from: value), - #"{"destinations":[{"topic":"device/state/up/122"}]}"# - ) + #expect(NumberText.json(from: value) == #"{"destinations":[{"topic":"device/state/up/122"}]}"#) } - func testBackslashesAreStillEscaped() { - XCTAssertEqual( - NumberText.json(from: ["path": #"a\b"#]), - #"{"path":"a\\b"}"# - ) + @Test func testBackslashesAreStillEscaped() { + #expect(NumberText.json(from: ["path": #"a\b"#]) == #"{"path":"a\\b"}"#) } - func testPrettyPrintedJsonUsesTheSameNumbers() { + @Test func testPrettyPrintedJsonUsesTheSameNumbers() { let json = NumberText.json(from: ["rate": 0.1], prettyPrinted: true) - XCTAssertEqual(json?.contains("0.1"), true) - XCTAssertEqual(json?.contains("0.10000000000000001"), false) + #expect(json?.contains("0.1") == true) + #expect(json?.contains("0.10000000000000001") == false) } - func testDoubleTextRoundTripsOverSeededSample() { + @Test func testDoubleTextRoundTripsOverSeededSample() { var generator = SplitMix64(seed: 0x5EED_1847_2700_0001) var checked = 0 for _ in 0 ..< 200_000 { let value = Double(bitPattern: UInt64.random(in: 0 ... UInt64.max, using: &generator)) guard value.isFinite else { continue } checked += 1 - XCTAssertEqual(Double(NumberText.text(for: value)), value) + #expect(Double(NumberText.text(for: value)) == value) } - XCTAssertGreaterThan(checked, 100_000) + #expect(checked > 100_000) } - func testFloatIsNotWidenedToDouble() { + @Test func testFloatIsNotWidenedToDouble() { let value: Float = 1_847.27 - XCTAssertEqual(Double(value).description, "1847.27001953125") - XCTAssertEqual(NumberText.text(for: value), "1847.27") - XCTAssertEqual(NumberText.text(for: NSNumber(value: value)), "1847.27") + #expect(Double(value).description == "1847.27001953125") + #expect(NumberText.text(for: value) == "1847.27") + #expect(NumberText.text(for: NSNumber(value: value)) == "1847.27") } - func testFloatTextRoundTripsOverSeededSample() { + @Test func testFloatTextRoundTripsOverSeededSample() { var generator = SplitMix64(seed: 0x5EED_0000_F10A_7000) for _ in 0 ..< 200_000 { let value = Float(bitPattern: UInt32.random(in: 0 ... UInt32.max, using: &generator)) guard value.isFinite else { continue } - XCTAssertEqual(Float(NumberText.text(for: value)), value) + #expect(Float(NumberText.text(for: value)) == value) } } - func testExponentialFormsAreNeverTrimmed() { + @Test func testExponentialFormsAreNeverTrimmed() { var generator = SplitMix64(seed: 0x5EED_E4A0_0000_0011) for _ in 0 ..< 200_000 { let value = Double(bitPattern: UInt64.random(in: 0 ... UInt64.max, using: &generator)) guard value.isFinite else { continue } let text = NumberText.text(for: value) if text.contains("e") { - XCTAssertFalse(text.hasSuffix(".0")) + #expect(!text.hasSuffix(".0")) } } } - func testBoundaryValues() { - XCTAssertEqual(NumberText.text(for: Double.leastNonzeroMagnitude), "5e-324") - XCTAssertEqual(NumberText.text(for: Double.greatestFiniteMagnitude), "1.7976931348623157e+308") - XCTAssertEqual(NumberText.text(for: -0.0), "-0") - XCTAssertEqual(NumberText.text(for: 0.0), "0") - XCTAssertEqual(Double(NumberText.text(for: -0.0))?.sign, .minus) + @Test func testBoundaryValues() { + #expect(NumberText.text(for: Double.leastNonzeroMagnitude) == "5e-324") + #expect(NumberText.text(for: Double.greatestFiniteMagnitude) == "1.7976931348623157e+308") + #expect(NumberText.text(for: -0.0) == "-0") + #expect(NumberText.text(for: 0.0) == "0") + #expect(Double(NumberText.text(for: -0.0))?.sign == .minus) } - func testIntegerNumbersAreUnchanged() { - XCTAssertEqual(NumberText.text(for: NSNumber(value: Int64.max)), "9223372036854775807") - XCTAssertEqual(NumberText.text(for: NSNumber(value: Int64.min)), "-9223372036854775808") - XCTAssertEqual(NumberText.text(for: NSNumber(value: UInt64.max)), "18446744073709551615") + @Test func testIntegerNumbersAreUnchanged() { + #expect(NumberText.text(for: NSNumber(value: Int64.max)) == "9223372036854775807") + #expect(NumberText.text(for: NSNumber(value: Int64.min)) == "-9223372036854775808") + #expect(NumberText.text(for: NSNumber(value: UInt64.max)) == "18446744073709551615") } - func testNestedJsonUsesShortestRoundTripNumbers() { + @Test func testNestedJsonUsesShortestRoundTripNumbers() { let value: [String: Any] = ["label": "a", "rate": 0.1, "ratio": 1.0 / 3.0, "qty": 3.0] - XCTAssertEqual( - NumberText.json(from: value), - #"{"label":"a","qty":3,"rate":0.1,"ratio":0.3333333333333333}"# - ) + #expect(NumberText.json(from: value) == #"{"label":"a","qty":3,"rate":0.1,"ratio":0.3333333333333333}"#) } - func testNestedJsonDiffersFromJSONSerialization() throws { + @Test func testNestedJsonDiffersFromJSONSerialization() throws { let value: [String: Any] = ["rate": 0.1] let serialized = try JSONSerialization.data(withJSONObject: value, options: [.sortedKeys]) - XCTAssertEqual(String(data: serialized, encoding: .utf8), #"{"rate":0.10000000000000001}"#) - XCTAssertEqual(NumberText.json(from: value), #"{"rate":0.1}"#) + #expect(String(data: serialized, encoding: .utf8) == #"{"rate":0.10000000000000001}"#) + #expect(NumberText.json(from: value) == #"{"rate":0.1}"#) } - func testNestedJsonHandlesArraysNullsAndBooleans() { + @Test func testNestedJsonHandlesArraysNullsAndBooleans() { let value: [String: Any] = [ "items": [1_847.27, 1.0, "x", NSNull()], "flag": true, "count": 5 ] - XCTAssertEqual( - NumberText.json(from: value), - #"{"count":5,"flag":true,"items":[1847.27,1,"x",null]}"# - ) + #expect(NumberText.json(from: value) == #"{"count":5,"flag":true,"items":[1847.27,1,"x",null]}"#) } - func testNestedJsonKeysAreSorted() { + @Test func testNestedJsonKeysAreSorted() { let value: [String: Any] = ["b": 1.5, "a": 2.5, "c": 3.5] - XCTAssertEqual(NumberText.json(from: value), #"{"a":2.5,"b":1.5,"c":3.5}"#) + #expect(NumberText.json(from: value) == #"{"a":2.5,"b":1.5,"c":3.5}"#) } - func testNestedJsonRejectsUnsupportedAndNonFiniteValues() { - XCTAssertNil(NumberText.json(from: ["date": Date()])) - XCTAssertNil(NumberText.json(from: ["value": Double.nan])) - XCTAssertNil(NumberText.json(from: ["value": Double.infinity])) + @Test func testNestedJsonRejectsUnsupportedAndNonFiniteValues() { + #expect(NumberText.json(from: ["date": Date()]) == nil) + #expect(NumberText.json(from: ["value": Double.nan]) == nil) + #expect(NumberText.json(from: ["value": Double.infinity]) == nil) } - func testRawNumberIsWrittenVerbatimAtAnyPrecision() { + @Test func testRawNumberIsWrittenVerbatimAtAnyPrecision() { let digits = "12345678901234567890123456789012" let raw = NumberText.RawNumber(digits) - XCTAssertNotNil(raw) - XCTAssertEqual(NumberText.json(from: ["v": raw as Any]), #"{"v":\#(digits)}"#) - XCTAssertEqual(NumberText.json(from: ["v": NumberText.RawNumber("0.100") as Any]), #"{"v":0.100}"#) + #expect(raw != nil) + #expect(NumberText.json(from: ["v": raw as Any]) == #"{"v":\#(digits)}"#) + #expect(NumberText.json(from: ["v": NumberText.RawNumber("0.100") as Any]) == #"{"v":0.100}"#) } - func testRawNumberRejectsAnythingThatIsNotAJsonNumber() { - XCTAssertNil(NumberText.RawNumber("NaN")) - XCTAssertNil(NumberText.RawNumber("Infinity")) - XCTAssertNil(NumberText.RawNumber("-Infinity")) - XCTAssertNil(NumberText.RawNumber("")) - XCTAssertNil(NumberText.RawNumber("1.2.3")) + @Test func testRawNumberRejectsAnythingThatIsNotAJsonNumber() { + #expect(NumberText.RawNumber("NaN") == nil) + #expect(NumberText.RawNumber("Infinity") == nil) + #expect(NumberText.RawNumber("-Infinity") == nil) + #expect(NumberText.RawNumber("") == nil) + #expect(NumberText.RawNumber("1.2.3") == nil) } - func testJsonNumberLiteralAcceptsWidthsBeyondInt64() { - XCTAssertTrue(NumberText.isJSONNumberLiteral("12345678901234567890123456789012")) - XCTAssertFalse(NumberText.isJSONNumber("12345678901234567890123456789012")) - XCTAssertFalse(NumberText.isJSONNumberLiteral("007")) - XCTAssertFalse(NumberText.isJSONNumberLiteral("NaN")) + @Test func testJsonNumberLiteralAcceptsWidthsBeyondInt64() { + #expect(NumberText.isJSONNumberLiteral("12345678901234567890123456789012")) + #expect(!NumberText.isJSONNumber("12345678901234567890123456789012")) + #expect(!NumberText.isJSONNumberLiteral("007")) + #expect(!NumberText.isJSONNumberLiteral("NaN")) } - func testTruncationOnlyMarksTextItActuallyCut() { + @Test func testTruncationOnlyMarksTextItActuallyCut() { let long = String(repeating: "a", count: 50) - XCTAssertEqual(JSONTruncation.truncate(#"{"a":1}"#, maxLength: 100), #"{"a":1}"#) - XCTAssertTrue(JSONTruncation.truncate(long, maxLength: 10).hasSuffix("...")) - XCTAssertEqual((JSONTruncation.truncate(long, maxLength: 10) as NSString).length, 13) + #expect(JSONTruncation.truncate(#"{"a":1}"#, maxLength: 100) == #"{"a":1}"#) + #expect(JSONTruncation.truncate(long, maxLength: 10).hasSuffix("...")) + #expect((JSONTruncation.truncate(long, maxLength: 10) as NSString).length == 13) } - func testTruncationCutsInUtf16UnitsNotCharacters() { + @Test func testTruncationCutsInUtf16UnitsNotCharacters() { let emoji = String(repeating: "🎉", count: 50) let cut = JSONTruncation.truncate(emoji, maxLength: 10) as NSString - XCTAssertLessThanOrEqual(cut.length, 10 + 3) + #expect(cut.length <= 10 + 3) } /// A half-typed brace is a value the user meant, not text this helper shortened. - func testIncompleteStructureIgnoresUserTypedText() { - XCTAssertFalse(JSONTruncation.isIncompleteStructure("{draft")) - XCTAssertFalse(JSONTruncation.isIncompleteStructure("[TODO")) - XCTAssertFalse(JSONTruncation.isIncompleteStructure(#"{"a":1}"#)) - XCTAssertFalse(JSONTruncation.isIncompleteStructure("plain text...")) - XCTAssertTrue(JSONTruncation.isIncompleteStructure(#"{"a":1, "b":"xx..."#)) + @Test func testIncompleteStructureIgnoresUserTypedText() { + #expect(!JSONTruncation.isIncompleteStructure("{draft")) + #expect(!JSONTruncation.isIncompleteStructure("[TODO")) + #expect(!JSONTruncation.isIncompleteStructure(#"{"a":1}"#)) + #expect(!JSONTruncation.isIncompleteStructure("plain text...")) + #expect(JSONTruncation.isIncompleteStructure(#"{"a":1, "b":"xx..."#)) } - func testIncompleteStructureCatchesEveryCutOfAStructure() { + @Test func testIncompleteStructureCatchesEveryCutOfAStructure() { let json = #"{"deep":[[1,2],[3,[4]]],"rows":[{"id":1,"ok":true},{"id":-2.5e3,"note":null}],"# + #""tags":["a","b\"c","é"],"ctl":"a\u0001b"}"# let length = (json as NSString).length for maxLength in 1.. Int in try await Task.sleep(nanoseconds: 5_000_000_000) return 1 } - XCTFail("Expected TimeoutError") + Issue.record("Expected TimeoutError") } catch let error as TimeoutError { - XCTAssertEqual(error.seconds, 0.05) + #expect(error.seconds == 0.05) } catch { - XCTFail("Expected TimeoutError, got \(error)") + Issue.record("Expected TimeoutError, got \(error)") } } - func testPropagatesOperationError() async { + @Test func testPropagatesOperationError() async { struct Boom: Error {} do { _ = try await withTimeout(seconds: 5) { () -> Int in throw Boom() } - XCTFail("Expected Boom") + Issue.record("Expected Boom") } catch is Boom { // expected } catch { - XCTFail("Expected Boom, got \(error)") + Issue.record("Expected Boom, got \(error)") } } - func testOnTimeoutUnblocksCancellationDeafOperation() async { + @Test func testOnTimeoutUnblocksCancellationDeafOperation() async { final class Latch: @unchecked Sendable { var continuation: CheckedContinuation? } @@ -51,7 +52,7 @@ final class AsyncTimeoutTests: XCTestCase { await withCheckedContinuation { latch.continuation = $0 } throw Closed() } - XCTFail("Expected an error") + Issue.record("Expected an error") } catch { // TimeoutError or Closed both prove onTimeout unblocked the operation. // Without it this test hangs: the continuation ignores task @@ -59,7 +60,7 @@ final class AsyncTimeoutTests: XCTestCase { } } - func testOnTimeoutNotInvokedWhenOperationWins() async throws { + @Test func testOnTimeoutNotInvokedWhenOperationWins() async throws { final class Flag: @unchecked Sendable { var value = false } @@ -67,7 +68,7 @@ final class AsyncTimeoutTests: XCTestCase { let value = try await withTimeout(seconds: 5, onTimeout: { flag.value = true }) { 42 } - XCTAssertEqual(value, 42) - XCTAssertFalse(flag.value) + #expect(value == 42) + #expect(!flag.value) } } diff --git a/Packages/TableProCore/Tests/TableProPluginKitTests/CSVTypeInferrerTests.swift b/Packages/TableProCore/Tests/TableProPluginKitTests/CSVTypeInferrerTests.swift index 527b2b0a72..9e979ccf37 100644 --- a/Packages/TableProCore/Tests/TableProPluginKitTests/CSVTypeInferrerTests.swift +++ b/Packages/TableProCore/Tests/TableProPluginKitTests/CSVTypeInferrerTests.swift @@ -1,77 +1,72 @@ +import Foundation @testable import TableProPluginKit -import XCTest +import Testing -final class CSVTypeInferrerTests: XCTestCase { - func testInfersInteger() { - XCTAssertEqual(CSVTypeInferrer.infer(column: ["1", "-42", "0"]), .integer) +struct CSVTypeInferrerTests { + @Test func testInfersInteger() { + #expect(CSVTypeInferrer.infer(column: ["1", "-42", "0"]) == .integer) } - func testInfersRealWhenNotEveryValueIsWhole() { - XCTAssertEqual(CSVTypeInferrer.infer(column: ["1", "2.5", "-0.25"]), .real) + @Test func testInfersRealWhenNotEveryValueIsWhole() { + #expect(CSVTypeInferrer.infer(column: ["1", "2.5", "-0.25"]) == .real) } - func testInfersBooleanFromEitherSpelling() { - XCTAssertEqual(CSVTypeInferrer.infer(column: ["true", "FALSE", "Yes", "n", "T"]), .boolean) + @Test func testInfersBooleanFromEitherSpelling() { + #expect(CSVTypeInferrer.infer(column: ["true", "FALSE", "Yes", "n", "T"]) == .boolean) } - func testInfersDateFromInternetDateTime() { - XCTAssertEqual(CSVTypeInferrer.infer(column: ["2026-08-20T12:34:56Z"]), .date) - XCTAssertEqual(CSVTypeInferrer.infer(column: ["2026-08-20T12:34:56+02:00"]), .date) - XCTAssertEqual(CSVTypeInferrer.infer(column: ["2026-08-20T12:34:56-0700"]), .date) + @Test func testInfersDateFromInternetDateTime() { + #expect(CSVTypeInferrer.infer(column: ["2026-08-20T12:34:56Z"]) == .date) + #expect(CSVTypeInferrer.infer(column: ["2026-08-20T12:34:56+02:00"]) == .date) + #expect(CSVTypeInferrer.infer(column: ["2026-08-20T12:34:56-0700"]) == .date) } - func testInfersDateFromFractionalSeconds() { - XCTAssertEqual(CSVTypeInferrer.infer(column: ["2026-08-20T12:34:56.123Z"]), .date) + @Test(arguments: ["2026-08-20T12:34:56.123Z", "2024-02-29"]) + func testInfersDateFromFractionalSecondsAndLeapDay(_ value: String) { + #expect(CSVTypeInferrer.infer(column: [value]) == .date) } - func testInfersDateFromCalendarDay() { - XCTAssertEqual(CSVTypeInferrer.infer(column: ["2026-08-20", "1999-01-01", "0001-01-01"]), .date) + @Test func testInfersDateFromCalendarDay() { + #expect(CSVTypeInferrer.infer(column: ["2026-08-20", "1999-01-01", "0001-01-01"]) == .date) } - func testInfersDateFromLeapDay() { - XCTAssertEqual(CSVTypeInferrer.infer(column: ["2024-02-29"]), .date) + @Test func testInfersDateFromTheLenientFormsTheFormatterAccepts() { + #expect(CSVTypeInferrer.infer(column: [" 2026-08-20"]) == .date) + #expect(CSVTypeInferrer.infer(column: ["2026/08/20"]) == .date) + #expect(CSVTypeInferrer.infer(column: ["-0001-01-01"]) == .date) + #expect(CSVTypeInferrer.infer(column: ["2023-02-29"]) == .date) } - func testInfersDateFromTheLenientFormsTheFormatterAccepts() { - XCTAssertEqual(CSVTypeInferrer.infer(column: [" 2026-08-20"]), .date) - XCTAssertEqual(CSVTypeInferrer.infer(column: ["2026/08/20"]), .date) - XCTAssertEqual(CSVTypeInferrer.infer(column: ["-0001-01-01"]), .date) - XCTAssertEqual(CSVTypeInferrer.infer(column: ["2023-02-29"]), .date) + @Test func testRejectsImpossibleCalendarDay() { + #expect(CSVTypeInferrer.infer(column: ["2026-13-45"]) == .text) } - func testRejectsImpossibleCalendarDay() { - XCTAssertEqual(CSVTypeInferrer.infer(column: ["2026-13-45"]), .text) + @Test func testRejectsBasicFormatDate() { + #expect(CSVTypeInferrer.infer(column: ["20260820"]) == .integer) } - func testRejectsBasicFormatDate() { - XCTAssertEqual(CSVTypeInferrer.infer(column: ["20260820"]), .integer) + @Test func testFallsBackToTextForMixedValues() { + #expect(CSVTypeInferrer.infer(column: ["2026-08-20", "not a date"]) == .text) + #expect(CSVTypeInferrer.infer(column: ["hello"]) == .text) } - func testFallsBackToTextForMixedValues() { - XCTAssertEqual(CSVTypeInferrer.infer(column: ["2026-08-20", "not a date"]), .text) - XCTAssertEqual(CSVTypeInferrer.infer(column: ["hello"]), .text) + @Test func testEmptyValuesAreSkippedAndAnEmptyColumnIsText() { + #expect(CSVTypeInferrer.infer(column: ["", "7", ""]) == .integer) + #expect(CSVTypeInferrer.infer(column: ["", ""]) == .text) + #expect(CSVTypeInferrer.infer(column: []) == .text) } - func testEmptyValuesAreSkippedAndAnEmptyColumnIsText() { - XCTAssertEqual(CSVTypeInferrer.infer(column: ["", "7", ""]), .integer) - XCTAssertEqual(CSVTypeInferrer.infer(column: ["", ""]), .text) - XCTAssertEqual(CSVTypeInferrer.infer(column: []), .text) - } - - func testInferColumnsHandlesRaggedRows() { + @Test func testInferColumnsHandlesRaggedRows() { let rows = [ ["1", "true", "2026-08-20"], ["2", "no"], ["3", "yes", "1999-01-01", "extra"] ] - XCTAssertEqual( - CSVTypeInferrer.inferColumns(rows: rows, columnCount: 3), - [.integer, .boolean, .date] - ) + #expect(CSVTypeInferrer.inferColumns(rows: rows, columnCount: 3) == [.integer, .boolean, .date]) } - func testOnlyTheFirstSampleSizeValuesAreRead() { + @Test func testOnlyTheFirstSampleSizeValuesAreRead() { let values = Array(repeating: "1", count: CSVTypeInferrer.sampleSize) + ["not a number"] - XCTAssertEqual(CSVTypeInferrer.infer(column: values), .integer) + #expect(CSVTypeInferrer.infer(column: values) == .integer) } } diff --git a/Packages/TableProCore/Tests/TableProPluginKitTests/HttpQueryTimeoutTests.swift b/Packages/TableProCore/Tests/TableProPluginKitTests/HttpQueryTimeoutTests.swift index e95c971ce1..03e9683b6f 100644 --- a/Packages/TableProCore/Tests/TableProPluginKitTests/HttpQueryTimeoutTests.swift +++ b/Packages/TableProCore/Tests/TableProPluginKitTests/HttpQueryTimeoutTests.swift @@ -20,15 +20,9 @@ struct HttpQueryTimeoutTests { #expect(timeout.requestTimeoutInterval == TimeInterval(125)) } - @Test("Zero server timeout falls back to the resource ceiling") - func zeroServerTimeoutUsesCeiling() { - let timeout = HttpQueryTimeout(serverTimeoutSeconds: 0) - #expect(timeout.requestTimeoutInterval == TimeInterval(HttpQueryTimeout.resourceCeilingSeconds)) - } - - @Test("Negative server timeout is treated as unlimited") - func negativeServerTimeoutUsesCeiling() { - let timeout = HttpQueryTimeout(serverTimeoutSeconds: -1) + @Test("A zero or negative server timeout is unlimited and falls back to the resource ceiling", arguments: [0, -1]) + func unlimitedServerTimeoutUsesCeiling(_ seconds: Int) { + let timeout = HttpQueryTimeout(serverTimeoutSeconds: seconds) #expect(timeout.requestTimeoutInterval == TimeInterval(HttpQueryTimeout.resourceCeilingSeconds)) } diff --git a/Packages/TableProCore/Tests/TableProPluginKitTests/OracleConnectErrorClassifierTests.swift b/Packages/TableProCore/Tests/TableProPluginKitTests/OracleConnectErrorClassifierTests.swift index be8cee1856..b46c3b5d30 100644 --- a/Packages/TableProCore/Tests/TableProPluginKitTests/OracleConnectErrorClassifierTests.swift +++ b/Packages/TableProCore/Tests/TableProPluginKitTests/OracleConnectErrorClassifierTests.swift @@ -1,57 +1,55 @@ +import Foundation @testable import TableProPluginKit -import XCTest +import Testing -final class OracleConnectErrorClassifierTests: XCTestCase { - func testClassifyKnownCodes() { - XCTAssertEqual(OracleConnectErrorClassifier.classify("uncleanShutdown"), .connectionDropped) - XCTAssertEqual(OracleConnectErrorClassifier.classify("serverVersionNotSupported"), .versionNotSupported) - XCTAssertEqual(OracleConnectErrorClassifier.classify("advancedNegotiationFailed"), .advancedNegotiationFailed) - XCTAssertEqual(OracleConnectErrorClassifier.classify("somethingElse"), .connectionFailed) - XCTAssertEqual( - OracleConnectErrorClassifier.classify("unsupportedVerifierType(0x12)"), - .verifierUnsupported(flag: "unsupportedVerifierType(0x12)") - ) +struct OracleConnectErrorClassifierTests { + @Test func testClassifyKnownCodes() { + #expect(OracleConnectErrorClassifier.classify("uncleanShutdown") == .connectionDropped) + #expect(OracleConnectErrorClassifier.classify("serverVersionNotSupported") == .versionNotSupported) + #expect(OracleConnectErrorClassifier.classify("advancedNegotiationFailed") == .advancedNegotiationFailed) + #expect(OracleConnectErrorClassifier.classify("somethingElse") == .connectionFailed) + #expect(OracleConnectErrorClassifier.classify("unsupportedVerifierType(0x12)") == .verifierUnsupported(flag: "unsupportedVerifierType(0x12)")) } - func testAdvancedNegotiationFailureIsEncryptionFailure() { - XCTAssertTrue(OracleConnectErrorClassifier.isLikelyNativeEncryptionFailure( + @Test func testAdvancedNegotiationFailureIsEncryptionFailure() { + #expect(OracleConnectErrorClassifier.isLikelyNativeEncryptionFailure( failure: .advancedNegotiationFailed, nativeNetworkEncryptionEnabled: true, timedOut: false )) } - func testEncryptionFailureRequiresEncryptionEnabled() { - XCTAssertFalse(OracleConnectErrorClassifier.isLikelyNativeEncryptionFailure( + @Test func testEncryptionFailureRequiresEncryptionEnabled() { + #expect(!OracleConnectErrorClassifier.isLikelyNativeEncryptionFailure( failure: .connectionFailed, nativeNetworkEncryptionEnabled: false, timedOut: true )) - XCTAssertFalse(OracleConnectErrorClassifier.isLikelyNativeEncryptionFailure( + #expect(!OracleConnectErrorClassifier.isLikelyNativeEncryptionFailure( failure: .connectionDropped, nativeNetworkEncryptionEnabled: false, timedOut: false )) } - func testTimeoutWithEncryptionIsEncryptionFailure() { - XCTAssertTrue(OracleConnectErrorClassifier.isLikelyNativeEncryptionFailure( + @Test func testTimeoutWithEncryptionIsEncryptionFailure() { + #expect(OracleConnectErrorClassifier.isLikelyNativeEncryptionFailure( failure: .connectionFailed, nativeNetworkEncryptionEnabled: true, timedOut: true )) } - func testHandshakeDropWithoutTimeoutIsNotEncryptionFailure() { + @Test func testHandshakeDropWithoutTimeoutIsNotEncryptionFailure() { // With encryption always offered at ACCEPTED, a plain handshake drop is // ambiguous (firewall, required-encryption server that RSTs, etc.), so it is // not auto-classified. Only a stalled (timed out) handshake or an explicit // advancedNegotiationFailed counts. - XCTAssertFalse(OracleConnectErrorClassifier.isLikelyNativeEncryptionFailure( + #expect(!OracleConnectErrorClassifier.isLikelyNativeEncryptionFailure( failure: .connectionDropped, nativeNetworkEncryptionEnabled: true, timedOut: false )) - XCTAssertFalse(OracleConnectErrorClassifier.isLikelyNativeEncryptionFailure( + #expect(!OracleConnectErrorClassifier.isLikelyNativeEncryptionFailure( failure: .connectionFailed, nativeNetworkEncryptionEnabled: true, timedOut: false )) } - func testAuthErrorsAreNotEncryptionFailures() { - XCTAssertFalse(OracleConnectErrorClassifier.isLikelyNativeEncryptionFailure( + @Test func testAuthErrorsAreNotEncryptionFailures() { + #expect(!(OracleConnectErrorClassifier.isLikelyNativeEncryptionFailure( failure: .verifierUnsupported(flag: "x"), nativeNetworkEncryptionEnabled: true, timedOut: false - )) - XCTAssertFalse(OracleConnectErrorClassifier.isLikelyNativeEncryptionFailure( + ))) + #expect(!OracleConnectErrorClassifier.isLikelyNativeEncryptionFailure( failure: .versionNotSupported, nativeNetworkEncryptionEnabled: true, timedOut: false )) } diff --git a/Packages/TableProCore/Tests/TableProPluginKitTests/PluginSSLClassifierTests.swift b/Packages/TableProCore/Tests/TableProPluginKitTests/PluginSSLClassifierTests.swift index 0a9ec7bc3f..6992b4e9dd 100644 --- a/Packages/TableProCore/Tests/TableProPluginKitTests/PluginSSLClassifierTests.swift +++ b/Packages/TableProCore/Tests/TableProPluginKitTests/PluginSSLClassifierTests.swift @@ -12,18 +12,11 @@ struct LibPQClassifierTests { } } - @Test("Classifies SSL-required as serverRequiresPlaintext") - func testSSLRequired() { - let msg = "FATAL: no pg_hba.conf entry for host \"1.2.3.4\", user \"u\", database \"d\", SSL on" - guard case .serverRequiresPlaintext = LibPQSSLClassifier.classifySSLError(msg) else { - Issue.record("Expected serverRequiresPlaintext") - return - } - } - - @Test("Classifies server-no-ssl-support as serverRequiresPlaintext") - func testServerNoSSL() { - let msg = "server does not support SSL, but SSL was required" + @Test("Classifies SSL-required and server-no-ssl-support as serverRequiresPlaintext", arguments: [ + "FATAL: no pg_hba.conf entry for host \"1.2.3.4\", user \"u\", database \"d\", SSL on", + "server does not support SSL, but SSL was required", + ]) + func testServerRequiresPlaintext(_ msg: String) { guard case .serverRequiresPlaintext = LibPQSSLClassifier.classifySSLError(msg) else { Issue.record("Expected serverRequiresPlaintext") return diff --git a/Packages/TableProCore/Tests/TableProPostgreSQLCoreTests/LibPQByteaDecoderTests.swift b/Packages/TableProCore/Tests/TableProPostgreSQLCoreTests/LibPQByteaDecoderTests.swift index 23472f268c..fdef7fd9a3 100644 --- a/Packages/TableProCore/Tests/TableProPostgreSQLCoreTests/LibPQByteaDecoderTests.swift +++ b/Packages/TableProCore/Tests/TableProPostgreSQLCoreTests/LibPQByteaDecoderTests.swift @@ -9,14 +9,9 @@ import Testing @testable import TableProPostgreSQLCore struct LibPQByteaDecoderHexTests { - @Test("Empty input returns empty Data") - func emptyInput() { - #expect(LibPQByteaDecoder.decode("") == Data()) - } - - @Test("Empty hex (\\x with no digits) returns empty Data") - func emptyHexPrefix() { - #expect(LibPQByteaDecoder.decode("\\x") == Data()) + @Test("Empty input, and \\x with no digits, return empty Data", arguments: ["", "\\x"]) + func emptyInput(_ input: String) { + #expect(LibPQByteaDecoder.decode(input) == Data()) } @Test("Single byte 0x00 round-trips") @@ -29,19 +24,9 @@ struct LibPQByteaDecoderHexTests { #expect(LibPQByteaDecoder.decode("\\xff") == Data([0xFF])) } - @Test("Lowercase hex parses correctly") - func lowercaseHex() { - #expect(LibPQByteaDecoder.decode("\\xdeadbeef") == Data([0xDE, 0xAD, 0xBE, 0xEF])) - } - - @Test("Uppercase hex parses correctly") - func uppercaseHex() { - #expect(LibPQByteaDecoder.decode("\\xDEADBEEF") == Data([0xDE, 0xAD, 0xBE, 0xEF])) - } - - @Test("Mixed-case hex parses correctly") - func mixedCaseHex() { - #expect(LibPQByteaDecoder.decode("\\xDeAdBeEf") == Data([0xDE, 0xAD, 0xBE, 0xEF])) + @Test("Hex parses in lowercase, uppercase and mixed case", arguments: ["\\xdeadbeef", "\\xDEADBEEF", "\\xDeAdBeEf"]) + func hexInAnyCase(_ input: String) { + #expect(LibPQByteaDecoder.decode(input) == Data([0xDE, 0xAD, 0xBE, 0xEF])) } @Test("Uppercase \\X prefix accepted") @@ -49,14 +34,9 @@ struct LibPQByteaDecoderHexTests { #expect(LibPQByteaDecoder.decode("\\X48656c6c6f") == Data([0x48, 0x65, 0x6C, 0x6C, 0x6F])) } - @Test("Odd hex length returns nil") - func oddHexLength() { - #expect(LibPQByteaDecoder.decode("\\xabc") == nil) - } - - @Test("Non-hex character returns nil") - func nonHexCharacter() { - #expect(LibPQByteaDecoder.decode("\\xdeadXX") == nil) + @Test("Odd hex length or a non-hex character returns nil", arguments: ["\\xabc", "\\xdeadXX"]) + func malformedHex(_ input: String) { + #expect(LibPQByteaDecoder.decode(input) == nil) } @Test("Embedded NUL byte 0x00 mid-stream round-trips") @@ -106,14 +86,9 @@ struct LibPQByteaDecoderEscapeTests { #expect(LibPQByteaDecoder.decode("\\000") == Data([0x00])) } - @Test("Bare backslash with insufficient digits returns nil") - func badEscapeShort() { - #expect(LibPQByteaDecoder.decode("\\1") == nil) - } - - @Test("Backslash followed by non-octal returns nil") - func badEscapeNonOctal() { - #expect(LibPQByteaDecoder.decode("\\xyz") == nil) + @Test("Backslash with too few digits, or followed by non-octal, returns nil", arguments: ["\\1", "\\xyz"]) + func badEscape(_ input: String) { + #expect(LibPQByteaDecoder.decode(input) == nil) } @Test("Bare trailing backslash returns nil") diff --git a/Packages/TableProCore/Tests/TableProRedisCoreTests/RedisArgumentCodecTests.swift b/Packages/TableProCore/Tests/TableProRedisCoreTests/RedisArgumentCodecTests.swift index 18e61bc97a..b1e2b5c1fb 100644 --- a/Packages/TableProCore/Tests/TableProRedisCoreTests/RedisArgumentCodecTests.swift +++ b/Packages/TableProCore/Tests/TableProRedisCoreTests/RedisArgumentCodecTests.swift @@ -94,14 +94,12 @@ struct RedisArgumentCodecGrammarTests { #expect(RedisArgumentCodec.split(" ")?.isEmpty == true) } - @Test("unbalanced quotes are rejected") - func unbalancedQuotesRejected() { - #expect(RedisArgumentCodec.split("SET k \"unterminated") == nil) - } - - @Test("a closing quote must be followed by whitespace") - func trailingTextAfterQuoteRejected() { - #expect(RedisArgumentCodec.split("SET k \"ab\"cd") == nil) + @Test("unbalanced quotes, and text right after a closing quote, are rejected", arguments: [ + "SET k \"unterminated", + "SET k \"ab\"cd", + ]) + func malformedQuotingRejected(_ command: String) { + #expect(RedisArgumentCodec.split(command) == nil) } @Test("a value cannot break out into extra arguments") diff --git a/Packages/TableProCore/Tests/TableProRedisCoreTests/RedisClusterRedirectTests.swift b/Packages/TableProCore/Tests/TableProRedisCoreTests/RedisClusterRedirectTests.swift index 016f02527a..d79a159170 100644 --- a/Packages/TableProCore/Tests/TableProRedisCoreTests/RedisClusterRedirectTests.swift +++ b/Packages/TableProCore/Tests/TableProRedisCoreTests/RedisClusterRedirectTests.swift @@ -90,18 +90,11 @@ struct RedisClusterRedirectRejectionTests { #expect(RedisClusterRedirect.parse(message, fallbackHost: "h") == nil) } - @Test("A MOVED without an endpoint is rejected rather than half-parsed") - func rejectsMissingEndpoint() { - #expect(RedisClusterRedirect.parse("MOVED 12182", fallbackHost: "h") == nil) - } - - @Test("A non-numeric slot is rejected") - func rejectsBadSlot() { - #expect(RedisClusterRedirect.parse("MOVED abc 127.0.0.1:7103", fallbackHost: "h") == nil) - } - - @Test("A port outside the legal range is rejected") - func rejectsBadPort() { - #expect(RedisClusterRedirect.parse("MOVED 1 127.0.0.1:70000", fallbackHost: "h") == nil) + @Test( + "A MOVED without an endpoint, with a non-numeric slot or with a port out of range is rejected", + arguments: ["MOVED 12182", "MOVED abc 127.0.0.1:7103", "MOVED 1 127.0.0.1:70000"] + ) + func rejectsMalformedRedirect(_ message: String) { + #expect(RedisClusterRedirect.parse(message, fallbackHost: "h") == nil) } } diff --git a/Packages/TableProCore/Tests/TableProRedisCoreTests/RedisConnectProbeTests.swift b/Packages/TableProCore/Tests/TableProRedisCoreTests/RedisConnectProbeTests.swift index b8ff8af915..c71c31d3e7 100644 --- a/Packages/TableProCore/Tests/TableProRedisCoreTests/RedisConnectProbeTests.swift +++ b/Packages/TableProCore/Tests/TableProRedisCoreTests/RedisConnectProbeTests.swift @@ -10,9 +10,12 @@ struct RedisConnectProbeTests { #expect(RedisConnectProbe.outcome(errorMessage: "") == .established) } - @Test("NOAUTH is the only reply that means no identity") - func noAuthIsFatal() { - #expect(RedisConnectProbe.outcome(errorMessage: "NOAUTH Authentication required.") == .unauthenticated) + @Test("NOAUTH, matched case-insensitively, is the only reply that means no identity", arguments: [ + "NOAUTH Authentication required.", + "noauth Authentication required.", + ]) + func noAuthIsFatal(_ message: String) { + #expect(RedisConnectProbe.outcome(errorMessage: message) == .unauthenticated) } @Test("NOPERM means authenticated but restricted, which is a usable session") @@ -36,11 +39,6 @@ struct RedisConnectProbeTests { #expect(RedisConnectProbe.outcome(errorMessage: "NOPERMISSION something new") != .established) } - @Test("the error class is matched case-insensitively") - func errorClassIsCaseInsensitive() { - #expect(RedisConnectProbe.outcome(errorMessage: "noauth Authentication required.") == .unauthenticated) - } - @Test("only a failing outcome carries a message, and only the unauthenticated one names a field") func messagesMatchOutcomes() { #expect(RedisConnectProbe.Outcome.established.failureMessage == nil) diff --git a/Packages/TableProCore/Tests/TableProSyncTests/FieldLevelMergeTests.swift b/Packages/TableProCore/Tests/TableProSyncTests/FieldLevelMergeTests.swift index 0b2479d51f..1ac8ed57ae 100644 --- a/Packages/TableProCore/Tests/TableProSyncTests/FieldLevelMergeTests.swift +++ b/Packages/TableProCore/Tests/TableProSyncTests/FieldLevelMergeTests.swift @@ -292,7 +292,7 @@ struct SyncRecordCacheTests { /// has to go, or the domain stays over the CFPreferences limit and every write keeps failing. @Test("A cache written to UserDefaults by an older build moves to disk and frees the key") func legacyCacheMigratesOffUserDefaults() throws { - let suite = "com.TablePro.tests.\(UUID().uuidString)" + let suite = ScratchDefaults.suiteName() let defaults = try #require(UserDefaults(suiteName: suite)) defer { defaults.removePersistentDomain(forName: suite) } @@ -326,7 +326,7 @@ struct SyncRecordCacheTests { @Test("Removing everything drops a legacy UserDefaults cache, and a later read never brings it back") func removeAllDropsLegacyCache() throws { - let suite = "com.TablePro.tests.\(UUID().uuidString)" + let suite = ScratchDefaults.suiteName() let defaults = try #require(UserDefaults(suiteName: suite)) defer { defaults.removePersistentDomain(forName: suite) } let record = makeRecord("Connection_Legacy") diff --git a/Packages/TableProCore/Tests/TableProSyncTests/ScratchDefaults.swift b/Packages/TableProCore/Tests/TableProSyncTests/ScratchDefaults.swift new file mode 100644 index 0000000000..2fba26d64e --- /dev/null +++ b/Packages/TableProCore/Tests/TableProSyncTests/ScratchDefaults.swift @@ -0,0 +1,14 @@ +import Foundation + +enum ScratchDefaults { + // A suite named by a path keeps its plist in the temporary directory; a bare name leaves one in ~/Library/Preferences. + static func suiteName() -> String { + FileManager.default.temporaryDirectory + .appendingPathComponent("com.TablePro.tests.\(UUID().uuidString)", isDirectory: false) + .path + } + + static func make() -> UserDefaults? { + UserDefaults(suiteName: suiteName()) + } +} diff --git a/Packages/TableProCore/Tests/TableProSyncTests/SyncMetadataStorageTests.swift b/Packages/TableProCore/Tests/TableProSyncTests/SyncMetadataStorageTests.swift index 11ba165393..dfad98f70a 100644 --- a/Packages/TableProCore/Tests/TableProSyncTests/SyncMetadataStorageTests.swift +++ b/Packages/TableProCore/Tests/TableProSyncTests/SyncMetadataStorageTests.swift @@ -7,7 +7,7 @@ import TableProSyncTransport @Suite("Sync metadata storage") struct SyncMetadataStorageTests { private func makeStorage() -> SyncMetadataStorage { - let defaults = UserDefaults(suiteName: "com.TablePro.tests.\(UUID().uuidString)") ?? .standard + let defaults = ScratchDefaults.make() ?? .standard return SyncMetadataStorage(userDefaults: defaults) } @@ -79,7 +79,7 @@ struct SyncMetadataStorageTests { @Test("Pruning drops tombstones older than the cutoff and keeps newer ones") func pruningDropsOldTombstonesOnly() throws { - let defaults = UserDefaults(suiteName: "com.TablePro.tests.\(UUID().uuidString)") ?? .standard + let defaults = ScratchDefaults.make() ?? .standard let old = Tombstone(id: "old", deletedAt: Date(timeIntervalSinceNow: -60 * 60 * 24 * 40)) let fresh = Tombstone(id: "fresh", deletedAt: Date()) let data = try JSONEncoder().encode([old, fresh]) @@ -93,7 +93,7 @@ struct SyncMetadataStorageTests { @Test("Pruning keeps an old tombstone that cannot be pushed yet") func pruningKeepsUnpushableTombstones() throws { - let defaults = UserDefaults(suiteName: "com.TablePro.tests.\(UUID().uuidString)") ?? .standard + let defaults = ScratchDefaults.make() ?? .standard let heldOwner = UUID() let fortyDaysAgo = Date(timeIntervalSinceNow: -60 * 60 * 24 * 40) let held = Tombstone(id: "held", deletedAt: fortyDaysAgo, owner: heldOwner) @@ -119,7 +119,7 @@ struct SyncMetadataStorageTests { @Test("A tombstone written before owners existed still decodes, with no owner") func legacyTombstoneDecodesWithoutOwner() throws { - let defaults = UserDefaults(suiteName: "com.TablePro.tests.\(UUID().uuidString)") ?? .standard + let defaults = ScratchDefaults.make() ?? .standard let legacy = Data(#"[{"id":"a","deletedAt":780000000}]"#.utf8) defaults.set(legacy, forKey: "com.TablePro.sync.tombstones.\(SyncRecordType.favoriteDatabase.rawValue)") @@ -131,7 +131,7 @@ struct SyncMetadataStorageTests { @Test("Owners kept off sync survive a new storage instance and can be released one by one") func ownersKeptOffSyncPersist() { - let defaults = UserDefaults(suiteName: "com.TablePro.tests.\(UUID().uuidString)") ?? .standard + let defaults = ScratchDefaults.make() ?? .standard let first = UUID() let second = UUID() SyncMetadataStorage(userDefaults: defaults).keepOffSync(owners: [first, second]) @@ -181,7 +181,7 @@ struct SyncMetadataStorageTests { @Test("The first account seen on a device that never synced is recorded and nothing queued is dropped") func firstAccountIsRecorded() { - let defaults = UserDefaults(suiteName: "com.TablePro.tests.\(UUID().uuidString)") ?? .standard + let defaults = ScratchDefaults.make() ?? .standard let storage = SyncMetadataStorage(userDefaults: defaults) storage.markDirty("a", type: .connection) storage.addTombstone("b", type: .connection) @@ -195,7 +195,7 @@ struct SyncMetadataStorageTests { @Test("An account recorded for the first time over an earlier sync starts sync over once and keeps what is queued") func unrecordedEarlierSyncStartsOverOnce() { - let defaults = UserDefaults(suiteName: "com.TablePro.tests.\(UUID().uuidString)") ?? .standard + let defaults = ScratchDefaults.make() ?? .standard let storage = SyncMetadataStorage(userDefaults: defaults) storage.markDirty("a", type: .connection) storage.addTombstone("b", type: .connection) @@ -217,7 +217,7 @@ struct SyncMetadataStorageTests { @Test("The same account changes nothing") func sameAccountChangesNothing() { - let defaults = UserDefaults(suiteName: "com.TablePro.tests.\(UUID().uuidString)") ?? .standard + let defaults = ScratchDefaults.make() ?? .standard let storage = SyncMetadataStorage(userDefaults: defaults) storage.lastAccountId = "account-a" storage.markDirty("a", type: .connection) @@ -233,7 +233,7 @@ struct SyncMetadataStorageTests { @Test("A different account clears the old account's token and deletions, and keeps edits waiting to go up") func differentAccountStartsOver() { - let defaults = UserDefaults(suiteName: "com.TablePro.tests.\(UUID().uuidString)") ?? .standard + let defaults = ScratchDefaults.make() ?? .standard let storage = SyncMetadataStorage(userDefaults: defaults) storage.lastAccountId = "account-a" storage.markDirty("a", type: .connection) @@ -282,7 +282,7 @@ struct SyncMetadataStorageTests { @Test("Storage keys are the ones already on disk") func storageKeysAreStable() { - let defaults = UserDefaults(suiteName: "com.TablePro.tests.\(UUID().uuidString)") ?? .standard + let defaults = ScratchDefaults.make() ?? .standard let storage = SyncMetadataStorage(userDefaults: defaults) storage.markDirty("a", type: .connection) storage.addTombstone("b", type: .connection) diff --git a/Packages/TableProCore/Tests/TableProSyncTests/SyncZoneStateTests.swift b/Packages/TableProCore/Tests/TableProSyncTests/SyncZoneStateTests.swift index 49dd31a3ec..a6ba7d246a 100644 --- a/Packages/TableProCore/Tests/TableProSyncTests/SyncZoneStateTests.swift +++ b/Packages/TableProCore/Tests/TableProSyncTests/SyncZoneStateTests.swift @@ -40,7 +40,7 @@ struct SyncZoneStateTests { /// zone and uploaded into it without asking. @Test("A removed zone still waits for the person after a relaunch") func removedSurvivesRelaunch() throws { - let defaults = try #require(UserDefaults(suiteName: "com.TablePro.tests.zone.\(UUID().uuidString)")) + let defaults = try #require(ScratchDefaults.make()) SyncMetadataStorage(userDefaults: defaults).zoneState = .removed let relaunched = SyncMetadataStorage(userDefaults: defaults) @@ -54,11 +54,11 @@ struct SyncZoneStateTests { @Test("A device an earlier build synced reads its zone as confirmed; a new install does not") func legacyDevicesHaveSeenTheZone() throws { let synced = SyncMetadataStorage( - userDefaults: try #require(UserDefaults(suiteName: "com.TablePro.tests.zone.\(UUID().uuidString)")) + userDefaults: try #require(ScratchDefaults.make()) ) synced.lastSyncDate = Date(timeIntervalSince1970: 1_000) let fresh = SyncMetadataStorage( - userDefaults: try #require(UserDefaults(suiteName: "com.TablePro.tests.zone.\(UUID().uuidString)")) + userDefaults: try #require(ScratchDefaults.make()) ) #expect(synced.zoneState == .confirmed) @@ -69,7 +69,7 @@ struct SyncZoneStateTests { /// zone stays known while the token it left behind is dropped. @Test("Adopting an account an earlier build never recorded keeps the zone it saw") func unrecordedAccountKeepsTheZone() throws { - let defaults = try #require(UserDefaults(suiteName: "com.TablePro.tests.zone.\(UUID().uuidString)")) + let defaults = try #require(ScratchDefaults.make()) let storage = SyncMetadataStorage(userDefaults: defaults) storage.lastSyncDate = Date(timeIntervalSince1970: 1_000) defaults.set(Data([0x01]), forKey: "com.TablePro.sync.serverChangeToken") @@ -82,7 +82,7 @@ struct SyncZoneStateTests { @Test("Another account forgets the zone") func accountSwitchForgetsTheZone() throws { - let defaults = try #require(UserDefaults(suiteName: "com.TablePro.tests.zone.\(UUID().uuidString)")) + let defaults = try #require(ScratchDefaults.make()) let storage = SyncMetadataStorage(userDefaults: defaults) storage.adoptAccount("a") storage.zoneState = .removed diff --git a/Packages/TableProCore/Tests/TableProTabularIOTests/DelimitedParityTests.swift b/Packages/TableProCore/Tests/TableProTabularIOTests/DelimitedParityTests.swift index 98bc99fa44..8e03c69e4f 100644 --- a/Packages/TableProCore/Tests/TableProTabularIOTests/DelimitedParityTests.swift +++ b/Packages/TableProCore/Tests/TableProTabularIOTests/DelimitedParityTests.swift @@ -1,9 +1,9 @@ import Foundation @testable import TableProPluginKit @testable import TableProTabularIO -import XCTest +import Testing -final class DelimitedParityTests: XCTestCase { +struct DelimitedParityTests { private struct SeededGenerator: RandomNumberGenerator { var state: UInt64 @@ -18,7 +18,7 @@ final class DelimitedParityTests: XCTestCase { private let alphabet: [UInt8] = Array("ab,;\"\"\"\n\r\\ x|".utf8) - func testRowIndexMatchesStreamingParserOnRandomInput() throws { + @Test func testRowIndexMatchesStreamingParserOnRandomInput() throws { var generator = SeededGenerator(state: 42) for trial in 0..<20_000 { let length = Int.random(in: 0...64, using: &generator) @@ -38,7 +38,7 @@ final class DelimitedParityTests: XCTestCase { try DelimitedRowIndexer.index($0, dialect: dialect, contentStart: hasBOM ? 3 : 0) } let actual = (0.. \(written.debugDescription)") + #expect(cellsByKey(of: reread) == outputExpected, "trial \(trial): \(text.debugDescription) -> \(written.debugDescription)") } } - func testMutatedDocumentsAgreeWithJSONSerialization() async throws { + @Test func testMutatedDocumentsAgreeWithJSONSerialization() async throws { var generator = DocumentGenerator(random: SeededGenerator(state: 99), allowsUnicodeEscapes: false) let alphabet = Array("{}[]\"\\:,01-.eatn x".utf8) var accepted = 0 @@ -169,31 +169,31 @@ final class JSONDifferentialTests: XCTestCase { do { ours = try await JSONFixtures.source(bytes, kind: .json) } catch let error as JSONTableError where referenceAccepts { - XCTAssertTrue(Self.isTrailingComma(in: bytes, closingAt: error.byteOffset), "trial \(trial): \(error) \(description)") + #expect(Self.isTrailingComma(in: bytes, closingAt: error.byteOffset), "trial \(trial): \(error) \(description)") continue } catch { - XCTAssertFalse(referenceAccepts, "trial \(trial): \(error) \(description)") + #expect(!referenceAccepts, "trial \(trial): \(error) \(description)") continue } - XCTAssertTrue(referenceAccepts, "trial \(trial): \(description)") + #expect(referenceAccepts, "trial \(trial): \(description)") guard let referenceRows, referenceAccepts else { continue } accepted += 1 - XCTAssertEqual(ours.rowCount, referenceRows.count, "trial \(trial)") - XCTAssertEqual(Set(ours.keys), Set(referenceRows.flatMap(\.keys)), "trial \(trial): \(description)") + #expect(ours.rowCount == referenceRows.count, "trial \(trial)") + #expect(Set(ours.keys) == Set(referenceRows.flatMap(\.keys)), "trial \(trial): \(description)") for (row, object) in referenceRows.enumerated() { let memberKeys = try ours.objectLayout(ofRow: row).members.map(\.key) let repeatedKeys = Set(memberKeys.filter { key in memberKeys.filter { $0 == key }.count > 1 }) for (column, key) in ours.keys.enumerated() where !repeatedKeys.contains(key) { let cell = ours.cell(row: row, column: column) guard let value = object[key] else { - XCTAssertEqual(cell.kind, .missing, "trial \(trial) row \(row) key \(key)") + #expect(cell.kind == .missing, "trial \(trial) row \(row) key \(key)") continue } - XCTAssertTrue(Self.cell(cell, matches: value), "trial \(trial) row \(row) key \(key): \(cell) vs \(value)") + #expect(Self.cell(cell, matches: value), "trial \(trial) row \(row) key \(key): \(cell) vs \(value)") } } } - XCTAssertGreaterThan(accepted, 300) + #expect(accepted > 300) } private static func isTrailingComma(in bytes: [UInt8], closingAt offset: Int) -> Bool { diff --git a/Packages/TableProCore/Tests/TableProTabularIOTests/JSONFixtures.swift b/Packages/TableProCore/Tests/TableProTabularIOTests/JSONFixtures.swift index 9b400f9b87..6b5c5b1e1a 100644 --- a/Packages/TableProCore/Tests/TableProTabularIOTests/JSONFixtures.swift +++ b/Packages/TableProCore/Tests/TableProTabularIOTests/JSONFixtures.swift @@ -1,6 +1,6 @@ import Foundation @testable import TableProTabularIO -import XCTest +import Testing enum JSONFixtures { static func index(_ text: String, kind: JSONTableFileKind = .jsonLines) throws -> JSONTableIndex { @@ -50,7 +50,7 @@ enum JSONFixtures { } static func text(_ bytes: [UInt8]) throws -> String { - try XCTUnwrap(String(bytes: bytes, encoding: .utf8)) + try #require(String(bytes: bytes, encoding: .utf8)) } static func texts(_ source: JSONSource, row: Int) -> [String] { diff --git a/Packages/TableProCore/Tests/TableProTabularIOTests/JSONSourceTests.swift b/Packages/TableProCore/Tests/TableProTabularIOTests/JSONSourceTests.swift index a4047e4b4d..ce8f502c2a 100644 --- a/Packages/TableProCore/Tests/TableProTabularIOTests/JSONSourceTests.swift +++ b/Packages/TableProCore/Tests/TableProTabularIOTests/JSONSourceTests.swift @@ -1,111 +1,105 @@ import Foundation @testable import TableProTabularIO -import XCTest +import Testing -final class JSONSourceTests: XCTestCase { - func testSourceDescribesItsColumnsAndRows() async throws { +struct JSONSourceTests { + @Test func testSourceDescribesItsColumnsAndRows() async throws { let source = try await JSONFixtures.source("{\"b\":1,\"a\":2}\n{\"c\":3}\n") - XCTAssertEqual(source.shape, .lines) - XCTAssertEqual(source.rowCount, 2) - XCTAssertEqual(source.columnCount, 3) - XCTAssertEqual(source.intrinsicColumnNames, ["b", "a", "c"]) - XCTAssertEqual(source.absentCell, .missing) + #expect(source.shape == .lines) + #expect(source.rowCount == 2) + #expect(source.columnCount == 3) + #expect(source.intrinsicColumnNames == ["b", "a", "c"]) + #expect(source.absentCell == .missing) } - func testNumberLexemesAreKeptExactly() async throws { + @Test func testNumberLexemesAreKeptExactly() async throws { let source = try await JSONFixtures.source( #"{"a":1.0,"b":-0,"c":1e5,"d":12345678901234567890,"e":0.1,"f":1E+2,"g":-1.5e-3}"# ) - XCTAssertEqual(JSONFixtures.texts(source, row: 0), ["1.0", "-0", "1e5", "12345678901234567890", "0.1", "1E+2", "-1.5e-3"]) - XCTAssertEqual(Set(JSONFixtures.kinds(source, row: 0)), [.number]) + #expect(JSONFixtures.texts(source, row: 0) == ["1.0", "-0", "1e5", "12345678901234567890", "0.1", "1E+2", "-1.5e-3"]) + #expect(Set(JSONFixtures.kinds(source, row: 0)) == [.number]) } - func testStringEscapesAndSurrogatePairsAreDecoded() async throws { + @Test func testStringEscapesAndSurrogatePairsAreDecoded() async throws { let source = try await JSONFixtures.source( #"{"a":"é","b":"😀","c":"\n\t\"\\\/\b\f\r","d":"\ud800x","e":"plain","f":"cafÉ"}"# ) - XCTAssertEqual( - JSONFixtures.texts(source, row: 0), - ["é", "😀", "\n\t\"\\/\u{08}\u{0C}\r", "\u{FFFD}x", "plain", "cafÉ"] - ) - XCTAssertEqual(Set(JSONFixtures.kinds(source, row: 0)), [.text]) + #expect(JSONFixtures.texts(source, row: 0) == ["é", "😀", "\n\t\"\\/\u{08}\u{0C}\r", "\u{FFFD}x", "plain", "cafÉ"]) + #expect(Set(JSONFixtures.kinds(source, row: 0)) == [.text]) } - func testRawUnicodeTextIsReadAsIs() async throws { + @Test func testRawUnicodeTextIsReadAsIs() async throws { let source = try await JSONFixtures.source("{\"tên\":\"Hà Nội 🇻🇳\"}") - XCTAssertEqual(source.intrinsicColumnNames, ["tên"]) - XCTAssertEqual(source.cell(row: 0, column: 0), TabularCell(kind: .text, text: "Hà Nội 🇻🇳")) + #expect(source.intrinsicColumnNames == ["tên"]) + #expect(source.cell(row: 0, column: 0) == TabularCell(kind: .text, text: "Hà Nội 🇻🇳")) } - func testEscapedAndPlainSpellingsOfAKeyFillOneColumn() async throws { + @Test func testEscapedAndPlainSpellingsOfAKeyFillOneColumn() async throws { let source = try await JSONFixtures.source("{\"a\\u0062\":1,\"z\":0}\n{\"z\":0,\"ab\":2}\n{\"\\u0061b\":3}\n") - XCTAssertEqual(source.intrinsicColumnNames, ["ab", "z"]) - XCTAssertEqual((0..<3).map { source.cell(row: $0, column: 0).text }, ["1", "2", "3"]) + #expect(source.intrinsicColumnNames == ["ab", "z"]) + #expect((0..<3).map { source.cell(row: $0, column: 0).text } == ["1", "2", "3"]) } - func testDuplicateKeysShowTheLastOccurrence() async throws { + @Test func testDuplicateKeysShowTheLastOccurrence() async throws { let source = try await JSONFixtures.source(#"{"a":1,"b":2,"a":"three"}"#) - XCTAssertEqual(source.intrinsicColumnNames, ["a", "b"]) - XCTAssertEqual(source.cells(row: 0), [TabularCell(kind: .text, text: "three"), TabularCell(kind: .number, text: "2")]) + #expect(source.intrinsicColumnNames == ["a", "b"]) + #expect(source.cells(row: 0) == [TabularCell(kind: .text, text: "three"), TabularCell(kind: .number, text: "2")]) } - func testNestedContainersAreShownCompact() async throws { + @Test func testNestedContainersAreShownCompact() async throws { let source = try await JSONFixtures.source( "{\"o\": { \"a\" : [1, 2],\n \"s\": \"x y\" }, \"e\": [ ], \"p\": {}, \"q\": [{\"k\":\"\\\" \"}]}" ) - XCTAssertEqual( - source.cells(row: 0), - [ - TabularCell(kind: .object, text: #"{"a":[1,2],"s":"x y"}"#), - TabularCell(kind: .array, text: "[]"), - TabularCell(kind: .object, text: "{}"), - TabularCell(kind: .array, text: #"[{"k":"\" "}]"#) - ] - ) + #expect(source.cells(row: 0) == [ + TabularCell(kind: .object, text: #"{"a":[1,2],"s":"x y"}"#), + TabularCell(kind: .array, text: "[]"), + TabularCell(kind: .object, text: "{}"), + TabularCell(kind: .array, text: #"[{"k":"\" "}]"#) + ]) } - func testMissingNullAndEmptyStringAreDistinct() async throws { + @Test func testMissingNullAndEmptyStringAreDistinct() async throws { let source = try await JSONFixtures.source("{\"a\":null,\"b\":\"\"}\n{\"b\":\"x\"}\n") - XCTAssertEqual(source.cells(row: 0), [TabularCell(kind: .null, text: "null"), TabularCell(kind: .text, text: "")]) - XCTAssertEqual(source.cells(row: 1), [TabularCell.missing, TabularCell(kind: .text, text: "x")]) + #expect(source.cells(row: 0) == [TabularCell(kind: .null, text: "null"), TabularCell(kind: .text, text: "")]) + #expect(source.cells(row: 1) == [TabularCell.missing, TabularCell(kind: .text, text: "x")]) } - func testBooleansKeepTheirLiteralText() async throws { + @Test func testBooleansKeepTheirLiteralText() async throws { let source = try await JSONFixtures.source(#"{"t":true,"f":false}"#) - XCTAssertEqual(source.cells(row: 0), [TabularCell(kind: .boolean, text: "true"), TabularCell(kind: .boolean, text: "false")]) + #expect(source.cells(row: 0) == [TabularCell(kind: .boolean, text: "true"), TabularCell(kind: .boolean, text: "false")]) } - func testCellOutsideTheTableIsMissing() async throws { + @Test func testCellOutsideTheTableIsMissing() async throws { let source = try await JSONFixtures.source(#"{"a":1}"#) - XCTAssertEqual(source.cell(row: 1, column: 0), .missing) - XCTAssertEqual(source.cell(row: 0, column: 1), .missing) - XCTAssertEqual(source.cell(row: -1, column: 0), .missing) - XCTAssertEqual(source.cells(row: 3), []) + #expect(source.cell(row: 1, column: 0) == .missing) + #expect(source.cell(row: 0, column: 1) == .missing) + #expect(source.cell(row: -1, column: 0) == .missing) + #expect(source.cells(row: 3).isEmpty) } - func testScanFillsRequestedColumnsInTheOrderAsked() async throws { + @Test func testScanFillsRequestedColumnsInTheOrderAsked() async throws { let source = try await JSONFixtures.source("{\"a\":\"a0\",\"b\":\"b0\",\"c\":\"c0\"}\n{\"c\":\"c1\",\"a\":\"a1\"}\n") var seen: [(Int, [String], [TabularCellKind])] = [] source.scan(columns: [2, 0, 1], rows: [1, 0]) { row, cells in seen.append((row, (0.. 1) + #expect(recorder.values.last == 1) + #expect(recorder.values == recorder.values.sorted()) } - func testFileKindFollowsTheExtension() { - XCTAssertEqual(JSONTableFileKind.forFileExtension("JSONL"), .jsonLines) - XCTAssertEqual(JSONTableFileKind.forFileExtension("ndjson"), .jsonLines) - XCTAssertEqual(JSONTableFileKind.forFileExtension("json"), .json) + @Test func testFileKindFollowsTheExtension() { + #expect(JSONTableFileKind.forFileExtension("JSONL") == .jsonLines) + #expect(JSONTableFileKind.forFileExtension("ndjson") == .jsonLines) + #expect(JSONTableFileKind.forFileExtension("json") == .json) } } diff --git a/Packages/TableProCore/Tests/TableProTabularIOTests/JSONTableWriterTests.swift b/Packages/TableProCore/Tests/TableProTabularIOTests/JSONTableWriterTests.swift index f7ed57aa60..8934c6aa02 100644 --- a/Packages/TableProCore/Tests/TableProTabularIOTests/JSONTableWriterTests.swift +++ b/Packages/TableProCore/Tests/TableProTabularIOTests/JSONTableWriterTests.swift @@ -1,8 +1,8 @@ import Foundation @testable import TableProTabularIO -import XCTest +import Testing -final class JSONTableWriterTests: XCTestCase { +struct JSONTableWriterTests { private static let pretty = "[\n {\n \"id\": 1,\n \"name\": \"Ann\",\n \"tags\": [ \"a\", \"b\" ]\n },\n {\n \"id\": 2,\n \"name\": \"Bob\"\n }\n]\n" private static let compact = #"[{"id":1,"name":"Ann"},{"id":2,"name":"Bob"}]"# @@ -24,7 +24,7 @@ final class JSONTableWriterTests: XCTestCase { return try JSONFixtures.written(source, rows: rows(source), keyChanges: keyChanges) } - func testUntouchedFilesRoundTripByteForByte() async throws { + @Test func testUntouchedFilesRoundTripByteForByte() async throws { let fixtures: [(String, JSONTableFileKind)] = [ (Self.pretty, .json), (Self.compact, .json), @@ -40,31 +40,31 @@ final class JSONTableWriterTests: XCTestCase { ] for (text, kind) in fixtures { let written = try await rewrite(text, kind: kind) { JSONFixtures.untouchedRows(of: $0) } - XCTAssertEqual(written, text, text.debugDescription) + #expect(written == text, "\(text.debugDescription)") } } - func testByteOrderMarkSurvivesARoundTrip() async throws { + @Test func testByteOrderMarkSurvivesARoundTrip() async throws { let bytes = JSONByte.utf8ByteOrderMark + Array("[{\"a\":1}]\n".utf8) let source = try await JSONFixtures.source(bytes, kind: .json) - XCTAssertEqual(try JSONTableWriter(source: source).encoded(rows: JSONFixtures.untouchedRows(of: source)), bytes) + #expect(try JSONTableWriter(source: source).encoded(rows: JSONFixtures.untouchedRows(of: source)) == bytes) let edited = try JSONTableWriter(source: source).encoded(rows: [.edited(0, replace("a", with: "2"))]) - XCTAssertEqual(edited, JSONByte.utf8ByteOrderMark + Array("[{\"a\":2}]\n".utf8)) + #expect(edited == JSONByte.utf8ByteOrderMark + Array("[{\"a\":2}]\n".utf8)) } - func testEditingAValueKeepsKeyOrderAndEveryOtherByte() async throws { + @Test func testEditingAValueKeepsKeyOrderAndEveryOtherByte() async throws { let written = try await rewrite(Self.pretty, kind: .json) { _ in [.edited(0, replace("name", with: "\"Ann Lee\"")), .source(1)] } - XCTAssertEqual(written, Self.pretty.replacingOccurrences(of: "\"Ann\"", with: "\"Ann Lee\"")) + #expect(written == Self.pretty.replacingOccurrences(of: "\"Ann\"", with: "\"Ann Lee\"")) } - func testEditingADuplicateKeyReplacesTheLastOccurrence() async throws { + @Test func testEditingADuplicateKeyReplacesTheLastOccurrence() async throws { let written = try await rewrite("{\"a\":1, \"b\":2, \"a\":3}\n") { _ in [.edited(0, replace("a", with: "9"))] } - XCTAssertEqual(written, "{\"a\":1, \"b\":2, \"a\":9}\n") + #expect(written == "{\"a\":1, \"b\":2, \"a\":9}\n") } - func testNullEmptyStringAndMissingAreWrittenDistinctly() async throws { + @Test func testNullEmptyStringAndMissingAreWrittenDistinctly() async throws { let edit = JSONObjectEdit( edits: [ JSONMemberEdit(sourceKey: "a", change: .replace(with: try JSONValueTyping.literal(for: "null", originalKind: .null))), @@ -73,30 +73,30 @@ final class JSONTableWriterTests: XCTestCase { ] ) let written = try await rewrite(#"{"a":1,"b":"x","c":true,"d":1}"#) { _ in [.edited(0, edit)] } - XCTAssertEqual(written, #"{"a":null,"b":"","d":1}"#) + #expect(written == #"{"a":null,"b":"","d":1}"#) let reread = try await JSONFixtures.source(written) - XCTAssertEqual(reread.cells(row: 0).map(\.kind), [.null, .text, .number]) + #expect(reread.cells(row: 0).map(\.kind) == [.null, .text, .number]) } - func testNumberLexemesAreWrittenExactly() async throws { + @Test func testNumberLexemesAreWrittenExactly() async throws { let lexemes = ["1.0", "-0", "1e5", "12345678901234567890", "0.1"] let appended = try lexemes.enumerated().map { offset, lexeme in JSONNewMember(key: "n\(offset)", literal: try JSONValueTyping.literal(for: lexeme, originalKind: .number)) } let written = try await rewrite(#"{"n":5}"#) { _ in [.edited(0, JSONObjectEdit(appended: appended))] } - XCTAssertEqual(written, #"{"n":5,"n0":1.0,"n1":-0,"n2":1e5,"n3":12345678901234567890,"n4":0.1}"#) + #expect(written == #"{"n":5,"n0":1.0,"n1":-0,"n2":1e5,"n3":12345678901234567890,"n4":0.1}"#) let reread = try await JSONFixtures.source(written) - XCTAssertEqual(Array(JSONFixtures.texts(reread, row: 0).dropFirst()), lexemes) + #expect(Array(JSONFixtures.texts(reread, row: 0).dropFirst()) == lexemes) } - func testReplacingANestedValueWritesTheNewLiteral() async throws { + @Test func testReplacingANestedValueWritesTheNewLiteral() async throws { let written = try await rewrite(Self.pretty, kind: .json) { _ in [.edited(0, replace("tags", with: #"["c"]"#)), .source(1)] } - XCTAssertEqual(written, Self.pretty.replacingOccurrences(of: "[ \"a\", \"b\" ]", with: #"["c"]"#)) + #expect(written == Self.pretty.replacingOccurrences(of: "[ \"a\", \"b\" ]", with: #"["c"]"#)) } - func testRemovingMembersKeepsTheNeighboursFormatting() async throws { + @Test func testRemovingMembersKeepsTheNeighboursFormatting() async throws { let compactObject = #"{"a":1,"b":2,"c":3}"# let expectations: [(String, [String], String)] = [ (compactObject, ["a"], #"{"b":2,"c":3}"#), @@ -111,11 +111,11 @@ final class JSONTableWriterTests: XCTestCase { for (text, removed, expected) in expectations { let edit = JSONObjectEdit(edits: removed.map { JSONMemberEdit(sourceKey: $0, change: .remove) }) let written = try await rewrite(text) { _ in [.edited(0, edit)] } - XCTAssertEqual(written, expected, "\(text) removing \(removed)") + #expect(written == expected, "\(text) removing \(removed)") } } - func testAppendedKeysFollowTheObjectsOwnStyle() async throws { + @Test func testAppendedKeysFollowTheObjectsOwnStyle() async throws { let expectations: [(String, String)] = [ ("{\n \"a\": 1,\n \"b\": 2\n}", "{\n \"a\": 1,\n \"b\": 2,\n \"c\": 3\n}"), (#"{"a":1}"#, #"{"a":1,"c":3}"#), @@ -125,187 +125,182 @@ final class JSONTableWriterTests: XCTestCase { ] for (text, expected) in expectations { let written = try await rewrite(text) { _ in [.edited(0, JSONObjectEdit(appended: [self.member("c", "3")]))] } - XCTAssertEqual(written, expected, text) + #expect(written == expected, "\(text)") } } - func testReplacingEveryMemberKeepsTheObjectsIndentation() async throws { + @Test func testReplacingEveryMemberKeepsTheObjectsIndentation() async throws { let edit = JSONObjectEdit( edits: [JSONMemberEdit(sourceKey: "a", change: .remove)], appended: [member("b", "2"), member("c", "3")] ) let written = try await rewrite("{\n \"a\": 1\n}") { _ in [.edited(0, edit)] } - XCTAssertEqual(written, "{\n \"b\": 2,\n \"c\": 3\n}") + #expect(written == "{\n \"b\": 2,\n \"c\": 3\n}") } - func testAssigningAKeyTheRowLacksAppendsIt() async throws { + @Test func testAssigningAKeyTheRowLacksAppendsIt() async throws { let written = try await rewrite("{\"a\":1}\n{\"b\":5}\n") { _ in [.edited(0, self.replace("b", with: "2")), .source(1)] } - XCTAssertEqual(written, "{\"a\":1,\"b\":2}\n{\"b\":5}\n") + #expect(written == "{\"a\":1,\"b\":2}\n{\"b\":5}\n") } - func testAppendingAKeyTheRowAlreadyHasReplacesItsValue() async throws { + @Test func testAppendingAKeyTheRowAlreadyHasReplacesItsValue() async throws { let written = try await rewrite(#"{"a":1,"b":2}"#) { _ in [.edited(0, JSONObjectEdit(appended: [self.member("a", "7")]))] } - XCTAssertEqual(written, #"{"a":7,"b":2}"#) + #expect(written == #"{"a":7,"b":2}"#) } - func testAnEditThatChangesNothingKeepsTheRowVerbatim() async throws { + @Test func testAnEditThatChangesNothingKeepsTheRowVerbatim() async throws { let text = "{ \"a\" : 1 }\n{\"a\":2}\n" let written = try await rewrite(text) { _ in [.edited(0, JSONObjectEdit()), .source(1)] } - XCTAssertEqual(written, text) + #expect(written == text) } - func testInsertedRowsInAnArrayUseTheFilesSeparator() async throws { + @Test func testInsertedRowsInAnArrayUseTheFilesSeparator() async throws { let newRow = JSONOutputRow.new([member("id", "3"), member("name", "\"Cy\"")]) let between = try await rewrite(Self.pretty, kind: .json) { _ in [.source(0), newRow, .source(1)] } let firstRow = "{\n \"id\": 1,\n \"name\": \"Ann\",\n \"tags\": [ \"a\", \"b\" ]\n }" let secondRow = "{\n \"id\": 2,\n \"name\": \"Bob\"\n }" let insertedRow = #"{"id":3,"name":"Cy"}"# - XCTAssertEqual(between, "[\n \(firstRow),\n \(insertedRow),\n \(secondRow)\n]\n") + #expect(between == "[\n \(firstRow),\n \(insertedRow),\n \(secondRow)\n]\n") let atEnd = try await rewrite(Self.pretty, kind: .json) { _ in [.source(0), .source(1), newRow] } - XCTAssertEqual(atEnd, "[\n \(firstRow),\n \(secondRow),\n \(insertedRow)\n]\n") + #expect(atEnd == "[\n \(firstRow),\n \(secondRow),\n \(insertedRow)\n]\n") let atStart = try await rewrite(Self.compact, kind: .json) { _ in [newRow, .source(0), .source(1)] } - XCTAssertEqual(atStart, #"[{"id":3,"name":"Cy"},{"id":1,"name":"Ann"},{"id":2,"name":"Bob"}]"#) + #expect(atStart == #"[{"id":3,"name":"Cy"},{"id":1,"name":"Ann"},{"id":2,"name":"Bob"}]"#) } - func testInsertedRowsInASmallArrayInferTheSeparator() async throws { + @Test func testInsertedRowsInASmallArrayInferTheSeparator() async throws { let single = try await rewrite("[\n {\"a\":1}\n]", kind: .json) { _ in [.source(0), .new([self.member("a", "2")])] } - XCTAssertEqual(single, "[\n {\"a\":1},\n {\"a\":2}\n]") + #expect(single == "[\n {\"a\":1},\n {\"a\":2}\n]") let empty = try await rewrite("[ ]\n", kind: .json) { _ in [.new([self.member("a", "1")]), .new([self.member("a", "2")])] } - XCTAssertEqual(empty, "[{\"a\":1},{\"a\":2}]\n") + #expect(empty == "[{\"a\":1},{\"a\":2}]\n") } - func testInsertedRowsInJSONLinesUseTheFilesLineEnding() async throws { + @Test func testInsertedRowsInJSONLinesUseTheFilesLineEnding() async throws { let crlf = try await rewrite("{\"id\":1}\r\n{\"id\":2}\r\n") { _ in [.source(0), .new([self.member("id", "3")]), .source(1)] } - XCTAssertEqual(crlf, "{\"id\":1}\r\n{\"id\":3}\r\n{\"id\":2}\r\n") + #expect(crlf == "{\"id\":1}\r\n{\"id\":3}\r\n{\"id\":2}\r\n") let noFinalNewline = try await rewrite("{\"id\":1}\n{\"id\":2}") { _ in [.source(0), .source(1), .new([self.member("id", "3")])] } - XCTAssertEqual(noFinalNewline, "{\"id\":1}\n{\"id\":2}\n{\"id\":3}") + #expect(noFinalNewline == "{\"id\":1}\n{\"id\":2}\n{\"id\":3}") let empty = try await rewrite("") { _ in [.new([self.member("a", "1")]), .new([self.member("a", "2")])] } - XCTAssertEqual(empty, "{\"a\":1}\n{\"a\":2}\n") + #expect(empty == "{\"a\":1}\n{\"a\":2}\n") } - func testDeletedRowsKeepTheirNeighboursAndTheFinalNewlineState() async throws { + @Test func testDeletedRowsKeepTheirNeighboursAndTheFinalNewlineState() async throws { let text = "\n{\"id\":1}\n\n\n{\"id\":2}\n {\"id\":3}" let withoutMiddle = try await rewrite(text) { _ in [.source(0), .source(2)] } - XCTAssertEqual(withoutMiddle, "\n{\"id\":1}\n\n\n{\"id\":3}") + #expect(withoutMiddle == "\n{\"id\":1}\n\n\n{\"id\":3}") let withoutLast = try await rewrite(text) { _ in [.source(0), .source(1)] } - XCTAssertEqual(withoutLast, "\n{\"id\":1}\n\n\n{\"id\":2}") + #expect(withoutLast == "\n{\"id\":1}\n\n\n{\"id\":2}") let withoutAny = try await rewrite(text) { _ in [] } - XCTAssertEqual(withoutAny, "") + #expect(withoutAny == "") let prettyWithoutFirst = try await rewrite(Self.pretty, kind: .json) { _ in [.source(1)] } - XCTAssertEqual(prettyWithoutFirst, "[\n {\n \"id\": 2,\n \"name\": \"Bob\"\n }\n]\n") + #expect(prettyWithoutFirst == "[\n {\n \"id\": 2,\n \"name\": \"Bob\"\n }\n]\n") let prettyWithoutAny = try await rewrite(Self.pretty, kind: .json) { _ in [] } - XCTAssertEqual(prettyWithoutAny, "[]\n") + #expect(prettyWithoutAny == "[]\n") } - func testReorderedRowsUseTheFilesSeparators() async throws { + @Test func testReorderedRowsUseTheFilesSeparators() async throws { let array = try await rewrite(Self.compact, kind: .json) { _ in [.source(1), .source(0)] } - XCTAssertEqual(array, #"[{"id":2,"name":"Bob"},{"id":1,"name":"Ann"}]"#) + #expect(array == #"[{"id":2,"name":"Bob"},{"id":1,"name":"Ann"}]"#) let lines = try await rewrite("{\"id\":1}\n{\"id\":2}\n") { _ in [.source(1), .source(0)] } - XCTAssertEqual(lines, "{\"id\":2}\n{\"id\":1}\n") + #expect(lines == "{\"id\":2}\n{\"id\":1}\n") } - func testRenamingAColumnRenamesTheKeyInEveryObject() async throws { + @Test func testRenamingAColumnRenamesTheKeyInEveryObject() async throws { let text = "{\"id\":1,\"name\":\"x\"}\n{\"name\":\"y\"}\n{\"na\\u006de\":\"z\", \"name\":\"w\"}\n{\"id\":4}\n" let written = try await rewrite(text, keyChanges: ["name": .rename(to: "full \"name\"")]) { JSONFixtures.untouchedRows(of: $0) } - XCTAssertEqual( - written, - "{\"id\":1,\"full \\\"name\\\"\":\"x\"}\n{\"full \\\"name\\\"\":\"y\"}\n{\"full \\\"name\\\"\":\"z\", \"full \\\"name\\\"\":\"w\"}\n{\"id\":4}\n" - ) + #expect(written == "{\"id\":1,\"full \\\"name\\\"\":\"x\"}\n{\"full \\\"name\\\"\":\"y\"}\n{\"full \\\"name\\\"\":\"z\", \"full \\\"name\\\"\":\"w\"}\n{\"id\":4}\n") } - func testDeletingAColumnRemovesTheKeyFromEveryObject() async throws { + @Test func testDeletingAColumnRemovesTheKeyFromEveryObject() async throws { let text = "{\"id\":1,\"name\":\"x\"}\n{\"name\":\"y\"}\n{\"id\":3}\n" let written = try await rewrite(text, keyChanges: ["name": .remove]) { JSONFixtures.untouchedRows(of: $0) } - XCTAssertEqual(written, "{\"id\":1}\n{}\n{\"id\":3}\n") + #expect(written == "{\"id\":1}\n{}\n{\"id\":3}\n") } - func testRowEditsCombineWithColumnChanges() async throws { + @Test func testRowEditsCombineWithColumnChanges() async throws { let text = "{\"id\":1,\"name\":\"x\"}\n{\"id\":2}\n" let written = try await rewrite(text, keyChanges: ["name": .rename(to: "n"), "id": .remove]) { _ in [.edited(0, self.replace("name", with: "\"q\"")), .edited(1, self.replace("name", with: "\"r\""))] } - XCTAssertEqual(written, "{\"n\":\"q\"}\n{\"n\":\"r\"}\n") + #expect(written == "{\"n\":\"q\"}\n{\"n\":\"r\"}\n") } - func testRowEditOfARemovedColumnIsDropped() async throws { + @Test func testRowEditOfARemovedColumnIsDropped() async throws { let written = try await rewrite("{\"a\":1,\"b\":2}\n", keyChanges: ["a": .remove]) { _ in [.edited(0, self.replace("a", with: "5"))] } - XCTAssertEqual(written, "{\"b\":2}\n") + #expect(written == "{\"b\":2}\n") } - func testRenameThatCollidesWithAnotherColumnThrows() async throws { + @Test func testRenameThatCollidesWithAnotherColumnThrows() async throws { let source = try await JSONFixtures.source("{\"id\":1,\"name\":\"x\"}\n") - XCTAssertThrowsError( - try JSONTableWriter(source: source, keyChanges: ["id": .rename(to: "name")]).encoded(rows: [.source(0)]) - ) { error in - XCTAssertEqual(error as? JSONTableWriteError, .duplicateKey("name")) + if let error = #expect(throws: (any Error).self, performing: { try JSONTableWriter(source: source, keyChanges: ["id": .rename(to: "name")]).encoded(rows: [.source(0)]) }) { + #expect(error as? JSONTableWriteError == .duplicateKey("name")) } let swapped = try JSONFixtures.written(source, rows: [.source(0)], keyChanges: ["id": .rename(to: "name"), "name": .remove]) - XCTAssertEqual(swapped, "{\"name\":1}\n") + #expect(swapped == "{\"name\":1}\n") } - func testInvalidLiteralsAreRefused() async throws { + @Test func testInvalidLiteralsAreRefused() async throws { let lines = try await JSONFixtures.source("{\"a\":1}\n") - XCTAssertThrowsError(try JSONTableWriter(source: lines).encoded(rows: [.edited(0, replace("a", with: "abc"))])) { error in - XCTAssertEqual(error as? JSONTableWriteError, .invalidLiteral("abc")) + if let error = #expect(throws: (any Error).self, performing: { try JSONTableWriter(source: lines).encoded(rows: [.edited(0, replace("a", with: "abc"))]) }) { + #expect(error as? JSONTableWriteError == .invalidLiteral("abc")) } - XCTAssertThrowsError(try JSONTableWriter(source: lines).encoded(rows: [.edited(0, replace("a", with: "[1,\n2]"))])) { error in - XCTAssertEqual(error as? JSONTableWriteError, .literalSpansLines("[1,\n2]")) + if let error = #expect(throws: (any Error).self, performing: { try JSONTableWriter(source: lines).encoded(rows: [.edited(0, replace("a", with: "[1,\n2]"))]) }) { + #expect(error as? JSONTableWriteError == .literalSpansLines("[1,\n2]")) } - XCTAssertThrowsError(try JSONTableWriter(source: lines).encoded(rows: [.new([member("a", "1 2")])])) { error in - XCTAssertEqual(error as? JSONTableWriteError, .invalidLiteral("1 2")) + if let error = #expect(throws: (any Error).self, performing: { try JSONTableWriter(source: lines).encoded(rows: [.new([member("a", "1 2")])]) }) { + #expect(error as? JSONTableWriteError == .invalidLiteral("1 2")) } let array = try await JSONFixtures.source("[{\"a\":1}]", kind: .json) let written = try JSONTableWriter(source: array).encoded(rows: [.edited(0, replace("a", with: "[1,\n2]"))]) - XCTAssertEqual(try JSONFixtures.text(written), "[{\"a\":[1,\n2]}]") + #expect(try JSONFixtures.text(written) == "[{\"a\":[1,\n2]}]") } - func testNewRowWithDuplicateKeysThrows() async throws { + @Test func testNewRowWithDuplicateKeysThrows() async throws { let source = try await JSONFixtures.source("{\"a\":1}\n") - XCTAssertThrowsError(try JSONTableWriter(source: source).encoded(rows: [.new([member("a", "1"), member("a", "2")])])) { error in - XCTAssertEqual(error as? JSONTableWriteError, .duplicateKey("a")) + if let error = #expect(throws: (any Error).self, performing: { try JSONTableWriter(source: source).encoded(rows: [.new([member("a", "1"), member("a", "2")])]) }) { + #expect(error as? JSONTableWriteError == .duplicateKey("a")) } } - func testRowsTheSourceDoesNotHaveThrow() async throws { + @Test func testRowsTheSourceDoesNotHaveThrow() async throws { let source = try await JSONFixtures.source("{\"a\":1}\n") - XCTAssertThrowsError(try JSONTableWriter(source: source).encoded(rows: [.source(5)])) { error in - XCTAssertEqual(error as? JSONTableWriteError, .sourceRowUnavailable(5)) + if let error = #expect(throws: (any Error).self, performing: { try JSONTableWriter(source: source).encoded(rows: [.source(5)]) }) { + #expect(error as? JSONTableWriteError == .sourceRowUnavailable(5)) } - XCTAssertThrowsError(try JSONTableWriter(shape: .lines).encoded(rows: [.edited(0, JSONObjectEdit())])) { error in - XCTAssertEqual(error as? JSONTableWriteError, .sourceRowUnavailable(0)) + if let error = #expect(throws: (any Error).self, performing: { try JSONTableWriter(shape: .lines).encoded(rows: [.edited(0, JSONObjectEdit())]) }) { + #expect(error as? JSONTableWriteError == .sourceRowUnavailable(0)) } } - func testWriterWithoutASourceSerializesNewRows() throws { + @Test func testWriterWithoutASourceSerializesNewRows() throws { let rows: [JSONOutputRow] = [.new([member("a", "1")]), .new([member("b", "\"x\"")])] - XCTAssertEqual(try JSONFixtures.text(JSONTableWriter(shape: .array).encoded(rows: rows)), "[\n{\"a\":1},\n{\"b\":\"x\"}\n]\n") - XCTAssertEqual(try JSONFixtures.text(JSONTableWriter(shape: .lines).encoded(rows: rows)), "{\"a\":1}\n{\"b\":\"x\"}\n") - XCTAssertEqual(try JSONFixtures.text(JSONTableWriter(shape: .array).encoded(rows: [])), "[]\n") - XCTAssertEqual(try JSONTableWriter(shape: .lines).encoded(rows: []), []) + #expect(try JSONFixtures.text(JSONTableWriter(shape: .array).encoded(rows: rows)) == "[\n{\"a\":1},\n{\"b\":\"x\"}\n]\n") + #expect(try JSONFixtures.text(JSONTableWriter(shape: .lines).encoded(rows: rows)) == "{\"a\":1}\n{\"b\":\"x\"}\n") + #expect(try JSONFixtures.text(JSONTableWriter(shape: .array).encoded(rows: [])) == "[]\n") + #expect(try JSONTableWriter(shape: .lines).encoded(rows: []).isEmpty) } - func testWritingToAFileMatchesTheEncodedBytes() async throws { + @Test func testWritingToAFileMatchesTheEncodedBytes() async throws { let source = try await JSONFixtures.source(Self.pretty, kind: .json) let rows: [JSONOutputRow] = [.edited(1, replace("id", with: "20")), .source(0)] let url = FileManager.default.temporaryDirectory.appendingPathComponent("json-writer-\(UUID().uuidString).json") defer { try? FileManager.default.removeItem(at: url) } let writer = JSONTableWriter(source: source) try writer.write(to: url, rows: rows) - XCTAssertEqual(try Data(contentsOf: url), Data(try writer.encoded(rows: rows))) + #expect(try Data(contentsOf: url) == Data(try writer.encoded(rows: rows))) } - func testWriteStopsWhenCancelled() async throws { + @Test func testWriteStopsWhenCancelled() async throws { let source = try await JSONFixtures.source(String(repeating: "{\"a\":\"\(String(repeating: "x", count: 100))\"}\n", count: 20_000)) let url = FileManager.default.temporaryDirectory.appendingPathComponent("json-writer-\(UUID().uuidString).jsonl") defer { try? FileManager.default.removeItem(at: url) } - XCTAssertThrowsError( + if let error = #expect(throws: (any Error).self, performing: { try JSONTableWriter(source: source).write(to: url, rows: JSONFixtures.untouchedRows(of: source), isCancelled: { true }) - ) { error in - XCTAssertTrue(error is TabularCancellation) + }) { + #expect(error is TabularCancellation) } } } diff --git a/Packages/TableProCore/Tests/TableProTabularIOTests/JSONValueTypingTests.swift b/Packages/TableProCore/Tests/TableProTabularIOTests/JSONValueTypingTests.swift index 1c2be014f2..d6a38a6ca5 100644 --- a/Packages/TableProCore/Tests/TableProTabularIOTests/JSONValueTypingTests.swift +++ b/Packages/TableProCore/Tests/TableProTabularIOTests/JSONValueTypingTests.swift @@ -1,63 +1,60 @@ import Foundation @testable import TableProTabularIO -import XCTest +import Testing -final class JSONValueTypingTests: XCTestCase { - func testNumberCellsKeepAValidLexemeExactly() throws { +struct JSONValueTypingTests { + @Test func testNumberCellsKeepAValidLexemeExactly() throws { for lexeme in ["1.0", "-0", "1e5", "12345678901234567890", "0.1", "1E+2", "-1.5e-3", "0"] { - XCTAssertEqual(try JSONValueTyping.literal(for: lexeme, originalKind: .number), lexeme) - XCTAssertTrue(JSONValueTyping.isNumberLexeme(lexeme), lexeme) + #expect(try JSONValueTyping.literal(for: lexeme, originalKind: .number) == lexeme) + #expect(JSONValueTyping.isNumberLexeme(lexeme), "\(lexeme)") } } - func testNumberCellsTurnInvalidLexemesIntoStrings() throws { + @Test func testNumberCellsTurnInvalidLexemesIntoStrings() throws { for text in ["01", "1.", "+1", ".5", " 1", "NaN", "Infinity", "0x10", "1e", "", "1,5", "--1"] { - XCTAssertEqual(try JSONValueTyping.literal(for: text, originalKind: .number), JSONText.stringLiteral(text)) - XCTAssertFalse(JSONValueTyping.isNumberLexeme(text), text) + #expect(try JSONValueTyping.literal(for: text, originalKind: .number) == JSONText.stringLiteral(text)) + #expect(!JSONValueTyping.isNumberLexeme(text), "\(text)") } } - func testBooleanAndNullCellsKeepTheirKindOnlyForTheirLiterals() throws { - XCTAssertEqual(try JSONValueTyping.literal(for: "false", originalKind: .boolean), "false") - XCTAssertEqual(try JSONValueTyping.literal(for: "True", originalKind: .boolean), "\"True\"") - XCTAssertEqual(try JSONValueTyping.literal(for: "null", originalKind: .null), "null") - XCTAssertEqual(try JSONValueTyping.literal(for: "", originalKind: .null), "\"\"") - XCTAssertEqual(try JSONValueTyping.literal(for: "1", originalKind: .boolean), "\"1\"") + @Test func testBooleanAndNullCellsKeepTheirKindOnlyForTheirLiterals() throws { + #expect(try JSONValueTyping.literal(for: "false", originalKind: .boolean) == "false") + #expect(try JSONValueTyping.literal(for: "True", originalKind: .boolean) == "\"True\"") + #expect(try JSONValueTyping.literal(for: "null", originalKind: .null) == "null") + #expect(try JSONValueTyping.literal(for: "", originalKind: .null) == "\"\"") + #expect(try JSONValueTyping.literal(for: "1", originalKind: .boolean) == "\"1\"") } - func testTextAndMissingCellsAlwaysBecomeStrings() throws { + @Test func testTextAndMissingCellsAlwaysBecomeStrings() throws { for kind in [TabularCellKind.text, .missing, .error, .date] { - XCTAssertEqual(try JSONValueTyping.literal(for: "42", originalKind: kind), "\"42\"") - XCTAssertEqual(try JSONValueTyping.literal(for: "true", originalKind: kind), "\"true\"") - XCTAssertEqual(try JSONValueTyping.literal(for: "null", originalKind: kind), "\"null\"") + #expect(try JSONValueTyping.literal(for: "42", originalKind: kind) == "\"42\"") + #expect(try JSONValueTyping.literal(for: "true", originalKind: kind) == "\"true\"") + #expect(try JSONValueTyping.literal(for: "null", originalKind: kind) == "\"null\"") } } - func testStringsAreEscaped() throws { - XCTAssertEqual( - try JSONValueTyping.literal(for: "a\"b\\c\n\r\t\u{08}\u{0C}\u{01}\u{1F}/é😀", originalKind: .text), - #""a\"b\\c\n\r\t\b\f\u0001\u001f/é😀""# - ) + @Test func testStringsAreEscaped() throws { + #expect(try JSONValueTyping.literal(for: "a\"b\\c\n\r\t\u{08}\u{0C}\u{01}\u{1F}/é😀", originalKind: .text) == #""a\"b\\c\n\r\t\b\f\u0001\u001f/é😀""#) } - func testEscapedStringsReadBackToTheSameText() throws { + @Test func testEscapedStringsReadBackToTheSameText() throws { let original = "quote \" backslash \\ tab \t newline \n control \u{07} emoji 👍🏽" let literal = try JSONValueTyping.literal(for: original, originalKind: .text) let bytes = Array("{\"k\":\(literal)}".utf8) try bytes.withUnsafeBufferPointer { buffer in let layout = try JSONRowParser.parseObject(in: buffer, at: 0) - XCTAssertEqual(JSONRowParser.cell(for: layout.members[0], in: buffer), TabularCell(kind: .text, text: original)) + #expect(JSONRowParser.cell(for: layout.members[0], in: buffer) == TabularCell(kind: .text, text: original)) } } - func testContainerCellsMustBeValidJSONAndAreCompacted() throws { - XCTAssertEqual(try JSONValueTyping.literal(for: "{ \"a\" : [1, 2], \"s\": \"x y\" }", originalKind: .object), #"{"a":[1,2],"s":"x y"}"#) - XCTAssertEqual(try JSONValueTyping.literal(for: "[\n 1,\n 2\n]", originalKind: .array), "[1,2]") - XCTAssertEqual(try JSONValueTyping.literal(for: " 7 ", originalKind: .array), "7") - XCTAssertEqual(try JSONValueTyping.literal(for: "\"text\"", originalKind: .object), "\"text\"") + @Test func testContainerCellsMustBeValidJSONAndAreCompacted() throws { + #expect(try JSONValueTyping.literal(for: "{ \"a\" : [1, 2], \"s\": \"x y\" }", originalKind: .object) == #"{"a":[1,2],"s":"x y"}"#) + #expect(try JSONValueTyping.literal(for: "[\n 1,\n 2\n]", originalKind: .array) == "[1,2]") + #expect(try JSONValueTyping.literal(for: " 7 ", originalKind: .array) == "7") + #expect(try JSONValueTyping.literal(for: "\"text\"", originalKind: .object) == "\"text\"") } - func testInvalidContainerTextThrows() { + @Test func testInvalidContainerTextThrows() { let cases: [(String, TabularCellKind, Int)] = [ ("{\"a\":}", .object, 5), ("[1,", .array, 3), @@ -66,8 +63,8 @@ final class JSONValueTypingTests: XCTestCase { ("[1] x", .array, 4) ] for (text, kind, offset) in cases { - XCTAssertThrowsError(try JSONValueTyping.literal(for: text, originalKind: kind), text) { error in - XCTAssertEqual(error as? JSONValueTypingError, .invalidJSON(kind: kind, byteOffset: offset), text) + if let error = #expect(throws: (any Error).self, "\(text)", performing: { try JSONValueTyping.literal(for: text, originalKind: kind) }) { + #expect(error as? JSONValueTypingError == .invalidJSON(kind: kind, byteOffset: offset), "\(text)") } } } diff --git a/Packages/TableProCore/Tests/TableProTabularIOTests/TabularEncodingDetectorTests.swift b/Packages/TableProCore/Tests/TableProTabularIOTests/TabularEncodingDetectorTests.swift index a2ddcf5cb2..eff67db53b 100644 --- a/Packages/TableProCore/Tests/TableProTabularIOTests/TabularEncodingDetectorTests.swift +++ b/Packages/TableProCore/Tests/TableProTabularIOTests/TabularEncodingDetectorTests.swift @@ -1,9 +1,9 @@ import CoreFoundation import Foundation @testable import TableProTabularIO -import XCTest +import Testing -final class TabularEncodingDetectorTests: XCTestCase { +struct TabularEncodingDetectorTests { private let japaneseRows = """ 商品コード,商品名,在庫管理区分,ザイコカンリクブン,備考 10001,ソフトウェア保守,在庫管理する,ザイコカンリスル,表示のみ @@ -15,54 +15,54 @@ final class TabularEncodingDetectorTests: XCTestCase { """ private func encoded(_ text: String, _ encoding: TabularTextEncoding) throws -> Data { - try XCTUnwrap(text.data(using: encoding.foundationEncoding, allowLossyConversion: false)) + try #require(text.data(using: encoding.foundationEncoding, allowLossyConversion: false)) } private func sniff(_ data: Data, isWholeFile: Bool = true) -> TabularEncodingSniff { TabularEncodingDetector.sniff(data, isWholeFile: isWholeFile) } - func testByteOrderMarksDecideTheEncoding() { - XCTAssertEqual(sniff(Data([0xEF, 0xBB, 0xBF, 0x61])), TabularEncodingSniff(encoding: .utf8, byteOrderMarkLength: 3)) - XCTAssertEqual(sniff(Data([0xFF, 0xFE, 0x61, 0x00])), TabularEncodingSniff(encoding: .utf16LittleEndian, byteOrderMarkLength: 2)) - XCTAssertEqual(sniff(Data([0xFE, 0xFF, 0x00, 0x61])), TabularEncodingSniff(encoding: .utf16BigEndian, byteOrderMarkLength: 2)) + @Test func testByteOrderMarksDecideTheEncoding() { + #expect(sniff(Data([0xEF, 0xBB, 0xBF, 0x61])) == TabularEncodingSniff(encoding: .utf8, byteOrderMarkLength: 3)) + #expect(sniff(Data([0xFF, 0xFE, 0x61, 0x00])) == TabularEncodingSniff(encoding: .utf16LittleEndian, byteOrderMarkLength: 2)) + #expect(sniff(Data([0xFE, 0xFF, 0x00, 0x61])) == TabularEncodingSniff(encoding: .utf16BigEndian, byteOrderMarkLength: 2)) } - func testASCIIAndUTF8ReadAsUTF8() { - XCTAssertEqual(sniff(Data("id,name\n1,Ann\n".utf8)).encoding, .utf8) - XCTAssertEqual(sniff(Data(japaneseRows.utf8)).encoding, .utf8) - XCTAssertEqual(sniff(Data()).encoding, .utf8) + @Test func testASCIIAndUTF8ReadAsUTF8() { + #expect(sniff(Data("id,name\n1,Ann\n".utf8)).encoding == .utf8) + #expect(sniff(Data(japaneseRows.utf8)).encoding == .utf8) + #expect(sniff(Data()).encoding == .utf8) } - func testShiftJISExportIsDetected() throws { - XCTAssertEqual(sniff(try encoded(japaneseRows, .shiftJIS)).encoding, .shiftJIS) + @Test func testShiftJISExportIsDetected() throws { + #expect(sniff(try encoded(japaneseRows, .shiftJIS)).encoding == .shiftJIS) } - func testHalfWidthKatakanaOnlyReadsAsShiftJIS() throws { + @Test func testHalfWidthKatakanaOnlyReadsAsShiftJIS() throws { let screenshotHeader: [UInt8] = [0xBB, 0xDE, 0xB2, 0xBA, 0xB6, 0xDD, 0xD8, 0xB8, 0xCC, 0xDE, 0xDD] var bytes = Array("CODE,".utf8) + screenshotHeader + [0x0A] let rows = "1,ザイコカンリスル\n2,チョウリアリ\n3,ソフトウェア\n4,ポイント\n" bytes += [UInt8](try encoded(rows, .shiftJIS)) - XCTAssertEqual(sniff(Data(bytes)).encoding, .shiftJIS) + #expect(sniff(Data(bytes)).encoding == .shiftJIS) } - func testTrailBytesThatSpellBackslashAndPipeStayShiftJIS() throws { + @Test func testTrailBytesThatSpellBackslashAndPipeStayShiftJIS() throws { let text = "名前|読み\nソフト|ソフト\n表計算|ヒョウケイサン\nポイント|ポイント\n" - XCTAssertEqual(sniff(try encoded(text, .shiftJIS)).encoding, .shiftJIS) + #expect(sniff(try encoded(text, .shiftJIS)).encoding == .shiftJIS) } - func testABadByteInTheSampleDoesNotHideTheEncoding() throws { + @Test func testABadByteInTheSampleDoesNotHideTheEncoding() throws { var bytes = try encoded(japaneseRows, .shiftJIS) bytes.append(contentsOf: Array("10006,".utf8) + [0xA0] + Array(",x\n".utf8)) bytes.append(try encoded(japaneseRows, .shiftJIS)) - XCTAssertEqual(sniff(bytes).encoding, .shiftJIS) + #expect(sniff(bytes).encoding == .shiftJIS) var halfWidth = try encoded(String(repeating: "1,ザイコカンリスル,チョウリアリ\n", count: 60), .shiftJIS) halfWidth.append(contentsOf: Array("2,".utf8) + [0xA0] + [0x0A]) - XCTAssertEqual(sniff(halfWidth).encoding, .shiftJIS) + #expect(sniff(halfWidth).encoding == .shiftJIS) } - func testEUCJPIsDetected() throws { + @Test func testEUCJPIsDetected() throws { let text = """ 商品コード,商品名,在庫管理区分,ザイコカンリクブン,備考 10001,ソフトウェア保守,在庫管理する,ザイコカンリスル,表示のみ @@ -72,10 +72,10 @@ final class TabularEncodingDetectorTests: XCTestCase { 10005,山崎さんの見積書,在庫管理する,ザイコカンリスル,確認済み """ - XCTAssertEqual(sniff(try encoded(text, .eucJP)).encoding, .eucJP) + #expect(sniff(try encoded(text, .eucJP)).encoding == .eucJP) } - func testSimplifiedChineseIsDetectedAsGB18030() throws { + @Test func testSimplifiedChineseIsDetectedAsGB18030() throws { let text = """ 客户编号,客户名称,所在城市,备注 1001,北京科技有限公司,北京市朝阳区,重要客户 @@ -83,10 +83,10 @@ final class TabularEncodingDetectorTests: XCTestCase { 1003,广州电子商务中心,广州市天河区,已经签约 """ - XCTAssertEqual(sniff(try encoded(text, .gb18030)).encoding, .gb18030) + #expect(sniff(try encoded(text, .gb18030)).encoding == .gb18030) } - func testTraditionalChineseWithWindowsOnlyCharactersIsDetectedAsBig5() throws { + @Test func testTraditionalChineseWithWindowsOnlyCharactersIsDetectedAsBig5() throws { let text = """ 客戶編號,客戶名稱,所在城市,備註 2001,臺北科技股份有限公司,臺北市信義區,碁盤裏的重要客戶 @@ -95,11 +95,11 @@ final class TabularEncodingDetectorTests: XCTestCase { """ let bytes = try encoded(text, .big5) - XCTAssertNil(String(data: bytes, encoding: Self.strictBig5), "the sample holds cp950-only characters") - XCTAssertEqual(sniff(bytes).encoding, .big5) + #expect(String(data: bytes, encoding: Self.strictBig5) == nil, "the sample holds cp950-only characters") + #expect(sniff(bytes).encoding == .big5) } - func testKoreanWithUnifiedHangulIsDetectedAsEUCKR() throws { + @Test func testKoreanWithUnifiedHangulIsDetectedAsEUCKR() throws { let text = """ 고객번호,고객명,도시,비고 3001,똠방각하 무역,서울특별시 강남구,중요 고객 @@ -107,10 +107,10 @@ final class TabularEncodingDetectorTests: XCTestCase { 3003,뷁 상사,대구광역시 수성구,계약 완료 """ - XCTAssertEqual(sniff(try encoded(text, .eucKR)).encoding, .eucKR) + #expect(sniff(try encoded(text, .eucKR)).encoding == .eucKR) } - func testWesternTextStaysWindows1252() throws { + @Test func testWesternTextStaysWindows1252() throws { let text = """ Kunde;Ort;Straße;Notiz Müller GmbH;München;Hauptstraße 12;“Wichtig” – 5 € @@ -118,10 +118,10 @@ final class TabularEncodingDetectorTests: XCTestCase { Société Générale;Genève;Rue du Rhône;Crème brûlée """ - XCTAssertEqual(sniff(try encoded(text, .windows1252)).encoding, .windows1252) + #expect(sniff(try encoded(text, .windows1252)).encoding == .windows1252) } - func testShortWesternFilesWithSymbolsAndCapitalsStayWindows1252() throws { + @Test func testShortWesternFilesWithSymbolsAndCapitalsStayWindows1252() throws { let samples = [ "Book,£12.50\nMusic,£9.99\n", "Item;Price\nBook;£12.50\nFood;€5.00\n", @@ -134,19 +134,19 @@ final class TabularEncodingDetectorTests: XCTestCase { "id,amount\n1,£5\n2,¥300\n3,€7\n4,¢9\n" ] for sample in samples { - XCTAssertEqual(sniff(try encoded(sample, .windows1252)).encoding, .windows1252, sample) + #expect(sniff(try encoded(sample, .windows1252)).encoding == .windows1252, "\(sample)") } } - func testMostlyUTF8WithAStrayByteStaysUTF8() { + @Test func testMostlyUTF8WithAStrayByteStaysUTF8() { var bytes = Data("name;city\nJürgen Müller;Köln\nStraße;Düsseldorf\n„Zitat“;Größe\n".utf8) bytes.append(contentsOf: Array("caf".utf8) + [0xE9] + Array(";x\n".utf8)) - XCTAssertEqual(sniff(bytes).encoding, .utf8) + #expect(sniff(bytes).encoding == .utf8) } - func testAGB18030CharacterAcrossTheSampleEdgeIsNotCut() throws { + @Test func testAGB18030CharacterAcrossTheSampleEdgeIsNotCut() throws { let fourByteCharacter: [UInt8] = [0x94, 0x39, 0xFC, 0x36] - XCTAssertNotNil(String(bytes: fourByteCharacter, encoding: TabularTextEncoding.gb18030.foundationEncoding)) + #expect(String(bytes: fourByteCharacter, encoding: TabularTextEncoding.gb18030.foundationEncoding) != nil) let row = try encoded("北京市朝阳区建国路八十八号", .gb18030) for shift in 0..<4 { var bytes = Data() @@ -156,53 +156,53 @@ final class TabularEncodingDetectorTests: XCTestCase { bytes.append(contentsOf: fourByteCharacter) bytes.append(row) bytes.append(0x0A) - XCTAssertEqual(sniff(bytes).encoding, .gb18030, "shift \(shift)") + #expect(sniff(bytes).encoding == .gb18030, "shift \(shift)") } } - func testUnmarkedUTF16CJKWithZeroLowBytesIsDetected() throws { + @Test func testUnmarkedUTF16CJKWithZeroLowBytesIsDetected() throws { let texts = [ String(repeating: "1000,山田\u{3000}太郎,東京都港区芝公園4-2-8,03-1234-5000\n", count: 30), String(repeating: "一一一一一一一一一一一一一一一一,一\n", count: 20), String(repeating: String(repeating: "漢字", count: 26) + "," + String(repeating: "漢字", count: 12) + "\n", count: 20) ] for text in texts { - XCTAssertEqual(sniff(try encoded(text, .utf16LittleEndian)).encoding, .utf16LittleEndian) - XCTAssertEqual(sniff(try encoded(text, .utf16BigEndian)).encoding, .utf16BigEndian) + #expect(sniff(try encoded(text, .utf16LittleEndian)).encoding == .utf16LittleEndian) + #expect(sniff(try encoded(text, .utf16BigEndian)).encoding == .utf16BigEndian) } } - func testAFileEndingInAWesternByteIsNotReadAsUTF8() { - XCTAssertEqual(sniff(Data([0x6E, 0x0A, 0x43, 0x61, 0x66, 0xE9])).encoding, .windows1252) + @Test func testAFileEndingInAWesternByteIsNotReadAsUTF8() { + #expect(sniff(Data([0x6E, 0x0A, 0x43, 0x61, 0x66, 0xE9])).encoding == .windows1252) } - func testUTF16WithoutAByteOrderMarkIsDetected() throws { + @Test func testUTF16WithoutAByteOrderMarkIsDetected() throws { let text = "名前,住所\n吉田,三上\n本田,日本\nAnn,Paris\n" - XCTAssertEqual(sniff(try encoded(text, .utf16LittleEndian)), TabularEncodingSniff(encoding: .utf16LittleEndian, byteOrderMarkLength: 0)) - XCTAssertEqual(sniff(try encoded(text, .utf16BigEndian)), TabularEncodingSniff(encoding: .utf16BigEndian, byteOrderMarkLength: 0)) + #expect(sniff(try encoded(text, .utf16LittleEndian)) == TabularEncodingSniff(encoding: .utf16LittleEndian, byteOrderMarkLength: 0)) + #expect(sniff(try encoded(text, .utf16BigEndian)) == TabularEncodingSniff(encoding: .utf16BigEndian, byteOrderMarkLength: 0)) } - func testShiftJISAfterALongASCIIPrefixIsDetected() throws { + @Test func testShiftJISAfterALongASCIIPrefixIsDetected() throws { var bytes = Data(String(repeating: "12345,ABCDE,2024-01-01\n", count: 14_000).utf8) - XCTAssertGreaterThan(bytes.count, 300_000) + #expect(bytes.count > 300_000) bytes.append(try encoded(japaneseRows, .shiftJIS)) - XCTAssertEqual(sniff(bytes).encoding, .shiftJIS) + #expect(sniff(bytes).encoding == .shiftJIS) } - func testCarriageReturnLinesAreDetected() throws { + @Test func testCarriageReturnLinesAreDetected() throws { let text = japaneseRows.replacingOccurrences(of: "\n", with: "\r") - XCTAssertEqual(sniff(try encoded(text, .shiftJIS)).encoding, .shiftJIS) + #expect(sniff(try encoded(text, .shiftJIS)).encoding == .shiftJIS) } - func testAPrefixCutInsideACharacterStaysUTF8() { + @Test func testAPrefixCutInsideACharacterStaysUTF8() { let whole = Data(String(repeating: "東京都港区,", count: 20_000).utf8) let cut = whole.prefix(TabularEncodingDetector.sampleLength + 1) - XCTAssertEqual(sniff(Data(cut), isWholeFile: false).encoding, .utf8) + #expect(sniff(Data(cut), isWholeFile: false).encoding == .utf8) } - func testEveryCandidateIsAnEncodingFoundationKnows() { + @Test func testEveryCandidateIsAnEncodingFoundationKnows() { for candidate in TabularEncodingDetector.legacyCandidates { - XCTAssertNotNil(String(data: Data("a".utf8), encoding: candidate.foundationEncoding), "\(candidate)") + #expect(String(data: Data("a".utf8), encoding: candidate.foundationEncoding) != nil, "\(candidate)") } } diff --git a/Packages/TableProCore/Tests/TableProTabularIOTests/TabularTextTranscoderTests.swift b/Packages/TableProCore/Tests/TableProTabularIOTests/TabularTextTranscoderTests.swift index b053f05542..f95d14a511 100644 --- a/Packages/TableProCore/Tests/TableProTabularIOTests/TabularTextTranscoderTests.swift +++ b/Packages/TableProCore/Tests/TableProTabularIOTests/TabularTextTranscoderTests.swift @@ -1,21 +1,21 @@ import Foundation @testable import TableProTabularIO -import XCTest +import Testing -final class TabularTextTranscoderTests: XCTestCase { - private var directory: URL! +final class TabularTextTranscoderTests { + private let directory: URL - override func setUpWithError() throws { + init() throws { directory = FileManager.default.temporaryDirectory.appendingPathComponent("TabularTextTranscoderTests-\(UUID().uuidString)") try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) } - override func tearDownWithError() throws { + deinit { try? FileManager.default.removeItem(at: directory) } private func encoded(_ text: String, _ encoding: TabularTextEncoding) throws -> Data { - try XCTUnwrap(text.data(using: encoding.foundationEncoding, allowLossyConversion: false)) + try #require(text.data(using: encoding.foundationEncoding, allowLossyConversion: false)) } private func transcode(_ data: Data, _ encoding: TabularTextEncoding, prefix: Int = 0) throws -> (String, TabularTranscodedText) { @@ -24,45 +24,45 @@ final class TabularTextTranscoderTests: XCTestCase { return (TabularTextCodec.utf8String(try Data(contentsOf: url)), result) } - func testShiftJISBecomesTheSameTextInUTF8() throws { + @Test func testShiftJISBecomesTheSameTextInUTF8() throws { let text = "id,名前\r\n1,髙橋 ①\r\n2,ザイコ ~\r\n" let (output, result) = try transcode(try encoded(text, .shiftJIS), .shiftJIS) - XCTAssertEqual(output, text) - XCTAssertEqual(result.undecodableLineCount, 0) + #expect(output == text) + #expect(result.undecodableLineCount == 0) } - func testABadByteSpoilsOnlyItsOwnLine() throws { + @Test func testABadByteSpoilsOnlyItsOwnLine() throws { var bytes = try encoded("a,髙橋\n", .shiftJIS) bytes.append(contentsOf: Array("b,".utf8) + [0x82] + Array("\nc,".utf8) + [0xA0] + Array("x\n".utf8)) bytes.append(try encoded("d,東京\ne,大阪\n", .shiftJIS)) let (output, result) = try transcode(bytes, .shiftJIS) - XCTAssertEqual(output, "a,髙橋\nb,\u{FFFD}\nc,\u{FFFD}x\nd,東京\ne,大阪\n") - XCTAssertEqual(result.undecodableLineCount, 2) + #expect(output == "a,髙橋\nb,\u{FFFD}\nc,\u{FFFD}x\nd,東京\ne,大阪\n") + #expect(result.undecodableLineCount == 2) } - func testLinesSplitAcrossChunksMatchAWholeFileDecode() throws { + @Test func testLinesSplitAcrossChunksMatchAWholeFileDecode() throws { let line = "10001,東京都港区芝公園4-2-8,ザイコカンリスル,髙橋\r\n" let text = String(repeating: line, count: 60_000) let bytes = try encoded(text, .shiftJIS) - XCTAssertGreaterThan(bytes.count, TabularTextTranscoder.chunkLength * 2) + #expect(bytes.count > TabularTextTranscoder.chunkLength * 2) let (output, result) = try transcode(bytes, .shiftJIS) - XCTAssertEqual(output, text) - XCTAssertGreaterThan(result.map.transcodedStarts.count, 2) - XCTAssertEqual(result.map.transcodedStarts.count, result.map.originalStarts.count) + #expect(output == text) + #expect(result.map.transcodedStarts.count > 2) + #expect(result.map.transcodedStarts.count == result.map.originalStarts.count) } - func testALineLongerThanAChunkIsCutAtACharacterBoundary() throws { + @Test func testALineLongerThanAChunkIsCutAtACharacterBoundary() throws { let field = String(repeating: "東京都港区芝公園4-2-8 ", count: 90_000) let text = "id,memo\n1," + field + "\n2,x\n" let bytes = try encoded(text, .shiftJIS) - XCTAssertGreaterThan(bytes.count, TabularTextTranscoder.chunkLength * 2) + #expect(bytes.count > TabularTextTranscoder.chunkLength * 2) let (output, result) = try transcode(bytes, .shiftJIS) - XCTAssertEqual(output, text) - XCTAssertEqual(result.undecodableLineCount, 0) - XCTAssertGreaterThan(result.map.transcodedStarts.count, 2) + #expect(output == text) + #expect(result.undecodableLineCount == 0) + #expect(result.map.transcodedStarts.count > 2) } - func testABrokenLineLongerThanAChunkIsCountedOnce() throws { + @Test func testABrokenLineLongerThanAChunkIsCountedOnce() throws { let piece = try encoded(String(repeating: "東京都港区芝公園 ", count: 5_000), .shiftJIS) var bytes = try encoded("id,memo\n1,", .shiftJIS) for _ in 0..<30 { @@ -70,62 +70,62 @@ final class TabularTextTranscoderTests: XCTestCase { bytes.append(0xA0) } bytes.append(contentsOf: Array("\n2,x\n".utf8)) - XCTAssertGreaterThan(bytes.count, TabularTextTranscoder.chunkLength * 2) + #expect(bytes.count > TabularTextTranscoder.chunkLength * 2) let (output, result) = try transcode(bytes, .shiftJIS) - XCTAssertEqual(result.undecodableLineCount, 1) - XCTAssertEqual(result.firstUndecodableLine, 2) - XCTAssertTrue(output.hasSuffix("\n2,x\n")) + #expect(result.undecodableLineCount == 1) + #expect(result.firstUndecodableLine == 2) + #expect(output.hasSuffix("\n2,x\n")) } - func testAMalformedEUCJPByteKeepsTheDelimiterAfterIt() throws { + @Test func testAMalformedEUCJPByteKeepsTheDelimiterAfterIt() throws { let bytes = Data(Array("1,".utf8) + [0x8E] + Array(",x\n\"a".utf8) + [0xA4] + Array("\",b\n".utf8)) let (output, result) = try transcode(bytes, .eucJP) - XCTAssertEqual(output, "1,\u{FFFD},x\n\"a\u{FFFD}\",b\n") - XCTAssertEqual(result.undecodableLineCount, 2) + #expect(output == "1,\u{FFFD},x\n\"a\u{FFFD}\",b\n") + #expect(result.undecodableLineCount == 2) } - func testTheFirstUndecodableLineIsNumberedFromOne() throws { + @Test func testTheFirstUndecodableLineIsNumberedFromOne() throws { var bytes = try encoded("a\r\nb\r\nc\r\n", .shiftJIS) bytes.append(contentsOf: [0x64, 0xA0, 0x0D, 0x0A]) let (_, result) = try transcode(bytes, .shiftJIS) - XCTAssertEqual(result.firstUndecodableLine, 4) - XCTAssertNil(try transcode(try encoded("a\nb\n", .shiftJIS), .shiftJIS).1.firstUndecodableLine) + #expect(result.firstUndecodableLine == 4) + #expect(try transcode(try encoded("a\nb\n", .shiftJIS), .shiftJIS).1.firstUndecodableLine == nil) } - func testTheFirstInvalidUTF8LineIsFound() { + @Test func testTheFirstInvalidUTF8LineIsFound() { let bytes = Data("a,b\nMüller,1\n".utf8) + Data([0x43, 0x61, 0x66, 0xE9, 0x0A]) + Data("z\n".utf8) - XCTAssertEqual(TabularTextTranscoder.firstInvalidUTF8Line(in: bytes, skippingPrefix: 0), 3) - XCTAssertNil(TabularTextTranscoder.firstInvalidUTF8Line(in: Data("a\nÜ\n".utf8), skippingPrefix: 0)) + #expect(TabularTextTranscoder.firstInvalidUTF8Line(in: bytes, skippingPrefix: 0) == 3) + #expect(TabularTextTranscoder.firstInvalidUTF8Line(in: Data("a\nÜ\n".utf8), skippingPrefix: 0) == nil) } - func testCarriageReturnOnlyLinesKeepTheirBreaks() throws { + @Test func testCarriageReturnOnlyLinesKeepTheirBreaks() throws { let text = "a,日本\rb,中文\rc,한국\r" let (output, _) = try transcode(try encoded(text, .utf16LittleEndian), .utf16LittleEndian) - XCTAssertEqual(output, text) + #expect(output == text) } - func testByteOrderMarkIsSkipped() throws { + @Test func testByteOrderMarkIsSkipped() throws { let text = "a,本\n" let bytes = Data([0xFF, 0xFE]) + (try encoded(text, .utf16LittleEndian)) let (output, result) = try transcode(bytes, .utf16LittleEndian, prefix: 2) - XCTAssertEqual(output, text) - XCTAssertEqual(result.map.originalStarts.first, 2) + #expect(output == text) + #expect(result.map.originalStarts.first == 2) } - func testAnOddTrailingUTF16ByteBecomesAReplacementCharacter() throws { + @Test func testAnOddTrailingUTF16ByteBecomesAReplacementCharacter() throws { let bytes = (try encoded("a,b\n日本", .utf16LittleEndian)) + Data([0x41]) let (output, result) = try transcode(bytes, .utf16LittleEndian) - XCTAssertEqual(output, "a,b\n日本\u{FFFD}") - XCTAssertEqual(result.undecodableLineCount, 1) + #expect(output == "a,b\n日本\u{FFFD}") + #expect(result.undecodableLineCount == 1) } - func testWindows1252UsesTheCodecsOwnTable() throws { + @Test func testWindows1252UsesTheCodecsOwnTable() throws { let (output, result) = try transcode(Data([0x43, 0x61, 0x66, 0xE9, 0x20, 0x80, 0x81, 0x0A]), .windows1252) - XCTAssertEqual(output, "Café €\u{81}\n") - XCTAssertEqual(result.undecodableLineCount, 0) + #expect(output == "Café €\u{81}\n") + #expect(result.undecodableLineCount == 0) } - func testAPrefixEndsOnTheLastWholeLine() throws { + @Test func testAPrefixEndsOnTheLastWholeLine() throws { let text = "a,日本\nb,中文\nc,한국\n" let bytes = try encoded(text, .utf16BigEndian) let firstTwoLines = try encoded("a,日本\nb,中文\n", .utf16BigEndian).count @@ -135,15 +135,17 @@ final class TabularTextTranscoderTests: XCTestCase { skippingPrefix: 0, wholeLinesWithin: firstTwoLines + 3 ) - XCTAssertEqual(TabularTextCodec.utf8String(prefix.data), "a,日本\nb,中文\n") - XCTAssertEqual(prefix.undecodableLineCount, 0) + #expect(TabularTextCodec.utf8String(prefix.data) == "a,日本\nb,中文\n") + #expect(prefix.undecodableLineCount == 0) } - func testCancellationStopsTheTranscode() throws { + @Test func testCancellationStopsTheTranscode() throws { let bytes = try encoded(String(repeating: "東京,大阪\n", count: 200_000), .shiftJIS) let url = directory.appendingPathComponent("cancelled") - XCTAssertThrowsError(try TabularTextTranscoder.transcode(bytes, from: .shiftJIS, skippingPrefix: 0, to: url, isCancelled: { true })) { - XCTAssertTrue($0 is TabularCancellation) + if let error = #expect(throws: (any Error).self, performing: { + try TabularTextTranscoder.transcode(bytes, from: .shiftJIS, skippingPrefix: 0, to: url, isCancelled: { true }) + }) { + #expect(error is TabularCancellation) } } } diff --git a/Packages/TableProCore/Tests/TableProTabularIOTests/XLSXCellStorageTests.swift b/Packages/TableProCore/Tests/TableProTabularIOTests/XLSXCellStorageTests.swift index beb73a401f..925dd89a82 100644 --- a/Packages/TableProCore/Tests/TableProTabularIOTests/XLSXCellStorageTests.swift +++ b/Packages/TableProCore/Tests/TableProTabularIOTests/XLSXCellStorageTests.swift @@ -1,71 +1,70 @@ import Foundation @testable import TableProTabularIO -import XCTest +import Testing -final class XLSXCellStorageTests: XCTestCase { - func testChunkedArrayAddressesEveryElementAcrossBlocks() { +struct XLSXCellStorageTests { + @Test func testChunkedArrayAddressesEveryElementAcrossBlocks() { var array = XLSXChunkedArray() - XCTAssertTrue(array.isEmpty) + #expect(array.isEmpty) for value in 0..<200_000 { array.append(UInt32(value * 2)) } - XCTAssertEqual(array.count, 200_000) - XCTAssertEqual(array[0], 0) - XCTAssertEqual(array[65_535], 131_070) - XCTAssertEqual(array[65_536], 131_072) - XCTAssertEqual(array[199_999], 399_998) - XCTAssertEqual(array.lowerBound(of: 131_072), 65_536) - XCTAssertEqual(array.lowerBound(of: 131_073), 65_537) - XCTAssertEqual(array.lowerBound(of: 400_000), 200_000) + #expect(array.count == 200_000) + #expect(array[0] == 0) + #expect(array[65_535] == 131_070) + #expect(array[65_536] == 131_072) + #expect(array[199_999] == 399_998) + #expect(array.lowerBound(of: 131_072) == 65_536) + #expect(array.lowerBound(of: 131_073) == 65_537) + #expect(array.lowerBound(of: 400_000) == 200_000) array[70_000] = 7 - XCTAssertEqual(array[70_000], 7) - XCTAssertEqual(array.firstIndex { $0 == 6 }, 3) - XCTAssertEqual(array.firstIndex { $0 == 7 }, 70_000) - XCTAssertEqual(array.lastIndex { $0 < 10 }, 70_000) + #expect(array[70_000] == 7) + #expect(array.firstIndex { $0 == 6 } == 3) + #expect(array.firstIndex { $0 == 7 } == 70_000) + #expect(array.lastIndex { $0 < 10 } == 70_000) } - func testByteHeapReturnsEveryEntryIncludingOnesLargerThanABlock() throws { + @Test func testByteHeapReturnsEveryEntryIncludingOnesLargerThanABlock() throws { let builder = XLSXByteHeapBuilder() let small = Array("small".utf8) let large = [UInt8](repeating: 0x61, count: 3 << 20) let empty: [UInt8] = [] let handles = [small, large, empty, small].map { entry in entry.withUnsafeBufferPointer { builder.append($0) } } let heap = builder.finish() - XCTAssertEqual(Array(heap.bytes(at: handles[0])), small) - XCTAssertEqual(Array(heap.bytes(at: handles[1])), large) - XCTAssertTrue(heap.bytes(at: handles[2]).isEmpty) - XCTAssertEqual(Array(heap.bytes(at: handles[3])), small) - XCTAssertNotEqual(handles[1] >> 32, handles[0] >> 32) + #expect(Array(heap.bytes(at: handles[0])) == small) + #expect(Array(heap.bytes(at: handles[1])) == large) + #expect(heap.bytes(at: handles[2]).isEmpty) + #expect(Array(heap.bytes(at: handles[3])) == small) + #expect((handles[1] >> 32) != (handles[0] >> 32)) } - func testDecimalTextRoundTripsThroughMantissaAndScale() { + @Test func testDecimalTextRoundTripsThroughMantissaAndScale() { for text in ["0", "5", "-5", "0.05", "12.50", "-0.001", "999999999999999999", "123456.789"] { var copy = text let parsed = copy.withUTF8 { XLSXDecimal.parse($0) } guard let parsed else { - XCTFail("\(text) should parse") + Issue.record("\(text) should parse") continue } var output: [UInt8] = [] XLSXDecimal.append(mantissa: parsed.mantissa, scale: parsed.scale, to: &output) - XCTAssertEqual(String(bytes: output, encoding: .utf8), text) + #expect(String(bytes: output, encoding: .utf8) == text) } for text in ["", "-", "-0", "007", ".5", "5.", "1e3", "1,5", "1234567890123456789", " 1"] { var copy = text - XCTAssertNil(copy.withUTF8 { XLSXDecimal.parse($0) }, text) + #expect(copy.withUTF8 { XLSXDecimal.parse($0) } == nil, "\(text)") } } - func testBuiltInNumberFormatsAreClassified() { - XCTAssertEqual((14...17).map(XLSXNumberFormat.category(ofBuiltInFormat:)), [.date, .date, .date, .date]) - XCTAssertEqual((18...21).map(XLSXNumberFormat.category(ofBuiltInFormat:)), [.time, .time, .time, .time]) - XCTAssertEqual(XLSXNumberFormat.category(ofBuiltInFormat: 22), .date) - XCTAssertEqual((45...47).map(XLSXNumberFormat.category(ofBuiltInFormat:)), [.time, .duration, .time]) - XCTAssertEqual([0, 1, 2, 3, 4, 9, 10, 11, 12, 13, 37, 44, 48, 49].map(XLSXNumberFormat.category(ofBuiltInFormat:)), - Array(repeating: .number, count: 14)) + @Test func testBuiltInNumberFormatsAreClassified() { + #expect((14...17).map(XLSXNumberFormat.category(ofBuiltInFormat:)) == [.date, .date, .date, .date]) + #expect((18...21).map(XLSXNumberFormat.category(ofBuiltInFormat:)) == [.time, .time, .time, .time]) + #expect(XLSXNumberFormat.category(ofBuiltInFormat: 22) == .date) + #expect((45...47).map(XLSXNumberFormat.category(ofBuiltInFormat:)) == [.time, .duration, .time]) + #expect([0, 1, 2, 3, 4, 9, 10, 11, 12, 13, 37, 44, 48, 49].map(XLSXNumberFormat.category(ofBuiltInFormat:)) == Array(repeating: .number, count: 14)) } - func testCustomFormatCodesAreClassifiedOutsideQuotesAndBrackets() { + @Test func testCustomFormatCodesAreClassifiedOutsideQuotesAndBrackets() { let expectations: [(String, XLSXNumberFormatCategory)] = [ ("yyyy-mm-dd", .date), ("dd/mm/yyyy hh:mm", .date), @@ -87,24 +86,24 @@ final class XLSXCellStorageTests: XCTestCase { ("*-0", .number) ] for (code, category) in expectations { - XCTAssertEqual(XLSXNumberFormat.category(ofFormatCode: code), category, code) + #expect(XLSXNumberFormat.category(ofFormatCode: code) == category, "\(code)") } } - func testDateSystemsProduceISOText() { - XCTAssertEqual(XLSXDateSystem.base1900.isoText(forSerial: 45_306), "2024-01-15") - XCTAssertEqual(XLSXDateSystem.base1900.isoText(forSerial: 45_306.5), "2024-01-15 12:00:00") - XCTAssertEqual(XLSXDateSystem.base1900.isoText(forSerial: 0.25, timeOnly: true), "06:00:00") - XCTAssertEqual(XLSXDateSystem.base1900.isoText(forSerial: 0.000005787, timeOnly: true), "00:00:00.500") - XCTAssertEqual(XLSXDateSystem.base1904.isoText(forSerial: 0), "1904-01-01") - XCTAssertEqual(XLSXDateSystem.base1904.isoText(forSerial: 1_462), "1908-01-02") - XCTAssertEqual(XLSXDateSystem.base1900.elapsedText(forSerial: 1.25), "30:00:00") - XCTAssertEqual(XLSXDateSystem.base1904.elapsedText(forSerial: 0.5), "12:00:00") - XCTAssertEqual(XLSXDateSystem.base1900.isoText(forSerial: .infinity), "") - XCTAssertEqual(XLSXDateSystem.base1900.elapsedText(forSerial: 1e300), "") - XCTAssertTrue(XLSXDateSystem.base1900.holds(serial: 2_958_465.9)) - XCTAssertFalse(XLSXDateSystem.base1900.holds(serial: 2_958_466)) - XCTAssertFalse(XLSXDateSystem.base1904.holds(serial: -1)) - XCTAssertFalse(XLSXDateSystem.base1900.holds(serial: .nan)) + @Test func testDateSystemsProduceISOText() { + #expect(XLSXDateSystem.base1900.isoText(forSerial: 45_306) == "2024-01-15") + #expect(XLSXDateSystem.base1900.isoText(forSerial: 45_306.5) == "2024-01-15 12:00:00") + #expect(XLSXDateSystem.base1900.isoText(forSerial: 0.25, timeOnly: true) == "06:00:00") + #expect(XLSXDateSystem.base1900.isoText(forSerial: 0.000005787, timeOnly: true) == "00:00:00.500") + #expect(XLSXDateSystem.base1904.isoText(forSerial: 0) == "1904-01-01") + #expect(XLSXDateSystem.base1904.isoText(forSerial: 1_462) == "1908-01-02") + #expect(XLSXDateSystem.base1900.elapsedText(forSerial: 1.25) == "30:00:00") + #expect(XLSXDateSystem.base1904.elapsedText(forSerial: 0.5) == "12:00:00") + #expect(XLSXDateSystem.base1900.isoText(forSerial: .infinity) == "") + #expect(XLSXDateSystem.base1900.elapsedText(forSerial: 1e300) == "") + #expect(XLSXDateSystem.base1900.holds(serial: 2_958_465.9)) + #expect(!XLSXDateSystem.base1900.holds(serial: 2_958_466)) + #expect(!XLSXDateSystem.base1904.holds(serial: -1)) + #expect(!XLSXDateSystem.base1900.holds(serial: .nan)) } } diff --git a/Packages/TableProCore/Tests/TableProTabularIOTests/XLSXSheetSourceTests.swift b/Packages/TableProCore/Tests/TableProTabularIOTests/XLSXSheetSourceTests.swift index 1387818c0a..ed3fb85c4d 100644 --- a/Packages/TableProCore/Tests/TableProTabularIOTests/XLSXSheetSourceTests.swift +++ b/Packages/TableProCore/Tests/TableProTabularIOTests/XLSXSheetSourceTests.swift @@ -1,8 +1,8 @@ import Foundation @testable import TableProTabularIO -import XCTest +import Testing -final class XLSXSheetSourceTests: XCTestCase { +struct XLSXSheetSourceTests { private func source( rows: String, trailer: String = "", @@ -29,7 +29,7 @@ final class XLSXSheetSourceTests: XCTestCase { return result } - func testEveryCellTypeReadsWithItsKindAndText() throws { + @Test func testEveryCellTypeReadsWithItsKindAndText() throws { let rows = """ \ 0\ @@ -44,7 +44,7 @@ final class XLSXSheetSourceTests: XCTestCase { """ let source = try source(rows: rows, sharedStrings: ["shared"]) - XCTAssertEqual(source.cells(row: 0), [ + #expect(source.cells(row: 0) == [ TabularCell(kind: .text, text: "shared"), TabularCell(kind: .text, text: "inline rich"), TabularCell(kind: .text, text: "shared & formula"), @@ -55,10 +55,10 @@ final class XLSXSheetSourceTests: XCTestCase { TabularCell(kind: .number, text: "-3.250"), TabularCell(kind: .date, text: "2024-01-15T10:00:00Z") ]) - XCTAssertEqual(scanned(source, rows: [0]), [source.cells(row: 0)]) + #expect(scanned(source, rows: [0]) == [source.cells(row: 0)]) } - func testNumbersKeepTheTextOfTheirValueExactly() throws { + @Test func testNumbersKeepTheTextOfTheirValueExactly() throws { let values = [ "0", "7", "-12", "1.5", "0.05", "12.50", "3.0000000000000004", "1E-3", "1.23E+20", "123456789012345678901234", "007", "-0", ".5", "999999999999999999", "-0.000001" @@ -66,12 +66,12 @@ final class XLSXSheetSourceTests: XCTestCase { let cells = values.enumerated().map { "\($0.element)" } let rows = cells.enumerated().map { "\($0.element)" }.joined() let source = try source(rows: rows) - XCTAssertEqual((0..\ 45306\ @@ -98,7 +98,7 @@ final class XLSXSheetSourceTests: XCTestCase { fixture.cellFormatIDs = [0, 14, 22, 164, 20, 165, 166, 167, 22, 168, 46] fixture.conditionalFormatID = 165 } - XCTAssertEqual(source.cells(row: 0), [ + #expect(source.cells(row: 0) == [ TabularCell(kind: .date, text: "2024-01-15"), TabularCell(kind: .date, text: "2024-01-15 12:30:00"), TabularCell(kind: .date, text: "2024-01-15 18:00:00"), @@ -111,76 +111,76 @@ final class XLSXSheetSourceTests: XCTestCase { TabularCell(kind: .date, text: "2024-01-15"), TabularCell(kind: .date, text: "12:00:00") ]) - XCTAssertEqual(scanned(source, rows: [0]), [source.cells(row: 0)]) + #expect(scanned(source, rows: [0]) == [source.cells(row: 0)]) } - func testTheNineteenHundredLeapYearBugIsKept() throws { + @Test func testTheNineteenHundredLeapYearBugIsKept() throws { let rows = """ 159\ 60612958465 """ let source = try source(rows: rows) { $0.cellFormatIDs = [0, 14] } - XCTAssertEqual(source.cells(row: 0).map(\.text), ["1900-01-01", "1900-02-28", "1900-02-29", "1900-03-01", "9999-12-31"]) + #expect(source.cells(row: 0).map(\.text) == ["1900-01-01", "1900-02-28", "1900-02-29", "1900-03-01", "9999-12-31"]) } - func testTheNineteenOhFourDateSystemShiftsSerials() throws { + @Test func testTheNineteenOhFourDateSystemShiftsSerials() throws { let rows = "04384443844.25" let source = try source(rows: rows) { fixture in fixture.usesDate1904 = true fixture.cellFormatIDs = [0, 14, 22] } - XCTAssertEqual(source.dateSystem, .base1904) - XCTAssertEqual(source.cells(row: 0).map(\.text), ["1904-01-01", "2024-01-15", "2024-01-15 06:00:00"]) + #expect(source.dateSystem == .base1904) + #expect(source.cells(row: 0).map(\.text) == ["1904-01-01", "2024-01-15", "2024-01-15 06:00:00"]) } - func testFormulasReadTheirCachedValue() throws { + @Test func testFormulasReadTheirCachedValue() throws { let rows = """ 1+23A1*26\ ""NOW() """ let source = try source(rows: rows) - XCTAssertEqual(source.cells(row: 0), [ + #expect(source.cells(row: 0) == [ TabularCell(kind: .number, text: "3"), TabularCell(kind: .number, text: "6"), TabularCell(kind: .text, text: "") ]) - XCTAssertEqual(source.columnCount, 3) + #expect(source.columnCount == 3) } - func testSparseRowsAndColumnsComeFromCellReferencesWithoutADimension() throws { + @Test func testSparseRowsAndColumnsComeFromCellReferencesWithoutADimension() throws { let rows = """ 1\ 2\ """ let source = try source(rows: rows) - XCTAssertEqual(source.rowCount, 4) - XCTAssertEqual(source.columnCount, 3) - XCTAssertEqual(source.firstRowIndex, 1) - XCTAssertEqual(source.firstColumnIndex, 1) - XCTAssertEqual(source.sheetReference(row: 3, column: 2).text, "D5") - XCTAssertEqual(grid(source), [["1", "", ""], ["", "", ""], ["", "", ""], ["", "", "2"]]) - XCTAssertEqual(source.cell(row: 1, column: 0), source.absentCell) - XCTAssertEqual(source.cell(row: 99, column: 0), source.absentCell) + #expect(source.rowCount == 4) + #expect(source.columnCount == 3) + #expect(source.firstRowIndex == 1) + #expect(source.firstColumnIndex == 1) + #expect(source.sheetReference(row: 3, column: 2).text == "D5") + #expect(grid(source) == [["1", "", ""], ["", "", ""], ["", "", ""], ["", "", "2"]]) + #expect(source.cell(row: 1, column: 0) == source.absentCell) + #expect(source.cell(row: 99, column: 0) == source.absentCell) } - func testRowsAndCellsWithoutReferencesAreCounted() throws { + @Test func testRowsAndCellsWithoutReferencesAreCounted() throws { let rows = "12456" let source = try source(rows: rows) - XCTAssertEqual(grid(source), [["1", "2", ""], ["", "4", ""], ["", "", ""], ["", "", ""], ["", "5", "6"]]) + #expect(grid(source) == [["1", "2", ""], ["", "4", ""], ["", "", ""], ["", "", ""], ["", "5", "6"]]) } - func testMergedCellsShowTheirValueInTheTopLeftCellOnly() throws { + @Test func testMergedCellsShowTheirValueInTheTopLeftCellOnly() throws { let rows = """ Title93\ 874 """ let source = try source(rows: rows, trailer: "") - XCTAssertEqual(grid(source), [["Title", "", "3"], ["", "", "4"]]) - XCTAssertEqual(source.mergedRanges, [XLSXCellRange(start: XLSXCellReference(row: 0, column: 0), end: XLSXCellReference(row: 1, column: 1))]) + #expect(grid(source) == [["Title", "", "3"], ["", "", "4"]]) + #expect(source.mergedRanges == [XLSXCellRange(start: XLSXCellReference(row: 0, column: 0), end: XLSXCellReference(row: 1, column: 1))]) } - func testLargeGapsAndOutOfOrderCellsStayAddressable() throws { + @Test func testLargeGapsAndOutOfOrderCellsStayAddressable() throws { let rows = """ 110\ 2\ @@ -189,17 +189,17 @@ final class XLSXSheetSourceTests: XCTestCase { 4 """ let source = try source(rows: rows) - XCTAssertEqual(source.rowCount, 5_000) - XCTAssertEqual(source.cell(row: 0, column: 0).text, "1") - XCTAssertEqual(source.cell(row: 1, column: 0).text, "2") - XCTAssertEqual(source.cell(row: 3, column: 0).text, "4") - XCTAssertEqual(source.cell(row: 4_999, column: 0).text, "3") - XCTAssertEqual(source.cell(row: 2, column: 1).text, "31") - XCTAssertEqual(source.cell(row: 2, column: 0), source.absentCell) - XCTAssertEqual(scanned(source, rows: [4_999, 0, 3]).map { $0.map(\.text) }, [["3", ""], ["1", "10"], ["4", ""]]) + #expect(source.rowCount == 5_000) + #expect(source.cell(row: 0, column: 0).text == "1") + #expect(source.cell(row: 1, column: 0).text == "2") + #expect(source.cell(row: 3, column: 0).text == "4") + #expect(source.cell(row: 4_999, column: 0).text == "3") + #expect(source.cell(row: 2, column: 1).text == "31") + #expect(source.cell(row: 2, column: 0) == source.absentCell) + #expect(scanned(source, rows: [4_999, 0, 3]).map { $0.map(\.text) } == [["3", ""], ["1", "10"], ["4", ""]]) } - func testPrefixedElementsAndCommentsAreUnderstood() throws { + @Test func testPrefixedElementsAndCommentsAreUnderstood() throws { var fixture = TestWorkbook() let body = """ \ @@ -207,10 +207,10 @@ final class XLSXSheetSourceTests: XCTestCase { 5 """ fixture.sheets = [TestWorkbook.Sheet(name: "Data", body: body)] - XCTAssertEqual(try fixture.source().cells(row: 0).map(\.text), ["prefixed", "5"]) + #expect(try fixture.source().cells(row: 0).map(\.text) == ["prefixed", "5"]) } - func testEveryChunkSizeReadsTheSameSheet() throws { + @Test func testEveryChunkSizeReadsTheSameSheet() throws { let strings = (0..<50).map { "name \($0) & co" } let rows = (1...400).map { row in "\(row % 50)\(row).25" @@ -221,14 +221,14 @@ final class XLSXSheetSourceTests: XCTestCase { fixture.cellFormatIDs = [0, 14] fixture.sheets = [TestWorkbook.Sheet(name: "Data", body: TestWorkbook.worksheet(rows: rows))] let reference = grid(try fixture.source()) - XCTAssertEqual(reference.count, 400) - XCTAssertEqual(reference[9], ["name 10 & co", "10.25", "row <10>", "2023-03-25"]) + #expect(reference.count == 400) + #expect(reference[9] == ["name 10 & co", "10.25", "row <10>", "2023-03-25"]) for chunkSize in [1, 5, 33, 512] { - XCTAssertEqual(grid(try fixture.source(chunkSize: chunkSize)), reference, "chunk \(chunkSize)") + #expect(grid(try fixture.source(chunkSize: chunkSize)) == reference, "chunk \(chunkSize)") } } - func testScanStopsWhenTheBodyAsks() throws { + @Test func testScanStopsWhenTheBodyAsks() throws { let rows = (1...10).map { "\($0)" }.joined() let source = try source(rows: rows) var visited: [Int] = [] @@ -236,7 +236,7 @@ final class XLSXSheetSourceTests: XCTestCase { visited.append(row) return row < 2 } - XCTAssertEqual(visited, [0, 1, 2]) + #expect(visited == [0, 1, 2]) } private func inline(_ reference: String, _ text: String) -> String { @@ -247,61 +247,61 @@ final class XLSXSheetSourceTests: XCTestCase { "\(value)" } - func testFirstRowHeaderHeuristic() throws { + @Test func testFirstRowHeaderHeuristic() throws { let labelled = try source(rows: """ \(inline("A1", "id"))\(inline("B1", "name"))\ \(number("A2", "1"))\(inline("B2", "Ada")) """) - XCTAssertTrue(labelled.firstRowLooksLikeHeader) + #expect(labelled.firstRowLooksLikeHeader) let numeric = try source(rows: "\(number("A1", "1"))\(number("B1", "2"))") - XCTAssertFalse(numeric.firstRowLooksLikeHeader) + #expect(!numeric.firstRowLooksLikeHeader) let title = try source(rows: """ \(inline("A1", "Report"))\ \(number("A2", "1"))\(number("B2", "2"))\(number("C2", "3")) """) - XCTAssertFalse(title.firstRowLooksLikeHeader) + #expect(!title.firstRowLooksLikeHeader) let numericText = try source(rows: "\(inline("A1", "2024"))1") - XCTAssertFalse(numericText.firstRowLooksLikeHeader) + #expect(!numericText.firstRowLooksLikeHeader) let empty = try source(rows: "") - XCTAssertEqual(empty.rowCount, 0) - XCTAssertEqual(empty.columnCount, 0) - XCTAssertFalse(empty.firstRowLooksLikeHeader) + #expect(empty.rowCount == 0) + #expect(empty.columnCount == 0) + #expect(!empty.firstRowLooksLikeHeader) } - func testCancellationStopsTheSheetParse() throws { + @Test func testCancellationStopsTheSheetParse() throws { var fixture = TestWorkbook() fixture.sheets = [TestWorkbook.Sheet(name: "Data", body: TestWorkbook.worksheet(rows: "1"))] let workbook = try fixture.workbook() - XCTAssertThrowsError(try workbook.source(for: workbook.sheets[0], isCancelled: { true })) { error in - XCTAssertTrue(error is TabularCancellation) + if let error = #expect(throws: (any Error).self, performing: { try workbook.source(for: workbook.sheets[0], isCancelled: { true }) }) { + #expect(error is TabularCancellation) } } - func testProgressIsReportedUpToCompletion() throws { + @Test func testProgressIsReportedUpToCompletion() throws { let rows = (1...2_000).map { "\($0)" }.joined() var fixture = TestWorkbook() fixture.sheets = [TestWorkbook.Sheet(name: "Data", body: TestWorkbook.worksheet(rows: rows))] let workbook = try fixture.workbook() var reported: [Double] = [] _ = try workbook.source(for: workbook.sheets[0], chunkSize: 1_024, progress: { reported.append($0) }, isCancelled: { false }) - XCTAssertGreaterThan(reported.count, 2) - XCTAssertEqual(reported, reported.sorted()) - XCTAssertEqual(reported.last, 1) + #expect(reported.count > 2) + #expect(reported == reported.sorted()) + #expect(reported.last == 1) } - func testCellReferencesParseAndPrint() { - XCTAssertEqual(XLSXCellReference("A1"), XLSXCellReference(row: 0, column: 0)) - XCTAssertEqual(XLSXCellReference("$AB$12"), XLSXCellReference(row: 11, column: 27)) - XCTAssertEqual(XLSXCellReference("XFD1048576"), XLSXCellReference(row: 1_048_575, column: 16_383)) - XCTAssertNil(XLSXCellReference("XFE1")) - XCTAssertNil(XLSXCellReference("A0")) - XCTAssertNil(XLSXCellReference("A1048577")) - XCTAssertNil(XLSXCellReference("1")) - XCTAssertEqual(XLSXCellReference.columnName(0), "A") - XCTAssertEqual(XLSXCellReference.columnName(25), "Z") - XCTAssertEqual(XLSXCellReference.columnName(26), "AA") - XCTAssertEqual(XLSXCellReference.columnName(16_383), "XFD") - XCTAssertEqual(XLSXCellRange("C3:A1")?.rows, 0...2) - XCTAssertEqual(XLSXCellRange("B2")?.columns, 1...1) + @Test func testCellReferencesParseAndPrint() { + #expect(XLSXCellReference("A1") == XLSXCellReference(row: 0, column: 0)) + #expect(XLSXCellReference("$AB$12") == XLSXCellReference(row: 11, column: 27)) + #expect(XLSXCellReference("XFD1048576") == XLSXCellReference(row: 1_048_575, column: 16_383)) + #expect(XLSXCellReference("XFE1") == nil) + #expect(XLSXCellReference("A0") == nil) + #expect(XLSXCellReference("A1048577") == nil) + #expect(XLSXCellReference("1") == nil) + #expect(XLSXCellReference.columnName(0) == "A") + #expect(XLSXCellReference.columnName(25) == "Z") + #expect(XLSXCellReference.columnName(26) == "AA") + #expect(XLSXCellReference.columnName(16_383) == "XFD") + #expect(XLSXCellRange("C3:A1")?.rows == 0...2) + #expect(XLSXCellRange("B2")?.columns == 1...1) } } diff --git a/Packages/TableProCore/Tests/TableProTabularIOTests/XLSXWorkbookTests.swift b/Packages/TableProCore/Tests/TableProTabularIOTests/XLSXWorkbookTests.swift index 3a0d3311d6..811a23804a 100644 --- a/Packages/TableProCore/Tests/TableProTabularIOTests/XLSXWorkbookTests.swift +++ b/Packages/TableProCore/Tests/TableProTabularIOTests/XLSXWorkbookTests.swift @@ -1,11 +1,11 @@ import Foundation @testable import TableProTabularIO -import XCTest +import Testing -final class XLSXWorkbookTests: XCTestCase { +struct XLSXWorkbookTests { private let emptySheet = TestWorkbook.worksheet(rows: "") - func testSheetsFollowWorkbookOrderWithNamesHiddenStateAndParts() throws { + @Test func testSheetsFollowWorkbookOrderWithNamesHiddenStateAndParts() throws { var fixture = TestWorkbook() fixture.sheets = [ TestWorkbook.Sheet(name: "Summary", partName: "sheet3.xml", body: emptySheet), @@ -13,15 +13,15 @@ final class XLSXWorkbookTests: XCTestCase { TestWorkbook.Sheet(name: "Secret", state: "veryHidden", partName: "sheet2.xml", body: emptySheet) ] let workbook = try fixture.workbook() - XCTAssertEqual(workbook.sheets.map(\.name), ["Summary", "R&D", "Secret"]) - XCTAssertEqual(workbook.sheets.map(\.visibility), [.visible, .hidden, .veryHidden]) - XCTAssertEqual(workbook.sheets.map(\.partPath), ["xl/worksheets/sheet3.xml", "xl/worksheets/sheet1.xml", "xl/worksheets/sheet2.xml"]) - XCTAssertEqual(workbook.sheets.map(\.index), [0, 1, 2]) - XCTAssertEqual(workbook.visibleSheets.map(\.name), ["Summary"]) - XCTAssertTrue(workbook.sheets[1].isHidden) + #expect(workbook.sheets.map(\.name) == ["Summary", "R&D", "Secret"]) + #expect(workbook.sheets.map(\.visibility) == [.visible, .hidden, .veryHidden]) + #expect(workbook.sheets.map(\.partPath) == ["xl/worksheets/sheet3.xml", "xl/worksheets/sheet1.xml", "xl/worksheets/sheet2.xml"]) + #expect(workbook.sheets.map(\.index) == [0, 1, 2]) + #expect(workbook.visibleSheets.map(\.name) == ["Summary"]) + #expect(workbook.sheets[1].isHidden) } - func testChartsheetsAreNotListedAsTables() throws { + @Test func testChartsheetsAreNotListedAsTables() throws { var fixture = TestWorkbook() fixture.sheets = [TestWorkbook.Sheet(name: "Data", body: emptySheet)] var builder = fixture.zipBuilder() @@ -40,25 +40,25 @@ final class XLSXWorkbookTests: XCTestCase { builder.add("xl/chartsheets/sheet1.xml", "") let archive = try ZipArchive(bytes: builder.build()) let workbook = try XLSXWorkbook(archive: archive) - XCTAssertEqual(workbook.sheets.map(\.name), ["Data"]) + #expect(workbook.sheets.map(\.name) == ["Data"]) } - func testRelationshipTargetsResolveRelativeAndAbsolute() { - XCTAssertEqual(XLSXPackage.resolvePath("worksheets/sheet1.xml", relativeTo: "xl/workbook.xml"), "xl/worksheets/sheet1.xml") - XCTAssertEqual(XLSXPackage.resolvePath("/xl/worksheets/sheet1.xml", relativeTo: "xl/workbook.xml"), "xl/worksheets/sheet1.xml") - XCTAssertEqual(XLSXPackage.resolvePath("../xl/./sharedStrings.xml", relativeTo: "xl/workbook.xml"), "xl/sharedStrings.xml") - XCTAssertEqual(XLSXPackage.relationshipsPath(ofPart: "xl/workbook.xml"), "xl/_rels/workbook.xml.rels") + @Test func testRelationshipTargetsResolveRelativeAndAbsolute() { + #expect(XLSXPackage.resolvePath("worksheets/sheet1.xml", relativeTo: "xl/workbook.xml") == "xl/worksheets/sheet1.xml") + #expect(XLSXPackage.resolvePath("/xl/worksheets/sheet1.xml", relativeTo: "xl/workbook.xml") == "xl/worksheets/sheet1.xml") + #expect(XLSXPackage.resolvePath("../xl/./sharedStrings.xml", relativeTo: "xl/workbook.xml") == "xl/sharedStrings.xml") + #expect(XLSXPackage.relationshipsPath(ofPart: "xl/workbook.xml") == "xl/_rels/workbook.xml.rels") } - func testDateSystemIsReadFromWorkbookProperties() throws { + @Test func testDateSystemIsReadFromWorkbookProperties() throws { var fixture = TestWorkbook() fixture.sheets = [TestWorkbook.Sheet(name: "Data", body: emptySheet)] - XCTAssertEqual(try fixture.workbook().dateSystem, .base1900) + #expect(try fixture.workbook().dateSystem == .base1900) fixture.usesDate1904 = true - XCTAssertEqual(try fixture.workbook().dateSystem, .base1904) + #expect(try fixture.workbook().dateSystem == .base1904) } - func testSharedStringsJoinRichTextRunsAndSkipPhoneticGuides() throws { + @Test func testSharedStringsJoinRichTextRunsAndSkipPhoneticGuides() throws { var fixture = TestWorkbook() fixture.sheets = [TestWorkbook.Sheet(name: "Data", body: emptySheet)] fixture.sharedStrings = [ @@ -72,34 +72,34 @@ final class XLSXWorkbookTests: XCTestCase { "]]>" ] let strings = try fixture.workbook().sharedStrings - XCTAssertEqual(strings.count, 8) - XCTAssertEqual((0.." ]) - XCTAssertNil(strings.string(at: 8)) + #expect(strings.string(at: 8) == nil) } - func testSharedStringsSurviveAnyChunkBoundary() throws { + @Test func testSharedStringsSurviveAnyChunkBoundary() throws { var fixture = TestWorkbook() fixture.sheets = [TestWorkbook.Sheet(name: "Data", body: emptySheet)] let expected = (0..<300).map { "value \($0) & more" } fixture.sharedStrings = expected.map { "\($0.replacingOccurrences(of: "&", with: "&"))" } for chunkSize in [1, 7, 64, 4_096] { let strings = try fixture.workbook(chunkSize: chunkSize).sharedStrings - XCTAssertEqual((0..") let archive = try ZipArchive(bytes: builder.build()) - XCTAssertThrowsError(try XLSXWorkbook(archive: archive)) { error in - XCTAssertEqual(error as? ZipArchive.Failure, .entryNotFound("xl/workbook.xml")) + if let error = #expect(throws: (any Error).self, performing: { try XLSXWorkbook(archive: archive) }) { + #expect(error as? ZipArchive.Failure == .entryNotFound("xl/workbook.xml")) } } - func testWorkbookWithoutRelationshipsFallsBackToConventionalParts() throws { + @Test func testWorkbookWithoutRelationshipsFallsBackToConventionalParts() throws { var builder = TestZipBuilder() builder.add( "xl/workbook.xml", @@ -108,26 +108,26 @@ final class XLSXWorkbookTests: XCTestCase { builder.add("xl/sharedStrings.xml", "hello") builder.add("xl/worksheets/sheet1.xml", TestWorkbook.worksheet(rows: "0")) let workbook = try XLSXWorkbook(archive: ZipArchive(bytes: builder.build())) - XCTAssertEqual(workbook.sheets.map(\.partPath), ["xl/worksheets/sheet1.xml"]) + #expect(workbook.sheets.map(\.partPath) == ["xl/worksheets/sheet1.xml"]) let source = try workbook.source(for: workbook.sheets[0]) - XCTAssertEqual(source.cell(row: 0, column: 0), TabularCell(kind: .text, text: "hello")) + #expect(source.cell(row: 0, column: 0) == TabularCell(kind: .text, text: "hello")) } - func testCancellingWhileReadingSharedStringsThrows() throws { + @Test func testCancellingWhileReadingSharedStringsThrows() throws { var fixture = TestWorkbook() fixture.sheets = [TestWorkbook.Sheet(name: "Data", body: emptySheet)] fixture.sharedStrings = ["a"] let archive = try fixture.archive() - XCTAssertThrowsError(try XLSXWorkbook(archive: archive, isCancelled: { true })) { error in - XCTAssertTrue(error is TabularCancellation) + if let error = #expect(throws: (any Error).self, performing: { try XLSXWorkbook(archive: archive, isCancelled: { true }) }) { + #expect(error is TabularCancellation) } } - func testStoredWorkbookPartsReadLikeDeflatedOnes() throws { + @Test func testStoredWorkbookPartsReadLikeDeflatedOnes() throws { var fixture = TestWorkbook() fixture.method = .stored fixture.sharedStrings = ["stored"] fixture.sheets = [TestWorkbook.Sheet(name: "Data", body: TestWorkbook.worksheet(rows: "0"))] - XCTAssertEqual(try fixture.source().cell(row: 0, column: 0).text, "stored") + #expect(try fixture.source().cell(row: 0, column: 0).text == "stored") } } diff --git a/Packages/TableProCore/Tests/TableProTabularIOTests/ZipArchiveTests.swift b/Packages/TableProCore/Tests/TableProTabularIOTests/ZipArchiveTests.swift index 4a351fbb11..226d0b4bc2 100644 --- a/Packages/TableProCore/Tests/TableProTabularIOTests/ZipArchiveTests.swift +++ b/Packages/TableProCore/Tests/TableProTabularIOTests/ZipArchiveTests.swift @@ -1,8 +1,8 @@ import Foundation @testable import TableProTabularIO -import XCTest +import Testing -final class ZipArchiveTests: XCTestCase { +struct ZipArchiveTests { private func archive(_ files: [(String, String, TestZipBuilder.Method)], configure: (inout TestZipBuilder) -> Void = { _ in }) throws -> ZipArchive { var builder = TestZipBuilder() configure(&builder) @@ -12,117 +12,117 @@ final class ZipArchiveTests: XCTestCase { return try ZipArchive(bytes: builder.build()) } - func testAnEntryLargerThanTheBufferLimitIsRefusedBeforeInflating() throws { + @Test func testAnEntryLargerThanTheBufferLimitIsRefusedBeforeInflating() throws { let archive = try archive([("xl/styles.xml", String(repeating: "a", count: 4_096), .deflate)]) - let entry = try XCTUnwrap(archive.entry(named: "xl/styles.xml")) - XCTAssertThrowsError(try archive.data(for: entry, limit: 1_024)) { error in - XCTAssertEqual(error as? ZipArchive.Failure, .entryTooLarge("xl/styles.xml")) + let entry = try #require(archive.entry(named: "xl/styles.xml")) + if let error = #expect(throws: (any Error).self, performing: { try archive.data(for: entry, limit: 1_024) }) { + #expect(error as? ZipArchive.Failure == .entryTooLarge("xl/styles.xml")) } } - func testAnEntryThatInflatesPastItsDeclaredSizeIsRefused() throws { + @Test func testAnEntryThatInflatesPastItsDeclaredSizeIsRefused() throws { var builder = TestZipBuilder() builder.add("xl/workbook.xml", String(repeating: "", count: 10_000), method: .deflate) var bytes = [UInt8](builder.build()) let signature: [UInt8] = [0x50, 0x4B, 0x01, 0x02] - let header = try XCTUnwrap((0...(bytes.count - 4)).first { Array(bytes[$0..<($0 + 4)]) == signature }) + let header = try #require((0...(bytes.count - 4)).first { Array(bytes[$0..<($0 + 4)]) == signature }) bytes.replaceSubrange((header + 24)..<(header + 28), with: [0x10, 0x00, 0x00, 0x00]) let archive = try ZipArchive(bytes: Data(bytes)) - XCTAssertThrowsError(try archive.data(named: "xl/workbook.xml")) { error in - XCTAssertEqual(error as? ZipArchive.Failure, .corruptEntry("xl/workbook.xml")) + if let error = #expect(throws: (any Error).self, performing: { try archive.data(named: "xl/workbook.xml") }) { + #expect(error as? ZipArchive.Failure == .corruptEntry("xl/workbook.xml")) } } - func testAnEntryThatExpandsFarBeyondItsCompressedSizeIsRefused() throws { + @Test func testAnEntryThatExpandsFarBeyondItsCompressedSizeIsRefused() throws { let policy = ZipExpansionPolicy(ratioLimit: ZipExpansionPolicy.standard.ratioLimit, grace: 1 << 20) let body = String(repeating: "A", count: 4 << 20) let archive = try archive([("xl/worksheets/sheet1.xml", body, .deflate)]) - let entry = try XCTUnwrap(archive.entry(named: "xl/worksheets/sheet1.xml")) - XCTAssertGreaterThan(entry.uncompressedSize / max(entry.compressedSize, 1), policy.ratioLimit) - XCTAssertEqual(try archive.data(for: entry).count, body.utf8.count) - XCTAssertThrowsError(try archive.data(for: entry, policy: policy)) { error in - XCTAssertEqual(error as? ZipArchive.Failure, .entryTooLarge("xl/worksheets/sheet1.xml")) + let entry = try #require(archive.entry(named: "xl/worksheets/sheet1.xml")) + #expect(entry.uncompressedSize / max(entry.compressedSize, 1) > policy.ratioLimit) + #expect(try archive.data(for: entry).count == body.utf8.count) + if let error = #expect(throws: (any Error).self, performing: { try archive.data(for: entry, policy: policy) }) { + #expect(error as? ZipArchive.Failure == .entryTooLarge("xl/worksheets/sheet1.xml")) } } - func testStoredEntryReadsBackByteForByte() throws { + @Test func testStoredEntryReadsBackByteForByte() throws { let archive = try archive([("a.xml", "stored", .stored)]) - XCTAssertEqual(try archive.data(named: "a.xml"), Data("stored".utf8)) + #expect(try archive.data(named: "a.xml") == Data("stored".utf8)) } - func testDeflatedEntryIsInflated() throws { + @Test func testDeflatedEntryIsInflated() throws { let body = String(repeating: "value", count: 5_000) let archive = try archive([("b.xml", body, .deflate)]) - XCTAssertEqual(try archive.data(named: "b.xml"), Data(body.utf8)) + #expect(try archive.data(named: "b.xml") == Data(body.utf8)) } - func testEveryEntryIsListedWithItsOwnPathAndMethod() throws { + @Test func testEveryEntryIsListedWithItsOwnPathAndMethod() throws { let archive = try archive([("xl/workbook.xml", "a", .stored), ("xl/worksheets/sheet1.xml", "b", .deflate)]) - XCTAssertEqual(archive.paths, ["xl/workbook.xml", "xl/worksheets/sheet1.xml"]) - XCTAssertEqual(archive.entry(named: "xl/worksheets/sheet1.xml")?.compressionMethod, ZipArchive.deflateMethod) - XCTAssertEqual(archive.entry(named: "xl/workbook.xml")?.compressionMethod, ZipArchive.storedMethod) + #expect(archive.paths == ["xl/workbook.xml", "xl/worksheets/sheet1.xml"]) + #expect(archive.entry(named: "xl/worksheets/sheet1.xml")?.compressionMethod == ZipArchive.deflateMethod) + #expect(archive.entry(named: "xl/workbook.xml")?.compressionMethod == ZipArchive.storedMethod) } - func testEntryLookupFallsBackToCaseInsensitiveMatch() throws { + @Test func testEntryLookupFallsBackToCaseInsensitiveMatch() throws { let archive = try archive([("xl/SharedStrings.xml", "a", .stored)]) - XCTAssertNil(archive.entry(named: "xl/sharedStrings.xml")) - XCTAssertEqual(archive.entry(matching: "xl/sharedStrings.xml")?.path, "xl/SharedStrings.xml") + #expect(archive.entry(named: "xl/sharedStrings.xml") == nil) + #expect(archive.entry(matching: "xl/sharedStrings.xml")?.path == "xl/SharedStrings.xml") } - func testMissingEntryIsNamedInTheError() throws { + @Test func testMissingEntryIsNamedInTheError() throws { let archive = try archive([("a.xml", "a", .stored)]) - XCTAssertThrowsError(try archive.data(named: "xl/sharedStrings.xml")) { error in - XCTAssertEqual(error as? ZipArchive.Failure, .entryNotFound("xl/sharedStrings.xml")) + if let error = #expect(throws: (any Error).self, performing: { try archive.data(named: "xl/sharedStrings.xml") }) { + #expect(error as? ZipArchive.Failure == .entryNotFound("xl/sharedStrings.xml")) } } - func testFileThatIsNotAZipIsRefused() { - XCTAssertThrowsError(try ZipArchive(bytes: Data("not a zip at all".utf8))) { error in - XCTAssertEqual(error as? ZipArchive.Failure, .notAZipArchive) + @Test func testFileThatIsNotAZipIsRefused() { + if let error = #expect(throws: (any Error).self, performing: { try ZipArchive(bytes: Data("not a zip at all".utf8)) }) { + #expect(error as? ZipArchive.Failure == .notAZipArchive) } - XCTAssertThrowsError(try ZipArchive(bytes: Data())) { error in - XCTAssertEqual(error as? ZipArchive.Failure, .notAZipArchive) + if let error = #expect(throws: (any Error).self, performing: { try ZipArchive(bytes: Data()) }) { + #expect(error as? ZipArchive.Failure == .notAZipArchive) } } - func testEmptyEntryReadsAsEmpty() throws { + @Test func testEmptyEntryReadsAsEmpty() throws { let archive = try archive([("empty.xml", "", .stored), ("empty-deflate.xml", "", .deflate)]) - XCTAssertTrue(try archive.data(named: "empty.xml").isEmpty) - XCTAssertTrue(try archive.data(named: "empty-deflate.xml").isEmpty) + #expect(try archive.data(named: "empty.xml").isEmpty) + #expect(try archive.data(named: "empty-deflate.xml").isEmpty) } - func testSizesDeferredToADataDescriptorAreReadFromTheDirectory() throws { + @Test func testSizesDeferredToADataDescriptorAreReadFromTheDirectory() throws { let body = String(repeating: "1", count: 2_000) let archive = try archive([("sheet.xml", body, .deflate), ("plain.xml", "plain", .stored)]) { builder in builder.usesDataDescriptors = true } - XCTAssertEqual(try archive.data(named: "sheet.xml"), Data(body.utf8)) - XCTAssertEqual(try archive.data(named: "plain.xml"), Data("plain".utf8)) + #expect(try archive.data(named: "sheet.xml") == Data(body.utf8)) + #expect(try archive.data(named: "plain.xml") == Data("plain".utf8)) } - func testZip64ExtraFieldSuppliesSizesAndOffset() throws { + @Test func testZip64ExtraFieldSuppliesSizesAndOffset() throws { let body = String(repeating: "x", count: 10_000) let archive = try archive([("first.xml", "first", .stored), ("second.xml", body, .deflate)]) { builder in builder.usesZip64Extra = true } - let entry = try XCTUnwrap(archive.entry(named: "second.xml")) - XCTAssertEqual(entry.uncompressedSize, 10_000) - XCTAssertEqual(try archive.data(for: entry), Data(body.utf8)) - XCTAssertEqual(try archive.data(named: "first.xml"), Data("first".utf8)) + let entry = try #require(archive.entry(named: "second.xml")) + #expect(entry.uncompressedSize == 10_000) + #expect(try archive.data(for: entry) == Data(body.utf8)) + #expect(try archive.data(named: "first.xml") == Data("first".utf8)) } - func testOutOfRangeZip64OffsetIsCorruptRatherThanATrap() throws { + @Test func testOutOfRangeZip64OffsetIsCorruptRatherThanATrap() throws { let archive = try archive([("a.xml", "a", .stored)]) { builder in builder.usesZip64Extra = true builder.zip64OffsetOverride = 0xFFFF_FFFF_FFFF_FFF0 } - XCTAssertEqual(archive.entry(named: "a.xml")?.localHeaderOffset, Int.max) - XCTAssertThrowsError(try archive.data(named: "a.xml")) { error in - XCTAssertEqual(error as? ZipArchive.Failure, .corruptEntry("a.xml")) + #expect(archive.entry(named: "a.xml")?.localHeaderOffset == Int.max) + if let error = #expect(throws: (any Error).self, performing: { try archive.data(named: "a.xml") }) { + #expect(error as? ZipArchive.Failure == .corruptEntry("a.xml")) } } - func testOutOfRangeZip64DirectoryOffsetIsNotAnArchive() { + @Test func testOutOfRangeZip64DirectoryOffsetIsNotAnArchive() { var bytes = Data() bytes.append(TestZipBuilder.uint32(0x0606_4B50)) bytes.append(Data(count: 44)) @@ -135,39 +135,39 @@ final class ZipArchiveTests: XCTestCase { bytes.append(Data(count: 12)) bytes.append(TestZipBuilder.uint32(0xFFFF_FFFF)) bytes.append(TestZipBuilder.uint16(0)) - XCTAssertThrowsError(try ZipArchive(bytes: bytes)) { error in - XCTAssertEqual(error as? ZipArchive.Failure, .notAZipArchive) + if let error = #expect(throws: (any Error).self, performing: { try ZipArchive(bytes: bytes) }) { + #expect(error as? ZipArchive.Failure == .notAZipArchive) } } - func testUnsupportedCompressionMethodIsReported() throws { + @Test func testUnsupportedCompressionMethodIsReported() throws { let archive = try archive([("odd.xml", "odd", .unsupported)]) - XCTAssertThrowsError(try archive.data(named: "odd.xml")) { error in - XCTAssertEqual(error as? ZipArchive.Failure, .unsupportedCompression(14)) + if let error = #expect(throws: (any Error).self, performing: { try archive.data(named: "odd.xml") }) { + #expect(error as? ZipArchive.Failure == .unsupportedCompression(14)) } } - func testTruncatedDeflateStreamIsCorrupt() throws { + @Test func testTruncatedDeflateStreamIsCorrupt() throws { var builder = TestZipBuilder() builder.add("sheet.xml", String(repeating: "abcdefgh", count: 4_000), method: .deflate) var bytes = builder.build() let archive = try ZipArchive(bytes: bytes) - let entry = try XCTUnwrap(archive.entry(named: "sheet.xml")) + let entry = try #require(archive.entry(named: "sheet.xml")) let payloadStart = entry.localHeaderOffset + 30 + "sheet.xml".utf8.count let payloadEnd = payloadStart + entry.compressedSize for index in (payloadEnd - entry.compressedSize / 2)..\($0)" }.joined() let archive = try archive([("sheet.xml", body, .deflate)]) - let entry = try XCTUnwrap(archive.entry(named: "sheet.xml")) + let entry = try #require(archive.entry(named: "sheet.xml")) var collected = Data() var fractions: [Double] = [] try archive.withReader(for: entry) { reader in @@ -180,14 +180,14 @@ final class ZipArchiveTests: XCTestCase { fractions.append(reader.fractionConsumed) } } - XCTAssertEqual(collected, Data(body.utf8)) - XCTAssertGreaterThan(fractions.count, 1) - XCTAssertEqual(fractions, fractions.sorted()) - XCTAssertEqual(fractions.last, 1) + #expect(collected == Data(body.utf8)) + #expect(fractions.count > 1) + #expect(fractions == fractions.sorted()) + #expect(fractions.last == 1) } - func testLocalizedDescriptionsNameTheEntry() { - XCTAssertEqual(ZipArchive.Failure.entryNotFound("xl/workbook.xml").errorDescription, "The workbook is missing xl/workbook.xml.") - XCTAssertEqual(ZipArchive.Failure.corruptEntry("a.xml").errorDescription, "Could not read a.xml from the workbook.") + @Test func testLocalizedDescriptionsNameTheEntry() { + #expect(ZipArchive.Failure.entryNotFound("xl/workbook.xml").errorDescription == "The workbook is missing xl/workbook.xml.") + #expect(ZipArchive.Failure.corruptEntry("a.xml").errorDescription == "Could not read a.xml from the workbook.") } } diff --git a/Packages/TableProCore/Tests/TableProTabularTests/TabularEditingTests.swift b/Packages/TableProCore/Tests/TableProTabularTests/TabularEditingTests.swift index 3ffd8ddb4c..4bc4bd1d8b 100644 --- a/Packages/TableProCore/Tests/TableProTabularTests/TabularEditingTests.swift +++ b/Packages/TableProCore/Tests/TableProTabularTests/TabularEditingTests.swift @@ -2,9 +2,9 @@ import Foundation import TableProNumberFormatting @testable import TableProTabular import TableProTabularIO -import XCTest +import Testing -final class TabularEditingTests: XCTestCase { +struct TabularEditingTests { private func table(_ text: String) async throws -> TabularTable { TabularTable(source: try await TabularTableTests.delimitedSource(text), usesFirstRowAsHeader: true) } @@ -21,7 +21,7 @@ final class TabularEditingTests: XCTestCase { return updated } - func testStatisticsCountDistinctEmptyAndNumbers() async throws { + @Test func testStatisticsCountDistinctEmptyAndNumbers() async throws { let table = try await table("n\n3\n1\n\n3\nx\n") let summary = try await TabularColumnStatistics.summarize( column: table.columns[0].id, @@ -29,18 +29,18 @@ final class TabularEditingTests: XCTestCase { keys: table.rowOrder.keys, in: table ) - XCTAssertEqual(summary.rowCount, 5) - XCTAssertEqual(summary.emptyCount, 1) - XCTAssertEqual(summary.distinctCount, 4) - XCTAssertEqual(summary.nonNumericCount, 1) - XCTAssertEqual(summary.numeric?.minimum, ExactNumber(decimal: 1)) - XCTAssertEqual(summary.numeric?.maximum, ExactNumber(decimal: 3)) - XCTAssertEqual(summary.numeric?.median, ExactNumber(decimal: 3)) - XCTAssertEqual(summary.numeric?.sum, ExactNumber(decimal: 7)) - XCTAssertEqual(summary.topValues.first, TabularValueCount(value: "3", isEmpty: false, count: 2)) + #expect(summary.rowCount == 5) + #expect(summary.emptyCount == 1) + #expect(summary.distinctCount == 4) + #expect(summary.nonNumericCount == 1) + #expect(summary.numeric?.minimum == ExactNumber(decimal: 1)) + #expect(summary.numeric?.maximum == ExactNumber(decimal: 3)) + #expect(summary.numeric?.median == ExactNumber(decimal: 3)) + #expect(summary.numeric?.sum == ExactNumber(decimal: 7)) + #expect(summary.topValues.first == TabularValueCount(value: "3", isEmpty: false, count: 2)) } - func testMedianMatchesASortedReference() { + @Test func testMedianMatchesASortedReference() { var generator = SystemRandomNumberGenerator() for count in [1, 2, 3, 4, 5, 10, 101, 1_000] { for _ in 0..<20 { @@ -49,13 +49,13 @@ final class TabularEditingTests: XCTestCase { let middle = count / 2 let expected = count.isMultiple(of: 2) ? (sorted[middle - 1], sorted[middle]) : (sorted[middle], sorted[middle]) let found = TabularColumnStatistics.middleValues(of: &numbers) - XCTAssertEqual(found.lower, expected.0, "\(sorted)") - XCTAssertEqual(found.upper, expected.1, "\(sorted)") + #expect(found.lower == expected.0, "\(sorted)") + #expect(found.upper == expected.1, "\(sorted)") } } } - func testTopValuesAcrossPartitionsMatchABruteForceRanking() async throws { + @Test func testTopValuesAcrossPartitionsMatchABruteForceRanking() async throws { var generator = SystemRandomNumberGenerator() let values = (0..<20_000).map { _ in "v\(Int.random(in: 0..<700, using: &generator))" } let table = try await table("v\n" + values.joined(separator: "\n") + "\n") @@ -68,44 +68,44 @@ final class TabularEditingTests: XCTestCase { ) var counts: [String: Int] = [:] for value in values { counts[value, default: 0] += 1 } - XCTAssertEqual(summary.distinctCount, counts.count) - XCTAssertEqual(summary.topValues.count, 25) + #expect(summary.distinctCount == counts.count) + #expect(summary.topValues.count == 25) let threshold = counts.values.sorted(by: >)[24] for entry in summary.topValues { - XCTAssertEqual(counts[entry.value], entry.count) - XCTAssertGreaterThanOrEqual(entry.count, threshold) + #expect(counts[entry.value] == entry.count) + #expect(entry.count >= threshold) } - XCTAssertEqual(summary.topValues.map(\.count), summary.topValues.map(\.count).sorted(by: >)) - XCTAssertEqual(Set(summary.topValues.map(\.value)).count, 25) + #expect(summary.topValues.map(\.count) == summary.topValues.map(\.count).sorted(by: >)) + #expect(Set(summary.topValues.map(\.value)).count == 25) } - func testFindHonoursCaseWholeWordsAndRegex() async throws { + @Test func testFindHonoursCaseWholeWordsAndRegex() async throws { let table = try await table("a,b\ncat,Category\nCAT,dog\nconcat,cat5\n") let all = table.rowOrder.keys let plain = try await TabularFinder.findAll(TabularFindQuery(text: "cat", columns: table.columnIDs), keys: all, in: table) - XCTAssertEqual(plain.count, 5) + #expect(plain.count == 5) let cased = try await TabularFinder.findAll( TabularFindQuery(text: "cat", matchesCase: true, columns: table.columnIDs), keys: all, in: table ) - XCTAssertEqual(cased.count, 3) + #expect(cased.count == 3) let words = try await TabularFinder.findAll( TabularFindQuery(text: "cat", matchesWholeWords: true, columns: table.columnIDs), keys: all, in: table ) - XCTAssertEqual(words.map(\.key), [1, 2]) + #expect(words.map(\.key) == [1, 2]) let regex = try await TabularFinder.findAll( TabularFindQuery(text: "^c.t\\d$", isRegularExpression: true, columns: table.columnIDs), keys: all, in: table ) - XCTAssertEqual(regex, [TabularFindMatch(key: 3, column: table.columns[1].id)]) - XCTAssertThrowsError(try TabularFindMatcher(TabularFindQuery(text: "(", isRegularExpression: true, columns: []))) + #expect(regex == [TabularFindMatch(key: 3, column: table.columns[1].id)]) + #expect(throws: (any Error).self) { try TabularFindMatcher(TabularFindQuery(text: "(", isRegularExpression: true, columns: [])) } } - func testReplaceAllRewritesOnlyMatchingCellsAndKeepsEditsInScope() async throws { + @Test func testReplaceAllRewritesOnlyMatchingCellsAndKeepsEditsInScope() async throws { var table = try await table("a\nfoo bar\nbaz\nfoo\n") table.setCell(.text("foo edited"), row: 1, column: 0) let result = try await TabularFinder.replaceAll( @@ -114,14 +114,14 @@ final class TabularEditingTests: XCTestCase { keys: table.rowOrder.keys, in: table ) - XCTAssertEqual(result.changedCells, 3) - XCTAssertEqual(result.replacements, 3) + #expect(result.changedCells == 3) + #expect(result.replacements == 3) let updated = applying(result.values, to: table) - XCTAssertEqual(texts(updated, column: 0), ["fox bar", "fox edited", "fox"]) - XCTAssertTrue(updated.columns[0].values.edits.isEmpty) + #expect(texts(updated, column: 0) == ["fox bar", "fox edited", "fox"]) + #expect(updated.columns[0].values.edits.isEmpty) } - func testLiteralReplaceTreatsDollarSignsAsText() async throws { + @Test func testLiteralReplaceTreatsDollarSignsAsText() async throws { let table = try await table("a\nprice 5\n") let result = try await TabularFinder.replaceAll( TabularFindQuery(text: "5", columns: table.columnIDs), @@ -129,54 +129,54 @@ final class TabularEditingTests: XCTestCase { keys: table.rowOrder.keys, in: table ) - XCTAssertEqual(texts(applying(result.values, to: table), column: 0), ["price $1"]) + #expect(texts(applying(result.values, to: table), column: 0) == ["price $1"]) } - func testCleanupOperationsReportChangedCells() async throws { + @Test func testCleanupOperationsReportChangedCells() async throws { let table = try await table("a,b\n x ,1\ny,2\n\t z ,3\n") let all = table.rowOrder.keys let trimmed = try await TabularCleanup.apply(.trimWhitespace, columns: [table.columns[0].id], keys: all, in: table) - XCTAssertEqual(trimmed.changedCells, 2) - XCTAssertEqual(texts(applying(trimmed.values, to: table), column: 0), ["x", "y", "z"]) + #expect(trimmed.changedCells == 2) + #expect(texts(applying(trimmed.values, to: table), column: 0) == ["x", "y", "z"]) let upper = try await TabularCleanup.apply(.changeCase(.uppercase), columns: [table.columns[0].id], keys: [2], in: table) - XCTAssertEqual(texts(applying(upper.values, to: table), column: 0), [" x ", "Y", "\t z "]) + #expect(texts(applying(upper.values, to: table), column: 0) == [" x ", "Y", "\t z "]) let filled = try await TabularCleanup.apply(.fillDown, columns: [table.columns[1].id], keys: [1, 2, 3], in: table) - XCTAssertEqual(filled.changedCells, 2) - XCTAssertEqual(texts(applying(filled.values, to: table), column: 1), ["1", "1", "1"]) + #expect(filled.changedCells == 2) + #expect(texts(applying(filled.values, to: table), column: 1) == ["1", "1", "1"]) let everywhere = try await TabularCleanup.apply(.setValue("k"), columns: [table.columns[1].id], keys: all, in: table) - XCTAssertEqual(everywhere.changedCells, 3) - XCTAssertEqual(texts(applying(everywhere.values, to: table), column: 1), ["k", "k", "k"]) + #expect(everywhere.changedCells == 3) + #expect(texts(applying(everywhere.values, to: table), column: 1) == ["k", "k", "k"]) } - func testDuplicateRowsKeepTheFirstOccurrence() async throws { + @Test func testDuplicateRowsKeepTheFirstOccurrence() async throws { let table = try await table("a,b\nx,1\nX ,1\nx,1\ny,2\n") let all = table.rowOrder.keys let exact = try await TabularDuplicates.duplicateKeys(comparing: table.columnIDs, keys: all, in: table) - XCTAssertEqual(exact, [3]) + #expect(exact == [3]) let loose = try await TabularDuplicates.duplicateKeys( comparing: table.columnIDs, keys: all, in: table, options: TabularDuplicateOptions(ignoresCase: true, ignoresSurroundingWhitespace: true) ) - XCTAssertEqual(loose, [2, 3]) + #expect(loose == [2, 3]) } - func testTypeInferenceIsStrict() { - XCTAssertEqual(TabularTypeInference.infer([(.text, "1"), (.text, "22")]), .integer) - XCTAssertEqual(TabularTypeInference.infer([(.text, "007"), (.text, "12")]), .text) - XCTAssertEqual(TabularTypeInference.infer([(.text, "1.5"), (.text, "2")]), .decimal) - XCTAssertEqual(TabularTypeInference.infer([(.text, "NaN"), (.text, "2")]), .text) - XCTAssertEqual(TabularTypeInference.infer([(.text, "0x1F")]), .text) - XCTAssertEqual(TabularTypeInference.infer([(.text, "yes"), (.text, "No")]), .boolean) - XCTAssertEqual(TabularTypeInference.infer([(.text, "2024-01-05"), (.text, "2024-02-10T10:00:00Z")]), .date) - XCTAssertEqual(TabularTypeInference.infer([(.text, "05/01/2024")]), .text) + @Test func testTypeInferenceIsStrict() { + #expect(TabularTypeInference.infer([(.text, "1"), (.text, "22")]) == .integer) + #expect(TabularTypeInference.infer([(.text, "007"), (.text, "12")]) == .text) + #expect(TabularTypeInference.infer([(.text, "1.5"), (.text, "2")]) == .decimal) + #expect(TabularTypeInference.infer([(.text, "NaN"), (.text, "2")]) == .text) + #expect(TabularTypeInference.infer([(.text, "0x1F")]) == .text) + #expect(TabularTypeInference.infer([(.text, "yes"), (.text, "No")]) == .boolean) + #expect(TabularTypeInference.infer([(.text, "2024-01-05"), (.text, "2024-02-10T10:00:00Z")]) == .date) + #expect(TabularTypeInference.infer([(.text, "05/01/2024")]) == .text) } - func testARunawayRegularExpressionStopsWhenItsTaskIsCancelled() async throws { + @Test func testARunawayRegularExpressionStopsWhenItsTaskIsCancelled() async throws { let expression = try NSRegularExpression(pattern: "(a+)+b") let text = String(repeating: "a", count: 64) let task = Task { TabularRegex.firstMatch(expression, in: text) != nil } @@ -184,27 +184,27 @@ final class TabularEditingTests: XCTestCase { let cancelled = ContinuousClock.now task.cancel() let matched = await task.value - XCTAssertFalse(matched) - XCTAssertLessThan(ContinuousClock.now - cancelled, .seconds(2)) + #expect(!matched) + #expect(ContinuousClock.now - cancelled < .seconds(2)) } - func testRegularExpressionReplaceMatchesFoundationsOwnReplacement() throws { + @Test func testRegularExpressionReplaceMatchesFoundationsOwnReplacement() throws { let query = TabularFindQuery(text: "(\\w+)@(\\w+)", isRegularExpression: true, columns: []) let matcher = try TabularFindMatcher(query) let text = "ann@x, bob@y and café@z" let expected = try NSRegularExpression(pattern: "(\\w+)@(\\w+)") .stringByReplacingMatches(in: text, range: NSRange(location: 0, length: (text as NSString).length), withTemplate: "$2:$1") let replaced = matcher.replacing(in: text, with: "$2:$1") - XCTAssertEqual(replaced.text, expected) - XCTAssertEqual(replaced.replacements, 3) + #expect(replaced.text == expected) + #expect(replaced.replacements == 3) } - func testRegularExpressionReplaceThatChangesLengthReadsEachMatchsOwnGroups() throws { + @Test func testRegularExpressionReplaceThatChangesLengthReadsEachMatchsOwnGroups() throws { let query = TabularFindQuery(text: "(\\w+)_id", isRegularExpression: true, columns: []) let matcher = try TabularFindMatcher(query) let text = "user_id, order_id, item_id" let replaced = matcher.replacing(in: text, with: "$1Id") - XCTAssertEqual(replaced.text, "userId, orderId, itemId") - XCTAssertEqual(replaced.replacements, 3) + #expect(replaced.text == "userId, orderId, itemId") + #expect(replaced.replacements == 3) } } diff --git a/Packages/TableProCore/Tests/TableProTabularTests/TabularQueryTests.swift b/Packages/TableProCore/Tests/TableProTabularTests/TabularQueryTests.swift index aca65732be..cf36d9d497 100644 --- a/Packages/TableProCore/Tests/TableProTabularTests/TabularQueryTests.swift +++ b/Packages/TableProCore/Tests/TableProTabularTests/TabularQueryTests.swift @@ -1,9 +1,9 @@ import Foundation @testable import TableProTabular import TableProTabularIO -import XCTest +import Testing -final class TabularQueryTests: XCTestCase { +struct TabularQueryTests { private func table(_ text: String) async throws -> TabularTable { TabularTable(source: try await TabularTableTests.delimitedSource(text), usesFirstRowAsHeader: true) } @@ -20,11 +20,10 @@ final class TabularQueryTests: XCTestCase { _ table: TabularTable, _ predicate: TabularRowPredicate, _ expected: [Int], - file: StaticString = #filePath, - line: UInt = #line + sourceLocation: SourceLocation = #_sourceLocation ) async throws { let actual = try await matches(table, predicate) - XCTAssertEqual(actual, expected, file: file, line: line) + #expect(actual == expected, sourceLocation: sourceLocation) } private func cell( @@ -48,7 +47,7 @@ final class TabularQueryTests: XCTestCase { )) } - func testTextOperatorsFollowCaseSensitivity() async throws { + @Test func testTextOperatorsFollowCaseSensitivity() async throws { let table = try await table("name\nAlice\nalice\nBob\n") try await expect(table, cell(table, 0, .equal, "ALICE"), [0, 1]) try await expect(table, cell(table, 0, .equal, "ALICE", caseSensitive: true), []) @@ -58,7 +57,7 @@ final class TabularQueryTests: XCTestCase { try await expect(table, cell(table, 0, .endsWith, "CE"), [0, 1]) } - func testNumericColumnsCompareAsNumbersAndFallBackToTextForOtherCells() async throws { + @Test func testNumericColumnsCompareAsNumbersAndFallBackToTextForOtherCells() async throws { let table = try await table("n\n5\n10\n5.0\nx\n\n") try await expect(table, cell(table, 0, .equal, "5", kind: .numeric), [0, 2]) try await expect(table, cell(table, 0, .greaterThan, "6", kind: .numeric), [1, 3]) @@ -67,12 +66,12 @@ final class TabularQueryTests: XCTestCase { try await expect(table, cell(table, 0, .inList, "", kind: .numeric, list: ["10", "x"]), [1, 3]) } - func testOrderingOnATextColumnPrefersNumbersWhenBothSidesParse() async throws { + @Test func testOrderingOnATextColumnPrefersNumbersWhenBothSidesParse() async throws { let table = try await table("v\n9\n10\nabc\n") try await expect(table, cell(table, 0, .greaterThan, "9"), [1, 2]) } - func testRegexAndAnyOf() async throws { + @Test func testRegexAndAnyOf() async throws { let table = try await table("code,city\nA1,Paris\nB2,Rome\nC3,Oslo\n") try await expect(table, cell(table, 0, .regex, "^[AB]\\d$"), [0, 1]) let either = TabularRowPredicate.any([cell(table, 1, .equal, "rome"), cell(table, 1, .equal, "oslo")]) @@ -81,45 +80,45 @@ final class TabularQueryTests: XCTestCase { try await expect(table, both, [1]) } - func testQuickSearchIgnoresCaseAndDiacritics() async throws { + @Test func testQuickSearchIgnoresCaseAndDiacritics() async throws { let table = try await table("a,b\nCafé,1\nnothing,2\nx,CAFE\n") let search = TabularRowPredicate.search(TabularSearch(text: "cafe", columns: table.columnIDs)) try await expect(table, search, [0, 2]) } - func testCSVCellsAreNeverNull() async throws { + @Test func testCSVCellsAreNeverNull() async throws { let table = try await table("a\n\nNULL\n") try await expect(table, cell(table, 0, .isNull, ""), []) try await expect(table, cell(table, 0, .equal, "NULL"), [1]) } - func testSortPutsBlanksLastAndSortsNaturally() async throws { + @Test func testSortPutsBlanksLastAndSortsNaturally() async throws { let table = try await table("name,n\nItem 10,3\nItem 2,\nitem 1,20\n,x\n") let byName = try await TabularSorter.sortedKeys( table.rowOrder.keys, in: table, by: [TabularSortKey(column: table.columns[0].id, ascending: true, numeric: false)] ) - XCTAssertEqual(byName, [3, 2, 1, 4]) + #expect(byName == [3, 2, 1, 4]) let byNumberDescending = try await TabularSorter.sortedKeys( table.rowOrder.keys, in: table, by: [TabularSortKey(column: table.columns[1].id, ascending: false, numeric: true)] ) - XCTAssertEqual(byNumberDescending, [3, 1, 4, 2]) + #expect(byNumberDescending == [3, 1, 4, 2]) } - func testSortIsStableForEqualKeys() async throws { + @Test func testSortIsStableForEqualKeys() async throws { let table = try await table("k,v\nb,1\na,2\nb,3\na,4\n") let sorted = try await TabularSorter.sortedKeys( table.rowOrder.keys, in: table, by: [TabularSortKey(column: table.columns[0].id, ascending: true, numeric: false)] ) - XCTAssertEqual(sorted, [2, 4, 1, 3]) + #expect(sorted == [2, 4, 1, 3]) } - func testNaturalSortOrdersDigitRunsByMagnitudeAndIgnoresCase() async throws { + @Test func testNaturalSortOrdersDigitRunsByMagnitudeAndIgnoresCase() async throws { let values = [ "Item 10", "Item 2", "Item 100", "Item 20", "file10.txt", "file9.txt", "v1.10.0", "v1.2.3", "ABC", "abc", "x007", "x7" @@ -130,10 +129,10 @@ final class TabularQueryTests: XCTestCase { in: table, by: [TabularSortKey(column: table.columns[0].id, ascending: true, numeric: false)] ) - XCTAssertEqual(sorted, [9, 10, 6, 5, 2, 1, 4, 3, 8, 7, 11, 12]) + #expect(sorted == [9, 10, 6, 5, 2, 1, 4, 3, 8, 7, 11, 12]) } - func testASortAcrossManyChunksMatchesAStableReferenceSort() async throws { + @Test func testASortAcrossManyChunksMatchesAStableReferenceSort() async throws { let count = TabularChunking.minimumChunkSize * 9 + 37 var generator = SystemRandomNumberGenerator() let values = (0.. right : lhs.offset < rhs.offset } .map(\.element) - XCTAssertEqual(byNumber, expectedByNumber) + #expect(byNumber == expectedByNumber) let byLabelThenNumber = try await TabularSorter.sortedKeys( table.rowOrder.keys, in: table, @@ -167,6 +166,6 @@ final class TabularQueryTests: XCTestCase { return left < right } .map(\.element) - XCTAssertEqual(byLabelThenNumber, expectedByLabel) + #expect(byLabelThenNumber == expectedByLabel) } } diff --git a/Packages/TableProCore/Tests/TableProTabularTests/TabularSaveTests.swift b/Packages/TableProCore/Tests/TableProTabularTests/TabularSaveTests.swift index 6e8b16936e..157849c304 100644 --- a/Packages/TableProCore/Tests/TableProTabularTests/TabularSaveTests.swift +++ b/Packages/TableProCore/Tests/TableProTabularTests/TabularSaveTests.swift @@ -1,17 +1,17 @@ import Foundation @testable import TableProTabular import TableProTabularIO -import XCTest +import Testing -final class TabularSaveTests: XCTestCase { - private var directory: URL! +final class TabularSaveTests { + private let directory: URL - override func setUpWithError() throws { + init() throws { directory = FileManager.default.temporaryDirectory.appendingPathComponent("TabularSaveTests-\(UUID().uuidString)") try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) } - override func tearDownWithError() throws { + deinit { try? FileManager.default.removeItem(at: directory) } @@ -37,98 +37,99 @@ final class TabularSaveTests: XCTestCase { return try [UInt8](Data(contentsOf: url)) } - func testUntouchedFileIsWrittenBackByteForByte() async throws { + @Test func testUntouchedFileIsWrittenBackByteForByte() async throws { let original = Array("id,name\r\n1,\"Doe, Jane\"\r\n2,\"say \"\"hi\"\"\"\r\n3,x".utf8) let (source, table) = try await open(original) - XCTAssertEqual(try save(table, source: source), original) + #expect(try save(table, source: source) == original) } - func testAChangedLineEndingRewritesEveryRow() async throws { + @Test func testAChangedLineEndingRewritesEveryRow() async throws { let (source, table) = try await open(Array("id,name\r\n1,a\r\n2,b\r\n".utf8)) var dialect = source.dialect dialect.lineEnding = .lf - XCTAssertEqual(try save(table, source: source, dialect: dialect), Array("id,name\n1,a\n2,b\n".utf8)) + #expect(try save(table, source: source, dialect: dialect) == Array("id,name\n1,a\n2,b\n".utf8)) } - func testAppendingToAFileWithoutAFinalNewlineKeepsRowsApart() async throws { + @Test func testAppendingToAFileWithoutAFinalNewlineKeepsRowsApart() async throws { let (source, original) = try await open(Array("name,age\nAlice,30".utf8)) var table = original table.insertRows([[.text("Bob"), .text("40")]], at: table.rowCount) - XCTAssertEqual(TabularTextCodec.utf8String(try save(table, source: source)), "name,age\nAlice,30\nBob,40") + #expect(TabularTextCodec.utf8String(try save(table, source: source)) == "name,age\nAlice,30\nBob,40") } - func testAddedColumnNameIsWritten() async throws { + @Test func testAddedColumnNameIsWritten() async throws { let (source, original) = try await open(Array("name,age\nAlice,30\n".utf8)) var table = original table.insertColumn(named: "city", at: 2) table.setCell(.text("Paris"), row: 0, column: 2) - XCTAssertEqual(TabularTextCodec.utf8String(try save(table, source: source)), "name,age,city\nAlice,30,Paris\n") + #expect(TabularTextCodec.utf8String(try save(table, source: source)) == "name,age,city\nAlice,30,Paris\n") } - func testOnlyTheEditedRowIsRewritten() async throws { + @Test func testOnlyTheEditedRowIsRewritten() async throws { let (source, original) = try await open(Array("a,b\n\"1\",2\n3,\"4\"\n".utf8)) var table = original table.setCell(.text("9"), row: 1, column: 0) - XCTAssertEqual(TabularTextCodec.utf8String(try save(table, source: source)), "a,b\n\"1\",2\n9,4\n") + #expect(TabularTextCodec.utf8String(try save(table, source: source)) == "a,b\n\"1\",2\n9,4\n") } - func testUnencodableTextFailsInsteadOfBlankingTheRow() async throws { + @Test func testUnencodableTextFailsInsteadOfBlankingTheRow() async throws { let (source, original) = try await open([0x6E, 0x0A, 0x43, 0x61, 0x66, 0xE9, 0x0A, 0x41, 0x0A]) - XCTAssertEqual(source.dialect.encoding, .windows1252) + #expect(source.dialect.encoding == .windows1252) var table = original table.setCell(.text("東京"), row: 1, column: 0) - XCTAssertThrowsError(try save(table, source: source)) { error in + if let error = #expect(throws: (any Error).self, performing: { try save(table, source: source) }) { guard case TabularWriteError.unencodable(let row, let column, _, .windows1252) = error else { - return XCTFail("unexpected error \(error)") + Issue.record("unexpected error \(error)") + return } - XCTAssertEqual(row, 2) - XCTAssertEqual(column, 0) + #expect(row == 2) + #expect(column == 0) } } - func testWindows1252UndefinedBytesRoundTrip() async throws { + @Test func testWindows1252UndefinedBytesRoundTrip() async throws { let original: [UInt8] = [0x6E, 0x0A, 0x5A, 0x6F, 0x81, 0x0A, 0x43, 0x61, 0x66, 0xE9, 0x0A] let (source, first) = try await open(original) var table = first table.setCell(.text("Caf\u{E9}!"), row: 1, column: 0) let saved = try save(table, source: source) - XCTAssertEqual(saved, [0x6E, 0x0A, 0x5A, 0x6F, 0x81, 0x0A, 0x43, 0x61, 0x66, 0xE9, 0x21, 0x0A]) + #expect(saved == [0x6E, 0x0A, 0x5A, 0x6F, 0x81, 0x0A, 0x43, 0x61, 0x66, 0xE9, 0x21, 0x0A]) } - func testSavingAsTabSeparatedWritesTabs() async throws { + @Test func testSavingAsTabSeparatedWritesTabs() async throws { let (source, table) = try await open(Array("a,b\n1,\"x,y\"\n".utf8)) var tsv = source.dialect tsv.delimiter = DelimitedDialect.tab - XCTAssertEqual(TabularTextCodec.utf8String(try save(table, source: source, dialect: tsv)), "a\tb\n1\tx,y\n") + #expect(TabularTextCodec.utf8String(try save(table, source: source, dialect: tsv)) == "a\tb\n1\tx,y\n") } - func testTurningTheHeaderOffWritesTheNamesAsData() async throws { + @Test func testTurningTheHeaderOffWritesTheNamesAsData() async throws { let (source, original) = try await open(Array("a,b\n1,2\n".utf8)) var table = original table.renameColumn(table.columns[0].id, to: "id") table.setUsesFirstRowAsHeader(false) var dialect = source.dialect dialect.hasHeaderRow = false - XCTAssertEqual(TabularTextCodec.utf8String(try save(table, source: source, dialect: dialect)), "id,b\n1,2\n") + #expect(TabularTextCodec.utf8String(try save(table, source: source, dialect: dialect)) == "id,b\n1,2\n") } - func testTabSeparatedExtensionForcesTabDelimiter() async throws { + @Test func testTabSeparatedExtensionForcesTabDelimiter() async throws { let (source, table) = try await open(Array("name\taddress\nAnn\t1 Main St, Apt 4, Springfield, IL\n".utf8), fileExtension: "tsv") - XCTAssertEqual(source.dialect.delimiter, DelimitedDialect.tab) - XCTAssertEqual(table.cells(row: 0).map(\.text), ["Ann", "1 Main St, Apt 4, Springfield, IL"]) + #expect(source.dialect.delimiter == DelimitedDialect.tab) + #expect(table.cells(row: 0).map(\.text) == ["Ann", "1 Main St, Apt 4, Springfield, IL"]) } - func testDelimiterDetectionIsDeterministicForSingleColumnFiles() async throws { + @Test func testDelimiterDetectionIsDeterministicForSingleColumnFiles() async throws { let (source, _) = try await open(Array("email\na@x.com\nb@y.org\n".utf8)) - XCTAssertEqual(source.dialect.delimiter, DelimitedDialect.comma) + #expect(source.dialect.delimiter == DelimitedDialect.comma) let (semicolonSource, _) = try await open(Array("a;b\n1;2\n".utf8)) - XCTAssertEqual(semicolonSource.dialect.delimiter, DelimitedDialect.semicolon) + #expect(semicolonSource.dialect.delimiter == DelimitedDialect.semicolon) let (tieSource, _) = try await open(Array("a,b;c\n".utf8)) - XCTAssertEqual(tieSource.dialect.delimiter, DelimitedDialect.comma) + #expect(tieSource.dialect.delimiter == DelimitedDialect.comma) } private func shiftJIS(_ text: String) throws -> [UInt8] { - [UInt8](try XCTUnwrap(text.data(using: .shiftJIS, allowLossyConversion: false))) + [UInt8](try #require(text.data(using: .shiftJIS, allowLossyConversion: false))) } private func quotedShiftJISExport() throws -> (bytes: [UInt8], rows: [[UInt8]]) { @@ -145,40 +146,37 @@ final class TabularSaveTests: XCTestCase { return (rows.flatMap { $0 }, rows) } - func testShiftJISFileOpensAsShiftJIS() async throws { + @Test func testShiftJISFileOpensAsShiftJIS() async throws { let (source, table) = try await open(try quotedShiftJISExport().bytes) - XCTAssertEqual(source.dialect.encoding, .shiftJIS) - XCTAssertNotNil(source.origin) - XCTAssertEqual(table.cells(row: 0).map(\.text), ["001", "\u{9AD9}橋", "たかはし", "ソフトウェアの保守"]) - XCTAssertEqual(table.cells(row: 1).map(\.text), ["002", "山\u{FA11}", "やまさき", "\u{2170}の注文"]) + #expect(source.dialect.encoding == .shiftJIS) + #expect(source.origin != nil) + #expect(table.cells(row: 0).map(\.text) == ["001", "\u{9AD9}橋", "たかはし", "ソフトウェアの保守"]) + #expect(table.cells(row: 1).map(\.text) == ["002", "山\u{FA11}", "やまさき", "\u{2170}の注文"]) } - func testUntouchedShiftJISRowsKeepTheirQuotesAndIBMExtensionCodes() async throws { + @Test func testUntouchedShiftJISRowsKeepTheirQuotesAndIBMExtensionCodes() async throws { let export = try quotedShiftJISExport() let (source, original) = try await open(export.bytes) - XCTAssertEqual(try save(original, source: source), export.bytes) + #expect(try save(original, source: source) == export.bytes) var table = original table.setCell(.text("鈴木"), row: 2, column: 1) let expected = export.rows[0] + export.rows[1] + export.rows[2] + (try shiftJIS("003,鈴木,たなか,ポイント交換\r\n")) + export.rows[4] - XCTAssertEqual(try save(table, source: source), expected) + #expect(try save(table, source: source) == expected) } - func testDeletingAndReorderingShiftJISRowsCopiesTheRestExactly() async throws { + @Test func testDeletingAndReorderingShiftJISRowsCopiesTheRestExactly() async throws { let export = try quotedShiftJISExport() let (source, original) = try await open(export.bytes) var table = original table.deleteRows(IndexSet(integer: 1)) - XCTAssertEqual(try save(table, source: source), export.rows[0] + export.rows[1] + export.rows[3] + export.rows[4]) + #expect(try save(table, source: source) == export.rows[0] + export.rows[1] + export.rows[3] + export.rows[4]) var reordered = original reordered.replaceRowOrder(.explicit([4, 2, 3, 1])) - XCTAssertEqual( - try save(reordered, source: source), - export.rows[0] + export.rows[4] + export.rows[2] + export.rows[3] + export.rows[1] - ) + #expect(try save(reordered, source: source) == export.rows[0] + export.rows[4] + export.rows[2] + export.rows[3] + export.rows[1]) } - func testAnEditNearTheEndOfALargeShiftJISFileLeavesEveryOtherByte() async throws { + @Test func testAnEditNearTheEndOfALargeShiftJISFileLeavesEveryOtherByte() async throws { let header = try shiftJIS("コード,名称,住所\r\n") let line = try shiftJIS(",東京都港区芝公園4-2-8,ザイコカンリスル ポイント\r\n") var bytes = header @@ -186,67 +184,67 @@ final class TabularSaveTests: XCTestCase { for row in 0.. TabularTextTranscoder.chunkLength * 2) let (source, original) = try await open(bytes) - XCTAssertEqual(source.dialect.encoding, .shiftJIS) + #expect(source.dialect.encoding == .shiftJIS) var table = original table.setCell(.text("999"), row: rowCount - 1, column: 0) let saved = try save(table, source: source) let lastRowStart = bytes.count - (Array(String(rowCount - 1).utf8) + line).count - XCTAssertEqual(Array(saved.prefix(lastRowStart)), Array(bytes.prefix(lastRowStart))) - XCTAssertEqual(Array(saved.suffix(from: lastRowStart)), Array("999".utf8) + line) + #expect(Array(saved.prefix(lastRowStart)) == Array(bytes.prefix(lastRowStart))) + #expect(Array(saved.suffix(from: lastRowStart)) == Array("999".utf8) + line) } - func testReversingALargeShiftJISFileCopiesEveryRow() async throws { + @Test func testReversingALargeShiftJISFileCopiesEveryRow() async throws { let header = try shiftJIS("コード,名称\r\n") let rows = try (0..<60_000).map { try shiftJIS("\($0),東京都港区芝公園4-2-8 ザイコカンリスル\r\n") } let bytes = header + rows.flatMap { $0 } - XCTAssertGreaterThan(bytes.count, TabularTextTranscoder.chunkLength * 2) + #expect(bytes.count > TabularTextTranscoder.chunkLength * 2) let (source, original) = try await open(bytes) - XCTAssertNotNil(source.origin) + #expect(source.origin != nil) var reversed = original reversed.replaceRowOrder(.explicit(Array((1...rows.count).reversed()))) - XCTAssertEqual(try save(reversed, source: source), header + rows.reversed().flatMap { $0 }) + #expect(try save(reversed, source: source) == header + rows.reversed().flatMap { $0 }) } - func testUntouchedUTF16FileIsWrittenBackByteForByte() async throws { + @Test func testUntouchedUTF16FileIsWrittenBackByteForByte() async throws { let text = "\"名前\"\t\"住所\"\r\n\"吉田\"\t\"三上\"\r\n\"本田\"\t\"日本\"\r\n" - let bytes = [0xFF, 0xFE] + [UInt8](try XCTUnwrap(text.data(using: .utf16LittleEndian))) + let bytes = [0xFF, 0xFE] + [UInt8](try #require(text.data(using: .utf16LittleEndian))) let (source, table) = try await open(bytes, fileExtension: "tsv") - XCTAssertEqual(source.dialect.encoding, .utf16LittleEndian) - XCTAssertEqual(table.cells(row: 1).map(\.text), ["本田", "日本"]) - XCTAssertEqual(try save(table, source: source), bytes) + #expect(source.dialect.encoding == .utf16LittleEndian) + #expect(table.cells(row: 1).map(\.text) == ["本田", "日本"]) + #expect(try save(table, source: source) == bytes) } - func testAStrayTrailingUTF16ByteDoesNotMisalignRowsWrittenAfterIt() async throws { + @Test func testAStrayTrailingUTF16ByteDoesNotMisalignRowsWrittenAfterIt() async throws { let text = "名前,住所\r\n吉田,東京\r\n本田,大阪" - let bytes = [0xFF, 0xFE] + [UInt8](try XCTUnwrap(text.data(using: .utf16LittleEndian))) + [0x0A] + let bytes = [0xFF, 0xFE] + [UInt8](try #require(text.data(using: .utf16LittleEndian))) + [0x0A] let (source, original) = try await open(bytes) - XCTAssertEqual(source.dialect.encoding, .utf16LittleEndian) + #expect(source.dialect.encoding == .utf16LittleEndian) var table = original table.insertRows([[.text("新規"), .text("行")]], at: table.rowCount) let saved = try save(table, source: source) - XCTAssertTrue(saved.count.isMultiple(of: 2)) + #expect(saved.count.isMultiple(of: 2)) let (_, reopened) = try await open(saved) - XCTAssertEqual(reopened.rowCount, 3) - XCTAssertEqual(reopened.cells(row: 2).map(\.text), ["新規", "行"]) + #expect(reopened.rowCount == 3) + #expect(reopened.cells(row: 2).map(\.text) == ["新規", "行"]) } - func testAYenSignTypedIntoAShiftJISFileSavesAsItsJISRomanByte() async throws { + @Test func testAYenSignTypedIntoAShiftJISFileSavesAsItsJISRomanByte() async throws { let (source, original) = try await open(try quotedShiftJISExport().bytes) var table = original table.setCell(.text("¥1,000‾"), row: 0, column: 3) let saved = try save(table, source: source) - XCTAssertTrue(saved.suffix(from: 0).starts(with: try quotedShiftJISExport().rows[0])) + #expect(saved.suffix(from: 0).starts(with: try quotedShiftJISExport().rows[0])) let editedRow = try shiftJIS("001,\u{9AD9}橋,たかはし,\"\\1,000~\"\r\n") - XCTAssertNotNil(saved.firstRange(of: editedRow)) + #expect(saved.firstRange(of: editedRow) != nil) } - func testSavingAShiftJISFileAsUTF8KeepsItsQuoting() async throws { + @Test func testSavingAShiftJISFileAsUTF8KeepsItsQuoting() async throws { let (source, table) = try await open(try quotedShiftJISExport().bytes) var utf8 = source.dialect utf8.encoding = .utf8 let saved = TabularTextCodec.utf8String(try save(table, source: source, dialect: utf8)) - XCTAssertTrue(saved.hasPrefix("\"顧客ID\",\"氏名\",\"よみがな\",\"備考\"\r\n\"001\",\"\u{9AD9}橋\"")) + #expect(saved.hasPrefix("\"顧客ID\",\"氏名\",\"よみがな\",\"備考\"\r\n\"001\",\"\u{9AD9}橋\"")) } } diff --git a/Packages/TableProCore/Tests/TableProTabularTests/TabularTableJSONOutputTests.swift b/Packages/TableProCore/Tests/TableProTabularTests/TabularTableJSONOutputTests.swift index 775a4cfc88..ecdfc6dd1d 100644 --- a/Packages/TableProCore/Tests/TableProTabularTests/TabularTableJSONOutputTests.swift +++ b/Packages/TableProCore/Tests/TableProTabularTests/TabularTableJSONOutputTests.swift @@ -1,9 +1,9 @@ import Foundation @testable import TableProTabular import TableProTabularIO -import XCTest +import Testing -final class TabularTableJSONOutputTests: XCTestCase { +struct TabularTableJSONOutputTests { private func source(_ text: String, kind: JSONTableFileKind = .jsonLines) async throws -> JSONSource { try await JSONSourceBuilder.build(bytes: Data(text.utf8), fileKind: kind) } @@ -16,35 +16,35 @@ final class TabularTableJSONOutputTests: XCTestCase { let rows = table.jsonOutputRows(sourceKeys: source.keys, literal: typedLiteral) let writer = JSONTableWriter(source: source, keyChanges: table.jsonKeyChanges(sourceKeys: source.keys)) let bytes = try writer.encoded(rows: rows) - XCTAssertNil(rows.status.failure) - return try XCTUnwrap(String(bytes: bytes, encoding: .utf8)) + #expect(rows.status.failure == nil) + return try #require(String(bytes: bytes, encoding: .utf8)) } - func testAnUntouchedTableWritesTheSourceBackByteForByte() async throws { + @Test func testAnUntouchedTableWritesTheSourceBackByteForByte() async throws { let text = "{\"id\": 1, \"name\":\"Ann\"}\n{ \"id\":2 ,\"tags\":[1, 2]}\n" let source = try await source(text) let table = TabularTable(source: source, usesFirstRowAsHeader: false) - XCTAssertEqual(try written(table, source: source), text) + #expect(try written(table, source: source) == text) } - func testAnEditedCellRewritesOnlyThatMemberAndKeepsItsType() async throws { + @Test func testAnEditedCellRewritesOnlyThatMemberAndKeepsItsType() async throws { let text = "{\"id\":1,\"name\":\"Ann\"}\n{\"id\":2,\"name\":\"Bob\"}\n" let source = try await source(text) var table = TabularTable(source: source, usesFirstRowAsHeader: false) table.setCells([(key: 1, columnID: table.columns[0].id, cell: TabularCell(kind: .number, text: "20"))]) - XCTAssertEqual(try written(table, source: source), "{\"id\":1,\"name\":\"Ann\"}\n{\"id\":20,\"name\":\"Bob\"}\n") + #expect(try written(table, source: source) == "{\"id\":1,\"name\":\"Ann\"}\n{\"id\":20,\"name\":\"Bob\"}\n") } - func testRenamedAndDeletedColumnsChangeEveryObject() async throws { + @Test func testRenamedAndDeletedColumnsChangeEveryObject() async throws { let text = "{\"id\":1,\"name\":\"Ann\",\"x\":0}\n{\"id\":2,\"x\":1}\n" let source = try await source(text) var table = TabularTable(source: source, usesFirstRowAsHeader: false) table.renameColumn(table.columns[0].id, to: "key") table.deleteColumns([table.columns[2].id]) - XCTAssertEqual(try written(table, source: source), "{\"key\":1,\"name\":\"Ann\"}\n{\"key\":2}\n") + #expect(try written(table, source: source) == "{\"key\":1,\"name\":\"Ann\"}\n{\"key\":2}\n") } - func testAnAddedColumnAppearsOnlyWhereItHasAValue() async throws { + @Test func testAnAddedColumnAppearsOnlyWhereItHasAValue() async throws { let source = try await source("{\"id\":1}\n{\"id\":2}\n") var table = TabularTable(source: source, usesFirstRowAsHeader: false) let added = table.insertColumn(named: "note", at: 1) @@ -53,42 +53,42 @@ final class TabularTableJSONOutputTests: XCTestCase { let objects = output.split(separator: "\n").map { line in try? JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: AnyHashable] } - XCTAssertEqual(objects[0], ["id": 1]) - XCTAssertEqual(objects[1], ["id": 2, "note": "hi"]) + #expect(objects[0] == ["id": 1]) + #expect(objects[1] == ["id": 2, "note": "hi"]) } - func testAnInsertedRowIsWrittenAsANewObject() async throws { + @Test func testAnInsertedRowIsWrittenAsANewObject() async throws { let source = try await source("{\"id\":1,\"name\":\"Ann\"}\n") var table = TabularTable(source: source, usesFirstRowAsHeader: false) _ = table.insertRows([[TabularCell(kind: .number, text: "2"), .missing]], at: 1) let output = try written(table, source: source) let lines = output.split(separator: "\n").map(String.init) - XCTAssertEqual(lines.first, "{\"id\":1,\"name\":\"Ann\"}") - let inserted = try XCTUnwrap(try JSONSerialization.jsonObject(with: Data(lines[1].utf8)) as? [String: AnyHashable]) - XCTAssertEqual(inserted, ["id": 2]) + #expect(lines.first == "{\"id\":1,\"name\":\"Ann\"}") + let inserted = try #require(try JSONSerialization.jsonObject(with: Data(lines[1].utf8)) as? [String: AnyHashable]) + #expect(inserted == ["id": 2]) } - func testAnInvalidObjectEditStopsTheWriteAndSaysWhere() async throws { + @Test func testAnInvalidObjectEditStopsTheWriteAndSaysWhere() async throws { let source = try await source("{\"meta\":{\"a\":1}}\n{\"meta\":{}}\n") var table = TabularTable(source: source, usesFirstRowAsHeader: false) let column = table.columns[0].id table.setCells([(key: 1, columnID: column, cell: TabularCell(kind: .object, text: "{broken"))]) let rows = table.jsonOutputRows(sourceKeys: source.keys, literal: typedLiteral) _ = try JSONTableWriter(source: source).encoded(rows: rows) - XCTAssertEqual(rows.status.failure?.key, 1) - XCTAssertEqual(rows.status.failure?.column, column) + #expect(rows.status.failure?.key == 1) + #expect(rows.status.failure?.column == column) } - func testRowsWithoutASourceAreAllNewObjects() async throws { + @Test func testRowsWithoutASourceAreAllNewObjects() async throws { let delimited = try await TabularTableTests.delimitedSource("a,b\n1,x\n") let table = TabularTable(source: delimited, usesFirstRowAsHeader: true) let rows = table.jsonOutputRows(sourceKeys: nil) { cell, _ in JSONText.stringLiteral(cell.text) } - let output = try XCTUnwrap(String(bytes: try JSONTableWriter(shape: .array).encoded(rows: rows), encoding: .utf8)) - let parsed = try XCTUnwrap(try JSONSerialization.jsonObject(with: Data(output.utf8)) as? [[String: String]]) - XCTAssertEqual(parsed, [["a": "1", "b": "x"]]) + let output = try #require(String(bytes: try JSONTableWriter(shape: .array).encoded(rows: rows), encoding: .utf8)) + let parsed = try #require(try JSONSerialization.jsonObject(with: Data(output.utf8)) as? [[String: String]]) + #expect(parsed == [["a": "1", "b": "x"]]) } - func testReplaceAllKeepsAJSONNumberANumber() async throws { + @Test func testReplaceAllKeepsAJSONNumberANumber() async throws { let source = try await source("{\"id\":1,\"name\":\"a1\"}\n") let table = TabularTable(source: source, usesFirstRowAsHeader: false) let query = TabularFindQuery( @@ -103,6 +103,6 @@ final class TabularTableJSONOutputTests: XCTestCase { for (id, values) in result.values { updated.replaceValues(of: id, with: values) } - XCTAssertEqual(try written(updated, source: source), "{\"id\":10,\"name\":\"a10\"}\n") + #expect(try written(updated, source: source) == "{\"id\":10,\"name\":\"a10\"}\n") } } diff --git a/Packages/TableProCore/Tests/TableProTabularTests/TabularTableJSONSourceTests.swift b/Packages/TableProCore/Tests/TableProTabularTests/TabularTableJSONSourceTests.swift index 905c6f5e5a..f22ddfa61b 100644 --- a/Packages/TableProCore/Tests/TableProTabularTests/TabularTableJSONSourceTests.swift +++ b/Packages/TableProCore/Tests/TableProTabularTests/TabularTableJSONSourceTests.swift @@ -1,23 +1,23 @@ import Foundation @testable import TableProTabular import TableProTabularIO -import XCTest +import Testing -final class TabularTableJSONSourceTests: XCTestCase { +struct TabularTableJSONSourceTests { private func table(_ text: String) async throws -> TabularTable { let source = try await JSONSourceBuilder.build(bytes: Data(text.utf8), fileKind: .jsonLines) return TabularTable(source: source, usesFirstRowAsHeader: true) } - func testKeysNameTheColumnsAndNoRowIsTakenAsAHeader() async throws { + @Test func testKeysNameTheColumnsAndNoRowIsTakenAsAHeader() async throws { let table = try await table("{\"id\":1,\"name\":\"Ann\"}\n{\"id\":2,\"city\":\"Hue\"}\n") - XCTAssertNil(table.headerRowKey) - XCTAssertEqual(table.columns.map(\.name), ["id", "name", "city"]) - XCTAssertEqual(table.rowCount, 2) - XCTAssertEqual(table.cells(row: 1), [TabularCell(kind: .number, text: "2"), .missing, .text("Hue")]) + #expect(table.headerRowKey == nil) + #expect(table.columns.map(\.name) == ["id", "name", "city"]) + #expect(table.rowCount == 2) + #expect(table.cells(row: 1) == [TabularCell(kind: .number, text: "2"), .missing, .text("Hue")]) } - func testScanCarriesKindsAndInsertedCellsReadAsMissing() async throws { + @Test func testScanCarriesKindsAndInsertedCellsReadAsMissing() async throws { var table = try await table("{\"a\":null,\"b\":true}\n{\"a\":\"x\"}\n") table.insertRows([[.text("new")]], at: 2) var seen: [[TabularCellKind]] = [] @@ -25,6 +25,6 @@ final class TabularTableJSONSourceTests: XCTestCase { seen.append(cells.kinds) return true } - XCTAssertEqual(seen, [[.null, .boolean], [.text, .missing], [.text, .missing]]) + #expect(seen == [[.null, .boolean], [.text, .missing], [.text, .missing]]) } } diff --git a/Packages/TableProCore/Tests/TableProTabularTests/TabularTableTests.swift b/Packages/TableProCore/Tests/TableProTabularTests/TabularTableTests.swift index 3038b33eac..a591506456 100644 --- a/Packages/TableProCore/Tests/TableProTabularTests/TabularTableTests.swift +++ b/Packages/TableProCore/Tests/TableProTabularTests/TabularTableTests.swift @@ -1,9 +1,9 @@ import Foundation @testable import TableProTabular import TableProTabularIO -import XCTest +import Testing -final class TabularTableTests: XCTestCase { +struct TabularTableTests { static func delimitedSource(_ text: String, dialect: DelimitedDialect = DelimitedDialect()) async throws -> DelimitedSource { try await DelimitedSourceBuilder.build( bytes: Data(text.utf8), @@ -13,25 +13,25 @@ final class TabularTableTests: XCTestCase { ) } - func testHeaderRowNamesColumnsAndIsNotAData() async throws { + @Test func testHeaderRowNamesColumnsAndIsNotAData() async throws { let source = try await Self.delimitedSource("name,age\nAlice,30\nBob,41\n") let table = TabularTable(source: source, usesFirstRowAsHeader: true) - XCTAssertEqual(table.columns.map(\.name), ["name", "age"]) - XCTAssertEqual(table.rowCount, 2) - XCTAssertEqual(table.cells(row: 1).map(\.text), ["Bob", "41"]) + #expect(table.columns.map(\.name) == ["name", "age"]) + #expect(table.rowCount == 2) + #expect(table.cells(row: 1).map(\.text) == ["Bob", "41"]) } - func testRaggedRowsWidenTheTable() async throws { + @Test func testRaggedRowsWidenTheTable() async throws { let source = try await Self.delimitedSource("name,age\nAlice,30,extra\nBob,41\n") let table = TabularTable(source: source, usesFirstRowAsHeader: true) - XCTAssertEqual(table.columnCount, 3) - XCTAssertEqual(table.columns.map(\.name), ["name", "age", ""]) - XCTAssertEqual(table.cells(row: 0).map(\.text), ["Alice", "30", "extra"]) - XCTAssertEqual(table.cells(row: 1).map(\.text), ["Bob", "41", ""]) - XCTAssertEqual(source.raggedRowCount, 1) + #expect(table.columnCount == 3) + #expect(table.columns.map(\.name) == ["name", "age", ""]) + #expect(table.cells(row: 0).map(\.text) == ["Alice", "30", "extra"]) + #expect(table.cells(row: 1).map(\.text) == ["Bob", "41", ""]) + #expect(source.raggedRowCount == 1) } - func testEditsOverrideTheSourceAndScanSeesThem() async throws { + @Test func testEditsOverrideTheSourceAndScanSeesThem() async throws { let source = try await Self.delimitedSource("a,b\n1,2\n3,4\n") var table = TabularTable(source: source, usesFirstRowAsHeader: true) table.setCell(.text("9"), row: 1, column: 1) @@ -40,10 +40,10 @@ final class TabularTableTests: XCTestCase { seen.append((0..] = [] + private var arrivals = 0 + private var arrivalWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + + public init() {} + + public func open() { + isOpen = true + let pending = waiters + waiters = [] + pending.forEach { $0.resume() } + } + + public func wait() async { + arrivals += 1 + let ready = arrivalWaiters.filter { $0.count <= arrivals } + arrivalWaiters.removeAll { $0.count <= arrivals } + ready.forEach { $0.continuation.resume() } + guard !isOpen else { return } + await withCheckedContinuation { waiters.append($0) } + } + + /// Returns once `count` callers have reached `wait()`, open or not. + public func arrived(_ count: Int) async { + guard arrivals < count else { return } + await withCheckedContinuation { arrivalWaiters.append((count, $0)) } + } +} diff --git a/Packages/TableProCore/Tests/TableProTestSupport/ManualClock.swift b/Packages/TableProCore/Tests/TableProTestSupport/ManualClock.swift new file mode 100644 index 0000000000..b91cbed619 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProTestSupport/ManualClock.swift @@ -0,0 +1,117 @@ +import Foundation + +/// Time moves only when a case advances it, so no case races the wall clock or a busy main actor. +public final class ManualClock: Clock, @unchecked Sendable { + public struct Instant: InstantProtocol { + public let offset: Duration + + public init(offset: Duration) { + self.offset = offset + } + + public func advanced(by duration: Duration) -> Instant { + Instant(offset: offset + duration) + } + + public func duration(to other: Instant) -> Duration { + other.offset - offset + } + + public static func < (lhs: Instant, rhs: Instant) -> Bool { + lhs.offset < rhs.offset + } + } + + private struct Sleeper { + let id: UUID + let deadline: Instant + let continuation: CheckedContinuation + } + + private let lock = NSLock() + private var current = Instant(offset: .zero) + private var sleepers: [Sleeper] = [] + private var sleeperWaiters: [CheckedContinuation] = [] + + public init() {} + + public var now: Instant { + lock.withLock { current } + } + + public var minimumResolution: Duration { + .zero + } + + public var pendingSleeperCount: Int { + lock.withLock { sleepers.count } + } + + public func sleep(until deadline: Instant, tolerance: Duration?) async throws { + let id = UUID() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + enqueue(Sleeper(id: id, deadline: deadline, continuation: continuation)) + } + } onCancel: { + removeSleeper(id: id)?.continuation.resume(throwing: CancellationError()) + } + } + + public func waitUntilSleeping() async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + let isSleeping = lock.withLock { + guard sleepers.isEmpty else { return true } + sleeperWaiters.append(continuation) + return false + } + if isSleeping { continuation.resume() } + } + } + + public func advance(by duration: Duration) { + let due = lock.withLock { + current = current.advanced(by: duration) + let reached = current + let woken = sleepers.filter { $0.deadline <= reached } + sleepers.removeAll { $0.deadline <= reached } + return woken + } + for sleeper in due { + sleeper.continuation.resume() + } + } + + private func enqueue(_ sleeper: Sleeper) { + enum Admission { + case wake + case cancel + case wait([CheckedContinuation]) + } + let admission: Admission = lock.withLock { + if Task.isCancelled { return .cancel } + if sleeper.deadline <= current { return .wake } + sleepers.append(sleeper) + let waiters = sleeperWaiters + sleeperWaiters.removeAll() + return .wait(waiters) + } + switch admission { + case .wake: + sleeper.continuation.resume() + case .cancel: + sleeper.continuation.resume(throwing: CancellationError()) + case .wait(let waiters): + for waiter in waiters { + waiter.resume() + } + } + } + + private func removeSleeper(id: UUID) -> Sleeper? { + lock.withLock { + guard let index = sleepers.firstIndex(where: { $0.id == id }) else { return nil } + return sleepers.remove(at: index) + } + } +} diff --git a/Packages/TableProCore/Tests/TableProTestSupport/WaitUntil.swift b/Packages/TableProCore/Tests/TableProTestSupport/WaitUntil.swift new file mode 100644 index 0000000000..5a91411811 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProTestSupport/WaitUntil.swift @@ -0,0 +1,23 @@ +import Foundation + +public struct WaitTimeout: Error, CustomStringConvertible { + public let description: String +} + +/// For an effect no event reports, such as a file appearing or a count another thread bumps. +/// It sleeps between checks, which frees the caller's actor; `Task.yield` gives no ordering. +nonisolated(nonsending) public func waitUntil( + timeout: Duration = .seconds(10), + fileID: String = #fileID, + line: Int = #line, + _ condition: nonisolated(nonsending) () async throws -> Bool +) async throws { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while try await !condition() { + guard clock.now < deadline else { + throw WaitTimeout(description: "\(fileID):\(line): condition still false after \(timeout)") + } + try await Task.sleep(for: .milliseconds(5)) + } +} diff --git a/Packages/TableProEditor/Package.swift b/Packages/TableProEditor/Package.swift index 7134aba618..8045b0db4b 100644 --- a/Packages/TableProEditor/Package.swift +++ b/Packages/TableProEditor/Package.swift @@ -11,6 +11,7 @@ let package = Package( ], dependencies: [ .package(path: "../TableProGrammars"), + .package(path: "../TableProCore"), .package(url: "https://github.com/ChimeHQ/TextStory", from: "0.9.0"), .package(url: "https://github.com/ChimeHQ/TextFormation", from: "0.8.2"), .package(url: "https://github.com/apple/swift-collections.git", .upToNextMajor(from: "1.0.0")) @@ -48,7 +49,8 @@ let package = Package( name: "TableProEditorKitTests", dependencies: [ "TableProEditorKit", - "TableProGrammars" + "TableProGrammars", + .product(name: "TableProTestSupport", package: "TableProCore") ], swiftSettings: [.swiftLanguageMode(.v5)] ) diff --git a/Packages/TableProEditor/Tests/TableProEditorKitTests/CaptureModifierSetTests.swift b/Packages/TableProEditor/Tests/TableProEditorKitTests/CaptureModifierSetTests.swift index 06c814bd5b..3d70aeab57 100644 --- a/Packages/TableProEditor/Tests/TableProEditorKitTests/CaptureModifierSetTests.swift +++ b/Packages/TableProEditor/Tests/TableProEditorKitTests/CaptureModifierSetTests.swift @@ -1,53 +1,55 @@ +import Foundation @testable import TableProEditorKit -import XCTest +import Testing -final class CaptureModifierSetTests: XCTestCase { - func test_init() { +@MainActor +struct CaptureModifierSetTests { + @Test func test_init() { // Init empty let set1 = CaptureModifierSet(rawValue: 0) - XCTAssertEqual(set1, []) - XCTAssertEqual(set1.values, []) + #expect(set1.isEmpty) + #expect(set1.values.isEmpty) // Init with multiple values let set2 = CaptureModifierSet(rawValue: 0b1101) - XCTAssertEqual(set2, [.declaration, .readonly, .static]) - XCTAssertEqual(set2.values, [.declaration, .readonly, .static]) + #expect(set2 == [.declaration, .readonly, .static]) + #expect(set2.values == [.declaration, .readonly, .static]) } - func test_insert() { + @Test func test_insert() { var set = CaptureModifierSet(rawValue: 0) - XCTAssertEqual(set, []) + #expect(set.isEmpty) // Insert one item set.insert(.declaration) - XCTAssertEqual(set, [.declaration]) + #expect(set == [.declaration]) // Inserting again does nothing set.insert(.declaration) - XCTAssertEqual(set, [.declaration]) + #expect(set == [.declaration]) // Insert more items set.insert(.declaration) set.insert(.async) set.insert(.documentation) - XCTAssertEqual(set, [.declaration, .async, .documentation]) + #expect(set == [.declaration, .async, .documentation]) // Order doesn't matter - XCTAssertEqual(set, [.async, .declaration, .documentation]) + #expect(set == [.async, .declaration, .documentation]) } - func test_values() { + @Test func test_values() { // Invalid rawValue returns non-garbage results var set = CaptureModifierSet([.declaration, .readonly, .static]) set.rawValue |= 1 << 48 // No real modifier with raw value 48, but we still have all the other values - XCTAssertEqual(set.values, [.declaration, .readonly, .static]) + #expect(set.values == [.declaration, .readonly, .static]) set = CaptureModifierSet() set.insert(.declaration) set.insert(.async) set.insert(.documentation) - XCTAssertEqual(set.values, [.declaration, .async, .documentation]) - XCTAssertNotEqual(set.values, [.declaration, .documentation, .async]) // Order matters + #expect(set.values == [.declaration, .async, .documentation]) + #expect(set.values != [.declaration, .documentation, .async]) // Order matters } } diff --git a/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeEditSourceEditorTests.swift b/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeEditSourceEditorTests.swift index 5e3aed95b9..3cbb21a42a 100644 --- a/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeEditSourceEditorTests.swift +++ b/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeEditSourceEditorTests.swift @@ -1,43 +1,39 @@ +import AppKit @testable import TableProEditorKit -import XCTest +import Testing // swiftlint:disable all -final class TableProEditorKitTests: XCTestCase { +@MainActor +struct TableProEditorKitTests { // MARK: NSFont Line Height - func test_LineHeight() throws { + @Test func test_LineHeight() throws { let font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular) let result = font.lineHeight let expected = 15.0 - XCTAssertEqual(result, expected) + #expect(result == expected) } - func test_LineHeight2() throws { + @Test func test_LineHeight2() throws { let font = NSFont.monospacedSystemFont(ofSize: 0, weight: .regular) let result = font.lineHeight let expected = 16.0 - XCTAssertEqual(result, expected) + #expect(result == expected) } // MARK: String NSRange - func test_StringSubscriptNSRange() throws { - let testString = "Hello, World" + @Test("An NSRange subscript reads the same text across a newline", arguments: [ + "Hello, World", + "Hello,\nWorld", + ]) + func test_StringSubscriptNSRange(_ testString: String) throws { let testRange = NSRange(location: 7, length: 5) let result = String(testString[testRange]!) let expected = "World" - XCTAssertEqual(result, expected) - } - - func test_StringSubscriptNSRange2() throws { - let testString = "Hello,\nWorld" - let testRange = NSRange(location: 7, length: 5) - - let result = String(testString[testRange]!) - let expected = "World" - XCTAssertEqual(result, expected) + #expect(result == expected) } } // swiftlint:enable all diff --git a/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeSuggestion/SuggestionApplyTests.swift b/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeSuggestion/SuggestionApplyTests.swift index 733a50f8f1..eb8e9c1209 100644 --- a/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeSuggestion/SuggestionApplyTests.swift +++ b/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeSuggestion/SuggestionApplyTests.swift @@ -1,10 +1,11 @@ import AppKit @testable import TableProEditorKit -import XCTest +import Testing -final class SuggestionApplyTests: XCTestCase { +@MainActor +struct SuggestionApplyTests { @MainActor - func test_apply_invokesOnApplyCallback() throws { + @Test func test_apply_invokesOnApplyCallback() throws { let model = SuggestionViewModel() let textViewController = Mock.textViewController(theme: Mock.theme()) let delegate = StubSuggestionDelegate() @@ -20,12 +21,12 @@ final class SuggestionApplyTests: XCTestCase { model.applySelectedItem(item: item) - XCTAssertEqual(onApplyCount, 1) - XCTAssertEqual(delegate.applyCallCount, 1) + #expect(onApplyCount == 1) + #expect(delegate.applyCallCount == 1) } @MainActor - func test_apply_ignoresTriggersFiredByTheInsertionItself() throws { + @Test func test_apply_ignoresTriggersFiredByTheInsertionItself() throws { let model = SuggestionViewModel() let textViewController = Mock.textViewController(theme: Mock.theme()) let delegate = StubSuggestionDelegate() @@ -39,7 +40,7 @@ final class SuggestionApplyTests: XCTestCase { var closeCount = 0 delegate.applyHook = { [weak model] in guard let model else { return } - XCTAssertTrue(model.isApplyingCompletion) + #expect(model.isApplyingCompletion) model.cursorsUpdated( textView: textViewController, delegate: delegate, @@ -54,26 +55,26 @@ final class SuggestionApplyTests: XCTestCase { model.applySelectedItem(item: item) - XCTAssertEqual(closeCount, 0) - XCTAssertNil(model.itemsRequestTask) - XCTAssertFalse(model.isApplyingCompletion) - XCTAssertEqual(model.items.map(\.label), ["users"]) + #expect(closeCount == 0) + #expect(model.itemsRequestTask == nil) + #expect(!model.isApplyingCompletion) + #expect(model.items.map(\.label) == ["users"]) } @MainActor - func test_willClose_notifiesDelegateOnce() throws { + @Test func test_willClose_notifiesDelegateOnce() throws { let model = SuggestionViewModel() let delegate = StubSuggestionDelegate() model.delegate = delegate model.willClose() - XCTAssertEqual(delegate.didCloseCount, 1) - XCTAssertNil(model.delegate) + #expect(delegate.didCloseCount == 1) + #expect(model.delegate == nil) } @MainActor - func test_apply_skipsCallbackWhenActiveTextViewIsNil() throws { + @Test func test_apply_skipsCallbackWhenActiveTextViewIsNil() throws { let model = SuggestionViewModel() let delegate = StubSuggestionDelegate() let item = StubSuggestionEntry(label: "users") @@ -86,12 +87,12 @@ final class SuggestionApplyTests: XCTestCase { model.applySelectedItem(item: item) - XCTAssertEqual(onApplyCount, 0) - XCTAssertEqual(delegate.applyCallCount, 0) + #expect(onApplyCount == 0) + #expect(delegate.applyCallCount == 0) } @MainActor - func test_nsWindowClose_clearsModelState() throws { + @Test func test_nsWindowClose_clearsModelState() throws { let controller = SuggestionController() let textViewController = Mock.textViewController(theme: Mock.theme()) @@ -102,12 +103,12 @@ final class SuggestionApplyTests: XCTestCase { controller.window?.close() - XCTAssertNil(controller.model.activeTextView) - XCTAssertTrue(controller.model.items.isEmpty) + #expect(controller.model.activeTextView == nil) + #expect(controller.model.items.isEmpty) } @MainActor - func test_controllerClose_clearsModelState() throws { + @Test func test_controllerClose_clearsModelState() throws { let controller = SuggestionController() let textViewController = Mock.textViewController(theme: Mock.theme()) @@ -118,12 +119,12 @@ final class SuggestionApplyTests: XCTestCase { controller.close() - XCTAssertNil(controller.model.activeTextView) - XCTAssertTrue(controller.model.items.isEmpty) + #expect(controller.model.activeTextView == nil) + #expect(controller.model.items.isEmpty) } @MainActor - func test_onBackgroundTap_closesControllerAndClearsModelState() throws { + @Test func test_onBackgroundTap_closesControllerAndClearsModelState() throws { let controller = SuggestionController() let textViewController = Mock.textViewController(theme: Mock.theme()) @@ -134,8 +135,8 @@ final class SuggestionApplyTests: XCTestCase { controller.model.onBackgroundTap?() - XCTAssertNil(controller.model.activeTextView) - XCTAssertTrue(controller.model.items.isEmpty) + #expect(controller.model.activeTextView == nil) + #expect(controller.model.items.isEmpty) } } diff --git a/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeSuggestion/SuggestionCursorsUpdatedTests.swift b/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeSuggestion/SuggestionCursorsUpdatedTests.swift index dec54ccd3c..15d8b7f50b 100644 --- a/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeSuggestion/SuggestionCursorsUpdatedTests.swift +++ b/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeSuggestion/SuggestionCursorsUpdatedTests.swift @@ -1,10 +1,11 @@ import AppKit @testable import TableProEditorKit -import XCTest +import Testing -final class SuggestionCursorsUpdatedTests: XCTestCase { +@MainActor +struct SuggestionCursorsUpdatedTests { @MainActor - func test_cursorsUpdated_keepsWindowOpenWhileRequestIsInFlight() throws { + @Test func test_cursorsUpdated_keepsWindowOpenWhileRequestIsInFlight() throws { let model = SuggestionViewModel() let textViewController = Mock.textViewController(theme: Mock.theme()) let delegate = FilteringStubDelegate(itemsOnCursorMove: nil) @@ -23,12 +24,12 @@ final class SuggestionCursorsUpdatedTests: XCTestCase { position: CursorPosition(range: NSRange(location: 1, length: 0)) ) { closeCount += 1 } - XCTAssertEqual(closeCount, 0) - XCTAssertNotNil(model.itemsRequestTask) + #expect(closeCount == 0) + #expect(model.itemsRequestTask != nil) } @MainActor - func test_cursorsUpdated_filtersItemsInPlaceWhenDelegateProvidesThem() throws { + @Test func test_cursorsUpdated_filtersItemsInPlaceWhenDelegateProvidesThem() throws { let model = SuggestionViewModel() let textViewController = Mock.textViewController(theme: Mock.theme()) let filtered: [CodeSuggestionEntry] = [StubSuggestionEntry(label: "SELECT"), StubSuggestionEntry(label: "SET")] @@ -47,13 +48,13 @@ final class SuggestionCursorsUpdatedTests: XCTestCase { position: CursorPosition(range: NSRange(location: 2, length: 0)) ) { closeCount += 1 } - XCTAssertEqual(closeCount, 0) - XCTAssertEqual(model.items.map(\.label), ["SELECT", "SET"]) - XCTAssertEqual(model.selectedIndex, 0) + #expect(closeCount == 0) + #expect(model.items.map(\.label) == ["SELECT", "SET"]) + #expect(model.selectedIndex == 0) } @MainActor - func test_cursorsUpdated_closesWhenNoItemsAndNoRequestInFlight() throws { + @Test func test_cursorsUpdated_closesWhenNoItemsAndNoRequestInFlight() throws { let model = SuggestionViewModel() let textViewController = Mock.textViewController(theme: Mock.theme()) let delegate = FilteringStubDelegate(itemsOnCursorMove: nil) @@ -70,11 +71,11 @@ final class SuggestionCursorsUpdatedTests: XCTestCase { position: CursorPosition(range: NSRange(location: 0, length: 0)) ) { closeCount += 1 } - XCTAssertEqual(closeCount, 1) + #expect(closeCount == 1) } @MainActor - func test_cursorsUpdated_cancelsRequestAndClosesForDifferentTextView() throws { + @Test func test_cursorsUpdated_cancelsRequestAndClosesForDifferentTextView() throws { let model = SuggestionViewModel() let activeController = Mock.textViewController(theme: Mock.theme()) let otherController = Mock.textViewController(theme: Mock.theme()) @@ -91,8 +92,8 @@ final class SuggestionCursorsUpdatedTests: XCTestCase { position: CursorPosition(range: NSRange(location: 0, length: 0)) ) { closeCount += 1 } - XCTAssertEqual(closeCount, 1) - XCTAssertNil(model.itemsRequestTask) + #expect(closeCount == 1) + #expect(model.itemsRequestTask == nil) } } diff --git a/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeSuggestion/SuggestionPanelFocusTests.swift b/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeSuggestion/SuggestionPanelFocusTests.swift index 770b45a339..2573fdf48b 100644 --- a/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeSuggestion/SuggestionPanelFocusTests.swift +++ b/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeSuggestion/SuggestionPanelFocusTests.swift @@ -1,38 +1,40 @@ import AppKit import SwiftUI @testable import TableProEditorKit -import XCTest +import TableProTestSupport +import Testing -final class SuggestionPanelFocusTests: XCTestCase { +@MainActor +struct SuggestionPanelFocusTests { @MainActor - func test_panel_neverAcceptsKeyOrMainStatus() throws { + @Test func test_panel_neverAcceptsKeyOrMainStatus() throws { let controller = SuggestionController() - let window = try XCTUnwrap(controller.window) + let window = try #require(controller.window) - XCTAssertFalse(window.canBecomeKey) - XCTAssertFalse(window.canBecomeMain) - XCTAssertTrue(window.styleMask.contains(.nonactivatingPanel)) - XCTAssertEqual((window as? NSPanel)?.becomesKeyOnlyIfNeeded, true) - XCTAssertEqual(window.tabbingMode, .disallowed) + #expect(!window.canBecomeKey) + #expect(!window.canBecomeMain) + #expect(window.styleMask.contains(.nonactivatingPanel)) + #expect((window as? NSPanel)?.becomesKeyOnlyIfNeeded == true) + #expect(window.tabbingMode == .disallowed) } @MainActor - func test_contentViewHierarchy_containsNoFocusableTableView() throws { + @Test func test_contentViewHierarchy_containsNoFocusableTableView() async throws { let controller = SuggestionController() - let window = try XCTUnwrap(controller.window) + let window = try #require(controller.window) controller.model.items = [PanelStubEntry(label: "SELECT"), PanelStubEntry(label: "SET")] window.setContentSize(NSSize(width: 300, height: 200)) window.orderFrontRegardless() defer { window.close() } - RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.2)) + try await waitUntilMounted(window) - let contentView = try XCTUnwrap(window.contentView) - XCTAssertNil(firstFocusableTableView(in: contentView)) + let contentView = try #require(window.contentView) + #expect(firstFocusableTableView(in: contentView) == nil) } @MainActor - func test_showWindowAttachedTo_preservesParentFirstResponder() throws { + @Test func test_showWindowAttachedTo_preservesParentFirstResponder() async throws { let (parentWindow, textViewController) = Mock.windowedTextViewController(theme: Mock.theme()) let controller = SuggestionController() defer { @@ -41,22 +43,22 @@ final class SuggestionPanelFocusTests: XCTestCase { } parentWindow.orderFrontRegardless() - let textView = try XCTUnwrap(textViewController.textView) - XCTAssertTrue(parentWindow.makeFirstResponder(textView)) + let textView = try #require(textViewController.textView) + #expect(parentWindow.makeFirstResponder(textView)) controller.model.items = [PanelStubEntry(label: "SELECT")] controller.showWindow(attachedTo: parentWindow) - RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.2)) + try await waitUntilMounted(try #require(controller.window)) - XCTAssertIdentical(parentWindow.firstResponder, textView) + #expect(parentWindow.firstResponder === textView) } @MainActor - func test_showWindowAttachedTo_reattachesFromPreviousParent() throws { + @Test func test_showWindowAttachedTo_reattachesFromPreviousParent() throws { let (firstParent, _) = Mock.windowedTextViewController(theme: Mock.theme()) let (secondParent, _) = Mock.windowedTextViewController(theme: Mock.theme()) let controller = SuggestionController() - let window = try XCTUnwrap(controller.window) + let window = try #require(controller.window) defer { controller.close() firstParent.close() @@ -64,36 +66,51 @@ final class SuggestionPanelFocusTests: XCTestCase { } controller.showWindow(attachedTo: firstParent) - XCTAssertIdentical(window.parent, firstParent) + #expect(window.parent === firstParent) controller.showWindow(attachedTo: secondParent) - XCTAssertIdentical(window.parent, secondParent) - XCTAssertFalse(firstParent.childWindows?.contains(window) ?? false) + #expect(window.parent === secondParent) + #expect(!(firstParent.childWindows?.contains(window) ?? false)) } @MainActor - func test_close_detachesPanelFromParent() throws { + @Test func test_close_detachesPanelFromParent() throws { let (parentWindow, _) = Mock.windowedTextViewController(theme: Mock.theme()) let controller = SuggestionController() - let window = try XCTUnwrap(controller.window) + let window = try #require(controller.window) defer { parentWindow.close() } controller.showWindow(attachedTo: parentWindow) - XCTAssertIdentical(window.parent, parentWindow) + #expect(window.parent === parentWindow) controller.close() - XCTAssertNil(window.parent) - XCTAssertFalse(parentWindow.childWindows?.contains(window) ?? false) + #expect(window.parent == nil) + #expect(!(parentWindow.childWindows?.contains(window) ?? false)) + } + + // The background mounts in the same SwiftUI pass as the rows, so its view proves the rows are in the hierarchy. + @MainActor + private func waitUntilMounted(_ window: NSWindow) async throws { + window.contentView?.layoutSubtreeIfNeeded() + try await waitUntil { + guard let contentView = window.contentView else { return false } + return firstView(of: NSVisualEffectView.self, in: contentView) != nil + } } @MainActor private func firstFocusableTableView(in view: NSView) -> NSTableView? { - if let tableView = view as? NSTableView { - return tableView + firstView(of: NSTableView.self, in: view) + } + + @MainActor + private func firstView(of type: View.Type, in view: NSView) -> View? { + if let match = view as? View { + return match } for subview in view.subviews { - if let found = firstFocusableTableView(in: subview) { + if let found = firstView(of: type, in: subview) { return found } } diff --git a/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeSuggestion/SuggestionPanelNativeColorTests.swift b/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeSuggestion/SuggestionPanelNativeColorTests.swift index 53a8b434f6..8526eda05d 100644 --- a/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeSuggestion/SuggestionPanelNativeColorTests.swift +++ b/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeSuggestion/SuggestionPanelNativeColorTests.swift @@ -5,6 +5,7 @@ import AppKit @testable import TableProEditorKit +import TableProTestSupport import Testing /// The panel used to build its background by scaling the editor background's red, green and blue components, which @@ -32,14 +33,15 @@ struct SuggestionPanelNativeColorTests { } @Test("The panel draws a menu material behind its rows") - func panelDrawsAMenuMaterial() throws { + func panelDrawsAMenuMaterial() async throws { let controller = SuggestionController() let window = try #require(controller.window) controller.model.items = [StubSuggestionEntry(label: "SELECT")] window.setContentSize(NSSize(width: 300, height: 200)) window.orderFrontRegardless() defer { window.close() } - RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.2)) + window.contentView?.layoutSubtreeIfNeeded() + try await waitUntil { visualEffectView(in: window.contentView) != nil } let background = try #require(visualEffectView(in: window.contentView)) #expect(background.material == .menu) diff --git a/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeSuggestion/SuggestionSessionLifetimeTests.swift b/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeSuggestion/SuggestionSessionLifetimeTests.swift index 830a302a61..b5ec834b28 100644 --- a/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeSuggestion/SuggestionSessionLifetimeTests.swift +++ b/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeSuggestion/SuggestionSessionLifetimeTests.swift @@ -1,10 +1,12 @@ import AppKit @testable import TableProEditorKit -import XCTest +import TableProTestSupport +import Testing -final class SuggestionSessionLifetimeTests: XCTestCase { +@MainActor +struct SuggestionSessionLifetimeTests { @MainActor - func test_showCompletions_endsSessionWhenDelegateReturnsNilItems() async throws { + @Test func test_showCompletions_endsSessionWhenDelegateReturnsNilItems() async throws { let (window, textViewController) = Mock.windowedTextViewController(theme: Mock.theme()) defer { window.close() } window.orderFrontRegardless() @@ -20,22 +22,23 @@ final class SuggestionSessionLifetimeTests: XCTestCase { await model.itemsRequestTask?.value - XCTAssertNil(model.activeTextView) - XCTAssertNil(model.delegate) - XCTAssertFalse(model.isPresented) - XCTAssertEqual(delegate.didCloseCount, 1) + #expect(model.activeTextView == nil) + #expect(model.delegate == nil) + #expect(!model.isPresented) + #expect(delegate.didCloseCount == 1) } @MainActor - func test_showCompletions_endsSessionWhenFirstResponderChangedDuringFetch() async throws { + @Test func test_showCompletions_endsSessionWhenFirstResponderChangedDuringFetch() async throws { let (window, textViewController) = Mock.windowedTextViewController(theme: Mock.theme()) defer { window.close() } window.orderFrontRegardless() - let textView = try XCTUnwrap(textViewController.textView) - XCTAssertTrue(window.makeFirstResponder(textView)) + let textView = try #require(textViewController.textView) + #expect(window.makeFirstResponder(textView)) let model = SuggestionViewModel() - let delegate = LifetimeStubDelegate(items: [StubSuggestionEntry(label: "SELECT")], sleepMilliseconds: 20) + let fetch = AsyncGate() + let delegate = LifetimeStubDelegate(items: [StubSuggestionEntry(label: "SELECT")], fetch: fetch) var presentationCount = 0 model.showCompletions( @@ -44,14 +47,16 @@ final class SuggestionSessionLifetimeTests: XCTestCase { cursorPosition: CursorPosition(range: NSRange(location: 0, length: 0)) ) { _, _ in presentationCount += 1 } + await fetch.arrived(1) window.makeFirstResponder(nil) + await fetch.open() await model.itemsRequestTask?.value - XCTAssertEqual(presentationCount, 0) - XCTAssertNil(model.activeTextView) - XCTAssertNil(model.delegate) - XCTAssertFalse(model.isPresented) - XCTAssertEqual(delegate.didCloseCount, 1) + #expect(presentationCount == 0) + #expect(model.activeTextView == nil) + #expect(model.delegate == nil) + #expect(!model.isPresented) + #expect(delegate.didCloseCount == 1) } /// A session that never got a window must not absorb the keystroke that could open one. @@ -59,7 +64,7 @@ final class SuggestionSessionLifetimeTests: XCTestCase { /// every later prefix the stale candidates could still rank updated an invisible panel, so /// the popup never came back. @MainActor - func test_cursorsUpdated_presentsRatherThanFilingItemsIntoAWindowThatIsNotShown() throws { + @Test func test_cursorsUpdated_presentsRatherThanFilingItemsIntoAWindowThatIsNotShown() throws { let model = SuggestionViewModel() let textViewController = Mock.textViewController(theme: Mock.theme()) let delegate = LifetimeStubDelegate(items: nil, cursorMoveItems: [StubSuggestionEntry(label: "SELECT")]) @@ -76,52 +81,56 @@ final class SuggestionSessionLifetimeTests: XCTestCase { position: CursorPosition(range: NSRange(location: 1, length: 0)) ) { closeCount += 1 } - XCTAssertEqual(closeCount, 1) - XCTAssertTrue(model.items.isEmpty) + #expect(closeCount == 1) + #expect(model.items.isEmpty) } @MainActor - func test_showCompletions_supersededRequestLeavesTheLiveRequestHandleAlone() async throws { + @Test func test_showCompletions_supersededRequestLeavesTheLiveRequestHandleAlone() async throws { let (window, textViewController) = Mock.windowedTextViewController(theme: Mock.theme()) defer { window.close() } window.orderFrontRegardless() let model = SuggestionViewModel() - let slow = LifetimeStubDelegate(items: nil, sleepMilliseconds: 200) - let fast = LifetimeStubDelegate(items: nil, sleepMilliseconds: 400) + let slowFetch = AsyncGate() + let liveFetch = AsyncGate() + let slow = LifetimeStubDelegate(items: nil, fetch: slowFetch) + let fast = LifetimeStubDelegate(items: nil, fetch: liveFetch) model.showCompletions( textView: textViewController, delegate: slow, cursorPosition: CursorPosition(range: NSRange(location: 0, length: 0)) ) { _, _ in } - let superseded = try XCTUnwrap(model.itemsRequestTask) + let superseded = try #require(model.itemsRequestTask) - try await Task.sleep(for: .milliseconds(20)) + await slowFetch.arrived(1) model.showCompletions( textView: textViewController, delegate: fast, cursorPosition: CursorPosition(range: NSRange(location: 0, length: 0)) ) { _, _ in } - let live = try XCTUnwrap(model.itemsRequestTask) + let live = try #require(model.itemsRequestTask) + await slowFetch.open() await superseded.value - XCTAssertNotNil(model.itemsRequestTask) - XCTAssertFalse(live.isCancelled) + #expect(model.itemsRequestTask != nil) + #expect(!live.isCancelled) model.willClose() + await liveFetch.open() } /// `completionWindowDidSelect` is public and fires synchronously from inside the presentation, /// so a conformer may dismiss from it. The panel must not go up anyway over a session that no /// longer exists. @MainActor - func test_showCompletions_doesNotPresentWhenTheSelectionCallbackDismissedTheSession() async throws { + @Test func test_showCompletions_doesNotPresentWhenTheSelectionCallbackDismissedTheSession() async throws { let (window, textViewController) = Mock.keyWindowedTextViewController() defer { window.close() } - XCTAssertIdentical(window.firstResponder, textViewController.textView) + #expect(window.firstResponder === textViewController.textView) let model = SuggestionViewModel() let delegate = LifetimeStubDelegate(items: [StubSuggestionEntry(label: "SELECT")]) @@ -136,16 +145,16 @@ final class SuggestionSessionLifetimeTests: XCTestCase { await model.itemsRequestTask?.value - XCTAssertEqual(presentationCount, 0) - XCTAssertFalse(model.isPresented) - XCTAssertNil(model.activeTextView) + #expect(presentationCount == 0) + #expect(!model.isPresented) + #expect(model.activeTextView == nil) } /// A shown panel posts `willCloseNotification` from inside `super.close()`, so the explicit /// call behind it must not run a second teardown over whatever that one's delegate callback /// started. @MainActor - func test_controllerClose_cleansUpOncePerCloseForAShownPanel() throws { + @Test func test_controllerClose_cleansUpOncePerCloseForAShownPanel() throws { let controller = SuggestionController() let textViewController = Mock.textViewController(theme: Mock.theme()) let delegate = LifetimeStubDelegate(items: nil) @@ -157,13 +166,13 @@ final class SuggestionSessionLifetimeTests: XCTestCase { controller.close() - XCTAssertEqual(delegate.didCloseCount, 1) - XCTAssertNil(controller.model.activeTextView) - XCTAssertFalse(controller.model.isPresented) + #expect(delegate.didCloseCount == 1) + #expect(controller.model.activeTextView == nil) + #expect(!controller.model.isPresented) } @MainActor - func test_controllerClose_endsTheSessionEvenWhenTheWindowWasNeverShown() throws { + @Test func test_controllerClose_endsTheSessionEvenWhenTheWindowWasNeverShown() throws { let controller = SuggestionController() let textViewController = Mock.textViewController(theme: Mock.theme()) let delegate = LifetimeStubDelegate(items: nil) @@ -174,18 +183,18 @@ final class SuggestionSessionLifetimeTests: XCTestCase { controller.close() - XCTAssertNil(controller.model.activeTextView) - XCTAssertNil(controller.model.delegate) - XCTAssertFalse(controller.model.isPresented) - XCTAssertEqual(delegate.didCloseCount, 1) + #expect(controller.model.activeTextView == nil) + #expect(controller.model.delegate == nil) + #expect(!controller.model.isPresented) + #expect(delegate.didCloseCount == 1) controller.model.activeTextView = textViewController controller.model.delegate = delegate controller.close() - XCTAssertNil(controller.model.activeTextView) - XCTAssertEqual(delegate.didCloseCount, 2) + #expect(controller.model.activeTextView == nil) + #expect(delegate.didCloseCount == 2) } } @@ -193,18 +202,18 @@ final class SuggestionSessionLifetimeTests: XCTestCase { private final class LifetimeStubDelegate: CodeSuggestionDelegate { private let items: [CodeSuggestionEntry]? private let cursorMoveItems: [CodeSuggestionEntry]? - private let sleepMilliseconds: Int + private let fetch: AsyncGate? private(set) var didCloseCount = 0 var onSelect: (() -> Void)? init( items: [CodeSuggestionEntry]?, cursorMoveItems: [CodeSuggestionEntry]? = nil, - sleepMilliseconds: Int = 0 + fetch: AsyncGate? = nil ) { self.items = items self.cursorMoveItems = cursorMoveItems - self.sleepMilliseconds = sleepMilliseconds + self.fetch = fetch } func completionSuggestionsRequested( @@ -212,13 +221,7 @@ private final class LifetimeStubDelegate: CodeSuggestionDelegate { cursorPosition: CursorPosition, isManualTrigger: Bool ) async -> CodeSuggestionResponse? { - if sleepMilliseconds > 0 { - do { - try await Task.sleep(for: .milliseconds(sleepMilliseconds)) - } catch { - return nil - } - } + await fetch?.wait() guard let items else { return nil } return .answeringAnEmptyPrefix(items, at: cursorPosition) } diff --git a/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeSuggestion/SuggestionShowCompletionsGuardTests.swift b/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeSuggestion/SuggestionShowCompletionsGuardTests.swift index c4e54f52b6..1d4a90533c 100644 --- a/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeSuggestion/SuggestionShowCompletionsGuardTests.swift +++ b/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeSuggestion/SuggestionShowCompletionsGuardTests.swift @@ -1,5 +1,6 @@ import AppKit @testable import TableProEditorKit +import TableProTestSupport import XCTest final class SuggestionShowCompletionsGuardTests: XCTestCase { @@ -12,7 +13,8 @@ final class SuggestionShowCompletionsGuardTests: XCTestCase { XCTAssertTrue(window.makeFirstResponder(textView)) let model = SuggestionViewModel() - let delegate = DelayedStubDelegate(items: [StubSuggestionEntry(label: "SELECT")]) + let fetch = AsyncGate() + let delegate = DelayedStubDelegate(items: [StubSuggestionEntry(label: "SELECT")], fetch: fetch) var presentationCount = 0 model.showCompletions( @@ -23,7 +25,9 @@ final class SuggestionShowCompletionsGuardTests: XCTestCase { presentationCount += 1 } + await fetch.arrived(1) window.makeFirstResponder(nil) + await fetch.open() await model.itemsRequestTask?.value XCTAssertEqual(presentationCount, 0) @@ -34,8 +38,7 @@ final class SuggestionShowCompletionsGuardTests: XCTestCase { let (window, textViewController) = Mock.windowedTextViewController(theme: Mock.theme()) defer { window.close() } window.makeKeyAndOrderFront(nil) - RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.1)) - guard window.isKeyWindow else { + guard (try? await waitUntil(timeout: .milliseconds(100)) { window.isKeyWindow }) != nil else { throw XCTSkip("Window cannot become key in this environment") } @@ -43,7 +46,9 @@ final class SuggestionShowCompletionsGuardTests: XCTestCase { XCTAssertTrue(window.makeFirstResponder(textView)) let model = SuggestionViewModel() - let delegate = DelayedStubDelegate(items: [StubSuggestionEntry(label: "SELECT")]) + let fetch = AsyncGate() + await fetch.open() + let delegate = DelayedStubDelegate(items: [StubSuggestionEntry(label: "SELECT")], fetch: fetch) var presentedWindows: [NSWindow] = [] model.showCompletions( @@ -64,9 +69,11 @@ final class SuggestionShowCompletionsGuardTests: XCTestCase { @MainActor private final class DelayedStubDelegate: CodeSuggestionDelegate { let items: [CodeSuggestionEntry] + private let fetch: AsyncGate - init(items: [CodeSuggestionEntry]) { + init(items: [CodeSuggestionEntry], fetch: AsyncGate) { self.items = items + self.fetch = fetch } func completionSuggestionsRequested( @@ -74,7 +81,7 @@ private final class DelayedStubDelegate: CodeSuggestionDelegate { cursorPosition: CursorPosition, isManualTrigger: Bool ) async -> CodeSuggestionResponse? { - try? await Task.sleep(for: .milliseconds(50)) + await fetch.wait() return .answeringAnEmptyPrefix(items, at: cursorPosition) } diff --git a/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeSuggestion/SuggestionWindowPlacementTests.swift b/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeSuggestion/SuggestionWindowPlacementTests.swift index 020db8a90d..21f87295e2 100644 --- a/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeSuggestion/SuggestionWindowPlacementTests.swift +++ b/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeSuggestion/SuggestionWindowPlacementTests.swift @@ -1,13 +1,14 @@ import AppKit @testable import TableProEditorKit -import XCTest +import Testing -final class SuggestionWindowPlacementTests: XCTestCase { +@MainActor +struct SuggestionWindowPlacementTests { private let screenFrame = NSRect(x: 0, y: 0, width: 1_440, height: 900) private let editorFrame = NSRect(x: 100, y: 150, width: 900, height: 600) private let font = NSFont.systemFont(ofSize: 12) - func test_compute_clampsCursorNearRightEdgeWithinEditorMaxX() { + @Test func test_compute_clampsCursorNearRightEdgeWithinEditorMaxX() { let cursorRect = NSRect(x: 980, y: 500, width: 6, height: 16) let windowSize = NSSize(width: 280, height: 200) @@ -16,11 +17,11 @@ final class SuggestionWindowPlacementTests: XCTestCase { screenFrame: screenFrame, editorFrame: editorFrame ) - XCTAssertGreaterThanOrEqual(placement.origin.x, editorFrame.minX) - XCTAssertLessThanOrEqual(placement.origin.x + windowSize.width, editorFrame.maxX) + #expect(placement.origin.x >= editorFrame.minX) + #expect(placement.origin.x + windowSize.width <= editorFrame.maxX) } - func test_compute_widthGrowthAcrossResizesStaysWithinEditorMaxX() { + @Test func test_compute_widthGrowthAcrossResizesStaysWithinEditorMaxX() { let cursorRect = NSRect(x: 900, y: 500, width: 6, height: 16) let narrow = SuggestionWindowPlacement.compute( @@ -32,11 +33,11 @@ final class SuggestionWindowPlacementTests: XCTestCase { screenFrame: screenFrame, editorFrame: editorFrame ) - XCTAssertLessThanOrEqual(narrow.origin.x + 100, editorFrame.maxX) - XCTAssertLessThanOrEqual(grown.origin.x + 280, editorFrame.maxX) + #expect(narrow.origin.x + 100 <= editorFrame.maxX) + #expect(grown.origin.x + 280 <= editorFrame.maxX) } - func test_compute_leftAlignsWhenWiderThanEditor() { + @Test func test_compute_leftAlignsWhenWiderThanEditor() { let narrowEditorFrame = NSRect(x: 100, y: 150, width: 200, height: 600) let cursorRect = NSRect(x: 200, y: 500, width: 6, height: 16) let windowSize = NSSize(width: 280, height: 200) @@ -46,10 +47,10 @@ final class SuggestionWindowPlacementTests: XCTestCase { screenFrame: screenFrame, editorFrame: narrowEditorFrame ) - XCTAssertEqual(placement.origin.x, narrowEditorFrame.minX + SuggestionWindowPlacement.edgePadding) + #expect(placement.origin.x == narrowEditorFrame.minX + SuggestionWindowPlacement.edgePadding) } - func test_compute_flipsAboveCursorWhenBelowScreenSpaceIsInsufficient() { + @Test func test_compute_flipsAboveCursorWhenBelowScreenSpaceIsInsufficient() { // Caret near the screen bottom: not enough room below on the screen, so the panel flips above. let cursorRect = NSRect(x: 300, y: 40, width: 6, height: 16) @@ -58,11 +59,11 @@ final class SuggestionWindowPlacementTests: XCTestCase { screenFrame: screenFrame, editorFrame: editorFrame ) - XCTAssertTrue(placement.isAboveCursor) - XCTAssertGreaterThanOrEqual(placement.origin.y, screenFrame.minY) + #expect(placement.isAboveCursor) + #expect(placement.origin.y >= screenFrame.minY) } - func test_compute_tallPanelNearEditorTopStaysBelowCaretAndOverflowsEditor() { + @Test func test_compute_tallPanelNearEditorTopStaysBelowCaretAndOverflowsEditor() { // The reported bug: a tall panel with the caret near the top of a short editor must drop below the caret // (overflowing the editor into the results area) instead of flipping up and covering the current line. let shortEditor = NSRect(x: 100, y: 300, width: 900, height: 300) @@ -73,14 +74,14 @@ final class SuggestionWindowPlacementTests: XCTestCase { screenFrame: screenFrame, editorFrame: shortEditor ) - XCTAssertFalse(placement.isAboveCursor) + #expect(!placement.isAboveCursor) // The panel's top sits at the caret's baseline, so the current line stays visible above it. - XCTAssertLessThanOrEqual(placement.origin.y + 500, caretNearEditorTop.origin.y) + #expect(placement.origin.y + 500 <= caretNearEditorTop.origin.y) // And it is allowed to overflow the editor's bottom, constrained only by the screen. - XCTAssertLessThan(placement.origin.y, shortEditor.minY) + #expect(placement.origin.y < shortEditor.minY) } - func test_compute_staysAnchoredBelowCaretEvenWithMoreRoomAbove() { + @Test func test_compute_staysAnchoredBelowCaretEvenWithMoreRoomAbove() { // Regression for the panel vanishing: with the caret lower on screen (more room above) but still enough // room below, the panel must stay anchored to the caret, not get pinned to the screen top. let cursorRect = NSRect(x: 300, y: 250, width: 6, height: 16) @@ -90,12 +91,12 @@ final class SuggestionWindowPlacementTests: XCTestCase { screenFrame: screenFrame, editorFrame: editorFrame ) - XCTAssertFalse(placement.isAboveCursor) + #expect(!placement.isAboveCursor) // The panel's top sits exactly at the caret's baseline, next to the caret, not up at the screen edge. - XCTAssertEqual(placement.origin.y + 500, cursorRect.origin.y, accuracy: 0.001) + #expect(abs((placement.origin.y + 500) - cursorRect.origin.y) <= 0.001) } - func test_compute_fallsBackToScreenFrameWhenEditorFrameIsNil() { + @Test func test_compute_fallsBackToScreenFrameWhenEditorFrameIsNil() { let cursorRect = NSRect(x: 1_300, y: 500, width: 6, height: 16) let windowSize = NSSize(width: 280, height: 200) @@ -104,6 +105,6 @@ final class SuggestionWindowPlacementTests: XCTestCase { screenFrame: screenFrame, editorFrame: nil ) - XCTAssertLessThanOrEqual(placement.origin.x + windowSize.width, screenFrame.maxX) + #expect(placement.origin.x + windowSize.width <= screenFrame.maxX) } } diff --git a/Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/PrecedingKeyDownClaimTests.swift b/Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/PrecedingKeyDownClaimTests.swift index 4f37a06afb..715680b510 100644 --- a/Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/PrecedingKeyDownClaimTests.swift +++ b/Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/PrecedingKeyDownClaimTests.swift @@ -21,8 +21,7 @@ private final class RecordingCoordinator: TextViewCoordinator { } } -/// Serialized because the claim is one static for every editor in the process. -@Suite("The app-wide claim runs ahead of every editor link", .serialized) +// Each case sets and clears the process-wide claim without suspending, so no other main-actor case can see it set. @MainActor internal struct PrecedingKeyDownClaimTests { @Test("A claimed key reaches neither the coordinators nor the text") diff --git a/Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/TextViewController+IndentTests.swift b/Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/TextViewController+IndentTests.swift index 7927768319..7c1bdf4054 100644 --- a/Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/TextViewController+IndentTests.swift +++ b/Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/TextViewController+IndentTests.swift @@ -1,3 +1,4 @@ +import Foundation // // TextViewController+IndentTests.swift // TableProEditorKit @@ -7,25 +8,26 @@ @testable import TableProEditorKit @testable import TableProTextEngine -import XCTest +import Testing -final class TextViewControllerIndentTests: XCTestCase { +@MainActor +final class TextViewControllerIndentTests { var controller: TextViewController! - override func setUpWithError() throws { + init() throws { controller = Mock.textViewController(theme: Mock.theme()) controller.loadView() } - func testHandleIndentWithSpacesInwards() { + @Test func testHandleIndentWithSpacesInwards() { controller.setText(" This is a test string") let cursorPositions = [CursorPosition(range: NSRange(location: 0, length: 0))] controller.cursorPositions = cursorPositions controller.textView.selectionManager.textSelections = [.init(range: NSRange(location: 0, length: 0))] controller.handleIndent(inwards: true) - XCTAssertEqual(controller.text, "This is a test string") + #expect(controller.text == "This is a test string") // Normally, 4 spaces are used for indentation; however, now we only insert 2 leading spaces. // The outcome should be the same, though. @@ -34,10 +36,10 @@ final class TextViewControllerIndentTests: XCTestCase { controller.textView.selectionManager.textSelections = [.init(range: NSRange(location: 0, length: 0))] controller.handleIndent(inwards: true) - XCTAssertEqual(controller.text, "This is a test string") + #expect(controller.text == "This is a test string") } - func testHandleIndentWithSpacesOutwards() { + @Test func testHandleIndentWithSpacesOutwards() { controller.setText("This is a test string") let cursorPositions = [CursorPosition(range: NSRange(location: 0, length: 0))] controller.textView.selectionManager.textSelections = [.init(range: NSRange(location: 0, length: 0))] @@ -45,10 +47,10 @@ final class TextViewControllerIndentTests: XCTestCase { controller.handleIndent(inwards: false) - XCTAssertEqual(controller.text, " This is a test string") + #expect(controller.text == " This is a test string") } - func testHandleIndentWithTabsInwards() { + @Test func testHandleIndentWithTabsInwards() { controller.setText("\tThis is a test string") controller.configuration.behavior .indentOption = .tab let cursorPositions = [CursorPosition(range: NSRange(location: 0, length: 0))] @@ -57,10 +59,10 @@ final class TextViewControllerIndentTests: XCTestCase { controller.handleIndent(inwards: true) - XCTAssertEqual(controller.text, "This is a test string") + #expect(controller.text == "This is a test string") } - func testHandleIndentWithTabsOutwards() { + @Test func testHandleIndentWithTabsOutwards() { controller.setText("This is a test string") controller.configuration.behavior.indentOption = .tab let cursorPositions = [CursorPosition(range: NSRange(location: 0, length: 0))] @@ -71,10 +73,10 @@ final class TextViewControllerIndentTests: XCTestCase { // Normally, we expect nothing to happen because only one line is selected. // However, this logic is not handled inside `handleIndent`. - XCTAssertEqual(controller.text, "\tThis is a test string") + #expect(controller.text == "\tThis is a test string") } - func testHandleIndentMultiLine() { + @Test func testHandleIndentMultiLine() { controller.configuration.behavior.indentOption = .tab let strings: [(NSString, Int)] = [ ("This is a test string\n", 0), @@ -94,10 +96,10 @@ final class TextViewControllerIndentTests: XCTestCase { controller.handleIndent() let expectedString = "\tThis is a test string\n\tWith multiple lines\n\tAnd some indentation" - XCTAssertEqual(controller.text, expectedString) + #expect(controller.text == expectedString) } - func testHandleInwardIndentMultiLine() { + @Test func testHandleInwardIndentMultiLine() { controller.configuration.behavior.indentOption = .tab let strings: [(NSString, NSRange)] = [ ("\tThis is a test string\n", NSRange(location: 0, length: 0)), @@ -117,19 +119,19 @@ final class TextViewControllerIndentTests: XCTestCase { controller.handleIndent(inwards: true) let expectedString = "This is a test string\nWith multiple lines\nAnd some indentation" - XCTAssertEqual(controller.text, expectedString) + #expect(controller.text == expectedString) } - func testMultipleLinesHighlighted() { + @Test func testMultipleLinesHighlighted() { controller.setText("\tThis is a test string\n\tWith multiple lines\n\tAnd some indentation") var cursorPositions = [CursorPosition(range: NSRange(location: 0, length: controller.text.count))] controller.cursorPositions = cursorPositions - XCTAssert(controller.multipleLinesHighlighted()) + #expect(controller.multipleLinesHighlighted()) cursorPositions = [CursorPosition(range: NSRange(location: 0, length: 5))] controller.cursorPositions = cursorPositions - XCTAssertFalse(controller.multipleLinesHighlighted()) + #expect(!controller.multipleLinesHighlighted()) } } diff --git a/Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/TextViewController+MoveLinesTests.swift b/Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/TextViewController+MoveLinesTests.swift index 8118b6e334..282cda6038 100644 --- a/Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/TextViewController+MoveLinesTests.swift +++ b/Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/TextViewController+MoveLinesTests.swift @@ -1,3 +1,4 @@ +import Foundation // // TextViewController+MoveLinesTests.swift // TableProEditorKit @@ -7,18 +8,19 @@ @testable import TableProEditorKit @testable import TableProTextEngine -import XCTest +import Testing -final class TextViewControllerMoveLinesTests: XCTestCase { +@MainActor +final class TextViewControllerMoveLinesTests { var controller: TextViewController! - override func setUpWithError() throws { + init() throws { controller = Mock.textViewController(theme: Mock.theme()) controller.loadView() } - func testHandleMoveLinesUpForSingleLine() { + @Test func testHandleMoveLinesUpForSingleLine() { let strings: [(NSString, Int)] = [ ("This is a test string\n", 0), ("With multiple lines\n", 22) @@ -36,10 +38,10 @@ final class TextViewControllerMoveLinesTests: XCTestCase { controller.moveLinesUp() let expectedString = "With multiple lines\nThis is a test string\n" - XCTAssertEqual(controller.text, expectedString) + #expect(controller.text == expectedString) } - func testHandleMoveLinesDownForSingleLine() { + @Test func testHandleMoveLinesDownForSingleLine() { let strings: [(NSString, Int)] = [ ("This is a test string\n", 0), ("With multiple lines\n", 22) @@ -57,10 +59,10 @@ final class TextViewControllerMoveLinesTests: XCTestCase { controller.moveLinesDown() let expectedString = "With multiple lines\nThis is a test string\n" - XCTAssertEqual(controller.text, expectedString) + #expect(controller.text == expectedString) } - func testHandleMoveLinesUpForMultiLine() { + @Test func testHandleMoveLinesUpForMultiLine() { let strings: [(NSString, Int)] = [ ("This is a test string\n", 0), ("With multiple lines\n", 22), @@ -79,10 +81,10 @@ final class TextViewControllerMoveLinesTests: XCTestCase { controller.moveLinesUp() let expectedString = "With multiple lines\nAnd additional info\nThis is a test string\n" - XCTAssertEqual(controller.text, expectedString) + #expect(controller.text == expectedString) } - func testHandleMoveLinesDownForMultiLine() { + @Test func testHandleMoveLinesDownForMultiLine() { let strings: [(NSString, Int)] = [ ("This is a test string\n", 0), ("With multiple lines\n", 22), @@ -101,6 +103,6 @@ final class TextViewControllerMoveLinesTests: XCTestCase { controller.moveLinesDown() let expectedString = "And additional info\nThis is a test string\nWith multiple lines\n" - XCTAssertEqual(controller.text, expectedString) + #expect(controller.text == expectedString) } } diff --git a/Packages/TableProEditor/Tests/TableProEditorKitTests/FindPanelReplaceTests.swift b/Packages/TableProEditor/Tests/TableProEditorKitTests/FindPanelReplaceTests.swift index feb4fa77c8..21cba25a68 100644 --- a/Packages/TableProEditor/Tests/TableProEditorKitTests/FindPanelReplaceTests.swift +++ b/Packages/TableProEditor/Tests/TableProEditorKitTests/FindPanelReplaceTests.swift @@ -1,12 +1,12 @@ import AppKit @testable import TableProEditorKit +import TableProTestSupport import TableProTextEngine import Testing /// Replace and All in the find panel, driven through a real editor so the edit's own text change runs the panel's /// search the way it does in the app. The expected states are the native find bar's (`NSTextFinder` replaceAndFind and /// replaceAll), measured on a stock `NSTextView`. -@Suite("Find panel replace", .serialized) @MainActor struct FindPanelReplaceTests { private func editor( @@ -301,14 +301,14 @@ struct FindPanelReplaceTests { } @Test("Replacing the document drops the matches it held") - func replacedDocumentIsSearchedAgain() throws { + func replacedDocumentIsSearchedAgain() async throws { let (controller, model) = try editor("foo bar foo", find: "foo", replaceWith: "X") controller.setText("SELECT * FROM users") model.replaceAll() #expect(controller.textView.string == "SELECT * FROM users") - RunLoop.main.run(until: Date().addingTimeInterval(0.1)) + try await waitUntil { model.findMatches.isEmpty } #expect(model.findMatches.isEmpty) #expect(model.currentFindMatchIndex == nil) } diff --git a/Packages/TableProEditor/Tests/TableProEditorKitTests/Highlighting/HighlighterTests.swift b/Packages/TableProEditor/Tests/TableProEditorKitTests/Highlighting/HighlighterTests.swift index 8ff1fe45f2..e17d0409f9 100644 --- a/Packages/TableProEditor/Tests/TableProEditorKitTests/Highlighting/HighlighterTests.swift +++ b/Packages/TableProEditor/Tests/TableProEditorKitTests/Highlighting/HighlighterTests.swift @@ -1,9 +1,11 @@ +import AppKit @testable import TableProEditorKit import TableProGrammars import TableProTextEngine -import XCTest +import Testing -final class HighlighterTests: XCTestCase { +@MainActor +final class HighlighterTests { class MockHighlightProvider: HighlightProviding { var setUpCount = 0 var queryCount = 0 @@ -56,14 +58,14 @@ final class HighlighterTests: XCTestCase { var attributeProvider: MockAttributeProvider! var textView: TextView! - override func setUp() { + init() { attributeProvider = MockAttributeProvider() textView = Mock.textView() textView.frame = NSRect(x: 0, y: 0, width: 1_000, height: 1_000) } @MainActor - func test_canceledHighlightsAreInvalidated() { + @Test func test_canceledHighlightsAreInvalidated() { var didQueryOnce = false var didQueryAgain = false @@ -87,15 +89,12 @@ final class HighlighterTests: XCTestCase { highlighter.invalidate() - XCTAssertTrue(didQueryOnce, "Highlighter did not invalidate text.") - XCTAssertTrue( - didQueryAgain, - "Highlighter did not query again after cancelling the first request" - ) + #expect(didQueryOnce, "Highlighter did not invalidate text.") + #expect(didQueryAgain, "Highlighter did not query again after cancelling the first request") } @MainActor - func test_highlightsDoNotInvalidateEntireTextView() { + @Test func test_highlightsDoNotInvalidateEntireTextView() { let highlightProvider = TreeSitterClient() highlightProvider.forceSyncOperation = true textView.setText("func helloWorld() {\n\tprint(\"Hello World!\")\n}") @@ -114,11 +113,11 @@ final class HighlighterTests: XCTestCase { let invalidSet = IndexSet(integersIn: NSRange(location: 0, length: 24)) highlighter.invalidate(invalidSet) // Invalidate first line - XCTAssertEqual(sentryStorage.editedIndices, invalidSet) // Should only cause highlights on the first line + #expect(sentryStorage.editedIndices == invalidSet) // Should only cause highlights on the first line } @MainActor - func test_insertedNewHighlightProvider() { + @Test func test_insertedNewHighlightProvider() { let highlightProvider1 = MockHighlightProvider(queryResponse: { .success([]) }) let attributeProvider = MockAttributeProvider() let textView = Mock.textView() @@ -130,17 +129,17 @@ final class HighlighterTests: XCTestCase { attributeProvider: attributeProvider ) - XCTAssertEqual(highlightProvider1.setUpCount, 1, "Highlighter 1 did not set up") + #expect(highlightProvider1.setUpCount == 1, "Highlighter 1 did not set up") let newProvider = MockHighlightProvider(queryResponse: { .success([]) }) highlighter.setProviders([highlightProvider1, newProvider]) - XCTAssertEqual(highlightProvider1.setUpCount, 1, "Highlighter 1 set up again") - XCTAssertEqual(newProvider.setUpCount, 1, "New highlighter did not set up") + #expect(highlightProvider1.setUpCount == 1, "Highlighter 1 set up again") + #expect(newProvider.setUpCount == 1, "New highlighter did not set up") } @MainActor - func test_removedHighlightProvider() { + @Test func test_removedHighlightProvider() { let highlightProvider1 = MockHighlightProvider() let highlightProvider2 = MockHighlightProvider() @@ -155,19 +154,19 @@ final class HighlighterTests: XCTestCase { attributeProvider: attributeProvider ) - XCTAssertEqual(highlightProvider1.setUpCount, 1, "Highlighter 1 did not set up") - XCTAssertEqual(highlightProvider2.setUpCount, 1, "Highlighter 2 did not set up") + #expect(highlightProvider1.setUpCount == 1, "Highlighter 1 did not set up") + #expect(highlightProvider2.setUpCount == 1, "Highlighter 2 did not set up") highlighter.setProviders([highlightProvider1]) highlighter.invalidate() - XCTAssertEqual(highlightProvider1.queryCount, 1, "Highlighter 1 was not queried") - XCTAssertEqual(highlightProvider2.queryCount, 0, "Removed highlighter was queried") + #expect(highlightProvider1.queryCount == 1, "Highlighter 1 was not queried") + #expect(highlightProvider2.queryCount == 0, "Removed highlighter was queried") } @MainActor - func test_movedHighlightProviderIsNotSetUpAgain() { + @Test func test_movedHighlightProviderIsNotSetUpAgain() { let highlightProvider1 = MockHighlightProvider() let highlightProvider2 = MockHighlightProvider() @@ -182,19 +181,19 @@ final class HighlighterTests: XCTestCase { attributeProvider: attributeProvider ) - XCTAssertEqual(highlightProvider1.setUpCount, 1, "Highlighter 1 did not set up") - XCTAssertEqual(highlightProvider2.setUpCount, 1, "Highlighter 2 did not set up") + #expect(highlightProvider1.setUpCount == 1, "Highlighter 1 did not set up") + #expect(highlightProvider2.setUpCount == 1, "Highlighter 2 did not set up") highlighter.setProviders([highlightProvider2, highlightProvider1]) highlighter.invalidate() - XCTAssertEqual(highlightProvider1.queryCount, 1, "Highlighter 1 was not queried") - XCTAssertEqual(highlightProvider2.queryCount, 1, "Highlighter 2 was not queried") + #expect(highlightProvider1.queryCount == 1, "Highlighter 1 was not queried") + #expect(highlightProvider2.queryCount == 1, "Highlighter 2 was not queried") } @MainActor - func test_randomHighlightProvidersChanging() { + @Test func test_randomHighlightProvidersChanging() { for _ in 0..<25 { let highlightProviders = (0..= 1 }), "Not all in second batch were queried") + #expect(secondSet.allSatisfy({ $0.setUpCount == 1 }), "All in second batch were not set up twice") + #expect(secondSet.allSatisfy({ $0.queryCount >= 1 }), "Not all in second batch were queried") highlighter.setProviders(thirdSet) highlighter.invalidate() // Can't check for == 1 here because some might be removed in #2 and added back in in #3 - XCTAssertTrue(thirdSet.allSatisfy({ $0.setUpCount >= 1 }), "Not all in third batch were set up") - XCTAssertTrue(thirdSet.allSatisfy({ $0.queryCount >= 1 }), "Not all in third batch were queried") + #expect(thirdSet.allSatisfy({ $0.setUpCount >= 1 }), "Not all in third batch were set up") + #expect(thirdSet.allSatisfy({ $0.queryCount >= 1 }), "Not all in third batch were queried") } } // This test isn't testing much highlighter functionality. However, we've seen crashes and other errors after normal // editing that were caused by the highlighter and would only have been caught by an integration test like this. @MainActor - func test_editFile() { + @Test func test_editFile() { let highlightProvider = TreeSitterClient() highlightProvider.forceSyncOperation = true textView.setText("func helloWorld() {\n\tprint(\"Hello World!\")\n}") // 44 chars @@ -255,7 +254,7 @@ final class HighlighterTests: XCTestCase { textView.replaceCharacters(in: [NSRange(location: 6, length: 5)], with: "") textView.replaceCharacters(in: [NSRange(location: 25, length: 5)], with: "") - XCTAssertEqual(textView.string, " hello() {\n\tprint(\"Hello !\")\n") + #expect(textView.string == " hello() {\n\tprint(\"Hello !\")\n") // Insert Characters textView.replaceCharacters(in: [NSRange(location: 29, length: 0)], with: "}") @@ -269,16 +268,16 @@ final class HighlighterTests: XCTestCase { textView.insertText("u") textView.insertText("n") textView.insertText("c") - XCTAssertEqual(textView.string, "func helloWorld() {\n\tprint(\"Hello World!\")\n}") + #expect(textView.string == "func helloWorld() {\n\tprint(\"Hello World!\")\n}") // Replace contents textView.replaceCharacters(in: textView.documentRange, with: "") textView.insertText("func helloWorld() {\n\tprint(\"Hello World!\")\n}") - XCTAssertEqual(textView.string, "func helloWorld() {\n\tprint(\"Hello World!\")\n}") + #expect(textView.string == "func helloWorld() {\n\tprint(\"Hello World!\")\n}") } @MainActor - func test_editDoesNotHighlightDocumentOverMaxLength() { + @Test func test_editDoesNotHighlightDocumentOverMaxLength() { let highlightProvider = MockHighlightProvider(queryResponse: { .success([]) }) let textView = Mock.textView() textView.frame = NSRect(x: 0, y: 0, width: 1_000, height: 1_000) @@ -294,15 +293,11 @@ final class HighlighterTests: XCTestCase { let baseline = highlightProvider.queryCount textView.replaceCharacters(in: NSRange(location: 0, length: 0), with: "b") - XCTAssertEqual( - highlightProvider.queryCount, - baseline, - "Editing a document over maxHighlightableLength must not query highlights" - ) + #expect(highlightProvider.queryCount == baseline, "Editing a document over maxHighlightableLength must not query highlights") } @MainActor - func test_editHighlightsDocumentUnderMaxLength() { + @Test func test_editHighlightsDocumentUnderMaxLength() { let highlightProvider = MockHighlightProvider(queryResponse: { .success([]) }) let textView = Mock.textView() textView.frame = NSRect(x: 0, y: 0, width: 1_000, height: 1_000) @@ -317,10 +312,6 @@ final class HighlighterTests: XCTestCase { textView.replaceCharacters(in: NSRange(location: 0, length: 0), with: "x") - XCTAssertGreaterThan( - highlightProvider.queryCount, - 0, - "A document under maxHighlightableLength should still be highlighted" - ) + #expect(highlightProvider.queryCount > 0, "A document under maxHighlightableLength should still be highlighted") } } diff --git a/Packages/TableProEditor/Tests/TableProEditorKitTests/Highlighting/PasteHighlightCancelTests.swift b/Packages/TableProEditor/Tests/TableProEditorKitTests/Highlighting/PasteHighlightCancelTests.swift index 69c664e1f5..18992d6dd7 100644 --- a/Packages/TableProEditor/Tests/TableProEditorKitTests/Highlighting/PasteHighlightCancelTests.swift +++ b/Packages/TableProEditor/Tests/TableProEditorKitTests/Highlighting/PasteHighlightCancelTests.swift @@ -17,6 +17,7 @@ import Foundation import Rearrange @preconcurrency @testable import TableProEditorKit import TableProGrammars +import TableProTestSupport import TableProTextEngine import Testing @@ -77,7 +78,6 @@ private final class SwitchableHighlightProvider: HighlightProviding { } @MainActor -@Suite("Highlighting survives a cancelled edit", .serialized) struct PasteHighlightCancelTests { private static let defaultText = NSColor.systemPurple private static let numbers = NSColor.systemYellow @@ -120,16 +120,16 @@ struct PasteHighlightCancelTests { func cancelledEditRepaintsCaretInsertion() async throws { let provider = SwitchableHighlightProvider() let controller = makeController(provider) - await settle(controller) - let token = (controller.textView.string as NSString).range(of: Self.originalToken) + await waitUntilPainted(token.location, in: controller) + #expect(color(at: token.location, in: controller) == Self.numbers) provider.mode = .editCancelled let caret = token.max controller.textView.selectionManager.setSelectedRange(NSRange(location: caret, length: 0)) controller.textView.insertText("999") - await settle(controller) + await waitUntilPainted(caret, in: controller) #expect( color(at: caret, in: controller) == Self.numbers, @@ -141,15 +141,15 @@ struct PasteHighlightCancelTests { func cancelledEditRepaintsSelectionReplacement() async throws { let provider = SwitchableHighlightProvider() let controller = makeController(provider) - await settle(controller) - let token = (controller.textView.string as NSString).range(of: Self.originalToken) + await waitUntilPainted(token.location, in: controller) + #expect(color(at: token.location, in: controller) == Self.numbers) provider.mode = .editCancelled controller.textView.selectionManager.setSelectedRange(token) controller.textView.insertText(Self.sameLengthToken) - await settle(controller) + await waitUntilPainted(token.location, in: controller) #expect(color(at: token.location, in: controller) == Self.numbers) } @@ -158,12 +158,12 @@ struct PasteHighlightCancelTests { func healthyEditRepaints() async throws { let provider = SwitchableHighlightProvider() let controller = makeController(provider) - await settle(controller) - let token = (controller.textView.string as NSString).range(of: Self.originalToken) + await waitUntilPainted(token.location, in: controller) + controller.textView.selectionManager.setSelectedRange(token) controller.textView.insertText(Self.sameLengthToken) - await settle(controller) + await waitUntilPainted(token.location, in: controller) #expect(color(at: token.location, in: controller) == Self.numbers) } @@ -218,10 +218,11 @@ struct PasteHighlightCancelTests { return storage.attribute(.foregroundColor, at: location, effectiveRange: nil) as? NSColor } - private func settle(_ controller: TextViewController) async { - for _ in 0..<8 { + // Gives up silently, so the expectation after it reports what was painted instead. + private func waitUntilPainted(_ location: Int, in controller: TextViewController) async { + try? await waitUntil(timeout: .seconds(5)) { controller.textView.layoutManager.layoutLines(in: Self.frame) - try? await Task.sleep(nanoseconds: 25_000_000) + return color(at: location, in: controller) == Self.numbers } } } diff --git a/Packages/TableProEditor/Tests/TableProEditorKitTests/Highlighting/StyledRangeContainerTests.swift b/Packages/TableProEditor/Tests/TableProEditorKitTests/Highlighting/StyledRangeContainerTests.swift index 2407ca981c..eafb8ff384 100644 --- a/Packages/TableProEditor/Tests/TableProEditorKitTests/Highlighting/StyledRangeContainerTests.swift +++ b/Packages/TableProEditor/Tests/TableProEditorKitTests/Highlighting/StyledRangeContainerTests.swift @@ -1,24 +1,23 @@ +import Foundation @testable import TableProEditorKit -import XCTest +import Testing -final class StyledRangeContainerTests: XCTestCase { +@MainActor +struct StyledRangeContainerTests { typealias Run = RangeStoreRun @MainActor - func test_init() { + @Test func test_init() { let providers = [0, 1] let store = StyledRangeContainer(documentLength: 100, providers: providers) // Have to do string conversion due to missing Comparable conformance pre-macOS 14 - XCTAssertEqual(store.providerStores.keys.sorted(), providers) - XCTAssert( - store.providerStores.values.allSatisfy({ $0.store.length == 100 }), - "One or more providers have incorrect length" - ) + #expect(store.providerStores.keys.sorted() == providers) + #expect(store.providerStores.values.allSatisfy({ $0.store.length == 100 }), "One or more providers have incorrect length") } @MainActor - func test_setHighlights() throws { + @Test func test_setHighlights() throws { let providers = [0, 1] let store = StyledRangeContainer(documentLength: 100, providers: providers) @@ -28,26 +27,23 @@ final class StyledRangeContainerTests: XCTestCase { rangeToHighlight: NSRange(location: 0, length: 100) ) - let storage = try XCTUnwrap(store.providerStores[providers[0]]) - XCTAssertEqual(storage.store.runCount, 3) + let storage = try #require(store.providerStores[providers[0]]) + #expect(storage.store.runCount == 3) let runs = storage.store.runs(in: 0..<100) - XCTAssertNil(runs[0].value?.capture) - XCTAssertEqual(runs[1].value?.capture, .comment) - XCTAssertNil(runs[2].value?.capture) - - XCTAssertEqual( - store.runsIn(range: NSRange(location: 0, length: 100)), - [ - Run(length: 40, value: nil), - Run(length: 10, value: .init(capture: .comment, modifiers: [])), - Run(length: 50, value: nil) - ] - ) + #expect(runs[0].value?.capture == nil) + #expect(runs[1].value?.capture == .comment) + #expect(runs[2].value?.capture == nil) + + #expect(store.runsIn(range: NSRange(location: 0, length: 100)) == [ + Run(length: 40, value: nil), + Run(length: 10, value: .init(capture: .comment, modifiers: [])), + Run(length: 50, value: nil) + ]) } @MainActor - func test_overlappingRuns() { + @Test func test_overlappingRuns() { let providers = [0, 1] let store = StyledRangeContainer(documentLength: 100, providers: providers) @@ -65,19 +61,16 @@ final class StyledRangeContainerTests: XCTestCase { rangeToHighlight: NSRange(location: 0, length: 100) ) - XCTAssertEqual( - store.runsIn(range: NSRange(location: 0, length: 100)), - [ - Run(length: 40, value: nil), - Run(length: 5, value: .init(capture: .comment, modifiers: [])), - Run(length: 5, value: .init(capture: .comment, modifiers: [.declaration])), - Run(length: 50, value: nil) - ] - ) + #expect(store.runsIn(range: NSRange(location: 0, length: 100)) == [ + Run(length: 40, value: nil), + Run(length: 5, value: .init(capture: .comment, modifiers: [])), + Run(length: 5, value: .init(capture: .comment, modifiers: [.declaration])), + Run(length: 50, value: nil) + ]) } @MainActor - func test_overlappingRunsWithMoreProviders() { + @Test func test_overlappingRunsWithMoreProviders() { let providers = [0, 1, 2] let store = StyledRangeContainer(documentLength: 200, providers: providers) @@ -110,18 +103,18 @@ final class StyledRangeContainerTests: XCTestCase { let runs = store.runsIn(range: NSRange(location: 0, length: 200)) - XCTAssertEqual(runs.reduce(0, { $0 + $1.length}), 200) - - XCTAssertEqual(runs[0], Run(length: 30, value: nil)) - XCTAssertEqual(runs[1], Run(length: 5, value: .init(capture: .comment, modifiers: []))) - XCTAssertEqual(runs[2], Run(length: 5, value: .init(capture: .comment, modifiers: [.declaration]))) - XCTAssertEqual(runs[3], Run(length: 5, value: .init(capture: .comment, modifiers: [.abstract, .declaration]))) - XCTAssertEqual(runs[4], Run(length: 5, value: .init(capture: .comment, modifiers: []))) - XCTAssertEqual(runs[5], Run(length: 30, value: nil)) - XCTAssertEqual(runs[6], Run(length: 10, value: .init(capture: .string, modifiers: []))) - XCTAssertEqual(runs[7], Run(length: 10, value: .init(capture: .string, modifiers: [.static]))) - XCTAssertEqual(runs[8], Run(length: 5, value: .init(capture: .string, modifiers: [.static, .modification]))) - XCTAssertEqual(runs[9], Run(length: 5, value: .init(capture: .string, modifiers: [.modification]))) - XCTAssertEqual(runs[10], Run(length: 90, value: nil)) + #expect(runs.reduce(0, { $0 + $1.length}) == 200) + + #expect(runs[0] == Run(length: 30, value: nil)) + #expect(runs[1] == Run(length: 5, value: .init(capture: .comment, modifiers: []))) + #expect(runs[2] == Run(length: 5, value: .init(capture: .comment, modifiers: [.declaration]))) + #expect(runs[3] == Run(length: 5, value: .init(capture: .comment, modifiers: [.abstract, .declaration]))) + #expect(runs[4] == Run(length: 5, value: .init(capture: .comment, modifiers: []))) + #expect(runs[5] == Run(length: 30, value: nil)) + #expect(runs[6] == Run(length: 10, value: .init(capture: .string, modifiers: []))) + #expect(runs[7] == Run(length: 10, value: .init(capture: .string, modifiers: [.static]))) + #expect(runs[8] == Run(length: 5, value: .init(capture: .string, modifiers: [.static, .modification]))) + #expect(runs[9] == Run(length: 5, value: .init(capture: .string, modifiers: [.modification]))) + #expect(runs[10] == Run(length: 90, value: nil)) } } diff --git a/Packages/TableProEditor/Tests/TableProEditorKitTests/Highlighting/VisibleRangeProviderTests.swift b/Packages/TableProEditor/Tests/TableProEditorKitTests/Highlighting/VisibleRangeProviderTests.swift index f1cbe3a8d6..8dd314ab49 100644 --- a/Packages/TableProEditor/Tests/TableProEditorKitTests/Highlighting/VisibleRangeProviderTests.swift +++ b/Packages/TableProEditor/Tests/TableProEditorKitTests/Highlighting/VisibleRangeProviderTests.swift @@ -1,9 +1,11 @@ +import AppKit @testable import TableProEditorKit -import XCTest +import Testing -final class VisibleRangeProviderTests: XCTestCase { +@MainActor +struct VisibleRangeProviderTests { @MainActor - func test_updateOnScroll() { + @Test func test_updateOnScroll() { let (scrollView, textView) = Mock.scrollingTextView() textView.string = Array(repeating: "\n", count: 400).joined() textView.layout() @@ -16,11 +18,11 @@ final class VisibleRangeProviderTests: XCTestCase { scrollView.layoutSubtreeIfNeeded() textView.layout() - XCTAssertNotEqual(originalSet, rangeProvider.visibleSet) + #expect(originalSet != rangeProvider.visibleSet) } @MainActor - func test_updateOnResize() { + @Test func test_updateOnResize() { let (scrollView, textView) = Mock.scrollingTextView() textView.string = Array(repeating: "\n", count: 400).joined() textView.layout() @@ -33,11 +35,11 @@ final class VisibleRangeProviderTests: XCTestCase { scrollView.layoutSubtreeIfNeeded() textView.layout() - XCTAssertNotEqual(originalSet, rangeProvider.visibleSet) + #expect(originalSet != rangeProvider.visibleSet) } @MainActor - func test_noScrollViewMakesTheWholeDocumentVisible() { + @Test func test_noScrollViewMakesTheWholeDocumentVisible() { let textView = Mock.textView() textView.frame = NSRect(x: 0, y: 0, width: 100, height: 100) textView.string = Array(repeating: "\n", count: 400).joined() @@ -46,11 +48,11 @@ final class VisibleRangeProviderTests: XCTestCase { let rangeProvider = VisibleRangeProvider(textView: textView) let wholeDocument = IndexSet(integersIn: rangeProvider.documentRange) - XCTAssertEqual(rangeProvider.visibleSet, wholeDocument) + #expect(rangeProvider.visibleSet == wholeDocument) textView.setFrameSize(NSSize(width: 350, height: 450)) textView.layout() - XCTAssertEqual(rangeProvider.visibleSet, wholeDocument) + #expect(rangeProvider.visibleSet == wholeDocument) } } diff --git a/Packages/TableProEditor/Tests/TableProEditorKitTests/LineFoldingTests/LineFoldChunkBoundaryTests.swift b/Packages/TableProEditor/Tests/TableProEditorKitTests/LineFoldingTests/LineFoldChunkBoundaryTests.swift index d850bb1931..5b236eed58 100644 --- a/Packages/TableProEditor/Tests/TableProEditorKitTests/LineFoldingTests/LineFoldChunkBoundaryTests.swift +++ b/Packages/TableProEditor/Tests/TableProEditorKitTests/LineFoldingTests/LineFoldChunkBoundaryTests.swift @@ -5,6 +5,7 @@ import AppKit @testable import TableProEditorKit +import TableProTestSupport import TableProTextEngine import Testing @@ -47,13 +48,8 @@ struct LineFoldChunkBoundaryTests { controller.foldProvider = provider let model = LineFoldModel(controller: controller, foldView: NSView()) - // The calculation runs on the main actor, which every other suite in this target shares, so wait for it to - // reach the last line rather than for a fixed time. The deadline only turns a hang into a failure; it is not - // a budget, and five seconds of it was not enough once the editor's own suites moved into this target. - let deadline = ContinuousClock.now + .seconds(60) - while provider.previousDepths[100] == nil, ContinuousClock.now < deadline { - try await Task.sleep(for: .milliseconds(10)) - } + // The calculation shares the main actor with every suite in this target; 5 s of bound was not enough there. + try await waitUntil(timeout: .seconds(60)) { provider.previousDepths[100] != nil } _ = model #expect(provider.previousDepths[49] == 1) diff --git a/Packages/TableProEditor/Tests/TableProEditorKitTests/RepresentableSyncPhaseTests.swift b/Packages/TableProEditor/Tests/TableProEditorKitTests/RepresentableSyncPhaseTests.swift index 472959a5a3..43d4fdd5ab 100644 --- a/Packages/TableProEditor/Tests/TableProEditorKitTests/RepresentableSyncPhaseTests.swift +++ b/Packages/TableProEditor/Tests/TableProEditorKitTests/RepresentableSyncPhaseTests.swift @@ -1,59 +1,61 @@ -import XCTest +import Foundation +import Testing @testable import TableProEditorKit -final class RepresentableSyncPhaseTests: XCTestCase { +@MainActor +struct RepresentableSyncPhaseTests { @MainActor - func test_startsIdle() { + @Test func test_startsIdle() { let phase = RepresentableSyncPhase() - XCTAssertEqual(phase.phase, .idle) - XCTAssertFalse(phase.isEditorChangePending) - XCTAssertFalse(phase.isApplyingRepresentableValue) + #expect(phase.phase == .idle) + #expect(!phase.isEditorChangePending) + #expect(!phase.isApplyingRepresentableValue) } @MainActor - func test_markEditorChangeLatchesUntilConsumed() { + @Test func test_markEditorChangeLatchesUntilConsumed() { let phase = RepresentableSyncPhase() phase.markEditorChange() phase.markEditorChange() - XCTAssertTrue(phase.isEditorChangePending) - XCTAssertTrue(phase.consumePendingEditorChange()) - XCTAssertEqual(phase.phase, .idle) + #expect(phase.isEditorChangePending) + #expect(phase.consumePendingEditorChange()) + #expect(phase.phase == .idle) } @MainActor - func test_consumeWithoutPendingChangeReturnsFalse() { + @Test func test_consumeWithoutPendingChangeReturnsFalse() { let phase = RepresentableSyncPhase() - XCTAssertFalse(phase.consumePendingEditorChange()) - XCTAssertEqual(phase.phase, .idle) + #expect(!phase.consumePendingEditorChange()) + #expect(phase.phase == .idle) } @MainActor - func test_markDuringRepresentableApplicationIsIgnored() { + @Test func test_markDuringRepresentableApplicationIsIgnored() { let phase = RepresentableSyncPhase() phase.applyRepresentableValue { phase.markEditorChange() - XCTAssertTrue(phase.isApplyingRepresentableValue) + #expect(phase.isApplyingRepresentableValue) } - XCTAssertEqual(phase.phase, .idle) - XCTAssertFalse(phase.isEditorChangePending) + #expect(phase.phase == .idle) + #expect(!phase.isEditorChangePending) } @MainActor - func test_applyRepresentableValueRestoresPriorPhase() { + @Test func test_applyRepresentableValueRestoresPriorPhase() { let phase = RepresentableSyncPhase() phase.markEditorChange() phase.applyRepresentableValue { - XCTAssertTrue(phase.isApplyingRepresentableValue) + #expect(phase.isApplyingRepresentableValue) } - XCTAssertTrue(phase.isEditorChangePending) + #expect(phase.isEditorChangePending) } } diff --git a/Packages/TableProEditor/Tests/TableProEditorKitTests/SourceEditorBindingSyncTests.swift b/Packages/TableProEditor/Tests/TableProEditorKitTests/SourceEditorBindingSyncTests.swift index 2d435ba636..e3bcfa45bc 100644 --- a/Packages/TableProEditor/Tests/TableProEditorKitTests/SourceEditorBindingSyncTests.swift +++ b/Packages/TableProEditor/Tests/TableProEditorKitTests/SourceEditorBindingSyncTests.swift @@ -1,14 +1,15 @@ import AppKit import SwiftUI -import XCTest +import Testing @testable import TableProEditorKit import TableProTextEngine -final class SourceEditorBindingSyncTests: XCTestCase { +@MainActor +final class SourceEditorBindingSyncTests { var controller: TextViewController! - override func setUpWithError() throws { + init() throws { controller = Mock.textViewController(theme: Mock.theme()) controller.loadView() controller.view.frame = NSRect(x: 0, y: 0, width: 1_000, height: 1_000) @@ -29,7 +30,7 @@ final class SourceEditorBindingSyncTests: XCTestCase { } @MainActor - func test_externalBindingChangeUpdatesTextView() { + @Test func test_externalBindingChangeUpdatesTextView() { var bound = "initial" let coordinator = makeCoordinator(get: { bound }, set: { bound = $0 }) controller.textView.setText("initial") @@ -37,24 +38,24 @@ final class SourceEditorBindingSyncTests: XCTestCase { bound = "updated" coordinator.textSync.applyRepresentableText(bound, controller: controller) - XCTAssertEqual(controller.textView.string, "updated") - XCTAssertEqual(coordinator.textSync.lastSyncedText, "updated") + #expect(controller.textView.string == "updated") + #expect(coordinator.textSync.lastSyncedText == "updated") } @MainActor - func test_syncSkipsWhenBindingMatchesLastSyncedText() { + @Test func test_syncSkipsWhenBindingMatchesLastSyncedText() { var bound = "before edit" let coordinator = makeCoordinator(get: { bound }, set: { bound = $0 }) controller.textView.setText("in-flight user edit") coordinator.textSync.applyRepresentableText(bound, controller: controller) - XCTAssertEqual(controller.textView.string, "in-flight user edit") - XCTAssertEqual(coordinator.textSync.lastSyncedText, "before edit") + #expect(controller.textView.string == "in-flight user edit") + #expect(coordinator.textSync.lastSyncedText == "before edit") } @MainActor - func test_textViewEditWritesBackAndRecordsLastSyncedText() { + @Test func test_textViewEditWritesBackAndRecordsLastSyncedText() { var bound = "" let coordinator = makeCoordinator(get: { bound }, set: { bound = $0 }) controller.textView.setText("typed text") @@ -63,13 +64,13 @@ final class SourceEditorBindingSyncTests: XCTestCase { Notification(name: TextView.textDidChangeNotification, object: controller.textView) ) - XCTAssertEqual(bound, "typed text") - XCTAssertEqual(coordinator.textSync.lastSyncedText, "typed text") - XCTAssertTrue(coordinator.phase.isEditorChangePending) + #expect(bound == "typed text") + #expect(coordinator.textSync.lastSyncedText == "typed text") + #expect(coordinator.phase.isEditorChangePending) } @MainActor - func test_editNotificationDuringProgrammaticSyncDoesNotWriteBack() { + @Test func test_editNotificationDuringProgrammaticSyncDoesNotWriteBack() { var bound = "external" var setterCallCount = 0 let coordinator = makeCoordinator(get: { bound }, set: { bound = $0; setterCallCount += 1 }) @@ -81,13 +82,13 @@ final class SourceEditorBindingSyncTests: XCTestCase { ) } - XCTAssertEqual(setterCallCount, 0) - XCTAssertFalse(coordinator.phase.isEditorChangePending) - XCTAssertEqual(bound, "external") + #expect(setterCallCount == 0) + #expect(!coordinator.phase.isEditorChangePending) + #expect(bound == "external") } @MainActor - func test_largeDocumentEditDebouncesWritebackWithoutClobbering() async throws { + @Test func test_largeDocumentEditDebouncesWritebackWithoutClobbering() async throws { var bound = "" let coordinator = makeCoordinator(get: { bound }, set: { bound = $0 }) let largeText = String(repeating: "a", count: 500_001) @@ -97,19 +98,19 @@ final class SourceEditorBindingSyncTests: XCTestCase { Notification(name: TextView.textDidChangeNotification, object: controller.textView) ) - XCTAssertEqual(bound, "") - XCTAssertTrue(coordinator.phase.isEditorChangePending) + #expect(bound == "") + #expect(coordinator.phase.isEditorChangePending) coordinator.textSync.applyRepresentableText(bound, controller: controller) - XCTAssertEqual(controller.textView.string, largeText) + #expect(controller.textView.string == largeText) await coordinator.textSync.writebackTask?.value - XCTAssertEqual(bound, largeText) - XCTAssertEqual(coordinator.textSync.lastSyncedText, largeText) + #expect(bound == largeText) + #expect(coordinator.textSync.lastSyncedText == largeText) } @MainActor - func test_syncSkipsStaleBindingSnapshotWhileEditIsInFlight() { + @Test func test_syncSkipsStaleBindingSnapshotWhileEditIsInFlight() { var bound = "" let coordinator = makeCoordinator(get: { bound }, set: { bound = $0 }) controller.textView.setText("s") @@ -117,17 +118,17 @@ final class SourceEditorBindingSyncTests: XCTestCase { coordinator.textViewDidChangeText( Notification(name: TextView.textDidChangeNotification, object: controller.textView) ) - XCTAssertEqual(bound, "s") - XCTAssertTrue(coordinator.phase.isEditorChangePending) + #expect(bound == "s") + #expect(coordinator.phase.isEditorChangePending) coordinator.textSync.applyRepresentableText("", controller: controller) - XCTAssertEqual(controller.textView.string, "s") - XCTAssertEqual(coordinator.textSync.lastSyncedText, "s") + #expect(controller.textView.string == "s") + #expect(coordinator.textSync.lastSyncedText == "s") } @MainActor - func test_syncAppliesExternalChangeAfterEditFlagIsCleared() { + @Test func test_syncAppliesExternalChangeAfterEditFlagIsCleared() { var bound = "" let coordinator = makeCoordinator(get: { bound }, set: { bound = $0 }) controller.textView.setText("select 1") @@ -140,29 +141,29 @@ final class SourceEditorBindingSyncTests: XCTestCase { bound = "SELECT 1" coordinator.textSync.applyRepresentableText(bound, controller: controller) - XCTAssertEqual(controller.textView.string, "SELECT 1") - XCTAssertEqual(coordinator.textSync.lastSyncedText, "SELECT 1") + #expect(controller.textView.string == "SELECT 1") + #expect(coordinator.textSync.lastSyncedText == "SELECT 1") } @MainActor - func test_syncRebuildsHighlighterForReplacementStorage() { + @Test func test_syncRebuildsHighlighterForReplacementStorage() { var bound = "select * from users" let coordinator = makeCoordinator(get: { bound }, set: { bound = $0 }) controller.textView.setText("select * from users") controller.setUpHighlighter() let highlighterBefore = controller.highlighter - XCTAssertNotNil(highlighterBefore) + #expect(highlighterBefore != nil) bound = "SELECT\n *\nFROM\n users" coordinator.textSync.applyRepresentableText(bound, controller: controller) - XCTAssertEqual(controller.textView.string, "SELECT\n *\nFROM\n users") - XCTAssertNotNil(controller.highlighter) - XCTAssertNotIdentical(controller.highlighter, highlighterBefore) + #expect(controller.textView.string == "SELECT\n *\nFROM\n users") + #expect(controller.highlighter != nil) + #expect(controller.highlighter !== highlighterBefore) } @MainActor - func test_syncQueriesHighlightsForReplacementStorage() { + @Test func test_syncQueriesHighlightsForReplacementStorage() { let provider = HighlighterTests.MockHighlightProvider() let providerController = TextViewController( string: "select * from users", @@ -183,12 +184,12 @@ final class SourceEditorBindingSyncTests: XCTestCase { bound = "SELECT\n *\nFROM\n users" coordinator.textSync.applyRepresentableText(bound, controller: providerController) - XCTAssertGreaterThan(provider.setUpCount, setUpsBefore) - XCTAssertGreaterThan(provider.queryCount, queriesBefore) + #expect(provider.setUpCount > setUpsBefore) + #expect(provider.queryCount > queriesBefore) } @MainActor - func test_syncShorterTextDropsOutOfBoundsSelection() { + @Test func test_syncShorterTextDropsOutOfBoundsSelection() { var bound = "select * from table" let coordinator = makeCoordinator(get: { bound }, set: { bound = $0 }) controller.textView.setText("select * from table") @@ -197,14 +198,14 @@ final class SourceEditorBindingSyncTests: XCTestCase { bound = "ab" coordinator.textSync.applyRepresentableText(bound, controller: controller) - XCTAssertEqual(controller.textView.string, "ab") + #expect(controller.textView.string == "ab") for selection in controller.textView.selectionManager.textSelections { - XCTAssertLessThanOrEqual(selection.range.max, 2) + #expect(selection.range.max <= 2) } } @MainActor - func test_repeatedSyncWithSameValueLeavesTextViewUntouched() { + @Test func test_repeatedSyncWithSameValueLeavesTextViewUntouched() { var bound = "stable" let coordinator = makeCoordinator(get: { bound }, set: { bound = $0 }) controller.textView.setText("stable") @@ -212,7 +213,7 @@ final class SourceEditorBindingSyncTests: XCTestCase { coordinator.textSync.applyRepresentableText(bound, controller: controller) - XCTAssertIdentical(controller.textView.textStorage, storageBefore) - XCTAssertEqual(controller.textView.string, "stable") + #expect(controller.textView.textStorage === storageBefore) + #expect(controller.textView.string == "stable") } } diff --git a/Packages/TableProEditor/Tests/TableProEditorKitTests/TrailingEdgeClickTests.swift b/Packages/TableProEditor/Tests/TableProEditorKitTests/TrailingEdgeClickTests.swift new file mode 100644 index 0000000000..7ad16ff552 --- /dev/null +++ b/Packages/TableProEditor/Tests/TableProEditorKitTests/TrailingEdgeClickTests.swift @@ -0,0 +1,32 @@ +// +// TrailingEdgeClickTests.swift +// TableProEditorKitTests +// + +import AppKit +@testable import TableProEditorKit +import TableProTextEngine +import Testing + +/// #2156: a view floating over the editor's trailing strip answered hit testing there and dropped every click, so +/// clicking past the end of a line did nothing. +@MainActor +@Suite("A click on the editor's trailing edge") +struct TrailingEdgeClickTests { + @Test("Lands in the text view and puts the caret at the end of the clicked line") + func trailingClickReachesTheLineEnd() throws { + let firstLine = "SELECT 1;" + let controller = Mock.loadedTextViewController(string: firstLine + "\nSELECT 22;") + let textView = try #require(controller.textView) + textView.layoutManager.layoutLines(in: NSRect(x: 0, y: 0, width: 1_000, height: 1_000)) + let line = try #require(textView.layoutManager.textLineForIndex(0)) + + // Clear of a legacy scroller, which a mouse-attached Mac draws along the same edge. + let point = NSPoint(x: textView.visibleRect.maxX - 40, y: line.yPos + line.height / 2) + try #require(point.x > 200, "The editor must be wide enough for the strip to sit past the text") + + let hit = controller.view.hitTest(textView.convert(point, to: controller.view)) + #expect(hit === textView || hit?.isDescendant(of: textView) == true, "Got \(String(describing: hit))") + #expect(textView.layoutManager.textOffsetAtPoint(point) == (firstLine as NSString).length) + } +} diff --git a/Packages/TableProEditor/Tests/TableProEditorKitTests/TreeSitterClientTests.swift b/Packages/TableProEditor/Tests/TableProEditorKitTests/TreeSitterClientTests.swift index 00e57e60c8..47ab62cea3 100644 --- a/Packages/TableProEditor/Tests/TableProEditorKitTests/TreeSitterClientTests.swift +++ b/Packages/TableProEditor/Tests/TableProEditorKitTests/TreeSitterClientTests.swift @@ -1,4 +1,5 @@ @testable import TableProEditorKit +import TableProTestSupport import TableProTextEngine import XCTest @@ -30,22 +31,13 @@ final class TreeSitterClientTests: XCTestCase { client.applyEdit(textView: textView, range: range, delta: delta, completion: completion) } - @MainActor - func test_clientSetup() async { + @MainActor + func test_clientSetup() async throws { let client = Mock.treeSitterClient() let textView = Mock.textView() client.setUp(textView: textView, codeLanguage: .sql) - let expectation = XCTestExpectation(description: "Setup occurs") - - Task.detached { - while client.state == nil { - try await Task.sleep(for: .seconds(0.5)) - } - expectation.fulfill() - } - - await fulfillment(of: [expectation], timeout: 5.0) + try await waitUntil(timeout: .seconds(5)) { client.state != nil } let primaryLanguage = client.state?.primaryLayer.id let layerCount = client.state?.layers.count @@ -104,7 +96,7 @@ final class TreeSitterClientTests: XCTestCase { } @MainActor - func test_multipleSetupsCancelAllOperations() async { + func test_multipleSetupsCancelAllOperations() async throws { let client = Mock.treeSitterClient() let textView = Mock.textView() @@ -134,16 +126,8 @@ final class TreeSitterClientTests: XCTestCase { // Second setup, which should cancel all previous operations client.setUp(textView: textView, codeLanguage: .sql) - let finalSetupExpectation = XCTestExpectation(description: "Final setup should complete successfully.") - - Task.detached { - while client.state?.primaryLayer.id != .sql { - try await Task.sleep(for: .seconds(0.5)) - } - finalSetupExpectation.fulfill() - } - - await fulfillment(of: [cancelledQuery, cancelledEdit, finalSetupExpectation], timeout: 5.0) + try await waitUntil(timeout: .seconds(5)) { client.state?.primaryLayer.id == .sql } + await fulfillment(of: [cancelledQuery, cancelledEdit], timeout: 5.0) // Ensure only the final setup's language is active let primaryLanguage = client.state?.primaryLayer.id diff --git a/Packages/TableProEditor/Tests/TableProEditorKitTests/TreeSitterExecutorTests.swift b/Packages/TableProEditor/Tests/TableProEditorKitTests/TreeSitterExecutorTests.swift index 141130d4cf..b5086a66b5 100644 --- a/Packages/TableProEditor/Tests/TableProEditorKitTests/TreeSitterExecutorTests.swift +++ b/Packages/TableProEditor/Tests/TableProEditorKitTests/TreeSitterExecutorTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProTestSupport import Testing @testable import TableProEditorKit @@ -9,7 +10,7 @@ struct TreeSitterExecutorTests { /// `Task.isCancelled` and called `onCancel` for the same task. For an edit that meant one keystroke delivering a /// success and a failure, and for `exec` it meant resuming one continuation twice, which traps. @Test("A task that is cancelled while it runs reports once") - func aCancelledRunningTaskReportsOnce() async { + func aCancelledRunningTaskReportsOnce() async throws { let executor = TreeSitterExecutor() let started = DispatchSemaphore(value: 0) let mayFinish = DispatchSemaphore(value: 0) @@ -30,12 +31,12 @@ struct TreeSitterExecutorTests { executor.cancelAll(below: .reset) mayFinish.signal() - try? await Task.sleep(for: .milliseconds(200)) + try await settled(executor, outcomes) #expect(outcomes.recorded == ["completed"]) } @Test("A task that abandons its work reports cancelled, not completed") - func anAbandonedTaskReportsCancelled() async { + func anAbandonedTaskReportsCancelled() async throws { let executor = TreeSitterExecutor() let started = DispatchSemaphore(value: 0) let mayFinish = DispatchSemaphore(value: 0) @@ -55,12 +56,12 @@ struct TreeSitterExecutorTests { executor.cancelAll(below: .reset) mayFinish.signal() - try? await Task.sleep(for: .milliseconds(200)) + try await settled(executor, outcomes) #expect(outcomes.recorded == ["cancelled"]) } @Test("A task cancelled before it runs reports cancelled once") - func aTaskCancelledBeforeItRunsReportsCancelled() async { + func aTaskCancelledBeforeItRunsReportsCancelled() async throws { let executor = TreeSitterExecutor() let outcomes = OutcomeBox() @@ -74,10 +75,18 @@ struct TreeSitterExecutorTests { ) executor.cancelAll(below: .reset) - try? await Task.sleep(for: .milliseconds(200)) + try await settled(executor, outcomes) #expect(outcomes.recorded.count <= 1, "reported \(outcomes.recorded)") } + // Reporting and leaving the queue are each path's last two steps, so a second report would already show here. + private func settled(_ executor: TreeSitterExecutor, _ outcomes: OutcomeBox) async throws { + try await waitUntil { + guard !outcomes.recorded.isEmpty, case .success = executor.execSync({}) else { return false } + return true + } + } + private final class OutcomeBox: @unchecked Sendable { private let lock = NSLock() private var values: [String] = [] diff --git a/Packages/TableProEditor/Tests/TableProTextEngineTests/AccessibilityTests.swift b/Packages/TableProEditor/Tests/TableProTextEngineTests/AccessibilityTests.swift index 568e1e4606..dc2643ba23 100644 --- a/Packages/TableProEditor/Tests/TableProTextEngineTests/AccessibilityTests.swift +++ b/Packages/TableProEditor/Tests/TableProTextEngineTests/AccessibilityTests.swift @@ -223,15 +223,9 @@ struct AccessibilityTests { #expect(range.length == 7) } - @Test - func accessibilityRangeForInvalidLine() { - let range = textView.accessibilityRange(forLine: 100) - #expect(range == .zero) - } - - @Test - func accessibilityRangeForNegativeLine() { - let range = textView.accessibilityRange(forLine: -1) + @Test(arguments: [100, -1]) + func accessibilityRangeForLineOutOfRange(_ line: Int) { + let range = textView.accessibilityRange(forLine: line) #expect(range == .zero) } diff --git a/Packages/TableProEditor/Tests/TableProTextEngineTests/CmdShiftLeftAtEndOfDocumentTests.swift b/Packages/TableProEditor/Tests/TableProTextEngineTests/CmdShiftLeftAtEndOfDocumentTests.swift index 472e8c222c..0efd31baba 100644 --- a/Packages/TableProEditor/Tests/TableProTextEngineTests/CmdShiftLeftAtEndOfDocumentTests.swift +++ b/Packages/TableProEditor/Tests/TableProTextEngineTests/CmdShiftLeftAtEndOfDocumentTests.swift @@ -23,23 +23,12 @@ struct CmdShiftLeftAtEndOfDocumentTests { // MARK: - rangeOfSelection (pure range computation) - @Test("Cmd+Left at end of single-line query returns range covering last word") - func cmdLeftRangeAtEndOfSingleLine() { - let textView = makeLaidOutTextView("select * from products") - let length = (textView.string as NSString).length - - let range = textView.selectionManager.rangeOfSelection( - from: length, - direction: .backward, - destination: .word - ) - - #expect(range == NSRange(location: 14, length: 8)) - } - - @Test("Cmd+Left at end of multi-line query returns range covering last word") - func cmdLeftRangeAtEndOfMultiLine() { - let textView = makeLaidOutTextView("select *\nfrom products") + @Test("Cmd+Left at end of a single- or multi-line query returns range covering last word", arguments: [ + "select * from products", + "select *\nfrom products", + ]) + func cmdLeftRangeAtEnd(_ query: String) { + let textView = makeLaidOutTextView(query) let length = (textView.string as NSString).length let range = textView.selectionManager.rangeOfSelection( diff --git a/Packages/TableProEditor/Tests/TableProTextEngineTests/CmdUpAtEndOfDocumentTests.swift b/Packages/TableProEditor/Tests/TableProTextEngineTests/CmdUpAtEndOfDocumentTests.swift index 13e12435bf..53aa65fa25 100644 --- a/Packages/TableProEditor/Tests/TableProTextEngineTests/CmdUpAtEndOfDocumentTests.swift +++ b/Packages/TableProEditor/Tests/TableProTextEngineTests/CmdUpAtEndOfDocumentTests.swift @@ -20,23 +20,9 @@ struct CmdUpAtEndOfDocumentTests { // MARK: - Cmd+Up / Cmd+Down (destination .document) - @Test("Cmd+Up at end of single-line query goes to offset 0") - func cmdUpEndOfSingleLine() { - let textView = makeLaidOutTextView("SELECT * FROM users") - let length = (textView.string as NSString).length - - let range = textView.selectionManager.rangeOfSelection( - from: length, - direction: .up, - destination: .document - ) - - #expect(range.location == 0) - } - - @Test("Cmd+Up at end of multi-line query goes to offset 0") - func cmdUpEndOfMultiLine() { - let textView = makeLaidOutTextView("abc\ndef") + @Test("Cmd+Up at end of a single- or multi-line query goes to offset 0", arguments: ["SELECT * FROM users", "abc\ndef"]) + func cmdUpEnd(_ query: String) { + let textView = makeLaidOutTextView(query) let length = (textView.string as NSString).length let range = textView.selectionManager.rangeOfSelection( diff --git a/Packages/TableProEditor/Tests/TableProTextEngineTests/CopyPasteTests.swift b/Packages/TableProEditor/Tests/TableProTextEngineTests/CopyPasteTests.swift index 3a02f85289..51fc117553 100644 --- a/Packages/TableProEditor/Tests/TableProTextEngineTests/CopyPasteTests.swift +++ b/Packages/TableProEditor/Tests/TableProTextEngineTests/CopyPasteTests.swift @@ -225,18 +225,9 @@ struct CopyPasteTests { } } - @Test("A read-only view disables Paste, Cut and Delete but still allows Copy") - func readOnlyValidation() { - let result = validation(editable: false, selectionLength: 3, clipboardHasText: true) - #expect(result["paste"] == false) - #expect(result["cut"] == false) - #expect(result["delete"] == false) - #expect(result["copy"] == true) - } - - @Test("A read-only view with no selection still disables everything but Copy") - func readOnlyEmptySelectionValidation() { - let result = validation(editable: false, selectionLength: 0, clipboardHasText: true) + @Test("A read-only view disables Paste, Cut and Delete but still allows Copy, with or without a selection", arguments: [3, 0]) + func readOnlyValidation(_ selectionLength: Int) { + let result = validation(editable: false, selectionLength: selectionLength, clipboardHasText: true) #expect(result["paste"] == false) #expect(result["cut"] == false) #expect(result["delete"] == false) diff --git a/Packages/TableProEditor/Tests/TableProTextEngineTests/KillRingTests.swift b/Packages/TableProEditor/Tests/TableProTextEngineTests/KillRingTests.swift index d3ae1a3662..77005ff274 100644 --- a/Packages/TableProEditor/Tests/TableProTextEngineTests/KillRingTests.swift +++ b/Packages/TableProEditor/Tests/TableProTextEngineTests/KillRingTests.swift @@ -1,34 +1,36 @@ +import Foundation @testable import TableProTextEngine -import XCTest +import Testing -class KillRingTests: XCTestCase { - func test_killRingYank() { +@MainActor +final class KillRingTests { + @Test func test_killRingYank() { var ring = KillRing.shared ring.kill(strings: ["hello"]) for _ in 0..<100 { - XCTAssertEqual(ring.yank(), ["hello"]) + #expect(ring.yank() == ["hello"]) } ring.kill(strings: ["hello", "multiple", "strings"]) // should never change on yank for _ in 0..<100 { - XCTAssertEqual(ring.yank(), ["hello", "multiple", "strings"]) + #expect(ring.yank() == ["hello", "multiple", "strings"]) } ring = KillRing(2) ring.kill(strings: ["hello"]) for _ in 0..<100 { - XCTAssertEqual(ring.yank(), ["hello"]) + #expect(ring.yank() == ["hello"]) } ring.kill(strings: ["hello", "multiple", "strings"]) // should never change on yank for _ in 0..<100 { - XCTAssertEqual(ring.yank(), ["hello", "multiple", "strings"]) + #expect(ring.yank() == ["hello", "multiple", "strings"]) } } - func test_killRingYankAndSelect() { + @Test func test_killRingYankAndSelect() { let ring = KillRing(5) ring.kill(strings: ["1"]) ring.kill(strings: ["2"]) @@ -37,37 +39,37 @@ class KillRingTests: XCTestCase { ring.kill(strings: ["5"]) // should loop for _ in 0..<5 { - XCTAssertEqual(ring.yankAndSelect(), ["5"]) - XCTAssertEqual(ring.yankAndSelect(), ["1"]) - XCTAssertEqual(ring.yankAndSelect(), ["2"]) - XCTAssertEqual(ring.yankAndSelect(), ["3", "3", "3"]) - XCTAssertEqual(ring.yankAndSelect(), ["4", "4"]) + #expect(ring.yankAndSelect() == ["5"]) + #expect(ring.yankAndSelect() == ["1"]) + #expect(ring.yankAndSelect() == ["2"]) + #expect(ring.yankAndSelect() == ["3", "3", "3"]) + #expect(ring.yankAndSelect() == ["4", "4"]) } } - func test_textViewYank() { + @Test func test_textViewYank() { let view = TextView(string: "Hello World") view.selectionManager.setSelectedRange(NSRange(location: 0, length: 1)) view.delete(self) - XCTAssertEqual(view.string, "ello World") + #expect(view.string == "ello World") view.yank(self) - XCTAssertEqual(view.string, "Hello World") + #expect(view.string == "Hello World") view.selectionManager.setSelectedRange(NSRange(location: 0, length: 0)) view.yank(self) - XCTAssertEqual(view.string, "HHello World") + #expect(view.string == "HHello World") } - func test_textViewYankMultipleCursors() { + @Test func test_textViewYankMultipleCursors() { let view = TextView(string: "Hello World") view.selectionManager.setSelectedRanges([NSRange(location: 1, length: 0), NSRange(location: 4, length: 0)]) view.delete(self) - XCTAssertEqual(view.string, "elo World") + #expect(view.string == "elo World") view.yank(self) - XCTAssertEqual(view.string, "Hello World") + #expect(view.string == "Hello World") view.selectionManager.setSelectedRanges([NSRange(location: 0, length: 0)]) view.yank(self) - XCTAssertEqual(view.string, "H\nlHello World") + #expect(view.string == "H\nlHello World") } } diff --git a/Packages/TableProEditor/Tests/TableProTextEngineTests/LayoutManager/TextLayoutManagerAttachmentsTests.swift b/Packages/TableProEditor/Tests/TableProTextEngineTests/LayoutManager/TextLayoutManagerAttachmentsTests.swift index 4097397774..57c6b7ab06 100644 --- a/Packages/TableProEditor/Tests/TableProTextEngineTests/LayoutManager/TextLayoutManagerAttachmentsTests.swift +++ b/Packages/TableProEditor/Tests/TableProTextEngineTests/LayoutManager/TextLayoutManagerAttachmentsTests.swift @@ -50,56 +50,27 @@ struct TextLayoutManagerAttachmentsTests { // MARK: - Determine Visible Line Tests - @Test - func determineVisibleLinesMovesForwards() throws { - // From middle of the first line, to middle of the third line - layoutManager.attachments.add(DemoTextAttachment(), for: NSRange(start: 2, end: 8)) - - // Start with the first line, should extend to the third line - let originalPosition = try #require(layoutManager.lineStorage.getLine(atIndex: 0)) // zero-indexed - let newPosition = try #require(layoutManager.determineVisiblePosition(for: originalPosition)) - - #expect(newPosition.indexRange == 0...2) - #expect(newPosition.position.range == NSRange(start: 0, end: 9)) // Lines one -> three - } - - @Test - func determineVisibleLinesMovesBackwards() throws { - // From middle of the first line, to middle of the third line + @Test(arguments: [0, 2]) + func determineVisibleLinesMovesEitherWay(_ startLine: Int) throws { layoutManager.attachments.add(DemoTextAttachment(), for: NSRange(start: 2, end: 8)) - // Start with the third line, should extend back to the first line - let originalPosition = try #require(layoutManager.lineStorage.getLine(atIndex: 2)) // zero-indexed + let originalPosition = try #require(layoutManager.lineStorage.getLine(atIndex: startLine)) let newPosition = try #require(layoutManager.determineVisiblePosition(for: originalPosition)) #expect(newPosition.indexRange == 0...2) - #expect(newPosition.position.range == NSRange(start: 0, end: 9)) // Lines one -> three + #expect(newPosition.position.range == NSRange(start: 0, end: 9)) } - @Test - func determineVisibleLinesMergesMultipleAttachments() throws { - // Two attachments, meeting at the third line. `determineVisiblePosition` should merge all four lines. - layoutManager.attachments.add(DemoTextAttachment(), for: NSRange(start: 2, end: 7)) - layoutManager.attachments.add(DemoTextAttachment(), for: NSRange(start: 7, end: 11)) - - let originalPosition = try #require(layoutManager.lineStorage.getLine(atIndex: 2)) // zero-indexed - let newPosition = try #require(layoutManager.determineVisiblePosition(for: originalPosition)) - - #expect(newPosition.indexRange == 0...3) - #expect(newPosition.position.range == NSRange(start: 0, end: 12)) // Lines one -> four - } - - @Test - func determineVisibleLinesMergesOverlappingAttachments() throws { - // Two attachments, overlapping at the third line. `determineVisiblePosition` should merge all four lines. + @Test(arguments: [7, 5]) + func determineVisibleLinesMergesAttachmentsMeetingOrOverlapping(_ secondStart: Int) throws { layoutManager.attachments.add(DemoTextAttachment(), for: NSRange(start: 2, end: 7)) - layoutManager.attachments.add(DemoTextAttachment(), for: NSRange(start: 5, end: 11)) + layoutManager.attachments.add(DemoTextAttachment(), for: NSRange(start: secondStart, end: 11)) - let originalPosition = try #require(layoutManager.lineStorage.getLine(atIndex: 2)) // zero-indexed + let originalPosition = try #require(layoutManager.lineStorage.getLine(atIndex: 2)) let newPosition = try #require(layoutManager.determineVisiblePosition(for: originalPosition)) #expect(newPosition.indexRange == 0...3) - #expect(newPosition.position.range == NSRange(start: 0, end: 12)) // Lines one -> four + #expect(newPosition.position.range == NSRange(start: 0, end: 12)) } // MARK: - Iterator Tests diff --git a/Packages/TableProEditor/Tests/TableProTextEngineTests/LineEndingTests.swift b/Packages/TableProEditor/Tests/TableProTextEngineTests/LineEndingTests.swift index fd63f6a69d..353a166dae 100644 --- a/Packages/TableProEditor/Tests/TableProTextEngineTests/LineEndingTests.swift +++ b/Packages/TableProEditor/Tests/TableProTextEngineTests/LineEndingTests.swift @@ -1,32 +1,34 @@ +import AppKit @testable import TableProTextEngine -import XCTest +import Testing -class LineEndingTests: XCTestCase { - func test_lineEndingCreateUnix() { +@MainActor +struct LineEndingTests { + @Test func test_lineEndingCreateUnix() { // The \n character - XCTAssertNotNil(LineEnding(rawValue: "\n"), "Line ending failed to initialize with the \\n character") + #expect(LineEnding(rawValue: "\n") != nil, "Line ending failed to initialize with the \\n character") let line = "Loren Ipsum\n" - XCTAssertNotNil(LineEnding(line: line), "Line ending failed to initialize with a line ending in \\n") + #expect(LineEnding(line: line) != nil, "Line ending failed to initialize with a line ending in \\n") } - func test_lineEndingCreateCRLF() { + @Test func test_lineEndingCreateCRLF() { // The \r\n sequence - XCTAssertNotNil(LineEnding(rawValue: "\r\n"), "Line ending failed to initialize with the \\r\\n sequence") + #expect(LineEnding(rawValue: "\r\n") != nil, "Line ending failed to initialize with the \\r\\n sequence") let line = "Loren Ipsum\r\n" - XCTAssertNotNil(LineEnding(line: line), "Line ending failed to initialize with a line ending in \\r\\n") + #expect(LineEnding(line: line) != nil, "Line ending failed to initialize with a line ending in \\r\\n") } - func test_lineEndingCreateMacOS() { + @Test func test_lineEndingCreateMacOS() { // The \r character - XCTAssertNotNil(LineEnding(rawValue: "\r"), "Line ending failed to initialize with the \\r character") + #expect(LineEnding(rawValue: "\r") != nil, "Line ending failed to initialize with the \\r character") let line = "Loren Ipsum\r" - XCTAssertNotNil(LineEnding(line: line), "Line ending failed to initialize with a line ending in \\r") + #expect(LineEnding(line: line) != nil, "Line ending failed to initialize with a line ending in \\r") } - func test_detectLineEndingDefault() { + @Test func test_detectLineEndingDefault() { // There was a bug in this that caused it to flake sometimes, so we run this a couple times to ensure it's not // flaky. // The odds of it being bad with the earlier bug after running 20 times is incredibly small @@ -35,7 +37,7 @@ class LineEndingTests: XCTestCase { let lineStorage = TextLineStorage() lineStorage.buildFromTextStorage(storage, estimatedLineHeight: 10) let detected = LineEnding.detectLineEnding(lineStorage: lineStorage, textStorage: storage) - XCTAssertEqual(detected, .lineFeed) + #expect(detected == .lineFeed) } } @@ -48,7 +50,7 @@ class LineEndingTests: XCTestCase { } } - func test_detectLineEndingUnix() { + @Test func test_detectLineEndingUnix() { let goalLineEnding = LineEnding.lineFeed let storage = NSTextStorage(string: makeRandomText(goalLineEnding)) @@ -56,10 +58,10 @@ class LineEndingTests: XCTestCase { lineStorage.buildFromTextStorage(storage, estimatedLineHeight: 10) let detected = LineEnding.detectLineEnding(lineStorage: lineStorage, textStorage: storage) - XCTAssertEqual(detected, goalLineEnding) + #expect(detected == goalLineEnding) } - func test_detectLineEndingCLRF() { + @Test func test_detectLineEndingCLRF() { let goalLineEnding = LineEnding.carriageReturnLineFeed let storage = NSTextStorage(string: makeRandomText(goalLineEnding)) @@ -67,10 +69,10 @@ class LineEndingTests: XCTestCase { lineStorage.buildFromTextStorage(storage, estimatedLineHeight: 10) let detected = LineEnding.detectLineEnding(lineStorage: lineStorage, textStorage: storage) - XCTAssertEqual(detected, goalLineEnding) + #expect(detected == goalLineEnding) } - func test_detectLineEndingMacOS() { + @Test func test_detectLineEndingMacOS() { let goalLineEnding = LineEnding.carriageReturn let storage = NSTextStorage(string: makeRandomText(goalLineEnding)) @@ -78,6 +80,6 @@ class LineEndingTests: XCTestCase { lineStorage.buildFromTextStorage(storage, estimatedLineHeight: 10) let detected = LineEnding.detectLineEnding(lineStorage: lineStorage, textStorage: storage) - XCTAssertEqual(detected, goalLineEnding) + #expect(detected == goalLineEnding) } } diff --git a/Packages/TableProEditor/Tests/TableProTextEngineTests/MarkedTextTests.swift b/Packages/TableProEditor/Tests/TableProTextEngineTests/MarkedTextTests.swift index 68f26a144b..949df5b177 100644 --- a/Packages/TableProEditor/Tests/TableProTextEngineTests/MarkedTextTests.swift +++ b/Packages/TableProEditor/Tests/TableProTextEngineTests/MarkedTextTests.swift @@ -1,127 +1,124 @@ +import Foundation @testable import TableProTextEngine -import XCTest +import Testing -class MarkedTextTests: XCTestCase { - func test_markedTextSingleChar() { +@MainActor +struct MarkedTextTests { + @Test func test_markedTextSingleChar() { let textView = TextView(string: "") textView.selectionManager.setSelectedRange(.zero) textView.setMarkedText("´", selectedRange: .notFound, replacementRange: .notFound) - XCTAssertEqual(textView.string, "´") + #expect(textView.string == "´") textView.insertText("é", replacementRange: .notFound) - XCTAssertEqual(textView.string, "é") - XCTAssertEqual(textView.selectionManager.textSelections.map(\.range), [NSRange(location: 1, length: 0)]) + #expect(textView.string == "é") + #expect(textView.selectionManager.textSelections.map(\.range) == [NSRange(location: 1, length: 0)]) } - func test_markedTextSingleCharInStrings() { + @Test func test_markedTextSingleCharInStrings() { let textView = TextView(string: "Lorem Ipsum") textView.selectionManager.setSelectedRange(NSRange(location: 5, length: 0)) textView.setMarkedText("´", selectedRange: .notFound, replacementRange: .notFound) - XCTAssertEqual(textView.string, "Lorem´ Ipsum") + #expect(textView.string == "Lorem´ Ipsum") textView.insertText("é", replacementRange: .notFound) - XCTAssertEqual(textView.string, "Loremé Ipsum") - XCTAssertEqual(textView.selectionManager.textSelections.map(\.range), [NSRange(location: 6, length: 0)]) + #expect(textView.string == "Loremé Ipsum") + #expect(textView.selectionManager.textSelections.map(\.range) == [NSRange(location: 6, length: 0)]) } - func test_markedTextReplaceSelection() { + @Test func test_markedTextReplaceSelection() { let textView = TextView(string: "ABCDE") textView.selectionManager.setSelectedRange(NSRange(location: 4, length: 1)) textView.setMarkedText("´", selectedRange: .notFound, replacementRange: .notFound) - XCTAssertEqual(textView.string, "ABCD´") + #expect(textView.string == "ABCD´") textView.insertText("é", replacementRange: .notFound) - XCTAssertEqual(textView.string, "ABCDé") - XCTAssertEqual(textView.selectionManager.textSelections.map(\.range), [NSRange(location: 5, length: 0)]) + #expect(textView.string == "ABCDé") + #expect(textView.selectionManager.textSelections.map(\.range) == [NSRange(location: 5, length: 0)]) } - func test_markedTextMultipleSelection() { + @Test func test_markedTextMultipleSelection() { let textView = TextView(string: "ABC") textView.selectionManager.setSelectedRanges([NSRange(location: 1, length: 0), NSRange(location: 2, length: 0)]) textView.setMarkedText("´", selectedRange: .notFound, replacementRange: .notFound) - XCTAssertEqual(textView.string, "A´B´C") + #expect(textView.string == "A´B´C") textView.insertText("é", replacementRange: .notFound) - XCTAssertEqual(textView.string, "AéBéC") - XCTAssertEqual( - textView.selectionManager.textSelections.map(\.range).sorted(by: { $0.location < $1.location }), - [NSRange(location: 2, length: 0), NSRange(location: 4, length: 0)] - ) + #expect(textView.string == "AéBéC") + #expect(textView.selectionManager.textSelections.map(\.range).sorted(by: { $0.location < $1.location }) == [ + NSRange(location: 2, length: 0), NSRange(location: 4, length: 0) + ]) } - func test_markedTextMultipleSelectionReplaceSelection() { + @Test func test_markedTextMultipleSelectionReplaceSelection() { let textView = TextView(string: "ABCDE") textView.selectionManager.setSelectedRanges([NSRange(location: 0, length: 1), NSRange(location: 4, length: 1)]) textView.setMarkedText("´", selectedRange: .notFound, replacementRange: .notFound) - XCTAssertEqual(textView.string, "´BCD´") + #expect(textView.string == "´BCD´") textView.insertText("é", replacementRange: .notFound) - XCTAssertEqual(textView.string, "éBCDé") - XCTAssertEqual( - textView.selectionManager.textSelections.map(\.range).sorted(by: { $0.location < $1.location }), - [NSRange(location: 1, length: 0), NSRange(location: 5, length: 0)] - ) + #expect(textView.string == "éBCDé") + #expect(textView.selectionManager.textSelections.map(\.range).sorted(by: { $0.location < $1.location }) == [ + NSRange(location: 1, length: 0), NSRange(location: 5, length: 0) + ]) } - func test_markedTextMultipleSelectionMultipleChar() { + @Test func test_markedTextMultipleSelectionMultipleChar() { let textView = TextView(string: "ABCDE") textView.selectionManager.setSelectedRanges([NSRange(location: 0, length: 1), NSRange(location: 4, length: 1)]) textView.setMarkedText("´", selectedRange: .notFound, replacementRange: .notFound) - XCTAssertEqual(textView.string, "´BCD´") + #expect(textView.string == "´BCD´") textView.setMarkedText("´´´", selectedRange: .notFound, replacementRange: .notFound) - XCTAssertEqual(textView.string, "´´´BCD´´´") - XCTAssertEqual( - textView.selectionManager.textSelections.map(\.range).sorted(by: { $0.location < $1.location }), - [NSRange(location: 3, length: 0), NSRange(location: 9, length: 0)] - ) + #expect(textView.string == "´´´BCD´´´") + #expect(textView.selectionManager.textSelections.map(\.range).sorted(by: { $0.location < $1.location }) == [ + NSRange(location: 3, length: 0), NSRange(location: 9, length: 0) + ]) textView.insertText("é", replacementRange: .notFound) - XCTAssertEqual(textView.string, "éBCDé") - XCTAssertEqual( - textView.selectionManager.textSelections.map(\.range).sorted(by: { $0.location < $1.location }), - [NSRange(location: 1, length: 0), NSRange(location: 5, length: 0)] - ) + #expect(textView.string == "éBCDé") + #expect(textView.selectionManager.textSelections.map(\.range).sorted(by: { $0.location < $1.location }) == [ + NSRange(location: 1, length: 0), NSRange(location: 5, length: 0) + ]) } - func test_cancelMarkedText() { + @Test func test_cancelMarkedText() { let textView = TextView(string: "") textView.selectionManager.setSelectedRange(.zero) textView.setMarkedText("´", selectedRange: .notFound, replacementRange: .notFound) - XCTAssertEqual(textView.string, "´") + #expect(textView.string == "´") // The NSTextInputContext performs the following actions when a marked text segment is ended w/o replacing the // marked text: textView.insertText("´", replacementRange: .notFound) textView.insertText("4", replacementRange: .notFound) - XCTAssertEqual(textView.string, "´4") - XCTAssertEqual(textView.selectionManager.textSelections.map(\.range), [NSRange(location: 2, length: 0)]) + #expect(textView.string == "´4") + #expect(textView.selectionManager.textSelections.map(\.range) == [NSRange(location: 2, length: 0)]) } - func test_cancelMarkedTextMultipleCursor() { + @Test func test_cancelMarkedTextMultipleCursor() { let textView = TextView(string: "ABC") textView.selectionManager.setSelectedRanges([NSRange(location: 1, length: 0), NSRange(location: 2, length: 0)]) textView.setMarkedText("´", selectedRange: .notFound, replacementRange: .notFound) - XCTAssertEqual(textView.string, "A´B´C") + #expect(textView.string == "A´B´C") // The NSTextInputContext performs the following actions when a marked text segment is ended w/o replacing the // marked text: textView.insertText("´", replacementRange: .notFound) textView.insertText("4", replacementRange: .notFound) - XCTAssertEqual(textView.string, "A´4B´4C") - XCTAssertEqual( - textView.selectionManager.textSelections.map(\.range).sorted(by: { $0.location < $1.location }), - [NSRange(location: 3, length: 0), NSRange(location: 6, length: 0)] - ) + #expect(textView.string == "A´4B´4C") + #expect(textView.selectionManager.textSelections.map(\.range).sorted(by: { $0.location < $1.location }) == [ + NSRange(location: 3, length: 0), NSRange(location: 6, length: 0) + ]) } } diff --git a/Packages/TableProEditor/Tests/TableProTextEngineTests/TextSelectionManagerTests.swift b/Packages/TableProEditor/Tests/TableProTextEngineTests/TextSelectionManagerTests.swift index 99c7d7f448..8b1c395bb7 100644 --- a/Packages/TableProEditor/Tests/TableProTextEngineTests/TextSelectionManagerTests.swift +++ b/Packages/TableProEditor/Tests/TableProTextEngineTests/TextSelectionManagerTests.swift @@ -1,7 +1,9 @@ +import AppKit @testable import TableProTextEngine -import XCTest +import Testing -final class TextSelectionManagerTests: XCTestCase { +@MainActor +final class TextSelectionManagerTests { var textStorage: NSTextStorage! var layoutManager: TextLayoutManager! @@ -22,7 +24,7 @@ final class TextSelectionManagerTests: XCTestCase { ) } - func test_updateSelectionLeft() { + @Test func test_updateSelectionLeft() { let selectionManager = selectionManager() let locations = [2, 0, 14, 14] let expectedRanges = [(1, 1), (0, 0), (12, 2), (12, 2)] @@ -36,20 +38,12 @@ final class TextSelectionManagerTests: XCTestCase { decomposeCharacters: decomposeCharacters[idx] ) - XCTAssertEqual( - range.location, - expectedRanges[idx].0, - "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)" - ) - XCTAssertEqual( - range.length, - expectedRanges[idx].1, - "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)" - ) + #expect(range.location == expectedRanges[idx].0, "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)") + #expect(range.length == expectedRanges[idx].1, "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)") } } - func test_updateSelectionRight() { + @Test func test_updateSelectionRight() { let selectionManager = selectionManager() let locations = [2, 0, 14, 13, 12] let expectedRanges = [(2, 1), (0, 1), (14, 0), (12, 2), (12, 2)] @@ -63,20 +57,12 @@ final class TextSelectionManagerTests: XCTestCase { decomposeCharacters: decomposeCharacters[idx] ) - XCTAssertEqual( - range.location, - expectedRanges[idx].0, - "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)" - ) - XCTAssertEqual( - range.length, - expectedRanges[idx].1, - "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)" - ) + #expect(range.location == expectedRanges[idx].0, "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)") + #expect(range.length == expectedRanges[idx].1, "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)") } } - func test_updateSelectionLeftWord() { + @Test func test_updateSelectionLeftWord() { let selectionManager = selectionManager() let locations = [2, 0, 12] let expectedRanges = [(0, 2), (0, 0), (6, 6)] @@ -89,20 +75,12 @@ final class TextSelectionManagerTests: XCTestCase { decomposeCharacters: false ) - XCTAssertEqual( - range.location, - expectedRanges[idx].0, - "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)" - ) - XCTAssertEqual( - range.length, - expectedRanges[idx].1, - "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)" - ) + #expect(range.location == expectedRanges[idx].0, "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)") + #expect(range.length == expectedRanges[idx].1, "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)") } } - func test_updateSelectionRightWord() { + @Test func test_updateSelectionRightWord() { // "Loren Ipsum 💯" let selectionManager = selectionManager() let locations = [2, 0, 6] @@ -116,20 +94,12 @@ final class TextSelectionManagerTests: XCTestCase { decomposeCharacters: false ) - XCTAssertEqual( - range.location, - expectedRanges[idx].0, - "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)" - ) - XCTAssertEqual( - range.length, - expectedRanges[idx].1, - "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)" - ) + #expect(range.location == expectedRanges[idx].0, "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)") + #expect(range.length == expectedRanges[idx].1, "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)") } } - func test_updateSelectionLeftLine() { + @Test func test_updateSelectionLeftLine() { // "Loren Ipsum 💯" let selectionManager = selectionManager() let locations = [2, 0, 14, 12] @@ -143,20 +113,12 @@ final class TextSelectionManagerTests: XCTestCase { decomposeCharacters: false ) - XCTAssertEqual( - range.location, - expectedRanges[idx].0, - "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)" - ) - XCTAssertEqual( - range.length, - expectedRanges[idx].1, - "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)" - ) + #expect(range.location == expectedRanges[idx].0, "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)") + #expect(range.length == expectedRanges[idx].1, "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)") } } - func test_updateSelectionRightLine() { + @Test func test_updateSelectionRightLine() { let selectionManager = selectionManager("Loren Ipsum 💯\nHello World") let locations = [2, 0, 14, 12, 17] let expectedRanges = [(2, 12), (0, 14), (14, 0), (12, 2), (17, 9)] @@ -169,20 +131,12 @@ final class TextSelectionManagerTests: XCTestCase { decomposeCharacters: false ) - XCTAssertEqual( - range.location, - expectedRanges[idx].0, - "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)" - ) - XCTAssertEqual( - range.length, - expectedRanges[idx].1, - "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)" - ) + #expect(range.location == expectedRanges[idx].0, "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)") + #expect(range.length == expectedRanges[idx].1, "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)") } } - func test_updateSelectionUpDocument() { + @Test func test_updateSelectionUpDocument() { let selectionManager = selectionManager("Loren Ipsum 💯\nHello World\n1\n2\n3\n") let locations = [0, 27, 30, 33] let expectedRanges = [(0, 0), (0, 27), (0, 30), (0, 33)] @@ -195,20 +149,12 @@ final class TextSelectionManagerTests: XCTestCase { decomposeCharacters: false ) - XCTAssertEqual( - range.location, - expectedRanges[idx].0, - "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)" - ) - XCTAssertEqual( - range.length, - expectedRanges[idx].1, - "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)" - ) + #expect(range.location == expectedRanges[idx].0, "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)") + #expect(range.length == expectedRanges[idx].1, "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)") } } - func test_updateSelectionDownDocument() { + @Test func test_updateSelectionDownDocument() { let selectionManager = selectionManager("Loren Ipsum 💯\nHello World\n1\n2\n3\n") let locations = [0, 2, 27, 30, 33] let expectedRanges = [(0, 33), (2, 31), (27, 6), (30, 3), (33, 0)] @@ -221,47 +167,39 @@ final class TextSelectionManagerTests: XCTestCase { decomposeCharacters: false ) - XCTAssertEqual( - range.location, - expectedRanges[idx].0, - "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)" - ) - XCTAssertEqual( - range.length, - expectedRanges[idx].1, - "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)" - ) + #expect(range.location == expectedRanges[idx].0, "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)") + #expect(range.length == expectedRanges[idx].1, "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)") } } - func test_selectionEndOfDocumentHasXPos() { + @Test func test_selectionEndOfDocumentHasXPos() { let selectionManager = selectionManager("1\n2\n3\n") selectionManager.setSelectedRange(NSRange(location: 6, length: 0)) // Beyond text.length, end of doc - XCTAssertNotNil(selectionManager.textSelections.first?.suggestedXPos) + #expect(selectionManager.textSelections.first?.suggestedXPos != nil) } - func test_setSelectedRangesClampsBeyondStorageInsteadOfDropping() { + @Test func test_setSelectedRangesClampsBeyondStorageInsteadOfDropping() { let selectionManager = selectionManager("short") selectionManager.setSelectedRanges([NSRange(location: 50, length: 0)]) - XCTAssertEqual(selectionManager.textSelections.count, 1) - XCTAssertEqual(selectionManager.textSelections.first?.range, NSRange(location: 5, length: 0)) + #expect(selectionManager.textSelections.count == 1) + #expect(selectionManager.textSelections.first?.range == NSRange(location: 5, length: 0)) } - func test_setSelectedRangesClampsAnOverlongRangeToTheStorage() { + @Test func test_setSelectedRangesClampsAnOverlongRangeToTheStorage() { let selectionManager = selectionManager("short") selectionManager.setSelectedRanges([NSRange(location: 2, length: 40)]) - XCTAssertEqual(selectionManager.textSelections.first?.range, NSRange(location: 2, length: 3)) + #expect(selectionManager.textSelections.first?.range == NSRange(location: 2, length: 3)) } - func test_setSelectedRangeClampsBeyondStorage() { + @Test func test_setSelectedRangeClampsBeyondStorage() { let selectionManager = selectionManager("short") selectionManager.setSelectedRange(NSRange(location: 50, length: 10)) - XCTAssertEqual(selectionManager.textSelections.first?.range, NSRange(location: 5, length: 0)) + #expect(selectionManager.textSelections.first?.range == NSRange(location: 5, length: 0)) } } diff --git a/Packages/TableProMacCore/Sources/TableProSSHConfig/SSHMatchExecutor.swift b/Packages/TableProMacCore/Sources/TableProSSHConfig/SSHMatchExecutor.swift index a8fee68a75..42b650155c 100644 --- a/Packages/TableProMacCore/Sources/TableProSSHConfig/SSHMatchExecutor.swift +++ b/Packages/TableProMacCore/Sources/TableProSSHConfig/SSHMatchExecutor.swift @@ -1,15 +1,10 @@ -// -// SSHMatchExecutor.swift -// TablePro -// - import Foundation import os import TableProLogRedaction enum SSHMatchExecutor { private static let logger = Logger(subsystem: "com.TablePro", category: "SSHMatchExecutor") - private static let timeoutSeconds: TimeInterval = 5 + static let timeoutSeconds: TimeInterval = 5 private static let killGraceSeconds: TimeInterval = 2 @@ -24,6 +19,10 @@ enum SSHMatchExecutor { /// `trap '' TERM; sleep 300` never returned. A handler that merely traps and exits is fine, so /// the grace period gives it its chance first. static func evaluate(command: String) -> Bool { + evaluate(command: command, timeout: timeoutSeconds) + } + + static func evaluate(command: String, timeout: TimeInterval) -> Bool { let trimmed = command.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty else { return false } @@ -58,7 +57,7 @@ enum SSHMatchExecutor { ) } DispatchQueue.global(qos: .utility).asyncAfter( - deadline: .now() + timeoutSeconds, + deadline: .now() + timeout, execute: timeoutWorkItem ) diff --git a/Packages/TableProMacCore/Sources/TableProUtilities/UI/SplitViewAutosaveKey.swift b/Packages/TableProMacCore/Sources/TableProUtilities/UI/SplitViewAutosaveKey.swift new file mode 100644 index 0000000000..13d463a2c3 --- /dev/null +++ b/Packages/TableProMacCore/Sources/TableProUtilities/UI/SplitViewAutosaveKey.swift @@ -0,0 +1,11 @@ +import Foundation + +/// Apple documents `autosaveName` and nothing else, so this spelling is a transcription that +/// `SplitViewAutosaveKeyTests` checks against a real `NSSplitView`. +public enum SplitViewAutosaveKey { + public static let prefix = "NSSplitView Subview Frames " + + public static func key(forAutosaveName name: String) -> String { + prefix + name + } +} diff --git a/Packages/TableProMacCore/Tests/TableProAutocompleteTests/DerivedTableParserTests.swift b/Packages/TableProMacCore/Tests/TableProAutocompleteTests/DerivedTableParserTests.swift index 121725ae7c..dcc4f1f19f 100644 --- a/Packages/TableProMacCore/Tests/TableProAutocompleteTests/DerivedTableParserTests.swift +++ b/Packages/TableProMacCore/Tests/TableProAutocompleteTests/DerivedTableParserTests.swift @@ -105,15 +105,11 @@ struct DerivedTableParserTests { #expect(table(parser.parse(query), alias: "d")?.columns == ["avg_score"]) } - @Test("Subquery in WHERE IN is not a derived table") - func subqueryInWhereIsNotDerived() { - let query = "SELECT * FROM users WHERE id IN (SELECT user_id FROM orders)" - #expect(parser.parse(query).isEmpty) - } - - @Test("Function call parentheses are not derived tables") - func functionCallNotDerived() { - let query = "SELECT COUNT(id), AVG(score) FROM users" + @Test("A subquery in WHERE IN and function call parentheses are not derived tables", arguments: [ + "SELECT * FROM users WHERE id IN (SELECT user_id FROM orders)", + "SELECT COUNT(id), AVG(score) FROM users", + ]) + func parenthesesThatAreNotDerivedTables(_ query: String) { #expect(parser.parse(query).isEmpty) } diff --git a/Packages/TableProMacCore/Tests/TableProAutocompleteTests/KeywordUppercaseHelperTests.swift b/Packages/TableProMacCore/Tests/TableProAutocompleteTests/KeywordUppercaseHelperTests.swift index 6871046ec7..a4ea58badf 100644 --- a/Packages/TableProMacCore/Tests/TableProAutocompleteTests/KeywordUppercaseHelperTests.swift +++ b/Packages/TableProMacCore/Tests/TableProAutocompleteTests/KeywordUppercaseHelperTests.swift @@ -5,19 +5,9 @@ import Testing struct KeywordUppercaseHelperTests { // MARK: - isWordBoundary - @Test("Space is a word boundary") - func spaceIsBoundary() { - #expect(KeywordUppercaseHelper.isWordBoundary(" ")) - } - - @Test("Tab is a word boundary") - func tabIsBoundary() { - #expect(KeywordUppercaseHelper.isWordBoundary("\t")) - } - - @Test("Newline is a word boundary") - func newlineIsBoundary() { - #expect(KeywordUppercaseHelper.isWordBoundary("\n")) + @Test("Space, tab and newline are word boundaries", arguments: [" ", "\t", "\n"]) + func whitespaceIsBoundary(_ string: String) { + #expect(KeywordUppercaseHelper.isWordBoundary(string)) } @Test("Parentheses are word boundaries") @@ -44,14 +34,9 @@ struct KeywordUppercaseHelperTests { #expect(!KeywordUppercaseHelper.isWordBoundary("ab")) } - @Test("Empty string is not a word boundary") - func emptyNotBoundary() { - #expect(!KeywordUppercaseHelper.isWordBoundary("")) - } - - @Test("Digits are not word boundaries") - func digitsNotBoundary() { - #expect(!KeywordUppercaseHelper.isWordBoundary("5")) + @Test("The empty string and digits are not word boundaries", arguments: ["", "5"]) + func notBoundary(_ string: String) { + #expect(!KeywordUppercaseHelper.isWordBoundary(string)) } // MARK: - isWordCharacter diff --git a/Packages/TableProMacCore/Tests/TableProAutocompleteTests/SQLContextAnalyzerCaseInsensitiveTests.swift b/Packages/TableProMacCore/Tests/TableProAutocompleteTests/SQLContextAnalyzerCaseInsensitiveTests.swift index 7fcd497daa..0f3fa1f88e 100644 --- a/Packages/TableProMacCore/Tests/TableProAutocompleteTests/SQLContextAnalyzerCaseInsensitiveTests.swift +++ b/Packages/TableProMacCore/Tests/TableProAutocompleteTests/SQLContextAnalyzerCaseInsensitiveTests.swift @@ -6,242 +6,122 @@ struct SQLContextAnalyzerCaseInsensitiveTests { // MARK: - SELECT - @Test("Uppercase SELECT detected") - func uppercaseSelect() { - let query = "SELECT " - let context = analyzer.analyze(query: query, cursorPosition: query.count) - #expect(context.clauseType == .select) - } - - @Test("Lowercase select detected") - func lowercaseSelect() { - let query = "select " - let context = analyzer.analyze(query: query, cursorPosition: query.count) - #expect(context.clauseType == .select) - } - - @Test("Mixed case Select detected") - func mixedCaseSelect() { - let query = "Select " + @Test("SELECT is detected in any case", arguments: [ + "SELECT ", + "select ", + "Select ", + ]) + func selectInAnyCase(_ query: String) { let context = analyzer.analyze(query: query, cursorPosition: query.count) #expect(context.clauseType == .select) } // MARK: - WHERE - @Test("Uppercase WHERE detected") - func uppercaseWhere() { - let query = "SELECT * FROM users WHERE " - let context = analyzer.analyze(query: query, cursorPosition: query.count) - #expect(context.clauseType == .where_) - } - - @Test("Lowercase where detected") - func lowercaseWhere() { - let query = "select * from users where " - let context = analyzer.analyze(query: query, cursorPosition: query.count) - #expect(context.clauseType == .where_) - } - - @Test("Mixed case Where detected") - func mixedCaseWhere() { - let query = "Select * From users Where " + @Test("WHERE is detected in any case, even with every keyword mixed", arguments: [ + "SELECT * FROM users WHERE ", + "select * from users where ", + "Select * From users Where ", + "sElEcT * fRoM users wHeRe ", + ]) + func whereInAnyCase(_ query: String) { let context = analyzer.analyze(query: query, cursorPosition: query.count) #expect(context.clauseType == .where_) } // MARK: - FROM - @Test("Uppercase FROM detected") - func uppercaseFrom() { - let query = "SELECT * FROM " - let context = analyzer.analyze(query: query, cursorPosition: query.count) - #expect(context.clauseType == .from) - } - - @Test("Lowercase from detected") - func lowercaseFrom() { - let query = "select * from " - let context = analyzer.analyze(query: query, cursorPosition: query.count) - #expect(context.clauseType == .from) - } - - @Test("Mixed case From detected") - func mixedCaseFrom() { - let query = "Select * From " + @Test("FROM and DELETE FROM are detected in any case", arguments: [ + "SELECT * FROM ", + "select * from ", + "Select * From ", + "DELETE FROM ", + "delete from ", + ]) + func fromInAnyCase(_ query: String) { let context = analyzer.analyze(query: query, cursorPosition: query.count) #expect(context.clauseType == .from) } // MARK: - INSERT INTO - @Test("Uppercase INSERT INTO detected") - func uppercaseInsertInto() { - let query = "INSERT INTO " - let context = analyzer.analyze(query: query, cursorPosition: query.count) - #expect(context.clauseType == .into) - } - - @Test("Lowercase insert into detected") - func lowercaseInsertInto() { - let query = "insert into " - let context = analyzer.analyze(query: query, cursorPosition: query.count) - #expect(context.clauseType == .into) - } - - @Test("Mixed case Insert Into detected") - func mixedCaseInsertInto() { - let query = "Insert Into " + @Test("INSERT INTO is detected in any case", arguments: [ + "INSERT INTO ", + "insert into ", + "Insert Into ", + ]) + func insertIntoInAnyCase(_ query: String) { let context = analyzer.analyze(query: query, cursorPosition: query.count) #expect(context.clauseType == .into) } // MARK: - UPDATE SET - @Test("Uppercase UPDATE SET detected") - func uppercaseUpdateSet() { - let query = "UPDATE users SET " + @Test("UPDATE SET is detected in any case", arguments: [ + "UPDATE users SET ", + "update users set ", + "Update users Set ", + ]) + func updateSetInAnyCase(_ query: String) { let context = analyzer.analyze(query: query, cursorPosition: query.count) #expect(context.clauseType == .set) } - @Test("Lowercase update set detected") - func lowercaseUpdateSet() { - let query = "update users set " - let context = analyzer.analyze(query: query, cursorPosition: query.count) - #expect(context.clauseType == .set) - } - - @Test("Mixed case Update Set detected") - func mixedCaseUpdateSet() { - let query = "Update users Set " - let context = analyzer.analyze(query: query, cursorPosition: query.count) - #expect(context.clauseType == .set) - } - - // MARK: - DELETE FROM - - @Test("Uppercase DELETE FROM detected") - func uppercaseDeleteFrom() { - let query = "DELETE FROM " - let context = analyzer.analyze(query: query, cursorPosition: query.count) - #expect(context.clauseType == .from) - } - - @Test("Lowercase delete from detected") - func lowercaseDeleteFrom() { - let query = "delete from " - let context = analyzer.analyze(query: query, cursorPosition: query.count) - #expect(context.clauseType == .from) - } - // MARK: - ORDER BY - @Test("Uppercase ORDER BY detected") - func uppercaseOrderBy() { - let query = "SELECT * FROM users ORDER BY " - let context = analyzer.analyze(query: query, cursorPosition: query.count) - #expect(context.clauseType == .orderBy) - } - - @Test("Lowercase order by detected") - func lowercaseOrderBy() { - let query = "select * from users order by " - let context = analyzer.analyze(query: query, cursorPosition: query.count) - #expect(context.clauseType == .orderBy) - } - - @Test("Mixed case Order By detected") - func mixedCaseOrderBy() { - let query = "Select * From users Order By " + @Test("ORDER BY is detected in any case, even after a randomly cased query", arguments: [ + "SELECT * FROM users ORDER BY ", + "select * from users order by ", + "Select * From users Order By ", + "SeLeCt id, name FrOm users WhErE active = 1 OrDeR bY ", + ]) + func orderByInAnyCase(_ query: String) { let context = analyzer.analyze(query: query, cursorPosition: query.count) #expect(context.clauseType == .orderBy) } // MARK: - GROUP BY - @Test("Uppercase GROUP BY detected") - func uppercaseGroupBy() { - let query = "SELECT * FROM users GROUP BY " - let context = analyzer.analyze(query: query, cursorPosition: query.count) - #expect(context.clauseType == .groupBy) - } - - @Test("Lowercase group by detected") - func lowercaseGroupBy() { - let query = "select * from users group by " + @Test("GROUP BY is detected in any case", arguments: [ + "SELECT * FROM users GROUP BY ", + "select * from users group by ", + ]) + func groupByInAnyCase(_ query: String) { let context = analyzer.analyze(query: query, cursorPosition: query.count) #expect(context.clauseType == .groupBy) } // MARK: - HAVING - @Test("Uppercase HAVING detected") - func uppercaseHaving() { - let query = "SELECT COUNT(*) FROM users GROUP BY status HAVING " - let context = analyzer.analyze(query: query, cursorPosition: query.count) - #expect(context.clauseType == .having) - } - - @Test("Lowercase having detected") - func lowercaseHaving() { - let query = "select count(*) from users group by status having " + @Test("HAVING is detected in any case", arguments: [ + "SELECT COUNT(*) FROM users GROUP BY status HAVING ", + "select count(*) from users group by status having ", + ]) + func havingInAnyCase(_ query: String) { let context = analyzer.analyze(query: query, cursorPosition: query.count) #expect(context.clauseType == .having) } // MARK: - JOIN - @Test("Uppercase JOIN detected") - func uppercaseJoin() { - let query = "SELECT * FROM users JOIN " - let context = analyzer.analyze(query: query, cursorPosition: query.count) - #expect(context.clauseType == .join) - } - - @Test("Lowercase join detected") - func lowercaseJoin() { - let query = "select * from users join " - let context = analyzer.analyze(query: query, cursorPosition: query.count) - #expect(context.clauseType == .join) - } - - @Test("Lowercase left join detected") - func lowercaseLeftJoin() { - let query = "select * from users left join " + @Test("JOIN and LEFT JOIN are detected in any case", arguments: [ + "SELECT * FROM users JOIN ", + "select * from users join ", + "select * from users left join ", + ]) + func joinInAnyCase(_ query: String) { let context = analyzer.analyze(query: query, cursorPosition: query.count) #expect(context.clauseType == .join) } // MARK: - ALTER TABLE - @Test("Lowercase alter table detected") - func lowercaseAlterTable() { - let query = "alter table users " + @Test("ALTER TABLE is detected in lowercase and mixed case", arguments: [ + "alter table users ", + "Alter Table users ", + ]) + func alterTableInAnyCase(_ query: String) { let context = analyzer.analyze(query: query, cursorPosition: query.count) #expect(context.clauseType == .alterTable) } - - @Test("Mixed case Alter Table detected") - func mixedCaseAlterTable() { - let query = "Alter Table users " - let context = analyzer.analyze(query: query, cursorPosition: query.count) - #expect(context.clauseType == .alterTable) - } - - // MARK: - Mixed Case Full Queries - - @Test("Fully mixed case query detects correct clause") - func fullyMixedCaseQuery() { - let query = "sElEcT * fRoM users wHeRe " - let context = analyzer.analyze(query: query, cursorPosition: query.count) - #expect(context.clauseType == .where_) - } - - @Test("Random casing on complex query") - func randomCasingComplexQuery() { - let query = "SeLeCt id, name FrOm users WhErE active = 1 OrDeR bY " - let context = analyzer.analyze(query: query, cursorPosition: query.count) - #expect(context.clauseType == .orderBy) - } } diff --git a/Packages/TableProMacCore/Tests/TableProAutocompleteTests/SQLContextAnalyzerTests.swift b/Packages/TableProMacCore/Tests/TableProAutocompleteTests/SQLContextAnalyzerTests.swift index 03c830ad8c..71444b4d95 100644 --- a/Packages/TableProMacCore/Tests/TableProAutocompleteTests/SQLContextAnalyzerTests.swift +++ b/Packages/TableProMacCore/Tests/TableProAutocompleteTests/SQLContextAnalyzerTests.swift @@ -542,44 +542,22 @@ struct SQLContextAnalyzerTests { // MARK: - P1: HP-1 - New Clause Types - @Test("RETURNING detected after INSERT VALUES") - func testReturningAfterInsertValues() { - let query = "INSERT INTO users (name) VALUES ('test') RETURNING " - let context = analyzer.analyze(query: query, cursorPosition: query.count) - #expect(context.clauseType == .returning) - } - - @Test("RETURNING detected after DELETE") - func testReturningAfterDelete() { - let query = "DELETE FROM users WHERE id = 1 RETURNING " + @Test("RETURNING is detected after INSERT VALUES, DELETE and UPDATE", arguments: [ + "INSERT INTO users (name) VALUES ('test') RETURNING ", + "DELETE FROM users WHERE id = 1 RETURNING ", + "UPDATE users SET name = 'x' RETURNING ", + ]) + func testReturningDetected(_ query: String) { let context = analyzer.analyze(query: query, cursorPosition: query.count) #expect(context.clauseType == .returning) } - @Test("RETURNING detected after UPDATE") - func testReturningAfterUpdate() { - let query = "UPDATE users SET name = 'x' RETURNING " - let context = analyzer.analyze(query: query, cursorPosition: query.count) - #expect(context.clauseType == .returning) - } - - @Test("UNION ALL detected as union clause") - func testUnionAllClause() { - let query = "SELECT id FROM users UNION ALL " - let context = analyzer.analyze(query: query, cursorPosition: query.count) - #expect(context.clauseType == .union) - } - - @Test("INTERSECT detected as union clause") - func testIntersectDetected() { - let query = "SELECT id FROM users INTERSECT " - let context = analyzer.analyze(query: query, cursorPosition: query.count) - #expect(context.clauseType == .union) - } - - @Test("EXCEPT detected as union clause") - func testExceptDetected() { - let query = "SELECT id FROM users EXCEPT " + @Test("UNION ALL, INTERSECT and EXCEPT are detected as the union clause", arguments: [ + "SELECT id FROM users UNION ALL ", + "SELECT id FROM users INTERSECT ", + "SELECT id FROM users EXCEPT ", + ]) + func testSetOperatorsDetectedAsUnion(_ query: String) { let context = analyzer.analyze(query: query, cursorPosition: query.count) #expect(context.clauseType == .union) } @@ -591,93 +569,43 @@ struct SQLContextAnalyzerTests { #expect(context.clauseType == .using) } - @Test("OVER clause detected for window function") - func testOverClause() { - let query = "SELECT ROW_NUMBER() OVER (" - let context = analyzer.analyze(query: query, cursorPosition: query.count) - #expect(context.clauseType == .window) - } - - @Test("PARTITION BY detected as window clause") - func testPartitionBy() { - let query = "SELECT ROW_NUMBER() OVER (PARTITION BY " + @Test("OVER and PARTITION BY are detected as the window clause", arguments: [ + "SELECT ROW_NUMBER() OVER (", + "SELECT ROW_NUMBER() OVER (PARTITION BY ", + ]) + func testWindowClause(_ query: String) { let context = analyzer.analyze(query: query, cursorPosition: query.count) #expect(context.clauseType == .window) } - @Test("DROP TABLE detected as dropObject") - func testDropTableDropObject() { - let query = "DROP TABLE " - let context = analyzer.analyze(query: query, cursorPosition: query.count) - #expect(context.clauseType == .dropObject) - } - - @Test("DROP VIEW detected as dropObject") - func testDropViewDropObject() { - let query = "DROP VIEW " - let context = analyzer.analyze(query: query, cursorPosition: query.count) - #expect(context.clauseType == .dropObject) - } - - @Test("DROP INDEX detected as dropObject") - func testDropIndexDropObject() { - let query = "DROP INDEX " + @Test("DROP TABLE, VIEW, INDEX and TABLE IF EXISTS are detected as dropObject", arguments: [ + "DROP TABLE ", + "DROP VIEW ", + "DROP INDEX ", + "DROP TABLE IF EXISTS ", + ]) + func testDropDetectedAsDropObject(_ query: String) { let context = analyzer.analyze(query: query, cursorPosition: query.count) #expect(context.clauseType == .dropObject) } - @Test("DROP TABLE IF EXISTS detected") - func testDropTableIfExists() { - let query = "DROP TABLE IF EXISTS " - let context = analyzer.analyze(query: query, cursorPosition: query.count) - #expect(context.clauseType == .dropObject) - } - - @Test("CREATE INDEX name detected") - func testCreateIndexName() { - let query = "CREATE INDEX " - let context = analyzer.analyze(query: query, cursorPosition: query.count) - #expect(context.clauseType == .createIndex) - } - - @Test("CREATE UNIQUE INDEX detected") - func testCreateUniqueIndex() { - let query = "CREATE UNIQUE INDEX " - let context = analyzer.analyze(query: query, cursorPosition: query.count) - #expect(context.clauseType == .createIndex) - } - - @Test("CREATE INDEX ON table inside parens detected") - func testCreateIndexOnTableParens() { - let query = "CREATE INDEX idx ON users (" + @Test("CREATE INDEX, CREATE UNIQUE INDEX and the column list inside ON table parens are detected", arguments: [ + "CREATE INDEX ", + "CREATE UNIQUE INDEX ", + "CREATE INDEX idx ON users (", + ]) + func testCreateIndexDetected(_ query: String) { let context = analyzer.analyze(query: query, cursorPosition: query.count) #expect(context.clauseType == .createIndex) } - @Test("CREATE VIEW detected") - func testCreateView() { - let query = "CREATE VIEW " - let context = analyzer.analyze(query: query, cursorPosition: query.count) - #expect(context.clauseType == .createView) - } - - @Test("CREATE OR REPLACE VIEW detected") - func testCreateOrReplaceView() { - let query = "CREATE OR REPLACE VIEW " - let context = analyzer.analyze(query: query, cursorPosition: query.count) - #expect(context.clauseType == .createView) - } - - @Test("CREATE VIEW AS detected") - func testCreateViewAs() { - let query = "CREATE VIEW my_view AS " - let context = analyzer.analyze(query: query, cursorPosition: query.count) - #expect(context.clauseType == .createView) - } - - @Test("CREATE MATERIALIZED VIEW detected") - func testCreateMaterializedView() { - let query = "CREATE MATERIALIZED VIEW " + @Test("CREATE VIEW, OR REPLACE, AS and MATERIALIZED VIEW are detected", arguments: [ + "CREATE VIEW ", + "CREATE OR REPLACE VIEW ", + "CREATE VIEW my_view AS ", + "CREATE MATERIALIZED VIEW ", + ]) + func testCreateViewDetected(_ query: String) { let context = analyzer.analyze(query: query, cursorPosition: query.count) #expect(context.clauseType == .createView) } @@ -740,16 +668,11 @@ struct SQLContextAnalyzerTests { #expect(context.clauseType != .functionArg) } - @Test("VALUES after closed parens still detected as values") - func testValuesAfterClosedParens() { - let query = "INSERT INTO users (name) VALUES ('test') " - let context = analyzer.analyze(query: query, cursorPosition: query.count) - #expect(context.clauseType == .values) - } - - @Test("VALUES with multiple tuples after closed parens") - func testValuesMultipleTuplesAfterClosed() { - let query = "INSERT INTO users (name) VALUES ('a'), ('b') " + @Test("VALUES after closed parens, with one or several tuples, is still detected as values", arguments: [ + "INSERT INTO users (name) VALUES ('test') ", + "INSERT INTO users (name) VALUES ('a'), ('b') ", + ]) + func testValuesAfterClosedParens(_ query: String) { let context = analyzer.analyze(query: query, cursorPosition: query.count) #expect(context.clauseType == .values) } @@ -844,9 +767,12 @@ struct SQLContextAnalyzerTests { // MARK: - P3: LP-6 - Subquery Context Detection - @Test("Subquery in WHERE IN - clause type is select inside subquery") - func testSubqueryInWhereIn() { - let query = "SELECT * FROM users WHERE id IN (SELECT " + @Test("A subquery in WHERE IN, in FROM and in EXISTS is a nested select", arguments: [ + "SELECT * FROM users WHERE id IN (SELECT ", + "SELECT * FROM (SELECT ", + "SELECT * FROM users WHERE EXISTS (SELECT ", + ]) + func testSubqueryIsNestedSelect(_ query: String) { let context = analyzer.analyze(query: query, cursorPosition: query.count) #expect(context.clauseType == .select) #expect(context.nestingLevel > 0) @@ -860,14 +786,6 @@ struct SQLContextAnalyzerTests { #expect(context.nestingLevel > 0) } - @Test("Subquery in FROM - detected as select") - func testSubqueryInFrom() { - let query = "SELECT * FROM (SELECT " - let context = analyzer.analyze(query: query, cursorPosition: query.count) - #expect(context.clauseType == .select) - #expect(context.nestingLevel > 0) - } - @Test("Nested subquery increases nesting level") func testNestedSubqueryLevel() { let query = "SELECT * FROM (SELECT * FROM (SELECT " @@ -892,14 +810,6 @@ struct SQLContextAnalyzerTests { #expect(context.nestingLevel == 0) } - @Test("EXISTS subquery detected") - func testExistsSubquery() { - let query = "SELECT * FROM users WHERE EXISTS (SELECT " - let context = analyzer.analyze(query: query, cursorPosition: query.count) - #expect(context.clauseType == .select) - #expect(context.nestingLevel > 0) - } - // MARK: - Schema-Qualified Table Name Tests @Test("FROM with schema-qualified table strips schema prefix") @@ -1019,24 +929,21 @@ struct SQLContextAnalyzerTests { // MARK: - Nearest-Clause Detection (multi-clause statements) - @Test("JOIN after an ON condition detects JOIN, not the earlier ON") - func testJoinAfterOnDetectsJoin() { - let query = "SELECT * FROM a JOIN b ON a.id = b.id INNER JOIN " + @Test("JOIN after an ON condition, or right after the keyword, detects JOIN and expects an object name", arguments: [ + "SELECT * FROM a JOIN b ON a.id = b.id INNER JOIN ", + "SELECT * FROM users JOIN ", + ]) + func testJoinExpectsObjectName(_ query: String) { let context = analyzer.analyze(query: query, cursorPosition: query.count) #expect(context.clauseType == .join) #expect(context.expectsObjectName) } - @Test("LEFT JOIN after an ON condition detects JOIN") - func testLeftJoinAfterOnDetectsJoin() { - let query = "SELECT * FROM a LEFT JOIN b ON a.x = b.x RIGHT JOIN " - let context = analyzer.analyze(query: query, cursorPosition: query.count) - #expect(context.clauseType == .join) - } - - @Test("Third JOIN in a chain detects JOIN, not the earlier ON") - func testThirdJoinInChainDetectsJoin() { - let query = "SELECT * FROM a JOIN b ON a.id = b.id JOIN c ON b.id = c.id INNER JOIN " + @Test("A LEFT JOIN, or a third JOIN in a chain, after an ON condition detects JOIN", arguments: [ + "SELECT * FROM a LEFT JOIN b ON a.x = b.x RIGHT JOIN ", + "SELECT * FROM a JOIN b ON a.id = b.id JOIN c ON b.id = c.id INNER JOIN ", + ]) + func testLaterJoinDetectsJoin(_ query: String) { let context = analyzer.analyze(query: query, cursorPosition: query.count) #expect(context.clauseType == .join) } @@ -1064,14 +971,6 @@ struct SQLContextAnalyzerTests { // MARK: - Table-Operand Slot Detection - @Test("Cursor right after JOIN keyword expects an object name") - func testJoinKeywordExpectsObjectName() { - let query = "SELECT * FROM users JOIN " - let context = analyzer.analyze(query: query, cursorPosition: query.count) - #expect(context.clauseType == .join) - #expect(context.expectsObjectName) - } - @Test("Cursor after a complete FROM table does not expect an object name") func testFromAfterTableDoesNotExpectObjectName() { let query = "SELECT * FROM users " diff --git a/Packages/TableProMacCore/Tests/TableProAutocompleteTests/SQLSnippetMarkerTests.swift b/Packages/TableProMacCore/Tests/TableProAutocompleteTests/SQLSnippetMarkerTests.swift index 33c6bde198..138a58afc9 100644 --- a/Packages/TableProMacCore/Tests/TableProAutocompleteTests/SQLSnippetMarkerTests.swift +++ b/Packages/TableProMacCore/Tests/TableProAutocompleteTests/SQLSnippetMarkerTests.swift @@ -3,14 +3,9 @@ import Foundation import Testing struct SQLSnippetMarkerTests { - @Test("Query without a marker expands to nil") - func noMarkerReturnsNil() { - #expect(SQLSnippetMarker.expand("SELECT * FROM users") == nil) - } - - @Test("Empty query expands to nil") - func emptyQueryReturnsNil() { - #expect(SQLSnippetMarker.expand("") == nil) + @Test("A query without a marker, and an empty query, expand to nil", arguments: ["SELECT * FROM users", ""]) + func noMarkerReturnsNil(_ query: String) { + #expect(SQLSnippetMarker.expand(query) == nil) } @Test("Marker at the start strips and places the caret at zero") diff --git a/Packages/TableProMacCore/Tests/TableProMCPTestSupport/MCPTestClock.swift b/Packages/TableProMacCore/Tests/TableProMCPTestSupport/MCPTestClock.swift index 1cf9e147e2..0f6dac84e7 100644 --- a/Packages/TableProMacCore/Tests/TableProMCPTestSupport/MCPTestClock.swift +++ b/Packages/TableProMacCore/Tests/TableProMCPTestSupport/MCPTestClock.swift @@ -34,8 +34,6 @@ public actor MCPTestClock: MCPClock { for sleep in due { sleep.continuation.resume() } - - await Task.yield() } public func setNow(_ date: Date) async { diff --git a/Packages/TableProMacCore/Tests/TableProMCPTests/Protocol/MCPCancellationTokenTests.swift b/Packages/TableProMacCore/Tests/TableProMCPTests/Protocol/MCPCancellationTokenTests.swift index 6edbf322dd..01aadc91d0 100644 --- a/Packages/TableProMacCore/Tests/TableProMCPTests/Protocol/MCPCancellationTokenTests.swift +++ b/Packages/TableProMacCore/Tests/TableProMCPTests/Protocol/MCPCancellationTokenTests.swift @@ -1,36 +1,36 @@ import Foundation @testable import TableProMCP import TableProMCPTestSupport -import XCTest +import Testing -final class MCPCancellationTokenTests: XCTestCase { - func testNewTokenIsNotCancelled() async { +struct MCPCancellationTokenTests { + @Test func testNewTokenIsNotCancelled() async { let token = MCPCancellationToken() let cancelled = await token.isCancelled let reason = await token.reason - XCTAssertFalse(cancelled) - XCTAssertNil(reason) + #expect(!cancelled) + #expect(reason == nil) } - func testCancelRecordsTheReason() async { + @Test func testCancelRecordsTheReason() async { let token = MCPCancellationToken() await token.cancel(reason: .clientDisconnected) let cancelled = await token.isCancelled let reason = await token.reason - XCTAssertTrue(cancelled) - XCTAssertEqual(reason, .clientDisconnected) + #expect(cancelled) + #expect(reason == .clientDisconnected) } - func testCancelDefaultsToAClientRequest() async { + @Test func testCancelDefaultsToAClientRequest() async { let token = MCPCancellationToken() await token.cancel() let reason = await token.reason - XCTAssertEqual(reason, .clientRequested(nil)) + #expect(reason == .clientRequested(nil)) } - func testCancelIsIdempotentAndKeepsTheFirstReason() async { + @Test func testCancelIsIdempotentAndKeepsTheFirstReason() async { let token = MCPCancellationToken() let flag = ObservedFlag() await token.onCancel { _ in await flag.set() } @@ -41,11 +41,11 @@ final class MCPCancellationTokenTests: XCTestCase { let reason = await token.reason let handlerRuns = await flag.times() - XCTAssertEqual(reason, .clientRequested("user pressed stop")) - XCTAssertEqual(handlerRuns, 1) + #expect(reason == .clientRequested("user pressed stop")) + #expect(handlerRuns == 1) } - func testOnCancelHandlersRunWhenCancelFires() async { + @Test func testOnCancelHandlersRunWhenCancelFires() async { let token = MCPCancellationToken() let first = ObservedFlag() let second = ObservedFlag() @@ -54,18 +54,18 @@ final class MCPCancellationTokenTests: XCTestCase { let beforeFirst = await first.value() let beforeSecond = await second.value() - XCTAssertFalse(beforeFirst) - XCTAssertFalse(beforeSecond) + #expect(!beforeFirst) + #expect(!beforeSecond) await token.cancel(reason: .credentialRevoked) let afterFirst = await first.value() let afterSecond = await second.value() - XCTAssertTrue(afterFirst) - XCTAssertTrue(afterSecond) + #expect(afterFirst) + #expect(afterSecond) } - func testHandlersReceiveTheCancellationReason() async { + @Test func testHandlersReceiveTheCancellationReason() async { let token = MCPCancellationToken() let observed = ObservedValue() await token.onCancel { reason in await observed.set(reason) } @@ -73,10 +73,10 @@ final class MCPCancellationTokenTests: XCTestCase { await token.cancel(reason: .deadlineExceeded) let reason = await observed.value() - XCTAssertEqual(reason, .deadlineExceeded) + #expect(reason == .deadlineExceeded) } - func testHandlerRegisteredAfterCancellationRunsImmediately() async { + @Test func testHandlerRegisteredAfterCancellationRunsImmediately() async { let token = MCPCancellationToken() await token.cancel(reason: .serverShuttingDown) @@ -84,10 +84,10 @@ final class MCPCancellationTokenTests: XCTestCase { await token.onCancel { reason in await observed.set(reason) } let reason = await observed.value() - XCTAssertEqual(reason, .serverShuttingDown) + #expect(reason == .serverShuttingDown) } - func testThrowIfCancelledOnlyThrowsAfterCancellation() async throws { + @Test func testThrowIfCancelledOnlyThrowsAfterCancellation() async throws { let token = MCPCancellationToken() try await token.throwIfCancelled() @@ -95,18 +95,18 @@ final class MCPCancellationTokenTests: XCTestCase { do { try await token.throwIfCancelled() - XCTFail("Expected a CancellationError") + Issue.record("Expected a CancellationError") } catch is CancellationError { return } } - func testEveryReasonHasAStableLabel() { - XCTAssertEqual(MCPCancellationReason.clientRequested(nil).label, "client_requested") - XCTAssertEqual(MCPCancellationReason.clientRequested("stop").label, "client_requested") - XCTAssertEqual(MCPCancellationReason.clientDisconnected.label, "client_disconnected") - XCTAssertEqual(MCPCancellationReason.deadlineExceeded.label, "deadline_exceeded") - XCTAssertEqual(MCPCancellationReason.credentialRevoked.label, "credential_revoked") - XCTAssertEqual(MCPCancellationReason.serverShuttingDown.label, "server_shutting_down") + @Test func testEveryReasonHasAStableLabel() { + #expect(MCPCancellationReason.clientRequested(nil).label == "client_requested") + #expect(MCPCancellationReason.clientRequested("stop").label == "client_requested") + #expect(MCPCancellationReason.clientDisconnected.label == "client_disconnected") + #expect(MCPCancellationReason.deadlineExceeded.label == "deadline_exceeded") + #expect(MCPCancellationReason.credentialRevoked.label == "credential_revoked") + #expect(MCPCancellationReason.serverShuttingDown.label == "server_shutting_down") } } diff --git a/Packages/TableProMacCore/Tests/TableProMCPTests/Protocol/MCPInflightRegistryTests.swift b/Packages/TableProMacCore/Tests/TableProMCPTests/Protocol/MCPInflightRegistryTests.swift index 47ba2115c6..53f45b3391 100644 --- a/Packages/TableProMacCore/Tests/TableProMCPTests/Protocol/MCPInflightRegistryTests.swift +++ b/Packages/TableProMacCore/Tests/TableProMCPTests/Protocol/MCPInflightRegistryTests.swift @@ -1,9 +1,9 @@ import Foundation @testable import TableProMCP import TableProMCPTestSupport -import XCTest +import Testing -final class MCPInflightRegistryTests: XCTestCase { +struct MCPInflightRegistryTests { private static let aliceTokenId = UUID(uuidString: "AAAAAAAA-0000-0000-0000-000000000001") ?? UUID() private static let bobTokenId = UUID(uuidString: "BBBBBBBB-0000-0000-0000-000000000002") ?? UUID() @@ -16,7 +16,7 @@ final class MCPInflightRegistryTests: XCTestCase { tokenId: MCPInflightRegistryTests.bobTokenId ) - func testCancelCancelsTheRegisteredToken() async { + @Test func testCancelCancelsTheRegisteredToken() async { let registry = MCPInflightRegistry() let token = MCPCancellationToken() let key = MCPInflightKey(principal: alice, requestId: .number(42)) @@ -24,14 +24,14 @@ final class MCPInflightRegistryTests: XCTestCase { await registry.register(key: key, token: token, tokenId: alice.tokenId, method: "tools/call", startedAt: .now) let cancelled = await registry.cancel(key: key, reason: .clientRequested("stop")) - XCTAssertTrue(cancelled) + #expect(cancelled) let tokenCancelled = await token.isCancelled let reason = await token.reason - XCTAssertTrue(tokenCancelled) - XCTAssertEqual(reason, .clientRequested("stop")) + #expect(tokenCancelled) + #expect(reason == .clientRequested("stop")) } - func testTheSameRequestIdFromTwoPrincipalsIsTwoEntries() async { + @Test func testTheSameRequestIdFromTwoPrincipalsIsTwoEntries() async { let registry = MCPInflightRegistry() let aliceToken = MCPCancellationToken() let bobToken = MCPCancellationToken() @@ -39,7 +39,7 @@ final class MCPInflightRegistryTests: XCTestCase { let aliceKey = MCPInflightKey(principal: alice, requestId: requestId) let bobKey = MCPInflightKey(principal: bob, requestId: requestId) - XCTAssertNotEqual(aliceKey, bobKey) + #expect(aliceKey != bobKey) let aliceRegistered = await registry.register( key: aliceKey, @@ -55,24 +55,24 @@ final class MCPInflightRegistryTests: XCTestCase { method: "tools/call", startedAt: .now ) - XCTAssertTrue(aliceRegistered) - XCTAssertTrue(bobRegistered) + #expect(aliceRegistered) + #expect(bobRegistered) let count = await registry.count() - XCTAssertEqual(count, 2) + #expect(count == 2) await registry.cancel(key: aliceKey, reason: .clientRequested(nil)) let aliceCancelled = await aliceToken.isCancelled let bobCancelled = await bobToken.isCancelled - XCTAssertTrue(aliceCancelled) - XCTAssertFalse(bobCancelled) + #expect(aliceCancelled) + #expect(!bobCancelled) let remaining = await registry.contains(key: bobKey) - XCTAssertTrue(remaining) + #expect(remaining) } - func testReusingAnInFlightRequestIdReportsTheDisplacement() async { + @Test func testReusingAnInFlightRequestIdReportsTheDisplacement() async { let registry = MCPInflightRegistry() let first = MCPCancellationToken() let second = MCPCancellationToken() @@ -92,18 +92,18 @@ final class MCPInflightRegistryTests: XCTestCase { method: "tools/call", startedAt: .now ) - XCTAssertTrue(firstRegistered) - XCTAssertFalse(secondRegistered) + #expect(firstRegistered) + #expect(!secondRegistered) await registry.cancel(key: key, reason: .clientRequested(nil)) let firstCancelled = await first.isCancelled let secondCancelled = await second.isCancelled - XCTAssertFalse(firstCancelled) - XCTAssertTrue(secondCancelled) + #expect(!firstCancelled) + #expect(secondCancelled) } - func testRemovingWithAStaleTokenLeavesTheEntryAlone() async { + @Test func testRemovingWithAStaleTokenLeavesTheEntryAlone() async { let registry = MCPInflightRegistry() let stale = MCPCancellationToken() let live = MCPCancellationToken() @@ -114,25 +114,25 @@ final class MCPInflightRegistryTests: XCTestCase { await registry.remove(key: key, token: stale) let stillThere = await registry.contains(key: key) - XCTAssertTrue(stillThere) + #expect(stillThere) await registry.remove(key: key, token: live) let gone = await registry.contains(key: key) - XCTAssertFalse(gone) + #expect(!gone) } - func testCancellingAnEntryThatIsNotInFlightIsANoop() async { + @Test func testCancellingAnEntryThatIsNotInFlightIsANoop() async { let registry = MCPInflightRegistry() let key = MCPInflightKey(principal: alice, requestId: .number(7)) let cancelled = await registry.cancel(key: key, reason: .clientRequested(nil)) - XCTAssertFalse(cancelled) + #expect(!cancelled) let count = await registry.count() - XCTAssertEqual(count, 0) + #expect(count == 0) } - func testCancellingTwiceOnlyReportsTheFirstCancellation() async { + @Test func testCancellingTwiceOnlyReportsTheFirstCancellation() async { let registry = MCPInflightRegistry() let key = MCPInflightKey(principal: alice, requestId: .number(9)) await registry.register( @@ -145,11 +145,11 @@ final class MCPInflightRegistryTests: XCTestCase { let first = await registry.cancel(key: key, reason: .clientRequested(nil)) let second = await registry.cancel(key: key, reason: .clientRequested(nil)) - XCTAssertTrue(first) - XCTAssertFalse(second) + #expect(first) + #expect(!second) } - func testRevokingATokenCancelsOnlyThatTokensRequests() async { + @Test func testRevokingATokenCancelsOnlyThatTokensRequests() async { let registry = MCPInflightRegistry() let aliceToken = MCPCancellationToken() let bobToken = MCPCancellationToken() @@ -175,17 +175,17 @@ final class MCPInflightRegistryTests: XCTestCase { matchingTokenId: Self.aliceTokenId, reason: .credentialRevoked ) - XCTAssertEqual(cancelledCount, 1) + #expect(cancelledCount == 1) let aliceCancelled = await aliceToken.isCancelled let bobCancelled = await bobToken.isCancelled let aliceReason = await aliceToken.reason - XCTAssertTrue(aliceCancelled) - XCTAssertFalse(bobCancelled) - XCTAssertEqual(aliceReason, .credentialRevoked) + #expect(aliceCancelled) + #expect(!bobCancelled) + #expect(aliceReason == .credentialRevoked) } - func testCancellingByFingerprintCancelsEveryRequestOfThatClient() async { + @Test func testCancellingByFingerprintCancelsEveryRequestOfThatClient() async { let registry = MCPInflightRegistry() let firstToken = MCPCancellationToken() let secondToken = MCPCancellationToken() @@ -217,16 +217,16 @@ final class MCPInflightRegistryTests: XCTestCase { matchingFingerprint: alice.tokenFingerprint, reason: .serverShuttingDown ) - XCTAssertEqual(cancelled, 2) + #expect(cancelled == 2) let remaining = await registry.count() - XCTAssertEqual(remaining, 1) + #expect(remaining == 1) let otherCancelled = await otherToken.isCancelled - XCTAssertFalse(otherCancelled) + #expect(!otherCancelled) } - func testShutdownCancelsEverythingAndEmptiesTheRegistry() async { + @Test func testShutdownCancelsEverythingAndEmptiesTheRegistry() async { let registry = MCPInflightRegistry() for index in 1 ... 3 { await registry.register( @@ -239,16 +239,16 @@ final class MCPInflightRegistryTests: XCTestCase { } let cancelled = await registry.cancelAll(reason: .serverShuttingDown) - XCTAssertEqual(cancelled, 3) + #expect(cancelled == 3) let count = await registry.count() - XCTAssertEqual(count, 0) + #expect(count == 0) } - func testKeyIsBuiltFromTheClientFingerprint() { + @Test func testKeyIsBuiltFromTheClientFingerprint() { let key = MCPInflightKey(principal: alice, requestId: .string("abc")) - XCTAssertEqual(key.clientFingerprint, alice.tokenFingerprint) - XCTAssertEqual(key.requestId, .string("abc")) - XCTAssertEqual(key, MCPInflightKey(clientFingerprint: "alice", requestId: .string("abc"))) + #expect(key.clientFingerprint == alice.tokenFingerprint) + #expect(key.requestId == .string("abc")) + #expect(key == MCPInflightKey(clientFingerprint: "alice", requestId: .string("abc"))) } } diff --git a/Packages/TableProMacCore/Tests/TableProMCPTests/Protocol/MCPProgressEmitterTests.swift b/Packages/TableProMacCore/Tests/TableProMCPTests/Protocol/MCPProgressEmitterTests.swift index a79e1a3484..8f6b63663c 100644 --- a/Packages/TableProMacCore/Tests/TableProMCPTests/Protocol/MCPProgressEmitterTests.swift +++ b/Packages/TableProMacCore/Tests/TableProMCPTests/Protocol/MCPProgressEmitterTests.swift @@ -1,26 +1,26 @@ import Foundation @testable import TableProMCP import TableProMCPTestSupport -import XCTest +import Testing -final class MCPProgressEmitterTests: XCTestCase { - func testEmitterWithoutATokenWritesNothing() async throws { +struct MCPProgressEmitterTests { + @Test func testEmitterWithoutATokenWritesNothing() async throws { let sink = RecordingResponderSink() let emitter = MCPProgressEmitter(progressToken: nil, responder: MCPResponder(sink: sink, requestId: .number(1))) let hasToken = await emitter.hasProgressToken - XCTAssertFalse(hasToken) + #expect(!hasToken) await emitter.emit(progress: 0.5) await emitter.emit(progress: 1.0, total: 1.0, message: "done") let frames = await sink.sseFrames let heads = await sink.sseHeadCount - XCTAssertTrue(frames.isEmpty) - XCTAssertEqual(heads, 0) + #expect(frames.isEmpty) + #expect(heads == 0) } - func testEmitWritesAProgressNotificationThroughTheResponder() async throws { + @Test func testEmitWritesAProgressNotificationThroughTheResponder() async throws { let sink = RecordingResponderSink() let emitter = MCPProgressEmitter( progressToken: .string("progress-1"), @@ -30,23 +30,23 @@ final class MCPProgressEmitterTests: XCTestCase { await emitter.emit(progress: 0.42) let heads = await sink.sseHeadCount - XCTAssertEqual(heads, 1) + #expect(heads == 1) let messages = try await sink.sseMessages() - XCTAssertEqual(messages.count, 1) + #expect(messages.count == 1) guard case .notification(let notification) = messages[0] else { - XCTFail("Expected a notification, got \(messages[0])") + Issue.record("Expected a notification, got \(messages[0])") return } - XCTAssertEqual(notification.method, "notifications/progress") - XCTAssertEqual(notification.params?["progressToken"]?.stringValue, "progress-1") - XCTAssertEqual(notification.params?["progress"]?.doubleValue, 0.42) - XCTAssertNil(notification.params?["total"]) - XCTAssertNil(notification.params?["message"]) + #expect(notification.method == "notifications/progress") + #expect(notification.params?["progressToken"]?.stringValue == "progress-1") + #expect(notification.params?["progress"]?.doubleValue == 0.42) + #expect(notification.params?["total"] == nil) + #expect(notification.params?["message"] == nil) } - func testAnIntegerProgressTokenIsEchoedAsAnInteger() async throws { + @Test func testAnIntegerProgressTokenIsEchoedAsAnInteger() async throws { let sink = RecordingResponderSink() let emitter = MCPProgressEmitter( progressToken: .number(17), @@ -56,14 +56,14 @@ final class MCPProgressEmitterTests: XCTestCase { await emitter.emit(progress: 1) let messages = try await sink.sseMessages() - guard case .notification(let notification) = try XCTUnwrap(messages.first) else { - XCTFail("Expected a notification") + guard case .notification(let notification) = try #require(messages.first) else { + Issue.record("Expected a notification") return } - XCTAssertEqual(notification.params?["progressToken"]?.intValue, 17) + #expect(notification.params?["progressToken"]?.intValue == 17) } - func testTotalAndMessageAreCarriedWhenProvided() async throws { + @Test func testTotalAndMessageAreCarriedWhenProvided() async throws { let sink = RecordingResponderSink() let emitter = MCPProgressEmitter( progressToken: .string("token"), @@ -73,16 +73,16 @@ final class MCPProgressEmitterTests: XCTestCase { await emitter.emit(progress: 3, total: 10, message: "reading rows") let messages = try await sink.sseMessages() - guard case .notification(let notification) = try XCTUnwrap(messages.first) else { - XCTFail("Expected a notification") + guard case .notification(let notification) = try #require(messages.first) else { + Issue.record("Expected a notification") return } - XCTAssertEqual(notification.params?["progress"]?.doubleValue, 3) - XCTAssertEqual(notification.params?["total"]?.doubleValue, 10) - XCTAssertEqual(notification.params?["message"]?.stringValue, "reading rows") + #expect(notification.params?["progress"]?.doubleValue == 3) + #expect(notification.params?["total"]?.doubleValue == 10) + #expect(notification.params?["message"]?.stringValue == "reading rows") } - func testAnEmptyMessageIsOmitted() async throws { + @Test func testAnEmptyMessageIsOmitted() async throws { let sink = RecordingResponderSink() let emitter = MCPProgressEmitter( progressToken: .string("token"), @@ -92,14 +92,14 @@ final class MCPProgressEmitterTests: XCTestCase { await emitter.emit(progress: 1, total: nil, message: "") let messages = try await sink.sseMessages() - guard case .notification(let notification) = try XCTUnwrap(messages.first) else { - XCTFail("Expected a notification") + guard case .notification(let notification) = try #require(messages.first) else { + Issue.record("Expected a notification") return } - XCTAssertNil(notification.params?["message"]) + #expect(notification.params?["message"] == nil) } - func testProgressThatDoesNotAdvanceIsDropped() async throws { + @Test func testProgressThatDoesNotAdvanceIsDropped() async throws { let sink = RecordingResponderSink() let emitter = MCPProgressEmitter( progressToken: .string("token"), @@ -112,16 +112,16 @@ final class MCPProgressEmitterTests: XCTestCase { await emitter.emit(progress: 0.6) let messages = try await sink.sseMessages() - XCTAssertEqual(messages.count, 2) + #expect(messages.count == 2) let reported = messages.compactMap { message -> Double? in guard case .notification(let notification) = message else { return nil } return notification.params?["progress"]?.doubleValue } - XCTAssertEqual(reported, [0.5, 0.6]) + #expect(reported == [0.5, 0.6]) } - func testProgressThatIsNotFiniteIsDropped() async throws { + @Test func testProgressThatIsNotFiniteIsDropped() async throws { let sink = RecordingResponderSink() let emitter = MCPProgressEmitter( progressToken: .string("token"), @@ -132,10 +132,10 @@ final class MCPProgressEmitterTests: XCTestCase { await emitter.emit(progress: .nan) let frames = await sink.sseFrames - XCTAssertTrue(frames.isEmpty) + #expect(frames.isEmpty) } - func testANonFiniteTotalIsOmitted() async throws { + @Test func testANonFiniteTotalIsOmitted() async throws { let sink = RecordingResponderSink() let emitter = MCPProgressEmitter( progressToken: .string("token"), @@ -145,32 +145,32 @@ final class MCPProgressEmitterTests: XCTestCase { await emitter.emit(progress: 1, total: .infinity) let messages = try await sink.sseMessages() - guard case .notification(let notification) = try XCTUnwrap(messages.first) else { - XCTFail("Expected a notification") + guard case .notification(let notification) = try #require(messages.first) else { + Issue.record("Expected a notification") return } - XCTAssertNil(notification.params?["total"]) + #expect(notification.params?["total"] == nil) } - func testTheEmitterTakesItsTokenFromTheRequestMeta() async throws { + @Test func testTheEmitterTakesItsTokenFromTheRequestMeta() async throws { let sink = RecordingResponderSink() let meta = MCPProtocolTestSupport.makeMeta(progressToken: .string("from-meta")) let emitter = MCPProgressEmitter(meta: meta, responder: MCPResponder(sink: sink, requestId: .number(1))) let hasToken = await emitter.hasProgressToken - XCTAssertTrue(hasToken) + #expect(hasToken) await emitter.emit(progress: 1) let messages = try await sink.sseMessages() - guard case .notification(let notification) = try XCTUnwrap(messages.first) else { - XCTFail("Expected a notification") + guard case .notification(let notification) = try #require(messages.first) else { + Issue.record("Expected a notification") return } - XCTAssertEqual(notification.params?["progressToken"]?.stringValue, "from-meta") + #expect(notification.params?["progressToken"]?.stringValue == "from-meta") } - func testARequestWithoutATokenInItsMetaEmitsNothing() async throws { + @Test func testARequestWithoutATokenInItsMetaEmitsNothing() async throws { let sink = RecordingResponderSink() let meta = MCPProtocolTestSupport.makeMeta() let emitter = MCPProgressEmitter(meta: meta, responder: MCPResponder(sink: sink, requestId: .number(1))) @@ -178,10 +178,10 @@ final class MCPProgressEmitterTests: XCTestCase { await emitter.emit(progress: 1) let frames = await sink.sseFrames - XCTAssertTrue(frames.isEmpty) + #expect(frames.isEmpty) } - func testProgressIsNotEmittedAfterTheResponseIsComplete() async throws { + @Test func testProgressIsNotEmittedAfterTheResponseIsComplete() async throws { let sink = RecordingResponderSink() let responder = MCPResponder(sink: sink, requestId: .number(1)) let emitter = MCPProgressEmitter(progressToken: .string("token"), responder: responder) @@ -190,6 +190,6 @@ final class MCPProgressEmitterTests: XCTestCase { await emitter.emit(progress: 1) let frames = await sink.sseFrames - XCTAssertTrue(frames.isEmpty) + #expect(frames.isEmpty) } } diff --git a/Packages/TableProMacCore/Tests/TableProMCPTests/Transport/MCPProtocolErrorTests.swift b/Packages/TableProMacCore/Tests/TableProMCPTests/Transport/MCPProtocolErrorTests.swift index 37eb2d1d24..3af9540609 100644 --- a/Packages/TableProMacCore/Tests/TableProMCPTests/Transport/MCPProtocolErrorTests.swift +++ b/Packages/TableProMacCore/Tests/TableProMCPTests/Transport/MCPProtocolErrorTests.swift @@ -1,186 +1,177 @@ import Foundation @testable import TableProMCP -import XCTest +import Testing -final class MCPProtocolErrorTests: XCTestCase { +struct MCPProtocolErrorTests { private func header(_ error: MCPProtocolError, _ name: String) -> String? { error.extraHeaders.first { $0.0.lowercased() == name.lowercased() }?.1 } - func testHeaderMismatchIsTheSpecCodeWithBadRequest() { + @Test func testHeaderMismatchIsTheSpecCodeWithBadRequest() { let error = MCPProtocolError.headerMismatch(detail: "Mcp-Name header is required for tools/call") - XCTAssertEqual(error.code, -32_020) - XCTAssertEqual(error.code, JsonRpcErrorCode.headerMismatch) - XCTAssertEqual(error.httpStatus, .badRequest) - XCTAssertTrue(error.message.contains("Mcp-Name")) + #expect(error.code == -32_020) + #expect(error.code == JsonRpcErrorCode.headerMismatch) + #expect(error.httpStatus == .badRequest) + #expect(error.message.contains("Mcp-Name")) } - func testMissingRequiredClientCapabilityListsTheCapabilities() { + @Test func testMissingRequiredClientCapabilityListsTheCapabilities() { let error = MCPProtocolError.missingRequiredClientCapability(["elicitation"]) - XCTAssertEqual(error.code, -32_021) - XCTAssertEqual(error.httpStatus, .badRequest) - XCTAssertEqual( - error.data?["requiredCapabilities"]?.arrayValue?.compactMap(\.stringValue), - ["elicitation"] - ) + #expect(error.code == -32_021) + #expect(error.httpStatus == .badRequest) + #expect(error.data?["requiredCapabilities"]?.arrayValue?.compactMap(\.stringValue) == ["elicitation"]) } - func testUnsupportedProtocolVersionListsTheSupportedVersions() { + @Test func testUnsupportedProtocolVersionListsTheSupportedVersions() { let error = MCPProtocolError.unsupportedProtocolVersion(requested: "2024-11-05") - XCTAssertEqual(error.code, -32_022) - XCTAssertEqual(error.httpStatus, .badRequest) - XCTAssertEqual( - error.data?["supported"]?.arrayValue?.compactMap(\.stringValue), - MCPProtocolVersion.supportedRawValues - ) - XCTAssertEqual(error.data?["requested"]?.stringValue, "2024-11-05") + #expect(error.code == -32_022) + #expect(error.httpStatus == .badRequest) + #expect(error.data?["supported"]?.arrayValue?.compactMap(\.stringValue) == MCPProtocolVersion.supportedRawValues) + #expect(error.data?["requested"]?.stringValue == "2024-11-05") } - func testUnsupportedProtocolVersionOmitsRequestedWhenUnknown() { + @Test func testUnsupportedProtocolVersionOmitsRequestedWhenUnknown() { let error = MCPProtocolError.unsupportedProtocolVersion(requested: nil) - XCTAssertNil(error.data?["requested"]) - XCTAssertNotNil(error.data?["supported"]) + #expect(error.data?["requested"] == nil) + #expect(error.data?["supported"] != nil) } - func testMethodNotFoundCarriesHttp404() { + @Test func testMethodNotFoundCarriesHttp404() { let error = MCPProtocolError.methodNotFound(method: "tools/teleport") - XCTAssertEqual(error.code, JsonRpcErrorCode.methodNotFound) - XCTAssertEqual(error.httpStatus, .notFound) + #expect(error.code == JsonRpcErrorCode.methodNotFound) + #expect(error.httpStatus == .notFound) } - func testNotFoundIsInvalidParams() { + @Test func testNotFoundIsInvalidParams() { let error = MCPProtocolError.notFound(detail: "No such resource") - XCTAssertEqual(error.code, -32_602) - XCTAssertEqual(error.code, JsonRpcErrorCode.invalidParams) - XCTAssertEqual(error.httpStatus, .badRequest) - XCTAssertEqual(error.message, "No such resource") + #expect(error.code == -32_602) + #expect(error.code == JsonRpcErrorCode.invalidParams) + #expect(error.httpStatus == .badRequest) + #expect(error.message == "No such resource") } - func testInvalidParamsMapping() { + @Test func testInvalidParamsMapping() { let error = MCPProtocolError.invalidParams(detail: "expected object") - XCTAssertEqual(error.code, JsonRpcErrorCode.invalidParams) - XCTAssertEqual(error.httpStatus, .badRequest) + #expect(error.code == JsonRpcErrorCode.invalidParams) + #expect(error.httpStatus == .badRequest) } - func testParseErrorMapping() { + @Test func testParseErrorMapping() { let error = MCPProtocolError.parseError(detail: "bad json") - XCTAssertEqual(error.code, JsonRpcErrorCode.parseError) - XCTAssertEqual(error.httpStatus, .badRequest) - XCTAssertTrue(error.message.contains("bad json")) + #expect(error.code == JsonRpcErrorCode.parseError) + #expect(error.httpStatus == .badRequest) + #expect(error.message.contains("bad json")) } - func testInvalidRequestMapping() { + @Test func testInvalidRequestMapping() { let error = MCPProtocolError.invalidRequest(detail: "missing method") - XCTAssertEqual(error.code, JsonRpcErrorCode.invalidRequest) - XCTAssertEqual(error.httpStatus, .badRequest) + #expect(error.code == JsonRpcErrorCode.invalidRequest) + #expect(error.httpStatus == .badRequest) } - func testInternalErrorMapping() { + @Test func testInternalErrorMapping() { let error = MCPProtocolError.internalError(detail: "boom") - XCTAssertEqual(error.code, JsonRpcErrorCode.internalError) - XCTAssertEqual(error.httpStatus, .internalServerError) + #expect(error.code == JsonRpcErrorCode.internalError) + #expect(error.httpStatus == .internalServerError) } - func testUnauthenticatedCarriesABearerChallenge() { + @Test func testUnauthenticatedCarriesABearerChallenge() { let error = MCPProtocolError.unauthenticated() - XCTAssertEqual(error.code, JsonRpcErrorCode.unauthenticated) - XCTAssertEqual(error.httpStatus, .unauthorized) - XCTAssertEqual(header(error, "WWW-Authenticate"), "Bearer realm=\"TablePro\"") + #expect(error.code == JsonRpcErrorCode.unauthenticated) + #expect(error.httpStatus == .unauthorized) + #expect(header(error, "WWW-Authenticate") == "Bearer realm=\"TablePro\"") } - func testTokenInvalidNamesTheInvalidTokenError() { + @Test func testTokenInvalidNamesTheInvalidTokenError() { let error = MCPProtocolError.tokenInvalid() - XCTAssertEqual(error.httpStatus, .unauthorized) - XCTAssertEqual(header(error, "WWW-Authenticate"), "Bearer realm=\"TablePro\", error=\"invalid_token\"") + #expect(error.httpStatus == .unauthorized) + #expect(header(error, "WWW-Authenticate") == "Bearer realm=\"TablePro\", error=\"invalid_token\"") } - func testTokenExpiredDescribesTheExpiry() throws { + @Test func testTokenExpiredDescribesTheExpiry() throws { let error = MCPProtocolError.tokenExpired() - XCTAssertEqual(error.code, JsonRpcErrorCode.expired) - XCTAssertEqual(error.httpStatus, .unauthorized) - let challenge = try XCTUnwrap(header(error, "WWW-Authenticate")) - XCTAssertTrue(challenge.contains("error=\"invalid_token\"")) - XCTAssertTrue(challenge.contains("error_description=\"token expired\"")) + #expect(error.code == JsonRpcErrorCode.expired) + #expect(error.httpStatus == .unauthorized) + let challenge = try #require(header(error, "WWW-Authenticate")) + #expect(challenge.contains("error=\"invalid_token\"")) + #expect(challenge.contains("error_description=\"token expired\"")) } - func testInsufficientScopeEmitsAStructuredChallengeAndData() throws { + @Test func testInsufficientScopeEmitsAStructuredChallengeAndData() throws { let error = MCPProtocolError.insufficientScope( required: [.toolsWrite, .toolsRead], reason: "tools/call requires tools:write" ) - XCTAssertEqual(error.code, JsonRpcErrorCode.forbidden) - XCTAssertEqual(error.httpStatus, .forbidden) - let challenge = try XCTUnwrap(header(error, "WWW-Authenticate")) - XCTAssertTrue(challenge.contains("error=\"insufficient_scope\"")) - XCTAssertTrue(challenge.contains("scope=\"tools:read tools:write\"")) - XCTAssertEqual( - error.data?["requiredScopes"]?.arrayValue?.compactMap(\.stringValue), - ["tools:read", "tools:write"] - ) + #expect(error.code == JsonRpcErrorCode.forbidden) + #expect(error.httpStatus == .forbidden) + let challenge = try #require(header(error, "WWW-Authenticate")) + #expect(challenge.contains("error=\"insufficient_scope\"")) + #expect(challenge.contains("scope=\"tools:read tools:write\"")) + #expect(error.data?["requiredScopes"]?.arrayValue?.compactMap(\.stringValue) == ["tools:read", "tools:write"]) } - func testForbiddenMapping() { + @Test func testForbiddenMapping() { let error = MCPProtocolError.forbidden(reason: "policy") - XCTAssertEqual(error.code, JsonRpcErrorCode.forbidden) - XCTAssertEqual(error.httpStatus, .forbidden) + #expect(error.code == JsonRpcErrorCode.forbidden) + #expect(error.httpStatus == .forbidden) } - func testRateLimitedIncludesRetryAfterOnlyWhenPositive() { + @Test func testRateLimitedIncludesRetryAfterOnlyWhenPositive() { let withRetry = MCPProtocolError.rateLimited(retryAfterSeconds: 30) - XCTAssertEqual(withRetry.code, JsonRpcErrorCode.rateLimited) - XCTAssertEqual(withRetry.httpStatus, .tooManyRequests) - XCTAssertEqual(header(withRetry, "Retry-After"), "30") + #expect(withRetry.code == JsonRpcErrorCode.rateLimited) + #expect(withRetry.httpStatus == .tooManyRequests) + #expect(header(withRetry, "Retry-After") == "30") - XCTAssertNil(header(MCPProtocolError.rateLimited(), "Retry-After")) - XCTAssertNil(header(MCPProtocolError.rateLimited(retryAfterSeconds: 0), "Retry-After")) + #expect(header(MCPProtocolError.rateLimited(), "Retry-After") == nil) + #expect(header(MCPProtocolError.rateLimited(retryAfterSeconds: 0), "Retry-After") == nil) } - func testPayloadTooLargeMapping() { + @Test func testPayloadTooLargeMapping() { let error = MCPProtocolError.payloadTooLarge() - XCTAssertEqual(error.code, JsonRpcErrorCode.tooLarge) - XCTAssertEqual(error.httpStatus, .payloadTooLarge) + #expect(error.code == JsonRpcErrorCode.tooLarge) + #expect(error.httpStatus == .payloadTooLarge) } - func testNotAcceptableMapping() { - XCTAssertEqual(MCPProtocolError.notAcceptable().httpStatus, .notAcceptable) + @Test func testNotAcceptableMapping() { + #expect(MCPProtocolError.notAcceptable().httpStatus == .notAcceptable) } - func testMethodNotAllowedAdvertisesTheAllowedMethods() { + @Test func testMethodNotAllowedAdvertisesTheAllowedMethods() { let error = MCPProtocolError.methodNotAllowed(allow: "POST, OPTIONS") - XCTAssertEqual(error.httpStatus, .methodNotAllowed) - XCTAssertEqual(header(error, "Allow"), "POST, OPTIONS") + #expect(error.httpStatus == .methodNotAllowed) + #expect(header(error, "Allow") == "POST, OPTIONS") } - func testCancelledAndTimedOutRequestsStayOnHttp200() { - XCTAssertEqual(MCPProtocolError.requestCancelled().code, JsonRpcErrorCode.requestCancelled) - XCTAssertEqual(MCPProtocolError.requestCancelled().httpStatus, .ok) - XCTAssertEqual(MCPProtocolError.requestTimeout(detail: "tools/call").code, JsonRpcErrorCode.requestTimeout) - XCTAssertEqual(MCPProtocolError.requestTimeout(detail: "tools/call").httpStatus, .ok) + @Test func testCancelledAndTimedOutRequestsStayOnHttp200() { + #expect(MCPProtocolError.requestCancelled().code == JsonRpcErrorCode.requestCancelled) + #expect(MCPProtocolError.requestCancelled().httpStatus == .ok) + #expect(MCPProtocolError.requestTimeout(detail: "tools/call").code == JsonRpcErrorCode.requestTimeout) + #expect(MCPProtocolError.requestTimeout(detail: "tools/call").httpStatus == .ok) } - func testServiceUnavailableAndServerDisabledMapping() { - XCTAssertEqual(MCPProtocolError.serviceUnavailable().code, JsonRpcErrorCode.serverError) - XCTAssertEqual(MCPProtocolError.serviceUnavailable().httpStatus, .serviceUnavailable) - XCTAssertEqual(MCPProtocolError.serverDisabled().code, JsonRpcErrorCode.serverDisabled) - XCTAssertEqual(MCPProtocolError.serverDisabled().httpStatus, .serviceUnavailable) + @Test func testServiceUnavailableAndServerDisabledMapping() { + #expect(MCPProtocolError.serviceUnavailable().code == JsonRpcErrorCode.serverError) + #expect(MCPProtocolError.serviceUnavailable().httpStatus == .serviceUnavailable) + #expect(MCPProtocolError.serverDisabled().code == JsonRpcErrorCode.serverDisabled) + #expect(MCPProtocolError.serverDisabled().httpStatus == .serviceUnavailable) } - func testToJsonRpcErrorResponseCarriesIdCodeAndData() { + @Test func testToJsonRpcErrorResponseCarriesIdCodeAndData() { let protocolError = MCPProtocolError.unsupportedProtocolVersion(requested: "1999-01-01") let response = protocolError.toJsonRpcErrorResponse(id: .number(7)) - XCTAssertEqual(response.id, .number(7)) - XCTAssertEqual(response.error.code, JsonRpcErrorCode.unsupportedProtocolVersion) - XCTAssertEqual(response.error.message, "Unsupported protocol version") - XCTAssertNotNil(response.error.data?["supported"]) + #expect(response.id == .number(7)) + #expect(response.error.code == JsonRpcErrorCode.unsupportedProtocolVersion) + #expect(response.error.message == "Unsupported protocol version") + #expect(response.error.data?["supported"] != nil) } - func testToJsonRpcErrorResponseWithNilId() { + @Test func testToJsonRpcErrorResponseWithNilId() { let response = MCPProtocolError.parseError(detail: "x").toJsonRpcErrorResponse(id: nil) - XCTAssertNil(response.id) - XCTAssertEqual(response.error.code, JsonRpcErrorCode.parseError) + #expect(response.id == nil) + #expect(response.error.code == JsonRpcErrorCode.parseError) } - func testEqualityComparesStatusAndHeaders() { + @Test func testEqualityComparesStatusAndHeaders() { let base = MCPProtocolError(code: -1, message: "x", httpStatus: .ok) let differentStatus = MCPProtocolError(code: -1, message: "x", httpStatus: .badRequest) let differentHeaders = MCPProtocolError( @@ -190,29 +181,23 @@ final class MCPProtocolErrorTests: XCTestCase { extraHeaders: [("Retry-After", "1")] ) - XCTAssertEqual(base, MCPProtocolError(code: -1, message: "x", httpStatus: .ok)) - XCTAssertNotEqual(base, differentStatus) - XCTAssertNotEqual(base, differentHeaders) + #expect(base == MCPProtocolError(code: -1, message: "x", httpStatus: .ok)) + #expect(base != differentStatus) + #expect(base != differentHeaders) } - func testSpecificationCodesLiveInTheReservedRange() { + @Test func testSpecificationCodesLiveInTheReservedRange() { for code in JsonRpcErrorCode.specificationDefined { - XCTAssertTrue( - JsonRpcErrorCode.isSpecificationReserved(code), - "\(code) must sit inside the range the specification reserves" - ) + #expect(JsonRpcErrorCode.isSpecificationReserved(code), "\(code) must sit inside the range the specification reserves") } - XCTAssertEqual( - JsonRpcErrorCode.specificationDefined, - [ - JsonRpcErrorCode.headerMismatch, - JsonRpcErrorCode.missingRequiredClientCapability, - JsonRpcErrorCode.unsupportedProtocolVersion - ] - ) + #expect(JsonRpcErrorCode.specificationDefined == [ + JsonRpcErrorCode.headerMismatch, + JsonRpcErrorCode.missingRequiredClientCapability, + JsonRpcErrorCode.unsupportedProtocolVersion + ]) } - func testTableProCodesLeftTheImplementationDefinedBlock() { + @Test func testTableProCodesLeftTheImplementationDefinedBlock() { let tableProCodes = [ JsonRpcErrorCode.serverError, JsonRpcErrorCode.sessionNotFound, @@ -226,12 +211,9 @@ final class MCPProtocolErrorTests: XCTestCase { JsonRpcErrorCode.rateLimited ] for code in tableProCodes { - XCTAssertTrue(JsonRpcErrorCode.tableProRange.contains(code), "\(code) must be a -33xxx code") - XCTAssertFalse( - JsonRpcErrorCode.legacyImplementationRange.contains(code), - "\(code) must have left the -32000 block" - ) - XCTAssertFalse(JsonRpcErrorCode.isSpecificationReserved(code), "\(code) must not squat a spec code") + #expect(JsonRpcErrorCode.tableProRange.contains(code), "\(code) must be a -33xxx code") + #expect(!JsonRpcErrorCode.legacyImplementationRange.contains(code), "\(code) must have left the -32000 block") + #expect(!JsonRpcErrorCode.isSpecificationReserved(code), "\(code) must not squat a spec code") } } } diff --git a/Packages/TableProMacCore/Tests/TableProMCPTests/Transport/MCPStreamableHttpClientTransportTests.swift b/Packages/TableProMacCore/Tests/TableProMCPTests/Transport/MCPStreamableHttpClientTransportTests.swift index cbaf232785..605a71cfc1 100644 --- a/Packages/TableProMacCore/Tests/TableProMCPTests/Transport/MCPStreamableHttpClientTransportTests.swift +++ b/Packages/TableProMacCore/Tests/TableProMCPTests/Transport/MCPStreamableHttpClientTransportTests.swift @@ -1,6 +1,7 @@ import Foundation @testable import TableProMCP import TableProMCPTestSupport +import TableProTestSupport import XCTest final class MCPStreamableHttpClientTransportTests: XCTestCase { @@ -237,7 +238,8 @@ final class MCPStreamableHttpClientTransportTests: XCTestCase { body: Data("kaboom".utf8) )) - let transport = makeTransport() + let logger = RecordingBridgeLogger() + let transport = makeTransport(errorLogger: logger) try await transport.send(MCPUpstreamFrame( body: Data(#"{"jsonrpc":"2.0","method":"notifications/cancelled"}"#.utf8), method: "notifications/cancelled" @@ -245,9 +247,11 @@ final class MCPStreamableHttpClientTransportTests: XCTestCase { let mock = server try await waitUntil { await mock?.requests.count == 1 } - let inbound = try? await firstInbound(transport: transport, timeout: 0.4) - XCTAssertNil(inbound, "A notification has no id, so there is nothing to answer") + // The transport logs as it decides the failure has no one to answer; closing then flushes anything it emitted. + try await waitUntil { !logger.entries.isEmpty } await transport.close() + let inbound = try await remainingInbound(transport: transport) + XCTAssertEqual(inbound, [], "A notification has no id, so there is nothing to answer") } func testUnauthorizedRefreshesCredentialsAndRetriesExactlyOnce() async throws { @@ -363,13 +367,17 @@ final class MCPStreamableHttpClientTransportTests: XCTestCase { func testClosingSendsNoDeleteAndNoFurtherRequest() async throws { await server.respondWithJson(try Self.successBody(id: .number(1))) - let transport = makeTransport() + let watcher = SessionTaskWatcher() + let session = URLSession(configuration: .ephemeral, delegate: watcher, delegateQueue: nil) + let transport = makeTransport(urlSession: session) try await transport.send(frame(method: "tools/list", requestId: .number(1))) _ = try await firstInbound(transport: transport) await transport.close() - try await Task.sleep(for: .milliseconds(200)) + // A session reports itself invalid only after every task it ran has finished. + await watcher.invalidated.wait() + XCTAssertEqual(watcher.createdMethods, ["POST"]) let requests = await server.requests XCTAssertEqual(requests.count, 1) XCTAssertFalse(requests.contains { $0.method == "DELETE" }) @@ -435,7 +443,9 @@ final class MCPStreamableHttpClientTransportTests: XCTestCase { } private func makeTransport( - credentialsProvider: (any MCPUpstreamCredentialsProviding)? = nil + credentialsProvider: (any MCPUpstreamCredentialsProviding)? = nil, + urlSession: URLSession? = nil, + errorLogger: (any MCPBridgeLogger)? = nil ) -> MCPStreamableHttpClientTransport { let credentials = MCPUpstreamCredentials( endpoint: URL(string: "http://127.0.0.1:\(server.port)/mcp") ?? URL(fileURLWithPath: "/"), @@ -446,7 +456,8 @@ final class MCPStreamableHttpClientTransportTests: XCTestCase { return MCPStreamableHttpClientTransport( configuration: MCPStreamableHttpClientConfiguration(requestTimeout: .seconds(5)), credentialsProvider: provider, - errorLogger: nil + urlSession: urlSession, + errorLogger: errorLogger ) } @@ -465,16 +476,12 @@ final class MCPStreamableHttpClientTransportTests: XCTestCase { return Data(text.utf8) } - private func waitUntil( - timeout: TimeInterval = 3.0, - condition: @Sendable () async -> Bool - ) async throws { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if await condition() { return } - try await Task.sleep(for: .milliseconds(20)) + private func remainingInbound(transport: MCPStreamableHttpClientTransport) async throws -> [Data] { + var collected: [Data] = [] + for try await next in transport.inbound { + collected.append(next) } - throw TransportTestError.timeout + return collected } private func firstInbound( @@ -515,6 +522,25 @@ final class MCPStreamableHttpClientTransportTests: XCTestCase { } } +private final class SessionTaskWatcher: NSObject, URLSessionTaskDelegate, @unchecked Sendable { + let invalidated = AsyncGate() + private let lock = NSLock() + private var methods: [String] = [] + + var createdMethods: [String] { + lock.withLock { methods } + } + + func urlSession(_ session: URLSession, didCreateTask task: URLSessionTask) { + lock.withLock { methods.append(task.originalRequest?.httpMethod ?? "") } + } + + func urlSession(_ session: URLSession, didBecomeInvalidWithError error: Error?) { + let invalidated = invalidated + Task { await invalidated.open() } + } +} + enum TransportTestError: Error { case timeout case noQueuedCredentials diff --git a/Packages/TableProMacCore/Tests/TableProMCPTests/Wire/HttpRequestParserTests.swift b/Packages/TableProMacCore/Tests/TableProMCPTests/Wire/HttpRequestParserTests.swift index d607c79232..ba47d6f138 100644 --- a/Packages/TableProMacCore/Tests/TableProMCPTests/Wire/HttpRequestParserTests.swift +++ b/Packages/TableProMacCore/Tests/TableProMCPTests/Wire/HttpRequestParserTests.swift @@ -1,254 +1,243 @@ import Foundation @testable import TableProMCP -import XCTest +import Testing -final class HttpRequestParserTests: XCTestCase { - func testParsesSimpleGetRequest() throws { +struct HttpRequestParserTests { + @Test func testParsesSimpleGetRequest() throws { let raw = "GET /index HTTP/1.1\r\nHost: example.com\r\n\r\n" let result = try HttpRequestParser.parse(Data(raw.utf8)) guard case .complete(let head, let body, let consumed) = result else { - XCTFail("Expected complete, got \(result)") + Issue.record("Expected complete, got \(result)") return } - XCTAssertEqual(head.method, .get) - XCTAssertEqual(head.path, "/index") - XCTAssertEqual(head.httpVersion, "HTTP/1.1") - XCTAssertEqual(head.headers.value(for: "Host"), "example.com") - XCTAssertEqual(body, Data()) - XCTAssertEqual(consumed, raw.utf8.count) + #expect(head.method == .get) + #expect(head.path == "/index") + #expect(head.httpVersion == "HTTP/1.1") + #expect(head.headers.value(for: "Host") == "example.com") + #expect(body == Data()) + #expect(consumed == raw.utf8.count) } - func testCaseInsensitiveHeaderLookup() throws { + @Test func testCaseInsensitiveHeaderLookup() throws { let raw = "GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: text/plain\r\n\r\n" let result = try HttpRequestParser.parse(Data(raw.utf8)) guard case .complete(let head, _, _) = result else { - XCTFail("Expected complete") + Issue.record("Expected complete") return } - XCTAssertEqual(head.headers.value(for: "content-type"), "text/plain") - XCTAssertEqual(head.headers.value(for: "CONTENT-TYPE"), "text/plain") + #expect(head.headers.value(for: "content-type") == "text/plain") + #expect(head.headers.value(for: "CONTENT-TYPE") == "text/plain") } - func testMcpRequestMetadataHeaderLookupIsCaseInsensitive() throws { + @Test func testMcpRequestMetadataHeaderLookupIsCaseInsensitive() throws { let raw = "POST /mcp HTTP/1.1\r\nHost: x\r\nmcp-protocol-version: 2026-07-28\r\nMCP-METHOD: tools/call\r\n" + "mcp-name: run_query\r\n\r\n" let result = try HttpRequestParser.parse(Data(raw.utf8)) guard case .complete(let head, _, _) = result else { - XCTFail("Expected complete") + Issue.record("Expected complete") return } - XCTAssertEqual(head.headers.value(for: MCPHttpHeaderValidator.protocolVersionHeader), "2026-07-28") - XCTAssertEqual(head.headers.value(for: MCPHttpHeaderValidator.methodHeader), "tools/call") - XCTAssertEqual(head.headers.value(for: MCPHttpHeaderValidator.nameHeader), "run_query") + #expect(head.headers.value(for: MCPHttpHeaderValidator.protocolVersionHeader) == "2026-07-28") + #expect(head.headers.value(for: MCPHttpHeaderValidator.methodHeader) == "tools/call") + #expect(head.headers.value(for: MCPHttpHeaderValidator.nameHeader) == "run_query") } - func testParameterHeaderPrefixMatchIsCaseInsensitive() throws { + @Test func testParameterHeaderPrefixMatchIsCaseInsensitive() throws { let raw = "POST /mcp HTTP/1.1\r\nHost: x\r\nmcp-param-Region: us-west1\r\nMcp-Param-Limit: 42\r\n\r\n" let result = try HttpRequestParser.parse(Data(raw.utf8)) guard case .complete(let head, _, _) = result else { - XCTFail("Expected complete") + Issue.record("Expected complete") return } let pairs = head.headers.pairs(withPrefix: MCPHttpHeaderValidator.parameterHeaderPrefix) - XCTAssertEqual(pairs.count, 2) - XCTAssertEqual(pairs.map(\.1).sorted(), ["42", "us-west1"]) + #expect(pairs.count == 2) + #expect(pairs.map(\.1).sorted() == ["42", "us-west1"]) } - func testHeaderValuesKeepTheirCase() throws { + @Test func testHeaderValuesKeepTheirCase() throws { let raw = "POST /mcp HTTP/1.1\r\nHost: x\r\nMcp-Name: RunQuery\r\n\r\n" let result = try HttpRequestParser.parse(Data(raw.utf8)) guard case .complete(let head, _, _) = result else { - XCTFail("Expected complete") + Issue.record("Expected complete") return } - XCTAssertEqual(head.headers.value(for: "mcp-name"), "RunQuery") + #expect(head.headers.value(for: "mcp-name") == "RunQuery") } - func testParsesPostBodyOfExactContentLength() throws { + @Test func testParsesPostBodyOfExactContentLength() throws { let body = "{\"x\":1}" let raw = "POST /rpc HTTP/1.1\r\nHost: x\r\nContent-Length: \(body.utf8.count)\r\n\r\n\(body)" let result = try HttpRequestParser.parse(Data(raw.utf8)) guard case .complete(let head, let parsedBody, let consumed) = result else { - XCTFail("Expected complete") + Issue.record("Expected complete") return } - XCTAssertEqual(head.method, .post) - XCTAssertEqual(parsedBody, Data(body.utf8)) - XCTAssertEqual(consumed, raw.utf8.count) + #expect(head.method == .post) + #expect(parsedBody == Data(body.utf8)) + #expect(consumed == raw.utf8.count) } - func testReportsExtraBytesAfterBodyViaConsumedBytes() throws { + @Test func testReportsExtraBytesAfterBodyViaConsumedBytes() throws { let body = "abc" let raw = "POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 3\r\n\r\n\(body)REMAINDER" let result = try HttpRequestParser.parse(Data(raw.utf8)) guard case .complete(_, let parsedBody, let consumed) = result else { - XCTFail("Expected complete") + Issue.record("Expected complete") return } - XCTAssertEqual(parsedBody, Data(body.utf8)) + #expect(parsedBody == Data(body.utf8)) let expectedConsumed = raw.utf8.count - "REMAINDER".utf8.count - XCTAssertEqual(consumed, expectedConsumed) + #expect(consumed == expectedConsumed) } - func testIncompleteWhenHeadersNotFinished() throws { - let raw = "GET / HTTP/1.1\r\nHost: x" + @Test("Unfinished headers, or a body shorter than Content-Length, is incomplete", arguments: [ + "GET / HTTP/1.1\r\nHost: x", + "POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 10\r\n\r\nshort", + ]) + func testIncompleteRequest(_ raw: String) throws { let result = try HttpRequestParser.parse(Data(raw.utf8)) - XCTAssertEqual(result, .incomplete) + #expect(result == .incomplete) } - func testIncompleteWhenBodyShorterThanContentLength() throws { - let raw = "POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 10\r\n\r\nshort" - let result = try HttpRequestParser.parse(Data(raw.utf8)) - XCTAssertEqual(result, .incomplete) - } - - func testRejectsBareLfAsTerminator() { - let raw = "GET / HTTP/1.1\nHost: x\n\n" - XCTAssertThrowsError(try HttpRequestParser.parse(Data(raw.utf8))) { error in - XCTAssertEqual(error as? HttpRequestParseError, .nonStrictLineEndings) + @Test("A bare LF as the terminator or inside a header line is rejected", arguments: [ + "GET / HTTP/1.1\nHost: x\n\n", + "GET / HTTP/1.1\r\nBad: value\nHost: x\r\n\r\n", + ]) + func testRejectsBareLf(_ raw: String) { + if let error = #expect(throws: (any Error).self, performing: { try HttpRequestParser.parse(Data(raw.utf8)) }) { + #expect(error as? HttpRequestParseError == .nonStrictLineEndings) } } - func testRejectsBareLfInHeaderLine() { - let raw = "GET / HTTP/1.1\r\nBad: value\nHost: x\r\n\r\n" - XCTAssertThrowsError(try HttpRequestParser.parse(Data(raw.utf8))) { error in - XCTAssertEqual(error as? HttpRequestParseError, .nonStrictLineEndings) - } - } - - func testRejectsHeaderTooLarge() { + @Test func testRejectsHeaderTooLarge() { let bigHeaderValue = String(repeating: "a", count: 17 * 1_024) let raw = "GET / HTTP/1.1\r\nX-Big: \(bigHeaderValue)\r\n\r\n" - XCTAssertThrowsError(try HttpRequestParser.parse(Data(raw.utf8))) { error in - XCTAssertEqual(error as? HttpRequestParseError, .headerTooLarge) + if let error = #expect(throws: (any Error).self, performing: { try HttpRequestParser.parse(Data(raw.utf8)) }) { + #expect(error as? HttpRequestParseError == .headerTooLarge) } } - func testRejectsHeaderTooLargeWithoutTerminator() { + @Test func testRejectsHeaderTooLargeWithoutTerminator() { let huge = String(repeating: "X-Pad: pad\r\n", count: 2_000) let raw = "GET / HTTP/1.1\r\n\(huge)" - XCTAssertThrowsError(try HttpRequestParser.parse(Data(raw.utf8))) { error in - XCTAssertEqual(error as? HttpRequestParseError, .headerTooLarge) + if let error = #expect(throws: (any Error).self, performing: { try HttpRequestParser.parse(Data(raw.utf8)) }) { + #expect(error as? HttpRequestParseError == .headerTooLarge) } } - func testUnknownMethodMappedToOther() throws { + @Test func testUnknownMethodMappedToOther() throws { let raw = "PROPFIND / HTTP/1.1\r\nHost: x\r\n\r\n" let result = try HttpRequestParser.parse(Data(raw.utf8)) guard case .complete(let head, _, _) = result else { - XCTFail("Expected complete") + Issue.record("Expected complete") return } - XCTAssertEqual(head.method, .other("PROPFIND")) + #expect(head.method == .other("PROPFIND")) } - func testRejectsBodyOverLimit() { + @Test func testRejectsBodyOverLimit() { let raw = "POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 99999999\r\n\r\n" - XCTAssertThrowsError(try HttpRequestParser.parse(Data(raw.utf8))) { error in + if let error = #expect(throws: (any Error).self, performing: { try HttpRequestParser.parse(Data(raw.utf8)) }) { guard case HttpRequestParseError.bodyTooLarge = error else { - XCTFail("Expected bodyTooLarge") + Issue.record("Expected bodyTooLarge") return } } } - func testPathPreservedVerbatim() throws { + @Test func testPathPreservedVerbatim() throws { let raw = "GET /path%20with%20spaces?x=1 HTTP/1.1\r\nHost: x\r\n\r\n" let result = try HttpRequestParser.parse(Data(raw.utf8)) guard case .complete(let head, _, _) = result else { - XCTFail("Expected complete") + Issue.record("Expected complete") return } - XCTAssertEqual(head.path, "/path%20with%20spaces?x=1") + #expect(head.path == "/path%20with%20spaces?x=1") } - func testDecodesAChunkedBodyRatherThanReadingItAsEmpty() throws { + @Test func testDecodesAChunkedBodyRatherThanReadingItAsEmpty() throws { let raw = "POST /mcp HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n" + "4\r\n{\"a\"\r\n4\r\n:1}\n\r\n0\r\n\r\n" let result = try HttpRequestParser.parse(Data(raw.utf8)) guard case .complete(_, let body, _) = result else { - XCTFail("Expected complete") + Issue.record("Expected complete") return } - XCTAssertEqual(String(data: body, encoding: .utf8), "{\"a\":1}\n") - XCTAssertFalse(body.isEmpty) + #expect(String(data: body, encoding: .utf8) == "{\"a\":1}\n") + #expect(!body.isEmpty) } - func testRejectsChunkedTogetherWithContentLength() { - let raw = "POST / HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\nContent-Length: 3\r\n\r\n0\r\n\r\n" - XCTAssertThrowsError(try HttpRequestParser.parse(Data(raw.utf8))) { error in - XCTAssertEqual(error as? HttpRequestParseError, .malformedHeader) + @Test("Chunked together with Content-Length, or whitespace before a header colon, is a malformed header", arguments: [ + "POST / HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\nContent-Length: 3\r\n\r\n0\r\n\r\n", + "GET / HTTP/1.1\r\nHost: x\r\nX-Bad : value\r\n\r\n", + ]) + func testRejectsMalformedHeader(_ raw: String) { + if let error = #expect(throws: (any Error).self, performing: { try HttpRequestParser.parse(Data(raw.utf8)) }) { + #expect(error as? HttpRequestParseError == .malformedHeader) } } - func testRejectsUnsupportedTransferCoding() { + @Test func testRejectsUnsupportedTransferCoding() { let raw = "POST / HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: gzip\r\n\r\n" - XCTAssertThrowsError(try HttpRequestParser.parse(Data(raw.utf8))) { error in - XCTAssertEqual(error as? HttpRequestParseError, .unsupportedTransferEncoding("gzip")) + if let error = #expect(throws: (any Error).self, performing: { try HttpRequestParser.parse(Data(raw.utf8)) }) { + #expect(error as? HttpRequestParseError == .unsupportedTransferEncoding("gzip")) } } - func testDeclaredContentLengthIsCheckedBeforeAnyBodyByteArrives() { + @Test func testDeclaredContentLengthIsCheckedBeforeAnyBodyByteArrives() { let limits = HttpParserLimits(maxHeaderBytes: 1_024, maxBodyBytes: 16) let raw = "POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 4096\r\n\r\n" - XCTAssertThrowsError(try HttpRequestParser.parse(Data(raw.utf8), limits: limits)) { error in - XCTAssertEqual(error as? HttpRequestParseError, .bodyTooLarge(limit: 16, actual: 4_096)) + if let error = #expect(throws: (any Error).self, performing: { try HttpRequestParser.parse(Data(raw.utf8), limits: limits) }) { + #expect(error as? HttpRequestParseError == .bodyTooLarge(limit: 16, actual: 4_096)) } } - func testRejectsNegativeAndNonNumericContentLength() { + @Test func testRejectsNegativeAndNonNumericContentLength() { for value in ["-1", "abc", "1 2"] { let raw = "POST / HTTP/1.1\r\nHost: x\r\nContent-Length: \(value)\r\n\r\n" - XCTAssertThrowsError(try HttpRequestParser.parse(Data(raw.utf8)), "Content-Length: \(value)") { error in - XCTAssertEqual(error as? HttpRequestParseError, .malformedHeader) + if let error = #expect(throws: (any Error).self, "Content-Length: \(value)", performing: { try HttpRequestParser.parse(Data(raw.utf8)) }) { + #expect(error as? HttpRequestParseError == .malformedHeader) } } } - func testRejectsAHeaderNameWithWhitespaceBeforeTheColon() { - let raw = "GET / HTTP/1.1\r\nHost: x\r\nX-Bad : value\r\n\r\n" - XCTAssertThrowsError(try HttpRequestParser.parse(Data(raw.utf8))) { error in - XCTAssertEqual(error as? HttpRequestParseError, .malformedHeader) - } - } - - func testRejectsAnUnknownHttpVersionToken() { + @Test func testRejectsAnUnknownHttpVersionToken() { let raw = "GET / RTSP/1.0\r\nHost: x\r\n\r\n" - XCTAssertThrowsError(try HttpRequestParser.parse(Data(raw.utf8))) { error in - XCTAssertEqual(error as? HttpRequestParseError, .unsupportedHttpVersion("RTSP/1.0")) + if let error = #expect(throws: (any Error).self, performing: { try HttpRequestParser.parse(Data(raw.utf8)) }) { + #expect(error as? HttpRequestParseError == .unsupportedHttpVersion("RTSP/1.0")) } } - func testHttpTenNeedsNoHostHeader() throws { + @Test func testHttpTenNeedsNoHostHeader() throws { let raw = "GET /mcp HTTP/1.0\r\n\r\n" let result = try HttpRequestParser.parse(Data(raw.utf8)) guard case .complete(let head, _, _) = result else { - XCTFail("Expected complete") + Issue.record("Expected complete") return } - XCTAssertEqual(head.httpVersion, "HTTP/1.0") - XCTAssertFalse(head.wantsKeepAlive) + #expect(head.httpVersion == "HTTP/1.0") + #expect(!head.wantsKeepAlive) } - func testKeepAliveDefaultsOnForHttpElevenAndOffForConnectionClose() throws { + @Test func testKeepAliveDefaultsOnForHttpElevenAndOffForConnectionClose() throws { let keepAlive = "GET / HTTP/1.1\r\nHost: x\r\n\r\n" let closing = "GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n" guard case .complete(let keepHead, _, _) = try HttpRequestParser.parse(Data(keepAlive.utf8)), case .complete(let closeHead, _, _) = try HttpRequestParser.parse(Data(closing.utf8)) else { - XCTFail("Expected complete") + Issue.record("Expected complete") return } - XCTAssertTrue(keepHead.wantsKeepAlive) - XCTAssertFalse(closeHead.wantsKeepAlive) + #expect(keepHead.wantsKeepAlive) + #expect(!closeHead.wantsKeepAlive) } - func testPathWithoutQueryStripsTheQueryString() throws { + @Test func testPathWithoutQueryStripsTheQueryString() throws { let raw = "POST /mcp?debug=1 HTTP/1.1\r\nHost: x\r\n\r\n" guard case .complete(let head, _, _) = try HttpRequestParser.parse(Data(raw.utf8)) else { - XCTFail("Expected complete") + Issue.record("Expected complete") return } - XCTAssertEqual(head.path, "/mcp?debug=1") - XCTAssertEqual(head.pathWithoutQuery, "/mcp") + #expect(head.path == "/mcp?debug=1") + #expect(head.pathWithoutQuery == "/mcp") } } diff --git a/Packages/TableProMacCore/Tests/TableProMCPTests/Wire/HttpRequestStreamParserTests.swift b/Packages/TableProMacCore/Tests/TableProMCPTests/Wire/HttpRequestStreamParserTests.swift index 0d85e5e5c3..8126703dfd 100644 --- a/Packages/TableProMacCore/Tests/TableProMCPTests/Wire/HttpRequestStreamParserTests.swift +++ b/Packages/TableProMacCore/Tests/TableProMCPTests/Wire/HttpRequestStreamParserTests.swift @@ -138,23 +138,13 @@ struct HttpRequestStreamParserTests { #expect(bodies == ["abcdef"]) } - @Test("A malformed chunk size is rejected") - func malformedChunkSize() { + @Test("A malformed chunk size, or a chunk not terminated by CRLF, is rejected", arguments: [ + "POST /mcp HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\nzz\r\n", + "POST /mcp HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n2\r\nhiXX", + ]) + func malformedChunk(_ request: String) { var parser = HttpRequestStreamParser() - parser.append( - Data("POST /mcp HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\nzz\r\n".utf8) - ) - #expect(throws: HttpRequestParseError.self) { - _ = try parser.next() - } - } - - @Test("A chunk that is not terminated by CRLF is rejected") - func malformedChunkTerminator() { - var parser = HttpRequestStreamParser() - parser.append( - Data("POST /mcp HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n2\r\nhiXX".utf8) - ) + parser.append(Data(request.utf8)) #expect(throws: HttpRequestParseError.self) { _ = try parser.next() } diff --git a/Packages/TableProMacCore/Tests/TableProMCPTests/Wire/JsonRpcIdTests.swift b/Packages/TableProMacCore/Tests/TableProMCPTests/Wire/JsonRpcIdTests.swift index 27a7b86c5e..6bcb54c20e 100644 --- a/Packages/TableProMacCore/Tests/TableProMCPTests/Wire/JsonRpcIdTests.swift +++ b/Packages/TableProMacCore/Tests/TableProMCPTests/Wire/JsonRpcIdTests.swift @@ -1,81 +1,72 @@ import Foundation @testable import TableProMCP -import XCTest +import Testing -final class JsonRpcIdTests: XCTestCase { - func testNullRoundTrip() throws { +struct JsonRpcIdTests { + @Test func testNullRoundTrip() throws { let id: JsonRpcId = .null let data = try JSONEncoder().encode(id) let decoded = try JSONDecoder().decode(JsonRpcId.self, from: data) - XCTAssertEqual(decoded, .null) + #expect(decoded == .null) } - func testNullEncodesAsJsonNull() throws { + @Test func testNullEncodesAsJsonNull() throws { let id: JsonRpcId = .null let data = try JSONEncoder().encode(id) - XCTAssertEqual(String(data: data, encoding: .utf8), "null") + #expect(String(data: data, encoding: .utf8) == "null") } - func testStringRoundTrip() throws { + @Test func testStringRoundTrip() throws { let id: JsonRpcId = .string("abc-123") let data = try JSONEncoder().encode(id) let decoded = try JSONDecoder().decode(JsonRpcId.self, from: data) - XCTAssertEqual(decoded, .string("abc-123")) + #expect(decoded == .string("abc-123")) } - func testNumberRoundTrip() throws { + @Test func testNumberRoundTrip() throws { let id: JsonRpcId = .number(42) let data = try JSONEncoder().encode(id) let decoded = try JSONDecoder().decode(JsonRpcId.self, from: data) - XCTAssertEqual(decoded, .number(42)) + #expect(decoded == .number(42)) } - func testLargeNumberRoundTrip() throws { + @Test func testLargeNumberRoundTrip() throws { let id: JsonRpcId = .number(Int64.max) let data = try JSONEncoder().encode(id) let decoded = try JSONDecoder().decode(JsonRpcId.self, from: data) - XCTAssertEqual(decoded, .number(Int64.max)) + #expect(decoded == .number(Int64.max)) } - func testDecodeJsonNullProducesNullCase() throws { + @Test func testDecodeJsonNullProducesNullCase() throws { let raw = Data("null".utf8) let decoded = try JSONDecoder().decode(JsonRpcId.self, from: raw) - XCTAssertEqual(decoded, .null) + #expect(decoded == .null) } - func testDecodeBoolThrows() { - let raw = Data("true".utf8) - XCTAssertThrowsError(try JSONDecoder().decode(JsonRpcId.self, from: raw)) + @Test(arguments: ["true", "[1,2]", "{}"]) + func testDecodeBoolArrayOrObjectThrows(_ json: String) { + let raw = Data(json.utf8) + #expect(throws: (any Error).self) { try JSONDecoder().decode(JsonRpcId.self, from: raw) } } - func testDecodeArrayThrows() { - let raw = Data("[1,2]".utf8) - XCTAssertThrowsError(try JSONDecoder().decode(JsonRpcId.self, from: raw)) + @Test func testAsJsonValueMapsEachCase() { + #expect(JsonRpcId.string("abc").asJsonValue == .string("abc")) + #expect(JsonRpcId.number(42).asJsonValue == .int(42)) + #expect(JsonRpcId.null.asJsonValue == .null) } - func testDecodeObjectThrows() { - let raw = Data("{}".utf8) - XCTAssertThrowsError(try JSONDecoder().decode(JsonRpcId.self, from: raw)) + @Test func testAsJsonValueClampsOversizedNumbers() { + #expect(JsonRpcId.number(Int64(Int.max)).asJsonValue == .int(Int.max)) + #expect(JsonRpcId.number(Int64(Int.min)).asJsonValue == .int(Int.min)) } - func testAsJsonValueMapsEachCase() { - XCTAssertEqual(JsonRpcId.string("abc").asJsonValue, .string("abc")) - XCTAssertEqual(JsonRpcId.number(42).asJsonValue, .int(42)) - XCTAssertEqual(JsonRpcId.null.asJsonValue, .null) + @Test func testIdsHashAndCompareByCase() { + #expect(JsonRpcId.number(1) == .number(1)) + #expect(JsonRpcId.number(1) != JsonRpcId.string("1")) + #expect(Set([JsonRpcId.number(1), .number(1), .string("1")]).count == 2) } - func testAsJsonValueClampsOversizedNumbers() { - XCTAssertEqual(JsonRpcId.number(Int64(Int.max)).asJsonValue, .int(Int.max)) - XCTAssertEqual(JsonRpcId.number(Int64(Int.min)).asJsonValue, .int(Int.min)) - } - - func testIdsHashAndCompareByCase() { - XCTAssertEqual(JsonRpcId.number(1), JsonRpcId.number(1)) - XCTAssertNotEqual(JsonRpcId.number(1), JsonRpcId.string("1")) - XCTAssertEqual(Set([JsonRpcId.number(1), .number(1), .string("1")]).count, 2) - } - - func testDecodeDoubleThrows() { - XCTAssertThrowsError(try JSONDecoder().decode(JsonRpcId.self, from: Data("1.5".utf8))) + @Test func testDecodeDoubleThrows() { + #expect(throws: (any Error).self) { try JSONDecoder().decode(JsonRpcId.self, from: Data("1.5".utf8)) } } } diff --git a/Packages/TableProMacCore/Tests/TableProMCPTests/Wire/JsonRpcMessageTests.swift b/Packages/TableProMacCore/Tests/TableProMCPTests/Wire/JsonRpcMessageTests.swift index bba916700f..2774866f98 100644 --- a/Packages/TableProMacCore/Tests/TableProMCPTests/Wire/JsonRpcMessageTests.swift +++ b/Packages/TableProMacCore/Tests/TableProMCPTests/Wire/JsonRpcMessageTests.swift @@ -1,9 +1,9 @@ import Foundation @testable import TableProMCP -import XCTest +import Testing -final class JsonRpcMessageTests: XCTestCase { - func testRequestRoundTrip() throws { +struct JsonRpcMessageTests { + @Test func testRequestRoundTrip() throws { let message = JsonRpcMessage.request( JsonRpcRequest( id: .number(1), @@ -13,35 +13,35 @@ final class JsonRpcMessageTests: XCTestCase { ) let data = try JsonRpcCodec.encode(message) let decoded = try JsonRpcCodec.decode(data) - XCTAssertEqual(decoded, message) + #expect(decoded == message) } - func testRequestWithoutParamsRoundTrip() throws { + @Test func testRequestWithoutParamsRoundTrip() throws { let message = JsonRpcMessage.request( JsonRpcRequest(id: .string("req-1"), method: "ping", params: nil) ) let data = try JsonRpcCodec.encode(message) let decoded = try JsonRpcCodec.decode(data) - XCTAssertEqual(decoded, message) + #expect(decoded == message) - let json = try XCTUnwrap(String(data: data, encoding: .utf8)) - XCTAssertFalse(json.contains("\"params\"")) + let json = try #require(String(data: data, encoding: .utf8)) + #expect(!json.contains("\"params\"")) } - func testNotificationRoundTrip() throws { + @Test func testNotificationRoundTrip() throws { let message = JsonRpcMessage.notification( JsonRpcNotification(method: "notifications/initialized", params: nil) ) let data = try JsonRpcCodec.encode(message) let decoded = try JsonRpcCodec.decode(data) - XCTAssertEqual(decoded, message) + #expect(decoded == message) - let json = try XCTUnwrap(String(data: data, encoding: .utf8)) - XCTAssertFalse(json.contains("\"id\"")) - XCTAssertFalse(json.contains("\"params\"")) + let json = try #require(String(data: data, encoding: .utf8)) + #expect(!json.contains("\"id\"")) + #expect(!json.contains("\"params\"")) } - func testNotificationWithParamsRoundTrip() throws { + @Test func testNotificationWithParamsRoundTrip() throws { let message = JsonRpcMessage.notification( JsonRpcNotification( method: "notifications/progress", @@ -50,10 +50,10 @@ final class JsonRpcMessageTests: XCTestCase { ) let data = try JsonRpcCodec.encode(message) let decoded = try JsonRpcCodec.decode(data) - XCTAssertEqual(decoded, message) + #expect(decoded == message) } - func testSuccessResponseRoundTrip() throws { + @Test func testSuccessResponseRoundTrip() throws { let message = JsonRpcMessage.successResponse( JsonRpcSuccessResponse( id: .number(7), @@ -62,10 +62,10 @@ final class JsonRpcMessageTests: XCTestCase { ) let data = try JsonRpcCodec.encode(message) let decoded = try JsonRpcCodec.decode(data) - XCTAssertEqual(decoded, message) + #expect(decoded == message) } - func testErrorResponseRoundTrip() throws { + @Test func testErrorResponseRoundTrip() throws { let message = JsonRpcMessage.errorResponse( JsonRpcErrorResponse( id: .number(8), @@ -74,32 +74,32 @@ final class JsonRpcMessageTests: XCTestCase { ) let data = try JsonRpcCodec.encode(message) let decoded = try JsonRpcCodec.decode(data) - XCTAssertEqual(decoded, message) + #expect(decoded == message) } - func testErrorResponseWithNullIdEncodesAsJsonNull() throws { + @Test func testErrorResponseWithNullIdEncodesAsJsonNull() throws { let message = JsonRpcMessage.errorResponse( JsonRpcErrorResponse(id: nil, error: JsonRpcError.parseError()) ) let data = try JsonRpcCodec.encode(message) - let json = try XCTUnwrap(String(data: data, encoding: .utf8)) - XCTAssertTrue(json.contains("\"id\":null")) + let json = try #require(String(data: data, encoding: .utf8)) + #expect(json.contains("\"id\":null")) } - func testErrorResponseWithExplicitNullIdRoundTrips() throws { + @Test func testErrorResponseWithExplicitNullIdRoundTrips() throws { let message = JsonRpcMessage.errorResponse( JsonRpcErrorResponse(id: .null, error: JsonRpcError.serverError()) ) let data = try JsonRpcCodec.encode(message) let decoded = try JsonRpcCodec.decode(data) if case .errorResponse(let response) = decoded { - XCTAssertEqual(response.id, .null) + #expect(response.id == .null) } else { - XCTFail("Expected errorResponse") + Issue.record("Expected errorResponse") } } - func testErrorResponseDataRoundTrip() throws { + @Test func testErrorResponseDataRoundTrip() throws { let message = JsonRpcMessage.errorResponse( JsonRpcErrorResponse( id: .number(9), @@ -112,10 +112,10 @@ final class JsonRpcMessageTests: XCTestCase { ) let data = try JsonRpcCodec.encode(message) let decoded = try JsonRpcCodec.decode(data) - XCTAssertEqual(decoded, message) + #expect(decoded == message) } - func testErrorResponseWithoutDataOmitsField() throws { + @Test func testErrorResponseWithoutDataOmitsField() throws { let message = JsonRpcMessage.errorResponse( JsonRpcErrorResponse( id: .number(10), @@ -123,88 +123,88 @@ final class JsonRpcMessageTests: XCTestCase { ) ) let data = try JsonRpcCodec.encode(message) - let json = try XCTUnwrap(String(data: data, encoding: .utf8)) - XCTAssertFalse(json.contains("\"data\"")) + let json = try #require(String(data: data, encoding: .utf8)) + #expect(!json.contains("\"data\"")) } - func testRejectsNon20JsonRpcVersion() { + @Test func testRejectsNon20JsonRpcVersion() { let raw = Data(#"{"jsonrpc":"1.0","id":1,"method":"ping"}"#.utf8) - XCTAssertThrowsError(try JsonRpcCodec.decode(raw)) { error in + if let error = #expect(throws: (any Error).self, performing: { try JsonRpcCodec.decode(raw) }) { guard case JsonRpcDecodingError.invalidJsonRpcVersion(let value) = error else { - XCTFail("Expected invalidJsonRpcVersion, got \(error)") + Issue.record("Expected invalidJsonRpcVersion, got \(error)") return } - XCTAssertEqual(value, "1.0") + #expect(value == "1.0") } } - func testRejectsMissingJsonRpcVersion() { + @Test func testRejectsMissingJsonRpcVersion() { let raw = Data(#"{"id":1,"method":"ping"}"#.utf8) - XCTAssertThrowsError(try JsonRpcCodec.decode(raw)) { error in - XCTAssertEqual(error as? JsonRpcDecodingError, .missingJsonRpcVersion) + if let error = #expect(throws: (any Error).self, performing: { try JsonRpcCodec.decode(raw) }) { + #expect(error as? JsonRpcDecodingError == .missingJsonRpcVersion) } } - func testRejectsBatchArray() { + @Test func testRejectsBatchArray() { let raw = Data(#"[{"jsonrpc":"2.0","id":1,"method":"ping"}]"#.utf8) - XCTAssertThrowsError(try JsonRpcCodec.decode(raw)) { error in - XCTAssertEqual(error as? JsonRpcDecodingError, .batchUnsupported) + if let error = #expect(throws: (any Error).self, performing: { try JsonRpcCodec.decode(raw) }) { + #expect(error as? JsonRpcDecodingError == .batchUnsupported) } } - func testRejectsBatchArrayWithLeadingWhitespace() { + @Test func testRejectsBatchArrayWithLeadingWhitespace() { let raw = Data(" \n[{\"jsonrpc\":\"2.0\"}]".utf8) - XCTAssertThrowsError(try JsonRpcCodec.decode(raw)) { error in - XCTAssertEqual(error as? JsonRpcDecodingError, .batchUnsupported) + if let error = #expect(throws: (any Error).self, performing: { try JsonRpcCodec.decode(raw) }) { + #expect(error as? JsonRpcDecodingError == .batchUnsupported) } } - func testEncodeLineAppendsNewline() throws { + @Test func testEncodeLineAppendsNewline() throws { let message = JsonRpcMessage.notification( JsonRpcNotification(method: "ping", params: nil) ) let data = try JsonRpcCodec.encodeLine(message) - XCTAssertEqual(data.last, 0x0A) + #expect(data.last == 0x0A) } - func testNullIdInRequestRoundTrips() throws { + @Test func testNullIdInRequestRoundTrips() throws { let message = JsonRpcMessage.request( JsonRpcRequest(id: .null, method: "test", params: nil) ) let data = try JsonRpcCodec.encode(message) let decoded = try JsonRpcCodec.decode(data) - XCTAssertEqual(decoded, message) + #expect(decoded == message) } - func testRejectsAmbiguousMessageWithMethodAndResult() { + @Test func testRejectsAmbiguousMessageWithMethodAndResult() { let raw = Data(#"{"jsonrpc":"2.0","id":1,"method":"foo","result":1}"#.utf8) - XCTAssertThrowsError(try JsonRpcCodec.decode(raw)) { error in - XCTAssertEqual(error as? JsonRpcDecodingError, .ambiguousMessage) + if let error = #expect(throws: (any Error).self, performing: { try JsonRpcCodec.decode(raw) }) { + #expect(error as? JsonRpcDecodingError == .ambiguousMessage) } } - func testRejectsResultAndError() { + @Test func testRejectsResultAndError() { let raw = Data(#"{"jsonrpc":"2.0","id":1,"result":1,"error":{"code":-32000,"message":"x"}}"#.utf8) - XCTAssertThrowsError(try JsonRpcCodec.decode(raw)) { error in - XCTAssertEqual(error as? JsonRpcDecodingError, .ambiguousMessage) + if let error = #expect(throws: (any Error).self, performing: { try JsonRpcCodec.decode(raw) }) { + #expect(error as? JsonRpcDecodingError == .ambiguousMessage) } } - func testRejectsEmptyEnvelope() { + @Test func testRejectsEmptyEnvelope() { let raw = Data(#"{"jsonrpc":"2.0","id":1}"#.utf8) - XCTAssertThrowsError(try JsonRpcCodec.decode(raw)) { error in - XCTAssertEqual(error as? JsonRpcDecodingError, .missingResultOrError) + if let error = #expect(throws: (any Error).self, performing: { try JsonRpcCodec.decode(raw) }) { + #expect(error as? JsonRpcDecodingError == .missingResultOrError) } } - func testRejectsEnvelopeWithoutMethodOrIdEvenWithVersion() { + @Test func testRejectsEnvelopeWithoutMethodOrIdEvenWithVersion() { let raw = Data(#"{"jsonrpc":"2.0"}"#.utf8) - XCTAssertThrowsError(try JsonRpcCodec.decode(raw)) { error in - XCTAssertEqual(error as? JsonRpcDecodingError, .missingMethod) + if let error = #expect(throws: (any Error).self, performing: { try JsonRpcCodec.decode(raw) }) { + #expect(error as? JsonRpcDecodingError == .missingMethod) } } - func testSpecificationErrorCodesSurviveARoundTrip() throws { + @Test func testSpecificationErrorCodesSurviveARoundTrip() throws { for code in [ JsonRpcErrorCode.headerMismatch, JsonRpcErrorCode.missingRequiredClientCapability, @@ -214,11 +214,11 @@ final class JsonRpcMessageTests: XCTestCase { JsonRpcErrorResponse(id: .number(1), error: JsonRpcError(code: code, message: "x")) ) let decoded = try JsonRpcCodec.decode(try JsonRpcCodec.encode(message)) - XCTAssertEqual(decoded, message, "code \(code) must survive the wire") + #expect(decoded == message, "code \(code) must survive the wire") } } - func testUnsupportedProtocolVersionDataRoundTrips() throws { + @Test func testUnsupportedProtocolVersionDataRoundTrips() throws { let payload = JsonValue.object([ "supported": .array(MCPProtocolVersion.supportedRawValues.map { .string($0) }), "requested": .string("1999-01-01") @@ -235,16 +235,13 @@ final class JsonRpcMessageTests: XCTestCase { ) let decoded = try JsonRpcCodec.decode(try JsonRpcCodec.encode(message)) guard case .errorResponse(let response) = decoded else { - XCTFail("Expected errorResponse") + Issue.record("Expected errorResponse") return } - XCTAssertEqual( - response.error.data?["supported"]?.arrayValue?.compactMap(\.stringValue), - MCPProtocolVersion.supportedRawValues - ) + #expect(response.error.data?["supported"]?.arrayValue?.compactMap(\.stringValue) == MCPProtocolVersion.supportedRawValues) } - func testRequestMetaSurvivesTheWire() throws { + @Test func testRequestMetaSurvivesTheWire() throws { let params = JsonValue.object([ "name": .string("run_query"), "_meta": .object([ @@ -255,37 +252,34 @@ final class JsonRpcMessageTests: XCTestCase { let message = JsonRpcMessage.request(JsonRpcRequest(id: .number(3), method: "tools/call", params: params)) let decoded = try JsonRpcCodec.decode(try JsonRpcCodec.encode(message)) guard case .request(let request) = decoded else { - XCTFail("Expected request") + Issue.record("Expected request") return } - XCTAssertTrue(MCPRequestMeta.declaresModernProtocol(params: request.params)) - XCTAssertEqual( - MCPRequestMeta.metaObject(in: request.params)?[MCPMetaKeys.protocolVersion]?.stringValue, - MCPProtocolVersion.latest.rawValue - ) + #expect(MCPRequestMeta.declaresModernProtocol(params: request.params)) + #expect(MCPRequestMeta.metaObject(in: request.params)?[MCPMetaKeys.protocolVersion]?.stringValue == MCPProtocolVersion.latest.rawValue) } - func testNotificationCarriesNoIdAfterARoundTrip() throws { + @Test func testNotificationCarriesNoIdAfterARoundTrip() throws { let message = JsonRpcMessage.notification( JsonRpcNotification(method: "notifications/progress", params: .object(["progress": .double(0.25)])) ) let data = try JsonRpcCodec.encode(message) let decoded = try JsonRpcCodec.decode(data) guard case .notification = decoded else { - XCTFail("Expected notification") + Issue.record("Expected notification") return } - let json = try XCTUnwrap(String(data: data, encoding: .utf8)) - XCTAssertFalse(json.contains("\"id\"")) + let json = try #require(String(data: data, encoding: .utf8)) + #expect(!json.contains("\"id\"")) } - func testDecodeRejectsAJsonFragment() { - XCTAssertThrowsError(try JsonRpcCodec.decode(Data("\"hello\"".utf8))) + @Test func testDecodeRejectsAJsonFragment() { + #expect(throws: (any Error).self) { try JsonRpcCodec.decode(Data("\"hello\"".utf8)) } } - func testDecodeRejectsAnEmptyBody() { - XCTAssertThrowsError(try JsonRpcCodec.decode(Data())) { error in - XCTAssertEqual(error as? JsonRpcDecodingError, .missingJsonRpcVersion) + @Test func testDecodeRejectsAnEmptyBody() { + if let error = #expect(throws: (any Error).self, performing: { try JsonRpcCodec.decode(Data()) }) { + #expect(error as? JsonRpcDecodingError == .missingJsonRpcVersion) } } } diff --git a/Packages/TableProMacCore/Tests/TableProMCPTests/Wire/SseEncoderDecoderTests.swift b/Packages/TableProMacCore/Tests/TableProMCPTests/Wire/SseEncoderDecoderTests.swift index 3a0e002d03..9a7a099bcc 100644 --- a/Packages/TableProMacCore/Tests/TableProMCPTests/Wire/SseEncoderDecoderTests.swift +++ b/Packages/TableProMacCore/Tests/TableProMCPTests/Wire/SseEncoderDecoderTests.swift @@ -1,39 +1,39 @@ import Foundation @testable import TableProMCP -import XCTest +import Testing -final class SseEncoderDecoderTests: XCTestCase { - func testRoundTripSingleLineFrame() async throws { +struct SseEncoderDecoderTests { + @Test func testRoundTripSingleLineFrame() async throws { let frame = SseFrame(event: "message", id: "1", data: "hello", retry: nil) let encoded = SseEncoder.encode(frame) let decoder = SseDecoder() let frames = await decoder.feed(encoded) - XCTAssertEqual(frames.count, 1) - XCTAssertEqual(frames.first?.event, "message") - XCTAssertEqual(frames.first?.id, "1") - XCTAssertEqual(frames.first?.data, "hello") + #expect(frames.count == 1) + #expect(frames.first?.event == "message") + #expect(frames.first?.id == "1") + #expect(frames.first?.data == "hello") } - func testEncodeMultiLineDataProducesMultipleDataLines() { + @Test func testEncodeMultiLineDataProducesMultipleDataLines() { let frame = SseFrame(data: "line1\nline2\nline3") let encoded = SseEncoder.encode(frame) let text = String(data: encoded, encoding: .utf8) ?? "" - XCTAssertTrue(text.contains("data: line1\n")) - XCTAssertTrue(text.contains("data: line2\n")) - XCTAssertTrue(text.contains("data: line3\n")) - XCTAssertTrue(text.hasSuffix("\n\n")) + #expect(text.contains("data: line1\n")) + #expect(text.contains("data: line2\n")) + #expect(text.contains("data: line3\n")) + #expect(text.hasSuffix("\n\n")) } - func testRoundTripMultiLineData() async throws { + @Test func testRoundTripMultiLineData() async throws { let frame = SseFrame(data: "alpha\nbeta\ngamma") let encoded = SseEncoder.encode(frame) let decoder = SseDecoder() let frames = await decoder.feed(encoded) - XCTAssertEqual(frames.count, 1) - XCTAssertEqual(frames.first?.data, "alpha\nbeta\ngamma") + #expect(frames.count == 1) + #expect(frames.first?.data == "alpha\nbeta\ngamma") } - func testDecodesMultipleFramesInOneChunk() async throws { + @Test func testDecodesMultipleFramesInOneChunk() async throws { let frameA = SseEncoder.encode(SseFrame(event: "a", data: "first")) let frameB = SseEncoder.encode(SseFrame(event: "b", data: "second")) var combined = Data() @@ -42,12 +42,12 @@ final class SseEncoderDecoderTests: XCTestCase { let decoder = SseDecoder() let frames = await decoder.feed(combined) - XCTAssertEqual(frames.count, 2) - XCTAssertEqual(frames[0].data, "first") - XCTAssertEqual(frames[1].data, "second") + #expect(frames.count == 2) + #expect(frames[0].data == "first") + #expect(frames[1].data == "second") } - func testBuffersPartialFramesAcrossChunks() async throws { + @Test func testBuffersPartialFramesAcrossChunks() async throws { let frame = SseFrame(event: "ping", data: "hello world") let encoded = SseEncoder.encode(frame) @@ -57,78 +57,79 @@ final class SseEncoderDecoderTests: XCTestCase { let decoder = SseDecoder() let firstFrames = await decoder.feed(Data(firstPart)) - XCTAssertTrue(firstFrames.isEmpty) + #expect(firstFrames.isEmpty) let secondFrames = await decoder.feed(Data(secondPart)) - XCTAssertEqual(secondFrames.count, 1) - XCTAssertEqual(secondFrames.first?.data, "hello world") + #expect(secondFrames.count == 1) + #expect(secondFrames.first?.data == "hello world") } - func testDecoderToleratesCrlfFieldSeparators() async throws { + @Test func testDecoderToleratesCrlfFieldSeparators() async throws { let raw = "event: x\r\nid: 7\r\ndata: hi\r\n\r\n" let decoder = SseDecoder() let frames = await decoder.feed(Data(raw.utf8)) - XCTAssertEqual(frames.count, 1) - XCTAssertEqual(frames.first?.event, "x") - XCTAssertEqual(frames.first?.id, "7") - XCTAssertEqual(frames.first?.data, "hi") + #expect(frames.count == 1) + #expect(frames.first?.event == "x") + #expect(frames.first?.id == "7") + #expect(frames.first?.data == "hi") } - func testDecoderJoinsMultipleDataFieldsWithNewline() async throws { + @Test func testDecoderJoinsMultipleDataFieldsWithNewline() async throws { let raw = "data: a\ndata: b\ndata: c\n\n" let decoder = SseDecoder() let frames = await decoder.feed(Data(raw.utf8)) - XCTAssertEqual(frames.count, 1) - XCTAssertEqual(frames.first?.data, "a\nb\nc") + #expect(frames.count == 1) + #expect(frames.first?.data == "a\nb\nc") } - func testDecoderIgnoresCommentLines() async throws { + @Test func testDecoderIgnoresCommentLines() async throws { let raw = ": this is a comment\ndata: payload\n\n" let decoder = SseDecoder() let frames = await decoder.feed(Data(raw.utf8)) - XCTAssertEqual(frames.count, 1) - XCTAssertEqual(frames.first?.data, "payload") + #expect(frames.count == 1) + #expect(frames.first?.data == "payload") } - func testEncoderIncludesRetry() { + @Test func testEncoderIncludesRetry() { let frame = SseFrame(data: "ping", retry: 5_000) let encoded = SseEncoder.encode(frame) let text = String(data: encoded, encoding: .utf8) ?? "" - XCTAssertTrue(text.contains("retry: 5000\n")) + #expect(text.contains("retry: 5000\n")) } - func testEncoderEndsWithDoubleNewline() { + @Test func testEncoderEndsWithDoubleNewline() { let frame = SseFrame(data: "x") let encoded = SseEncoder.encode(frame) - XCTAssertEqual(encoded.suffix(2), Data([0x0A, 0x0A])) + #expect(encoded.suffix(2) == Data([0x0A, 0x0A])) } - func testEncoderOmitsEventAndIdWhenAbsent() { + @Test func testEncoderOmitsEventAndIdWhenAbsent() { let encoded = SseEncoder.encode(SseFrame(data: "payload")) let text = String(data: encoded, encoding: .utf8) ?? "" - XCTAssertEqual(text, "data: payload\n\n") + #expect(text == "data: payload\n\n") } - func testEncoderSplitsCrlfInsideDataIntoTwoDataLines() { + @Test func testEncoderSplitsCrlfInsideDataIntoTwoDataLines() { let encoded = SseEncoder.encode(SseFrame(data: "first\r\nsecond")) let text = String(data: encoded, encoding: .utf8) ?? "" - XCTAssertEqual(text, "data: first\ndata: second\n\n") + #expect(text == "data: first\ndata: second\n\n") } - func testCrlfInsideDataSurvivesAsALineBreakThroughARoundTrip() async { + @Test func testCrlfInsideDataSurvivesAsALineBreakThroughARoundTrip() async { let encoded = SseEncoder.encode(SseFrame(data: "first\r\nsecond")) let decoder = SseDecoder() let frames = await decoder.feed(encoded) - XCTAssertEqual(frames.count, 1) - XCTAssertEqual(frames.first?.data, "first\nsecond") + #expect(frames.count == 1) + #expect(frames.first?.data == "first\nsecond") } - func testKeepAliveCommentAloneProducesNoFrames() async { + @Test("A keep-alive comment alone, or a frame without data, emits nothing", arguments: [":\r\n", "event: ping\n\n"]) + func testNothingToEmit(_ stream: String) async { let decoder = SseDecoder() - let frames = await decoder.feed(Data(":\r\n".utf8)) - XCTAssertTrue(frames.isEmpty) + let frames = await decoder.feed(Data(stream.utf8)) + #expect(frames.isEmpty) } - func testKeepAliveCommentsBetweenFramesAreIgnored() async { + @Test func testKeepAliveCommentsBetweenFramesAreIgnored() async { var stream = Data(":\r\n".utf8) stream.append(SseEncoder.encode(SseFrame(data: "one"))) stream.append(Data(":\r\n".utf8)) @@ -136,33 +137,27 @@ final class SseEncoderDecoderTests: XCTestCase { let decoder = SseDecoder() let frames = await decoder.feed(stream) - XCTAssertEqual(frames.map(\.data), ["one", "two"]) + #expect(frames.map(\.data) == ["one", "two"]) } - func testUnknownFieldsAreIgnored() async { + @Test func testUnknownFieldsAreIgnored() async { let decoder = SseDecoder() let frames = await decoder.feed(Data("unknown: 1\ndata: payload\n\n".utf8)) - XCTAssertEqual(frames.count, 1) - XCTAssertEqual(frames.first?.data, "payload") + #expect(frames.count == 1) + #expect(frames.first?.data == "payload") } - func testAFrameWithoutDataIsNotEmitted() async { - let decoder = SseDecoder() - let frames = await decoder.feed(Data("event: ping\n\n".utf8)) - XCTAssertTrue(frames.isEmpty) - } - - func testAJsonRpcResponseTravelsAsASingleDataLine() async throws { + @Test func testAJsonRpcResponseTravelsAsASingleDataLine() async throws { let message = JsonRpcMessage.successResponse( JsonRpcSuccessResponse(id: .number(1), result: .object(["ok": .bool(true)])) ) - let payload = try XCTUnwrap(String(data: try JsonRpcCodec.encode(message), encoding: .utf8)) + let payload = try #require(String(data: try JsonRpcCodec.encode(message), encoding: .utf8)) let encoded = SseEncoder.encode(SseFrame(data: payload)) - XCTAssertEqual(String(data: encoded, encoding: .utf8), "data: \(payload)\n\n") + #expect(String(data: encoded, encoding: .utf8) == "data: \(payload)\n\n") let decoder = SseDecoder() let frames = await decoder.feed(encoded) - XCTAssertEqual(frames.count, 1) - XCTAssertEqual(try JsonRpcCodec.decode(Data((frames.first?.data ?? "").utf8)), message) + #expect(frames.count == 1) + #expect(try JsonRpcCodec.decode(Data((frames.first?.data ?? "").utf8)) == message) } } diff --git a/Packages/TableProMacCore/Tests/TableProSSHConfigTests/SSHMatchExecutorTests.swift b/Packages/TableProMacCore/Tests/TableProSSHConfigTests/SSHMatchExecutorTests.swift index d9e1c7cefc..af60cb0870 100644 --- a/Packages/TableProMacCore/Tests/TableProSSHConfigTests/SSHMatchExecutorTests.swift +++ b/Packages/TableProMacCore/Tests/TableProSSHConfigTests/SSHMatchExecutorTests.swift @@ -1,8 +1,3 @@ -// -// SSHMatchExecutorTests.swift -// TableProTests -// - import Foundation import Testing @@ -27,6 +22,8 @@ struct SSHMatchExecutorTests { @Test("Slow command times out and does not match") func timeoutDoesNotMatch() { - #expect(!SSHMatchExecutor.evaluate(command: "sleep 6")) + #expect(SSHMatchExecutor.timeoutSeconds > 0) + #expect(SSHMatchExecutor.timeoutSeconds < 6, "The connect path must stop a 6 second command") + #expect(!SSHMatchExecutor.evaluate(command: "sleep 6", timeout: 0.1)) } } diff --git a/Packages/TableProMacCore/Tests/TableProSSHConfigTests/SSHUnsupportedDirectiveTests.swift b/Packages/TableProMacCore/Tests/TableProSSHConfigTests/SSHUnsupportedDirectiveTests.swift index cde03a3bb8..864f1fd1f0 100644 --- a/Packages/TableProMacCore/Tests/TableProSSHConfigTests/SSHUnsupportedDirectiveTests.swift +++ b/Packages/TableProMacCore/Tests/TableProSSHConfigTests/SSHUnsupportedDirectiveTests.swift @@ -12,9 +12,9 @@ import Testing @testable import TableProSSHConfig struct SSHUnsupportedDirectiveTests { - @Test("ProxyCommand is reported") - func proxyCommandChangesRouting() { - #expect(SSHUnsupportedDirective.changesRouting(key: "ProxyCommand")) + @Test("ProxyCommand and ProxyUseFdpass are reported", arguments: ["ProxyCommand", "ProxyUseFdpass"]) + func proxyDirectivesChangeRouting(_ key: String) { + #expect(SSHUnsupportedDirective.changesRouting(key: key)) } @Test("The match ignores case, the way ssh_config keywords do") @@ -23,11 +23,6 @@ struct SSHUnsupportedDirectiveTests { #expect(SSHUnsupportedDirective.changesRouting(key: "PROXYCOMMAND")) } - @Test("ProxyUseFdpass is reported") - func proxyUseFdpassChangesRouting() { - #expect(SSHUnsupportedDirective.changesRouting(key: "ProxyUseFdpass")) - } - @Test("Directives TablePro honours are not reported") func supportedDirectivesAreNotReported() { #expect(!SSHUnsupportedDirective.changesRouting(key: "ProxyJump")) diff --git a/Packages/TableProMacCore/Tests/TableProSSHConfigTests/TOTP/Base32Tests.swift b/Packages/TableProMacCore/Tests/TableProSSHConfigTests/TOTP/Base32Tests.swift index 984fc898c7..92ad6b32ae 100644 --- a/Packages/TableProMacCore/Tests/TableProSSHConfigTests/TOTP/Base32Tests.swift +++ b/Packages/TableProMacCore/Tests/TableProSSHConfigTests/TOTP/Base32Tests.swift @@ -1,128 +1,120 @@ +import Foundation // // Base32Tests.swift // TableProTests // -import XCTest +import Testing @testable import TableProSSHConfig -final class Base32Tests: XCTestCase { +struct Base32Tests { // MARK: - RFC 4648 Test Vectors - func testDecodeEmptyString() { + @Test func testDecodeEmptyString() { let result = Base32.decode("") - XCTAssertNotNil(result) - XCTAssertEqual(result, Data()) + #expect(result != nil) + #expect(result == Data()) } - func testDecodeSingleCharacter() { - // "MY" → "f" (0x66) - let result = Base32.decode("MY") - XCTAssertEqual(result, Data([0x66])) + @Test(arguments: ["MY", "MY======"]) + func testDecodeSingleCharacter(_ encoded: String) { + let result = Base32.decode(encoded) + #expect(result == Data([0x66])) } - func testDecodeTwoCharacters() { + @Test func testDecodeTwoCharacters() { // "MZXQ" → "fo" let result = Base32.decode("MZXQ") - XCTAssertEqual(result, Data("fo".utf8)) + #expect(result == Data("fo".utf8)) } - func testDecodeThreeCharacters() { + @Test func testDecodeThreeCharacters() { // "MZXW6" → "foo" let result = Base32.decode("MZXW6") - XCTAssertEqual(result, Data("foo".utf8)) + #expect(result == Data("foo".utf8)) } - func testDecodeFourCharacters() { + @Test func testDecodeFourCharacters() { // "MZXW6YQ" → "foob" let result = Base32.decode("MZXW6YQ") - XCTAssertEqual(result, Data("foob".utf8)) + #expect(result == Data("foob".utf8)) } - func testDecodeFiveCharacters() { + @Test func testDecodeFiveCharacters() { // "MZXW6YTB" → "fooba" let result = Base32.decode("MZXW6YTB") - XCTAssertEqual(result, Data("fooba".utf8)) + #expect(result == Data("fooba".utf8)) } - func testDecodeSixCharacters() { + @Test func testDecodeSixCharacters() { // "MZXW6YTBOI" → "foobar" let result = Base32.decode("MZXW6YTBOI") - XCTAssertEqual(result, Data("foobar".utf8)) + #expect(result == Data("foobar".utf8)) } // MARK: - Case Insensitivity - func testDecodeLowercase() { + @Test func testDecodeLowercase() { let result = Base32.decode("mzxw6ytboi") - XCTAssertEqual(result, Data("foobar".utf8)) + #expect(result == Data("foobar".utf8)) } - func testDecodeMixedCase() { + @Test func testDecodeMixedCase() { let result = Base32.decode("MzXw6YtBoI") - XCTAssertEqual(result, Data("foobar".utf8)) + #expect(result == Data("foobar".utf8)) } // MARK: - Padding - func testDecodeWithPadding() { + @Test func testDecodeWithPadding() { let result = Base32.decode("MZXW6YTBOI======") - XCTAssertEqual(result, Data("foobar".utf8)) - } - - func testDecodeWithPartialPadding() { - let result = Base32.decode("MY======") - XCTAssertEqual(result, Data([0x66])) + #expect(result == Data("foobar".utf8)) } // MARK: - Whitespace and Dashes - func testDecodeWithSpaces() { + @Test func testDecodeWithSpaces() { let result = Base32.decode("MZXW 6YTB OI") - XCTAssertEqual(result, Data("foobar".utf8)) + #expect(result == Data("foobar".utf8)) } - func testDecodeWithDashes() { + @Test func testDecodeWithDashes() { let result = Base32.decode("MZXW-6YTB-OI") - XCTAssertEqual(result, Data("foobar".utf8)) + #expect(result == Data("foobar".utf8)) } - func testDecodeWithSpacesAndDashes() { + @Test func testDecodeWithSpacesAndDashes() { let result = Base32.decode("MZXW - 6YTB - OI") - XCTAssertEqual(result, Data("foobar".utf8)) + #expect(result == Data("foobar".utf8)) } - func testDecodeWithTabs() { + @Test func testDecodeWithTabs() { let result = Base32.decode("MZXW6\tYTBOI") - XCTAssertEqual(result, Data("foobar".utf8)) + #expect(result == Data("foobar".utf8)) } // MARK: - Invalid Input - func testDecodeInvalidCharacter() { - let result = Base32.decode("1") - XCTAssertNil(result) - } - - func testDecodeInvalidCharacterInMiddle() { - let result = Base32.decode("MF!GG") - XCTAssertNil(result) + @Test(arguments: ["1", "MF!GG"]) + func testDecodeInvalidCharacter(_ encoded: String) { + let result = Base32.decode(encoded) + #expect(result == nil) } // MARK: - Real-World TOTP Secrets - func testDecodeTypicalTotpSecret() { + @Test func testDecodeTypicalTotpSecret() { // "JBSWY3DPEHPK3PXP" is a common TOTP example secret let result = Base32.decode("JBSWY3DPEHPK3PXP") - XCTAssertNotNil(result) - XCTAssertEqual(result?.count, 10) + #expect(result != nil) + #expect(result?.count == 10) } - func testDecodeSecretWithSpacesAndDashes() { + @Test func testDecodeSecretWithSpacesAndDashes() { // Same secret formatted as users might copy it let clean = Base32.decode("JBSWY3DPEHPK3PXP") let withFormatting = Base32.decode("JBSW Y3DP-EHPK-3PXP") - XCTAssertEqual(clean, withFormatting) + #expect(clean == withFormatting) } } diff --git a/Packages/TableProMacCore/Tests/TableProSSHConfigTests/TOTP/TOTPGeneratorTests.swift b/Packages/TableProMacCore/Tests/TableProSSHConfigTests/TOTP/TOTPGeneratorTests.swift index ff76af2a94..6e5eef08da 100644 --- a/Packages/TableProMacCore/Tests/TableProSSHConfigTests/TOTP/TOTPGeneratorTests.swift +++ b/Packages/TableProMacCore/Tests/TableProSSHConfigTests/TOTP/TOTPGeneratorTests.swift @@ -1,13 +1,14 @@ +import Foundation // // TOTPGeneratorTests.swift // TableProTests // -import XCTest +import Testing @testable import TableProSSHConfig -final class TOTPGeneratorTests: XCTestCase { +struct TOTPGeneratorTests { // MARK: - RFC 6238 SHA1 Test Vectors (8 digits) /// RFC 6238 SHA1 secret: "12345678901234567890" (20 bytes ASCII) @@ -25,182 +26,178 @@ final class TOTPGeneratorTests: XCTestCase { Data("1234567890123456789012345678901234567890123456789012345678901234".utf8) } - func testSha1At59Seconds() { + @Test func testSha1At59Seconds() { let generator = TOTPGenerator(secret: sha1Secret, algorithm: .sha1, digits: 8, period: 30) let date = Date(timeIntervalSince1970: 59) - XCTAssertEqual(generator.generate(at: date), "94287082") + #expect(generator.generate(at: date) == "94287082") } - func testSha1At1111111109() { + @Test func testSha1At1111111109() { let generator = TOTPGenerator(secret: sha1Secret, algorithm: .sha1, digits: 8, period: 30) let date = Date(timeIntervalSince1970: 1_111_111_109) - XCTAssertEqual(generator.generate(at: date), "07081804") + #expect(generator.generate(at: date) == "07081804") } - func testSha1At1111111111() { + @Test func testSha1At1111111111() { let generator = TOTPGenerator(secret: sha1Secret, algorithm: .sha1, digits: 8, period: 30) let date = Date(timeIntervalSince1970: 1_111_111_111) - XCTAssertEqual(generator.generate(at: date), "14050471") + #expect(generator.generate(at: date) == "14050471") } - func testSha1At1234567890() { + @Test func testSha1At1234567890() { let generator = TOTPGenerator(secret: sha1Secret, algorithm: .sha1, digits: 8, period: 30) let date = Date(timeIntervalSince1970: 1_234_567_890) - XCTAssertEqual(generator.generate(at: date), "89005924") + #expect(generator.generate(at: date) == "89005924") } - func testSha1At2000000000() { + @Test func testSha1At2000000000() { let generator = TOTPGenerator(secret: sha1Secret, algorithm: .sha1, digits: 8, period: 30) let date = Date(timeIntervalSince1970: 2_000_000_000) - XCTAssertEqual(generator.generate(at: date), "69279037") + #expect(generator.generate(at: date) == "69279037") } // MARK: - RFC 6238 SHA256 Test Vectors (8 digits) - func testSha256At59Seconds() { + @Test func testSha256At59Seconds() { let generator = TOTPGenerator(secret: sha256Secret, algorithm: .sha256, digits: 8, period: 30) let date = Date(timeIntervalSince1970: 59) - XCTAssertEqual(generator.generate(at: date), "46119246") + #expect(generator.generate(at: date) == "46119246") } - func testSha256At1111111109() { + @Test func testSha256At1111111109() { let generator = TOTPGenerator(secret: sha256Secret, algorithm: .sha256, digits: 8, period: 30) let date = Date(timeIntervalSince1970: 1_111_111_109) - XCTAssertEqual(generator.generate(at: date), "68084774") + #expect(generator.generate(at: date) == "68084774") } - func testSha256At1234567890() { + @Test func testSha256At1234567890() { let generator = TOTPGenerator(secret: sha256Secret, algorithm: .sha256, digits: 8, period: 30) let date = Date(timeIntervalSince1970: 1_234_567_890) - XCTAssertEqual(generator.generate(at: date), "91819424") + #expect(generator.generate(at: date) == "91819424") } - func testSha256At2000000000() { + @Test func testSha256At2000000000() { let generator = TOTPGenerator(secret: sha256Secret, algorithm: .sha256, digits: 8, period: 30) let date = Date(timeIntervalSince1970: 2_000_000_000) - XCTAssertEqual(generator.generate(at: date), "90698825") + #expect(generator.generate(at: date) == "90698825") } // MARK: - RFC 6238 SHA512 Test Vectors (8 digits) - func testSha512At59Seconds() { + @Test func testSha512At59Seconds() { let generator = TOTPGenerator(secret: sha512Secret, algorithm: .sha512, digits: 8, period: 30) let date = Date(timeIntervalSince1970: 59) - XCTAssertEqual(generator.generate(at: date), "90693936") + #expect(generator.generate(at: date) == "90693936") } - func testSha512At1111111109() { + @Test func testSha512At1111111109() { let generator = TOTPGenerator(secret: sha512Secret, algorithm: .sha512, digits: 8, period: 30) let date = Date(timeIntervalSince1970: 1_111_111_109) - XCTAssertEqual(generator.generate(at: date), "25091201") + #expect(generator.generate(at: date) == "25091201") } - func testSha512At1234567890() { + @Test func testSha512At1234567890() { let generator = TOTPGenerator(secret: sha512Secret, algorithm: .sha512, digits: 8, period: 30) let date = Date(timeIntervalSince1970: 1_234_567_890) - XCTAssertEqual(generator.generate(at: date), "93441116") + #expect(generator.generate(at: date) == "93441116") } - func testSha512At2000000000() { + @Test func testSha512At2000000000() { let generator = TOTPGenerator(secret: sha512Secret, algorithm: .sha512, digits: 8, period: 30) let date = Date(timeIntervalSince1970: 2_000_000_000) - XCTAssertEqual(generator.generate(at: date), "38618901") + #expect(generator.generate(at: date) == "38618901") } // MARK: - 6-Digit Tests (last 6 digits of 8-digit result) - func testSixDigitSha1At59Seconds() { + @Test func testSixDigitSha1At59Seconds() { let generator = TOTPGenerator(secret: sha1Secret, algorithm: .sha1, digits: 6, period: 30) let date = Date(timeIntervalSince1970: 59) - XCTAssertEqual(generator.generate(at: date), "287082") + #expect(generator.generate(at: date) == "287082") } - func testSixDigitSha1At1111111109() { + @Test func testSixDigitSha1At1111111109() { let generator = TOTPGenerator(secret: sha1Secret, algorithm: .sha1, digits: 6, period: 30) let date = Date(timeIntervalSince1970: 1_111_111_109) - XCTAssertEqual(generator.generate(at: date), "081804") + #expect(generator.generate(at: date) == "081804") } - func testSixDigitOutputLength() { + @Test func testSixDigitOutputLength() { let generator = TOTPGenerator(secret: sha1Secret, algorithm: .sha1, digits: 6, period: 30) let code = generator.generate(at: Date(timeIntervalSince1970: 59)) - XCTAssertEqual(code.count, 6) + #expect(code.count == 6) } - func testEightDigitOutputLength() { + @Test func testEightDigitOutputLength() { let generator = TOTPGenerator(secret: sha1Secret, algorithm: .sha1, digits: 8, period: 30) let code = generator.generate(at: Date(timeIntervalSince1970: 59)) - XCTAssertEqual(code.count, 8) + #expect(code.count == 8) } // MARK: - secondsRemaining - func testSecondsRemainingAtPeriodStart() { + @Test func testSecondsRemainingAtPeriodStart() { let generator = TOTPGenerator(secret: sha1Secret) // Timestamp 0 is exactly at a period boundary let date = Date(timeIntervalSince1970: 0) - XCTAssertEqual(generator.secondsRemaining(at: date), 30) + #expect(generator.secondsRemaining(at: date) == 30) } - func testSecondsRemainingMidPeriod() { + @Test func testSecondsRemainingMidPeriod() { let generator = TOTPGenerator(secret: sha1Secret) let date = Date(timeIntervalSince1970: 10) - XCTAssertEqual(generator.secondsRemaining(at: date), 20) + #expect(generator.secondsRemaining(at: date) == 20) } - func testSecondsRemainingNearEnd() { + @Test func testSecondsRemainingNearEnd() { let generator = TOTPGenerator(secret: sha1Secret) let date = Date(timeIntervalSince1970: 29) - XCTAssertEqual(generator.secondsRemaining(at: date), 1) + #expect(generator.secondsRemaining(at: date) == 1) } // MARK: - fromBase32Secret - func testFromBase32SecretValid() { + @Test func testFromBase32SecretValid() { // "GEZDGNBVGY3TQOJQ" is base32 for "12345678901234" (14 bytes) let generator = TOTPGenerator.fromBase32Secret("GEZDGNBVGY3TQOJQ") - XCTAssertNotNil(generator) + #expect(generator != nil) } - func testFromBase32SecretWithSpaces() { + @Test func testFromBase32SecretWithSpaces() { let clean = TOTPGenerator.fromBase32Secret("GEZDGNBVGY3TQOJQ") let spaced = TOTPGenerator.fromBase32Secret("GEZD GNBV GY3T QOJQ") - XCTAssertNotNil(clean) - XCTAssertNotNil(spaced) + #expect(clean != nil) + #expect(spaced != nil) // Both should produce the same code at any given time let date = Date(timeIntervalSince1970: 59) - XCTAssertEqual(clean?.generate(at: date), spaced?.generate(at: date)) + #expect(clean?.generate(at: date) == spaced?.generate(at: date)) } - func testFromBase32SecretInvalid() { - let generator = TOTPGenerator.fromBase32Secret("!!!invalid!!!") - XCTAssertNil(generator) - } - - func testFromBase32SecretEmpty() { - let generator = TOTPGenerator.fromBase32Secret("") - XCTAssertNil(generator) + @Test(arguments: ["!!!invalid!!!", ""]) + func testFromBase32SecretInvalidOrEmpty(_ secret: String) { + let generator = TOTPGenerator.fromBase32Secret(secret) + #expect(generator == nil) } // MARK: - Default Parameters - func testDefaultAlgorithm() { + @Test func testDefaultAlgorithm() { let generator = TOTPGenerator(secret: sha1Secret) // Default is SHA1, 6 digits, 30s period let date = Date(timeIntervalSince1970: 59) // 6-digit SHA1 at T=59 should be "287082" - XCTAssertEqual(generator.generate(at: date), "287082") + #expect(generator.generate(at: date) == "287082") } // MARK: - Code Changes at Period Boundary - func testCodeChangesAtPeriodBoundary() { + @Test func testCodeChangesAtPeriodBoundary() { let generator = TOTPGenerator(secret: sha1Secret, algorithm: .sha1, digits: 8, period: 30) let beforeBoundary = Date(timeIntervalSince1970: 59) let afterBoundary = Date(timeIntervalSince1970: 60) let codeBefore = generator.generate(at: beforeBoundary) let codeAfter = generator.generate(at: afterBoundary) // T=59 → counter 1, T=60 → counter 2 — different codes - XCTAssertNotEqual(codeBefore, codeAfter) + #expect(codeBefore != codeAfter) } } diff --git a/Packages/TableProMacCore/Tests/TableProUtilitiesTests/JavaScriptStatementScannerTests.swift b/Packages/TableProMacCore/Tests/TableProUtilitiesTests/JavaScriptStatementScannerTests.swift index 988a16b8cd..9c0a4dcf17 100644 --- a/Packages/TableProMacCore/Tests/TableProUtilitiesTests/JavaScriptStatementScannerTests.swift +++ b/Packages/TableProMacCore/Tests/TableProUtilitiesTests/JavaScriptStatementScannerTests.swift @@ -13,15 +13,23 @@ struct JavaScriptStatementScannerTests { #expect(texts("db.a.find(); db.b.find();") == ["db.a.find();", "db.b.find();"]) } - @Test("A semicolon inside a function body is not a boundary") - func semicolonInsideFunction() { - let source = "db.a.find().forEach(function (d) { var x = d.n; print(x); });" - #expect(texts(source) == [source]) - } - - @Test("A for header's semicolons are not boundaries") - func forHeader() { - let source = "for (var i = 0; i < 3; i++) { print(i); }" + @Test("One statement is not split at a semicolon, brace or newline inside it", arguments: [ + "db.a.find().forEach(function (d) { var x = d.n; print(x); });", + "for (var i = 0; i < 3; i++) { print(i); }", + "if (a) { print(1); } else { print(2); }", + "try { db.a.drop(); } catch (e) { print(e); } finally { print(\"done\"); }", + "db.orders.find({})\n .sort({a: 1})\n .limit(5)", + "var total = 1 +\n2", + "db.a.find({note: \"one; two\"});", + "var q = `one; ${a + 1}; two`;", + "db.a.find({name: /one;two/i});", + "var f = function () { return 1; } + 1;", + "if (ready)\n{\n print(1);\n}\nelse\n{\n print(2);\n}", + "for (var i = 0; i < 3; i++)\n{\n print(i);\n}", + "try\n{\n db.a.drop();\n}\ncatch (e)\n{\n print(e);\n}", + "db.a.find({status: 1", + ]) + func oneStatementIsNotSplit(_ source: String) { #expect(texts(source) == [source]) } @@ -34,47 +42,11 @@ struct JavaScriptStatementScannerTests { #expect(texts(source) == ["function add(a, b) { return a + b; }", "db.orders.find({})"]) } - @Test("else keeps an if statement together") - func ifElseStaysOneStatement() { - let source = "if (a) { print(1); } else { print(2); }" - #expect(texts(source) == [source]) - } - - @Test("try/catch/finally stays one statement") - func tryCatchStaysOneStatement() { - let source = "try { db.a.drop(); } catch (e) { print(e); } finally { print(\"done\"); }" - #expect(texts(source) == [source]) - } - @Test("A newline splits when the next token cannot continue the expression") func newlineAsSemicolon() { #expect(texts("db.a.find()\ndb.b.find()") == ["db.a.find()", "db.b.find()"]) } - @Test("A newline before a leading dot does not split") - func newlineBeforeDotDoesNotSplit() { - let source = "db.orders.find({})\n .sort({a: 1})\n .limit(5)" - #expect(texts(source) == [source]) - } - - @Test("A newline after a trailing operator does not split") - func newlineAfterOperatorDoesNotSplit() { - let source = "var total = 1 +\n2" - #expect(texts(source) == [source]) - } - - @Test("A semicolon in a string literal is not a boundary") - func semicolonInString() { - let source = "db.a.find({note: \"one; two\"});" - #expect(texts(source) == [source]) - } - - @Test("A semicolon in a template literal is not a boundary") - func semicolonInTemplate() { - let source = "var q = `one; ${a + 1}; two`;" - #expect(texts(source) == [source]) - } - @Test("A semicolon in a line comment is not a boundary") func semicolonInLineComment() { let source = """ @@ -89,12 +61,6 @@ struct JavaScriptStatementScannerTests { #expect(texts("db.a.find(/* one; two */ {});") == ["db.a.find(/* one; two */ {});"]) } - @Test("A regular expression containing a semicolon is not a boundary") - func semicolonInRegularExpression() { - let source = "db.a.find({name: /one;two/i});" - #expect(texts(source) == [source]) - } - @Test("Division is not read as the start of a regular expression") func divisionIsNotRegex() { let source = """ @@ -110,12 +76,6 @@ struct JavaScriptStatementScannerTests { #expect(texts(source) == ["function add(a, b) { return a + b; }", "db.orders.find({})"]) } - @Test("A brace that closes a function expression does not end the assignment") - func braceInsideAssignmentDoesNotSplit() { - let source = "var f = function () { return 1; } + 1;" - #expect(texts(source) == [source]) - } - @Test("A function expression assigned to a variable stays with its assignment") func functionExpressionStaysWithAssignment() { let source = """ @@ -125,24 +85,6 @@ struct JavaScriptStatementScannerTests { #expect(texts(source) == ["var f = function () { return 1; }", "db.a.find({})"]) } - @Test("Allman bracing stays one statement") - func allmanBracing() { - let source = "if (ready)\n{\n print(1);\n}\nelse\n{\n print(2);\n}" - #expect(texts(source) == [source]) - } - - @Test("An Allman for loop stays one statement") - func allmanLoop() { - let source = "for (var i = 0; i < 3; i++)\n{\n print(i);\n}" - #expect(texts(source) == [source]) - } - - @Test("An Allman try/catch stays one statement") - func allmanTryCatch() { - let source = "try\n{\n db.a.drop();\n}\ncatch (e)\n{\n print(e);\n}" - #expect(texts(source) == [source]) - } - @Test("A trailing comment is not an executable statement") func trailingCommentIsNotAStatement() { #expect(texts("db.a.find(); // note") == ["db.a.find();"]) @@ -175,10 +117,4 @@ struct JavaScriptStatementScannerTests { #expect(texts("").isEmpty) #expect(texts(" \n ").isEmpty) } - - @Test("An unterminated brace keeps the rest as one statement rather than splitting it") - func unterminatedBrace() { - let source = "db.a.find({status: 1" - #expect(texts(source) == [source]) - } } diff --git a/Packages/TableProMacCore/Tests/TableProUtilitiesTests/Process/DescriptorReadTests.swift b/Packages/TableProMacCore/Tests/TableProUtilitiesTests/Process/DescriptorReadTests.swift index 4fb4332678..0f81756178 100644 --- a/Packages/TableProMacCore/Tests/TableProUtilitiesTests/Process/DescriptorReadTests.swift +++ b/Packages/TableProMacCore/Tests/TableProUtilitiesTests/Process/DescriptorReadTests.swift @@ -87,11 +87,7 @@ struct DescriptorReadTests { #expect(!DescriptorRead.hasInputWithoutWaiting(descriptor)) try pipe.fileHandleForWriting.close() - let deadline = ContinuousClock.now + .seconds(10) - while !DescriptorRead.hasInputWithoutWaiting(descriptor), ContinuousClock.now < deadline { - try await Task.sleep(for: .milliseconds(5)) - } - #expect(DescriptorRead.hasInputWithoutWaiting(descriptor)) + try await waitUntil { DescriptorRead.hasInputWithoutWaiting(descriptor) } } @Test("On a non-blocking pipe the next read waits for bytes instead of failing with EAGAIN", .timeLimit(.minutes(1))) diff --git a/Packages/TableProMacCore/Tests/TableProUtilitiesTests/QuickSwitcherFrecencyStoreTests.swift b/Packages/TableProMacCore/Tests/TableProUtilitiesTests/QuickSwitcherFrecencyStoreTests.swift index dc3f43105a..cffa3bb1f0 100644 --- a/Packages/TableProMacCore/Tests/TableProUtilitiesTests/QuickSwitcherFrecencyStoreTests.swift +++ b/Packages/TableProMacCore/Tests/TableProUtilitiesTests/QuickSwitcherFrecencyStoreTests.swift @@ -3,15 +3,21 @@ import Foundation import Testing final class QuickSwitcherFrecencyStoreTests { - private let suiteName = "QuickSwitcherFrecencyTests.\(UUID().uuidString)" + // A suite named by a path keeps its plist in the directory, out of ~/Library/Preferences. + private let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("QuickSwitcherFrecencyTests-\(UUID().uuidString)", isDirectory: true) + private let suiteName: String private let suite: UserDefaults init() throws { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + suiteName = directory.appendingPathComponent("defaults", isDirectory: false).path suite = try #require(UserDefaults(suiteName: suiteName)) } deinit { suite.removePersistentDomain(forName: suiteName) + try? FileManager.default.removeItem(at: directory) } private func makeStore() -> (store: QuickSwitcherFrecencyStore, defaults: UserDefaults, connectionId: UUID) { diff --git a/Packages/TableProMacCore/Tests/TableProUtilitiesTests/SplitViewAutosaveKeyTests.swift b/Packages/TableProMacCore/Tests/TableProUtilitiesTests/SplitViewAutosaveKeyTests.swift new file mode 100644 index 0000000000..59b56a40df --- /dev/null +++ b/Packages/TableProMacCore/Tests/TableProUtilitiesTests/SplitViewAutosaveKeyTests.swift @@ -0,0 +1,41 @@ +import AppKit +import Foundation +import TableProUtilities +import Testing + +// Hostless on purpose: AppKit files the record in the process's standard domain, which here is the +// test runner's rather than the app's com.TablePro. +@MainActor +struct SplitViewAutosaveKeyTests { + @Test("The key prefix is the one AppKit actually writes") + func keyPrefixMatchesAppKit() { + let name = "SplitViewAutosaveKeyTests-\(UUID().uuidString)" + // swiftlint:disable:next storage_environment_defaults + let defaults = UserDefaults.standard + let key = SplitViewAutosaveKey.key(forAutosaveName: name) + defaults.removeObject(forKey: key) + defer { defaults.removeObject(forKey: key) } + + let controller = NSSplitViewController() + controller.splitView.isVertical = true + for _ in 0 ..< 2 { + let pane = NSViewController() + pane.view = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 200)) + controller.addSplitViewItem(NSSplitViewItem(viewController: pane)) + } + controller.view.frame = NSRect(x: 0, y: 0, width: 400, height: 200) + controller.splitView.autosaveName = NSSplitView.AutosaveName(name) + controller.splitView.setPosition(150, ofDividerAt: 0) + + // AppKit saves from a delayed perform, which only a default-mode run loop pass delivers. + let deadline = Date(timeIntervalSinceNow: 10) + while defaults.object(forKey: key) == nil, Date() < deadline { + RunLoop.main.run(mode: .default, before: Date(timeIntervalSinceNow: 0.01)) + } + + #expect( + defaults.object(forKey: key) != nil, + "AppKit no longer writes its record under \(key), so the sweep matches a stale spelling" + ) + } +} diff --git a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleAsyncTimeoutTests.swift b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleAsyncTimeoutTests.swift index 1c15616b6d..72af967fec 100644 --- a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleAsyncTimeoutTests.swift +++ b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleAsyncTimeoutTests.swift @@ -1,24 +1,25 @@ +import Foundation @testable import TableProOracleCore -import XCTest +import Testing -final class OracleAsyncTimeoutTests: XCTestCase { - func testOperationCompletingBeforeDeadlineReturnsItsValue() async throws { +struct OracleAsyncTimeoutTests { + @Test func testOperationCompletingBeforeDeadlineReturnsItsValue() async throws { let result = try await withOracleTimeout(seconds: 5) { "done" } - XCTAssertEqual(result, "done") + #expect(result == "done") } - func testOperationCompletingBeforeDeadlineNeverCallsOnTimeout() async throws { + @Test func testOperationCompletingBeforeDeadlineNeverCallsOnTimeout() async throws { let timeoutCount = Counter() let result = try await withOracleTimeout( seconds: 5, onTimeout: { timeoutCount.increment() }, operation: { 42 } ) - XCTAssertEqual(result, 42) - XCTAssertEqual(timeoutCount.value, 0) + #expect(result == 42) + #expect(timeoutCount.value == 0) } - func testOperationExceedingDeadlineThrowsTimeoutAndCallsOnTimeoutOnce() async { + @Test func testOperationExceedingDeadlineThrowsTimeoutAndCallsOnTimeoutOnce() async { let timeoutCount = Counter() do { _ = try await withOracleTimeout( @@ -29,19 +30,19 @@ final class OracleAsyncTimeoutTests: XCTestCase { return "never" } ) - XCTFail("expected the deadline to fire") + Issue.record("expected the deadline to fire") } catch let error as OracleTimeoutError { - XCTAssertEqual(error.seconds, 0.05) + #expect(error.seconds == 0.05) } catch { - XCTFail("expected OracleTimeoutError, got \(error)") + Issue.record("expected OracleTimeoutError, got \(error)") } - XCTAssertEqual(timeoutCount.value, 1) + #expect(timeoutCount.value == 1) } /// The task group waits for the operation child even after the deadline /// fires, so an `onTimeout` that does not force the operation to finish /// leaves the caller blocked past its own deadline. - func testOnTimeoutMustUnblockAnOperationThatIgnoresCancellation() async { + @Test func testOnTimeoutMustUnblockAnOperationThatIgnoresCancellation() async { let release = DispatchSemaphore(value: 0) let released = Counter() @@ -61,16 +62,12 @@ final class OracleAsyncTimeoutTests: XCTestCase { } ) - XCTAssertEqual( - released.value, - 1, - "onTimeout must release an operation that ignores cancellation, or the task group never unwinds" - ) + #expect(released.value == 1, "onTimeout must release an operation that ignores cancellation, or the task group never unwinds") } /// A login does not observe task cancellation, so it can finish after its caller gave up. That /// session reaches nobody, and only `onAbandoned` can close it. - func testValueArrivingAfterCancellationGoesToOnAbandoned() async { + @Test func testValueArrivingAfterCancellationGoesToOnAbandoned() async { let abandoned = Counter() let task = Task { try await withOracleTimeout( @@ -88,14 +85,14 @@ final class OracleAsyncTimeoutTests: XCTestCase { switch await task.result { case .success: - XCTFail("a cancelled call must not return the late value") + Issue.record("a cancelled call must not return the late value") case .failure(let error): - XCTAssertTrue(error is CancellationError, "expected CancellationError, got \(error)") + #expect(error is CancellationError, "expected CancellationError, got \(error)") } - XCTAssertEqual(abandoned.value, 1) + #expect(abandoned.value == 1) } - func testValueArrivingAfterTheDeadlineGoesToOnAbandoned() async { + @Test func testValueArrivingAfterTheDeadlineGoesToOnAbandoned() async { let abandoned = Counter() do { _ = try await withOracleTimeout( @@ -107,14 +104,14 @@ final class OracleAsyncTimeoutTests: XCTestCase { return "late" } ) - XCTFail("expected the deadline to fire") + Issue.record("expected the deadline to fire") } catch { - XCTAssertTrue(error is OracleTimeoutError, "expected OracleTimeoutError, got \(error)") + #expect(error is OracleTimeoutError, "expected OracleTimeoutError, got \(error)") } - XCTAssertEqual(abandoned.value, 1) + #expect(abandoned.value == 1) } - func testValueReturnedInTimeIsNotAbandoned() async throws { + @Test func testValueReturnedInTimeIsNotAbandoned() async throws { let abandoned = Counter() let result = try await withOracleTimeout( seconds: 5, @@ -122,8 +119,8 @@ final class OracleAsyncTimeoutTests: XCTestCase { onAbandoned: { _ in abandoned.increment() }, operation: { "done" } ) - XCTAssertEqual(result, "done") - XCTAssertEqual(abandoned.value, 0) + #expect(result == "done") + #expect(abandoned.value == 0) } } diff --git a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleBindValueTests.swift b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleBindValueTests.swift index 2b66fbda41..f315ea6a97 100644 --- a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleBindValueTests.swift +++ b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleBindValueTests.swift @@ -1,36 +1,37 @@ +import Foundation import NIOCore import OracleNIO @testable import TableProOracleCore -import XCTest +import Testing -final class OracleBindValueTests: XCTestCase { - func testEveryValueTakesItsPosition() { +struct OracleBindValueTests { + @Test func testEveryValueTakesItsPosition() { let bindings = OracleBindValue.bindings(for: [.text("a"), .bytes(Data([1, 2])), .null, .text(String(repeating: "x", count: 3_000))]) - XCTAssertEqual(bindings.count, 4) + #expect(bindings.count == 4) } /// A character can take more than four bytes, and a bind sized by characters was refused with /// ORA-01460 or ORA-01461 (measured on Oracle 23ai). - func testCharBindIsSizedInUTF8Bytes() { - XCTAssertEqual(OracleCharBind(text: "👍🏽").size, 8) - XCTAssertEqual(OracleCharBind(text: "🇻🇳").size, 8) - XCTAssertEqual(OracleCharBind(text: "x\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F466}y").size, 27) - XCTAssertEqual(OracleCharBind(text: "").size, 1) + @Test func testCharBindIsSizedInUTF8Bytes() { + #expect(OracleCharBind(text: "👍🏽").size == 8) + #expect(OracleCharBind(text: "🇻🇳").size == 8) + #expect(OracleCharBind(text: "x\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F466}y").size == 27) + #expect(OracleCharBind(text: "").size == 1) } - func testCharBindWritesItsOwnLength() { + @Test func testCharBindWritesItsOwnLength() { var short = ByteBuffer() OracleCharBind(text: "abc")._encodeRaw(into: &short, context: .default) - XCTAssertEqual(Array(short.readableBytesView), [3, 0x61, 0x62, 0x63]) + #expect(Array(short.readableBytesView) == [3, 0x61, 0x62, 0x63]) var long = ByteBuffer() OracleCharBind(text: String(repeating: "é", count: 1_000))._encodeRaw(into: &long, context: .default) - XCTAssertEqual(long.getInteger(at: 0, as: UInt8.self), 254) + #expect(long.getInteger(at: 0, as: UInt8.self) == 254) } /// Text up to a CHAR's 2,000 bytes binds as CHAR, so a `CHAR(10)` holding `abc` still matches it. - func testShortTextBindsAsChar() { - XCTAssertEqual(OracleCharBind.defaultOracleType, .char) - XCTAssertEqual(OracleBindValue.charByteLimit, 2_000) + @Test func testShortTextBindsAsChar() { + #expect(OracleCharBind.defaultOracleType == .char) + #expect(OracleBindValue.charByteLimit == 2_000) } } diff --git a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleCellDecodingTests.swift b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleCellDecodingTests.swift index cde6c96349..dbc3ed24d6 100644 --- a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleCellDecodingTests.swift +++ b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleCellDecodingTests.swift @@ -1,10 +1,11 @@ +import Foundation import NIOCore import OracleNIO @testable import TableProOracleCore -import XCTest +import Testing /// Cells built from wire bytes measured on Oracle 23ai, decoded the way a result decodes them. -final class OracleCellDecodingTests: XCTestCase { +struct OracleCellDecodingTests { private static let utcPlusSeven = OracleSessionTimeZones( database: .gmt, session: TimeZone(secondsFromGMT: 7 * 3_600) ?? .gmt @@ -35,70 +36,64 @@ final class OracleCellDecodingTests: XCTestCase { } /// Every NCHAR value used to read ``. - func testNCharDecodesAsText() { + @Test func testNCharDecodesAsText() { let (value, _) = decode(Self.hex("006800e9006c006c006f0020"), as: .nChar) - XCTAssertEqual(value, .string("héllo ")) + #expect(value == .string("héllo ")) } /// Every native JSON value used to read ``. - func testJSONDecodesAsTextInStoredOrder() { + @Test func testJSONDecodesAsTextInStoredOrder() { let oson = Self.hex( "ff4a5a01612605000a0030000b2c5273ade500020004000800060000016201610163017a0164840305010200110014" + "002606000031323021c102c0050011002000240010000e22c10333017884020403002e000f0179" ) let (value, _) = decode(oson, as: .json) - XCTAssertEqual(value, .string(#"{"b":1,"a":[1,2.5,"x",null,true],"c":{"z":"y","d":false}}"#)) + #expect(value == .string(#"{"b":1,"a":[1,2.5,"x",null,true],"c":{"z":"y","d":false}}"#)) } /// A NUMBER past 19 digits trapped the integer decode and crashed the app; past 15 the text was a /// rounded Double in exponent notation. - func testNumberKeepsEveryDigit() { - XCTAssertEqual(decode(Self.hex("cc0d23394f5b0d23394f5b0d23"), as: .number).0, .string("123456789012345678901234")) - XCTAssertEqual(decode(Self.hex("4559432d170b59432d1766"), as: .number).0, .string("-0.000000000000123456789012345678")) - XCTAssertEqual(decode(Self.hex("c902182e445a02182e441a"), as: .number).0, .string("12345678901234567.25")) - XCTAssertEqual(decode(Self.hex("c102"), as: .number).0, .string("1")) + @Test func testNumberKeepsEveryDigit() { + #expect(decode(Self.hex("cc0d23394f5b0d23394f5b0d23"), as: .number).0 == .string("123456789012345678901234")) + #expect(decode(Self.hex("4559432d170b59432d1766"), as: .number).0 == .string("-0.000000000000123456789012345678")) + #expect(decode(Self.hex("c902182e445a02182e441a"), as: .number).0 == .string("12345678901234567.25")) + #expect(decode(Self.hex("c102"), as: .number).0 == .string("1")) } /// A negative INTERVAL DAY TO SECOND trapped in the driver's decode and crashed the app. - func testNegativeIntervalDecodes() { - XCTAssertEqual(decode(Self.hex("7fffffff3a393862329b00"), as: .intervalDS).0, .string("-1 02:03:04.5")) + @Test func testNegativeIntervalDecodes() { + #expect(decode(Self.hex("7fffffff3a393862329b00"), as: .intervalDS).0 == .string("-1 02:03:04.5")) } - func testDatetimesUseTheColumnsScale() { - XCTAssertEqual(decode(Self.hex("787e0a06101715"), as: .date).0, .string("2026-10-06 15:22:20")) - XCTAssertEqual(decode(Self.hex("787c010204050602faf080"), as: .timestamp, scale: 6).0, .string("2024-01-02 03:04:05.050000")) - XCTAssertEqual(decode(Self.hex("787c0102040506"), as: .timestamp, scale: 0).0, .string("2024-01-02 03:04:05")) - XCTAssertEqual( - decode(Self.hex("787c0102092306075bca000f1e"), as: .timestampTZ, scale: 6).0, - .string("2024-01-02 03:04:05.123456-05:30") - ) - XCTAssertEqual( - decode(Self.hex("787c0102020506000003e8"), as: .timestampLTZ, scale: 6).0, - .string("2024-01-02 08:04:05.000001+07:00") - ) + @Test func testDatetimesUseTheColumnsScale() { + #expect(decode(Self.hex("787e0a06101715"), as: .date).0 == .string("2026-10-06 15:22:20")) + #expect(decode(Self.hex("787c010204050602faf080"), as: .timestamp, scale: 6).0 == .string("2024-01-02 03:04:05.050000")) + #expect(decode(Self.hex("787c0102040506"), as: .timestamp, scale: 0).0 == .string("2024-01-02 03:04:05")) + #expect(decode(Self.hex("787c0102092306075bca000f1e"), as: .timestampTZ, scale: 6).0 == .string("2024-01-02 03:04:05.123456-05:30")) + #expect(decode(Self.hex("787c0102020506000003e8"), as: .timestampLTZ, scale: 6).0 == .string("2024-01-02 08:04:05.000001+07:00")) } - func testUniversalRowIDReadsAsOracleSpellsIt() { - XCTAssertEqual(decode(Array("*BAYAAesCwQL+".utf8), as: .uRowID).0, .string("*BAYAAesCwQL+")) + @Test func testUniversalRowIDReadsAsOracleSpellsIt() { + #expect(decode(Array("*BAYAAesCwQL+".utf8), as: .uRowID).0 == .string("*BAYAAesCwQL+")) } - func testRefIsAPlaceholderThatIsReported() { + @Test func testRefIsAPlaceholderThatIsReported() { let (value, unsupported) = decode(Self.hex("002202085d2faade1176048de063020011acd7a1"), as: .ref) - XCTAssertEqual(value, .string("")) - XCTAssertEqual(unsupported, ["ref"]) + #expect(value == .string("")) + #expect(unsupported == ["ref"]) } - func testBFILENamesItsFile() { + @Test func testBFILENamesItsFile() { let locator = Self.hex("00240001080800000001000090000000000d444154415f50554d505f4449520005782e62696e") - XCTAssertEqual(decode(locator, as: .bFile).0, .string("BFILENAME('DATA_PUMP_DIR', 'x.bin')")) + #expect(decode(locator, as: .bFile).0 == .string("BFILENAME('DATA_PUMP_DIR', 'x.bin')")) } /// A BLOB arrives as LONG RAW, which is still bytes. - func testBLOBFetchedAsLongRawIsBytes() { - XCTAssertEqual(decode([0xde, 0xad], as: .longRAW, declared: .blob).0, .bytes(Data([0xde, 0xad]))) + @Test func testBLOBFetchedAsLongRawIsBytes() { + #expect(decode([0xde, 0xad], as: .longRAW, declared: .blob).0 == .bytes(Data([0xde, 0xad]))) } - func testMissingBytesAreNull() { - XCTAssertEqual(decode(nil, as: .varchar).0, .null) + @Test func testMissingBytesAreNull() { + #expect(decode(nil, as: .varchar).0 == .null) } } diff --git a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleCellFormattingTests.swift b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleCellFormattingTests.swift index d5fe99d56b..96687599e5 100644 --- a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleCellFormattingTests.swift +++ b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleCellFormattingTests.swift @@ -1,8 +1,9 @@ +import Foundation @testable import TableProOracleCore -import XCTest +import Testing /// Wire bytes measured on Oracle 23ai, with a session time zone of +07:00 and a database time zone of +00:00. -final class OracleCellFormattingTests: XCTestCase { +struct OracleCellFormattingTests { /// 2024-01-02 03:04:05, the date and time every TIMESTAMP case below starts from. private static let wholeSecond: [UInt8] = [0x78, 0x7c, 0x01, 0x02, 0x04, 0x05, 0x06] @@ -11,140 +12,98 @@ final class OracleCellFormattingTests: XCTestCase { } /// Oracle's DATE always carries a time of day, and dropping it lost the time from every read. - func testDateKeepsItsTimeOfDay() { - XCTAssertEqual( - OracleCellFormatting.dateText(wire: [0x78, 0x7e, 0x0a, 0x06, 0x10, 0x17, 0x15]), - "2026-10-06 15:22:20" - ) + @Test func testDateKeepsItsTimeOfDay() { + #expect(OracleCellFormatting.dateText(wire: [0x78, 0x7e, 0x0a, 0x06, 0x10, 0x17, 0x15]) == "2026-10-06 15:22:20") } - func testDateBeforeYear1000KeepsFourDigits() { - XCTAssertEqual(OracleCellFormatting.dateText(wire: [0x64, 0x69, 0x03, 0x04, 0x02, 0x03, 0x04]), "0005-03-04 01:02:03") + @Test func testDateBeforeYear1000KeepsFourDigits() { + #expect(OracleCellFormatting.dateText(wire: [0x64, 0x69, 0x03, 0x04, 0x02, 0x03, 0x04]) == "0005-03-04 01:02:03") } - func testBCYearsKeepOraclesSign() { - XCTAssertEqual(OracleCellFormatting.dateText(wire: [0x35, 0x58, 0x01, 0x01, 0x01, 0x01, 0x01]), "-4712-01-01 00:00:00") - XCTAssertEqual( - OracleCellFormatting.timestampText(wire: [0x64, 0x38, 0x03, 0x0f, 0x0d, 0x01, 0x01], fractionDigits: 6), - "-0044-03-15 12:00:00.000000" - ) + @Test func testBCYearsKeepOraclesSign() { + #expect(OracleCellFormatting.dateText(wire: [0x35, 0x58, 0x01, 0x01, 0x01, 0x01, 0x01]) == "-4712-01-01 00:00:00") + #expect(OracleCellFormatting.timestampText(wire: [0x64, 0x38, 0x03, 0x0f, 0x0d, 0x01, 0x01], fractionDigits: 6) == "-0044-03-15 12:00:00.000000") } /// The fraction is nanoseconds. Reading it through the driver's `Date` turned `.05` into `.5` and /// `.000001` into `.1`. - func testFractionHasExactlyTheColumnsDigits() { - XCTAssertEqual( - OracleCellFormatting.timestampText(wire: Self.timestamp(nanoseconds: [0x02, 0xfa, 0xf0, 0x80]), fractionDigits: 6), - "2024-01-02 03:04:05.050000" - ) - XCTAssertEqual( - OracleCellFormatting.timestampText(wire: Self.timestamp(nanoseconds: [0x00, 0x00, 0x03, 0xe8]), fractionDigits: 6), - "2024-01-02 03:04:05.000001" - ) - XCTAssertEqual( - OracleCellFormatting.timestampText(wire: Self.timestamp(nanoseconds: [0x3b, 0x9a, 0xc9, 0xff]), fractionDigits: 9), - "2024-01-02 03:04:05.999999999" - ) + @Test func testFractionHasExactlyTheColumnsDigits() { + #expect(OracleCellFormatting.timestampText(wire: Self.timestamp(nanoseconds: [0x02, 0xfa, 0xf0, 0x80]), fractionDigits: 6) == "2024-01-02 03:04:05.050000") + #expect(OracleCellFormatting.timestampText(wire: Self.timestamp(nanoseconds: [0x00, 0x00, 0x03, 0xe8]), fractionDigits: 6) == "2024-01-02 03:04:05.000001") + #expect(OracleCellFormatting.timestampText(wire: Self.timestamp(nanoseconds: [0x3b, 0x9a, 0xc9, 0xff]), fractionDigits: 9) == "2024-01-02 03:04:05.999999999") } /// A whole second arrives as seven bytes. TIMESTAMP(0) has no fraction at all, and TIMESTAMP(3) still /// shows its three digits, so neither ends in a bare `.`. - func testWholeSecondFollowsThePrecision() { - XCTAssertEqual(OracleCellFormatting.timestampText(wire: Self.wholeSecond, fractionDigits: 0), "2024-01-02 03:04:05") - XCTAssertEqual(OracleCellFormatting.timestampText(wire: Self.wholeSecond, fractionDigits: 3), "2024-01-02 03:04:05.000") + @Test func testWholeSecondFollowsThePrecision() { + #expect(OracleCellFormatting.timestampText(wire: Self.wholeSecond, fractionDigits: 0) == "2024-01-02 03:04:05") + #expect(OracleCellFormatting.timestampText(wire: Self.wholeSecond, fractionDigits: 3) == "2024-01-02 03:04:05.000") } /// The bytes are UTC and the text is the wall clock at the value's own offset. A zone west of UTC /// sends bytes below their bias, which the driver's decode trapped on. - func testTimeZoneTextUsesTheValuesOwnOffset() { + @Test func testTimeZoneTextUsesTheValuesOwnOffset() { let minusFiveThirty: [UInt8] = [0x78, 0x7c, 0x01, 0x02, 0x09, 0x23, 0x06, 0x07, 0x5b, 0xca, 0x00, 0x0f, 0x1e] - XCTAssertEqual( - OracleCellFormatting.timestampWithTimeZoneText(wire: minusFiveThirty, fractionDigits: 6), - "2024-01-02 03:04:05.123456-05:30" - ) + #expect(OracleCellFormatting.timestampWithTimeZoneText(wire: minusFiveThirty, fractionDigits: 6) == "2024-01-02 03:04:05.123456-05:30") let plusTwo: [UInt8] = [0x78, 0x7c, 0x01, 0x02, 0x02, 0x05, 0x06, 0x1d, 0xcd, 0x65, 0x00, 0x16, 0x3c] - XCTAssertEqual( - OracleCellFormatting.timestampWithTimeZoneText(wire: plusTwo, fractionDigits: 1), - "2024-01-02 03:04:05.5+02:00" - ) + #expect(OracleCellFormatting.timestampWithTimeZoneText(wire: plusTwo, fractionDigits: 1) == "2024-01-02 03:04:05.5+02:00") } - func testTimeZoneOffsetCanCrossIntoTheNextYear() { + @Test func testTimeZoneOffsetCanCrossIntoTheNextYear() { let utc: [UInt8] = [0x78, 0x7b, 0x0c, 0x1f, 0x14, 0x1f, 0x01, 0x00, 0x00, 0x00, 0x00, 0x19, 0x3c] - XCTAssertEqual( - OracleCellFormatting.timestampWithTimeZoneText(wire: utc, fractionDigits: 0), - "2024-01-01 00:30:00+05:00" - ) + #expect(OracleCellFormatting.timestampWithTimeZoneText(wire: utc, fractionDigits: 0) == "2024-01-01 00:30:00+05:00") } /// A zone stored by region name is an id into Oracle's time zone file. It used to read ``. - func testRegionNamedZoneIsAPlaceholder() { + @Test func testRegionNamedZoneIsAPlaceholder() { let paris: [UInt8] = [0x78, 0x7c, 0x01, 0x02, 0x03, 0x05, 0x06, 0x1d, 0xcd, 0x65, 0x00, 0x85, 0xf8] - XCTAssertEqual( - OracleCellFormatting.timestampWithTimeZoneText(wire: paris, fractionDigits: 6), - "" - ) + #expect(OracleCellFormatting.timestampWithTimeZoneText(wire: paris, fractionDigits: 6) == "") } /// The bytes are the value in the database's zone; the text is the session's wall clock and the session's /// offset, so the instant survives a copy into a type with a zone. - func testLocalTimeZoneTextIsTheSessionsWallClockAndOffset() throws { + @Test func testLocalTimeZoneTextIsTheSessionsWallClockAndOffset() throws { var bytes = Self.timestamp(nanoseconds: [0x00, 0x00, 0x03, 0xe8]) bytes[4] = 0x02 // 01:04:05 in the database's zone - let plusSeven = try XCTUnwrap(TimeZone(secondsFromGMT: 7 * 3_600)) - XCTAssertEqual( - OracleCellFormatting.localTimestampText( + let plusSeven = try #require(TimeZone(secondsFromGMT: 7 * 3_600)) + #expect(OracleCellFormatting.localTimestampText( wire: bytes, fractionDigits: 6, zones: OracleSessionTimeZones(database: .gmt, session: plusSeven) - ), - "2024-01-02 08:04:05.000001+07:00" - ) - let minusThree = try XCTUnwrap(TimeZone(secondsFromGMT: -3 * 3_600)) - XCTAssertEqual( - OracleCellFormatting.localTimestampText( + ) == "2024-01-02 08:04:05.000001+07:00") + let minusThree = try #require(TimeZone(secondsFromGMT: -3 * 3_600)) + #expect(OracleCellFormatting.localTimestampText( wire: bytes, fractionDigits: 6, zones: OracleSessionTimeZones(database: .gmt, session: minusThree) - ), - "2024-01-01 22:04:05.000001-03:00" - ) + ) == "2024-01-01 22:04:05.000001-03:00") } - func testLocalTimeZoneTextFollowsTheSessionRegionsDaylightSaving() throws { - let paris = try XCTUnwrap(TimeZone(identifier: "Europe/Paris")) + @Test func testLocalTimeZoneTextFollowsTheSessionRegionsDaylightSaving() throws { + let paris = try #require(TimeZone(identifier: "Europe/Paris")) let zones = OracleSessionTimeZones(database: .gmt, session: paris) let july: [UInt8] = [0x78, 0x7c, 0x07, 0x01, 0x0d, 0x01, 0x01] - XCTAssertEqual( - OracleCellFormatting.localTimestampText(wire: july, fractionDigits: 0, zones: zones), - "2024-07-01 14:00:00+02:00" - ) + #expect(OracleCellFormatting.localTimestampText(wire: july, fractionDigits: 0, zones: zones) == "2024-07-01 14:00:00+02:00") let january: [UInt8] = [0x78, 0x7c, 0x01, 0x01, 0x0d, 0x01, 0x01] - XCTAssertEqual( - OracleCellFormatting.localTimestampText(wire: january, fractionDigits: 0, zones: zones), - "2024-01-01 13:00:00+01:00" - ) + #expect(OracleCellFormatting.localTimestampText(wire: january, fractionDigits: 0, zones: zones) == "2024-01-01 13:00:00+01:00") } - func testLocalTimeZoneTextMovesFromANonUTCDatabaseZone() throws { - let plusTwo = try XCTUnwrap(TimeZone(secondsFromGMT: 2 * 3_600)) + @Test func testLocalTimeZoneTextMovesFromANonUTCDatabaseZone() throws { + let plusTwo = try #require(TimeZone(secondsFromGMT: 2 * 3_600)) let zones = OracleSessionTimeZones(database: plusTwo, session: .gmt) - XCTAssertEqual( - OracleCellFormatting.localTimestampText(wire: Self.wholeSecond, fractionDigits: 0, zones: zones), - "2024-01-02 01:04:05+00:00" - ) + #expect(OracleCellFormatting.localTimestampText(wire: Self.wholeSecond, fractionDigits: 0, zones: zones) == "2024-01-02 01:04:05+00:00") } /// Paris kept local mean time, nine minutes and 21 seconds ahead of UTC, until 1891. The suffix holds whole /// minutes, so the wall clock moves by the same whole minutes and the text still names the instant. - func testLocalTimeZoneOffsetIsWholeMinutes() throws { - let paris = try XCTUnwrap(TimeZone(identifier: "Europe/Paris")) + @Test func testLocalTimeZoneOffsetIsWholeMinutes() throws { + let paris = try #require(TimeZone(identifier: "Europe/Paris")) let zones = OracleSessionTimeZones(database: .gmt, session: paris) let noonIn1880: [UInt8] = [0x76, 0x64 + 80, 0x06, 0x01, 0x0d, 0x01, 0x01] - let text = try XCTUnwrap(OracleCellFormatting.localTimestampText(wire: noonIn1880, fractionDigits: 0, zones: zones)) - XCTAssertTrue(text.hasPrefix("1880-06-01 12:09:00"), text) - XCTAssertTrue(text.hasSuffix("+00:09"), text) + let text = try #require(OracleCellFormatting.localTimestampText(wire: noonIn1880, fractionDigits: 0, zones: zones)) + #expect(text.hasPrefix("1880-06-01 12:09:00"), "\(text)") + #expect(text.hasSuffix("+00:09"), "\(text)") } /// The grid reads a datetime through `DatabaseDateParser`, whose grammar takes an offset straight after the /// seconds or the fraction. - func testDatetimeSpellingsFitTheGridsGrammar() throws { + @Test func testDatetimeSpellingsFitTheGridsGrammar() throws { let grammar = try NSRegularExpression( pattern: #"^(?:(\d{4})-(\d{1,2})-(\d{1,2}))?(?:([ T])?(\d{1,2}):(\d{2}):(\d{2})(\.\d+)?)?(Z|[+-]\d{2}(?::?\d{2})?)?$"# ) @@ -157,144 +116,135 @@ final class OracleCellFormattingTests: XCTestCase { ] for spelling in spellings { let range = NSRange(spelling.startIndex..., in: spelling) - XCTAssertNotNil(grammar.firstMatch(in: spelling, range: range), spelling) + #expect(grammar.firstMatch(in: spelling, range: range) != nil, "\(spelling)") } } - func testTooShortWireIsNotADate() { - XCTAssertNil(OracleCellFormatting.dateText(wire: [0x78, 0x7c])) + @Test func testTooShortWireIsNotADate() { + #expect(OracleCellFormatting.dateText(wire: [0x78, 0x7c]) == nil) } /// A schema can hold its own DUAL, which an unqualified name resolves to first. - func testZoneQueryReadsTheDictionarysDual() { - XCTAssertTrue(OracleSessionTimeZones.query.hasSuffix("FROM \(OracleDictionary.dual)")) + @Test func testZoneQueryReadsTheDictionarysDual() { + #expect(OracleSessionTimeZones.query.hasSuffix("FROM \(OracleDictionary.dual)")) } - func testSessionZonesReadOffsetsAndRegionNames() throws { - let measured = try XCTUnwrap(OracleSessionTimeZones(row: [.string("+00:00"), .string("+07:00"), .string("+00:00"), .string("+07:00")])) - XCTAssertEqual(measured.database.secondsFromGMT(), 0) - XCTAssertEqual(measured.session.secondsFromGMT(), 7 * 3_600) + @Test func testSessionZonesReadOffsetsAndRegionNames() throws { + let measured = try #require(OracleSessionTimeZones(row: [.string("+00:00"), .string("+07:00"), .string("+00:00"), .string("+07:00")])) + #expect(measured.database.secondsFromGMT() == 0) + #expect(measured.session.secondsFromGMT() == 7 * 3_600) - let region = try XCTUnwrap(OracleSessionTimeZones(row: [.string("-05:30"), .string("Europe/Paris"), .string("-05:30"), .string("+01:00")])) - XCTAssertEqual(region.database.secondsFromGMT(), -(5 * 3_600 + 30 * 60)) - XCTAssertEqual(region.session.identifier, "Europe/Paris") + let region = try #require(OracleSessionTimeZones(row: [.string("-05:30"), .string("Europe/Paris"), .string("-05:30"), .string("+01:00")])) + #expect(region.database.secondsFromGMT() == -(5 * 3_600 + 30 * 60)) + #expect(region.session.identifier == "Europe/Paris") - let unknownRegion = try XCTUnwrap(OracleSessionTimeZones(row: [.string("+00:00"), .string("Not/AZone"), .string("+00:00"), .string("+04:00")])) - XCTAssertEqual(unknownRegion.session.secondsFromGMT(), 4 * 3_600) + let unknownRegion = try #require(OracleSessionTimeZones(row: [.string("+00:00"), .string("Not/AZone"), .string("+00:00"), .string("+04:00")])) + #expect(unknownRegion.session.secondsFromGMT() == 4 * 3_600) - XCTAssertNil(OracleSessionTimeZones(row: [.null, .null, .null, .null])) + #expect(OracleSessionTimeZones(row: [.null, .null, .null, .null]) == nil) } /// The zone text comes from the server. Out-of-range parts overflowed the arithmetic; they now read as an /// unparseable zone, which falls back to the offset column and then to no zones at all. - func testOutOfRangeOffsetsAreRefused() { - XCTAssertNil(OracleSessionTimeZones.zone(fromOffset: "+99999999999999999:00")) - XCTAssertNil(OracleSessionTimeZones.zone(fromOffset: "-19:00")) - XCTAssertNil(OracleSessionTimeZones.zone(fromOffset: "+05:60")) - XCTAssertNil(OracleSessionTimeZones.zone(fromOffset: "+05:-1")) - XCTAssertEqual(OracleSessionTimeZones.zone(fromOffset: "+14:00")?.secondsFromGMT(), 14 * 3_600) - XCTAssertEqual(OracleSessionTimeZones.zone(fromOffset: "-12:00")?.secondsFromGMT(), -12 * 3_600) + @Test func testOutOfRangeOffsetsAreRefused() { + #expect(OracleSessionTimeZones.zone(fromOffset: "+99999999999999999:00") == nil) + #expect(OracleSessionTimeZones.zone(fromOffset: "-19:00") == nil) + #expect(OracleSessionTimeZones.zone(fromOffset: "+05:60") == nil) + #expect(OracleSessionTimeZones.zone(fromOffset: "+05:-1") == nil) + #expect(OracleSessionTimeZones.zone(fromOffset: "+14:00")?.secondsFromGMT() == 14 * 3_600) + #expect(OracleSessionTimeZones.zone(fromOffset: "-12:00")?.secondsFromGMT() == -12 * 3_600) let hostile = OracleSessionTimeZones(row: [ .string("+99999999999999999:00"), .string("+07:00"), .string("+00:00"), .string("+07:00"), ]) - XCTAssertEqual(hostile?.database.secondsFromGMT(), 0) - XCTAssertNil(OracleSessionTimeZones(row: [ + #expect(hostile?.database.secondsFromGMT() == 0) + #expect(OracleSessionTimeZones(row: [ .string("+99999999999999999:00"), .string("+07:00"), .string("+99999999999999999:00"), .string("+07:00"), - ])) + ]) == nil) } - func testAlterSessionAndPLSQLCanChangeTheZones() { - XCTAssertTrue(OracleSessionTimeZones.mayChange(after: "ALTER SESSION SET TIME_ZONE = '-03:00'")) - XCTAssertTrue(OracleSessionTimeZones.mayChange(after: " alter session set time_zone = dbtimezone")) - XCTAssertFalse(OracleSessionTimeZones.mayChange(after: "ALTER TABLE t ADD c NUMBER")) - XCTAssertFalse(OracleSessionTimeZones.mayChange(after: "SELECT 1 FROM DUAL")) - XCTAssertFalse(OracleSessionTimeZones.mayChange(after: "UPDATE t SET c = 1")) - XCTAssertTrue(OracleSessionTimeZones.mayChange( + @Test func testAlterSessionAndPLSQLCanChangeTheZones() { + #expect(OracleSessionTimeZones.mayChange(after: "ALTER SESSION SET TIME_ZONE = '-03:00'")) + #expect(OracleSessionTimeZones.mayChange(after: " alter session set time_zone = dbtimezone")) + #expect(!OracleSessionTimeZones.mayChange(after: "ALTER TABLE t ADD c NUMBER")) + #expect(!OracleSessionTimeZones.mayChange(after: "SELECT 1 FROM DUAL")) + #expect(!OracleSessionTimeZones.mayChange(after: "UPDATE t SET c = 1")) + #expect(OracleSessionTimeZones.mayChange( after: "BEGIN EXECUTE IMMEDIATE 'ALTER SESSION SET TIME_ZONE = ''-03:00'''; END;" )) - XCTAssertTrue(OracleSessionTimeZones.mayChange(after: "declare x number; begin null; end;")) - XCTAssertTrue(OracleSessionTimeZones.mayChange(after: "CALL set_zone()")) - XCTAssertTrue(OracleSessionTimeZones.mayChange(after: "<> BEGIN NULL; END;")) + #expect(OracleSessionTimeZones.mayChange(after: "declare x number; begin null; end;")) + #expect(OracleSessionTimeZones.mayChange(after: "CALL set_zone()")) + #expect(OracleSessionTimeZones.mayChange(after: "<> BEGIN NULL; END;")) } - func testBFILETextIsTheCallThatCreatesIt() { - XCTAssertEqual( - OracleCellFormatting.bfileText(directory: "DATA_PUMP_DIR", fileName: "x.bin"), - "BFILENAME('DATA_PUMP_DIR', 'x.bin')" - ) - XCTAssertEqual( - OracleCellFormatting.bfileText(directory: "D", fileName: "it's.bin"), - "BFILENAME('D', 'it''s.bin')" - ) + @Test func testBFILETextIsTheCallThatCreatesIt() { + #expect(OracleCellFormatting.bfileText(directory: "DATA_PUMP_DIR", fileName: "x.bin") == "BFILENAME('DATA_PUMP_DIR', 'x.bin')") + #expect(OracleCellFormatting.bfileText(directory: "D", fileName: "it's.bin") == "BFILENAME('D', 'it''s.bin')") } - func testIntervalMillisecondsTrimToSignificantDigits() { + @Test func testIntervalMillisecondsTrimToSignificantDigits() { let result = OracleCellFormatting.formatIntervalDS( days: 2, hours: 3, minutes: 4, seconds: 5, nanoseconds: 678_000_000 ) - XCTAssertEqual(result, "2 03:04:05.678") + #expect(result == "2 03:04:05.678") } - func testIntervalPreservesNanosecondPrecision() { + @Test func testIntervalPreservesNanosecondPrecision() { let result = OracleCellFormatting.formatIntervalDS( days: 0, hours: 0, minutes: 0, seconds: 0, nanoseconds: 123_456_789 ) - XCTAssertEqual(result, "0 00:00:00.123456789") + #expect(result == "0 00:00:00.123456789") } - func testNegativeIntervalPrefixesSingleMinus() { + @Test func testNegativeIntervalPrefixesSingleMinus() { let result = OracleCellFormatting.formatIntervalDS( days: -1, hours: -2, minutes: -3, seconds: -4, nanoseconds: -50_000_000 ) - XCTAssertEqual(result, "-1 02:03:04.05") + #expect(result == "-1 02:03:04.05") } - func testZeroFractionalDropsDecimalPoint() { + @Test func testZeroFractionalDropsDecimalPoint() { let result = OracleCellFormatting.formatIntervalDS( days: 5, hours: 3, minutes: 14, seconds: 0, nanoseconds: 0 ) - XCTAssertEqual(result, "5 03:14:00") + #expect(result == "5 03:14:00") } - func testZeroIntervalHasNoSignAndNoDecimal() { + @Test func testZeroIntervalHasNoSignAndNoDecimal() { let result = OracleCellFormatting.formatIntervalDS( days: 0, hours: 0, minutes: 0, seconds: 0, nanoseconds: 0 ) - XCTAssertEqual(result, "0 00:00:00") + #expect(result == "0 00:00:00") } - func testIntervalYearToMonthPositive() { - XCTAssertEqual(OracleCellFormatting.formatIntervalYM(years: 5, months: 3), "5-03") + @Test func testIntervalYearToMonthPositive() { + #expect(OracleCellFormatting.formatIntervalYM(years: 5, months: 3) == "5-03") } - func testIntervalYearToMonthNegative() { - XCTAssertEqual(OracleCellFormatting.formatIntervalYM(years: -2, months: -7), "-2-07") + @Test func testIntervalYearToMonthNegative() { + #expect(OracleCellFormatting.formatIntervalYM(years: -2, months: -7) == "-2-07") } - func testIntervalYearToMonthZero() { - XCTAssertEqual(OracleCellFormatting.formatIntervalYM(years: 0, months: 0), "0-00") + @Test func testIntervalYearToMonthZero() { + #expect(OracleCellFormatting.formatIntervalYM(years: 0, months: 0) == "0-00") } - func testHexEncodeProducesLowercaseBytes() { - XCTAssertEqual(OracleCellFormatting.hexEncode([0x00, 0xff, 0xab, 0x10]), "00ffab10") + @Test func testHexEncodeProducesLowercaseBytes() { + #expect(OracleCellFormatting.hexEncode([0x00, 0xff, 0xab, 0x10]) == "00ffab10") } - func testHexEncodeEmptyInput() { - XCTAssertEqual(OracleCellFormatting.hexEncode([]), "") + @Test func testHexEncodeEmptyInput() { + #expect(OracleCellFormatting.hexEncode([]) == "") } - func testHexEncodeTruncatesAndReportsTotalSize() { + @Test func testHexEncodeTruncatesAndReportsTotalSize() { let result = OracleCellFormatting.hexEncode([UInt8](repeating: 0xab, count: 5_000)) - XCTAssertTrue(result.hasSuffix("… (5000 bytes)")) + #expect(result.hasSuffix("… (5000 bytes)")) let hexPart = result.replacingOccurrences(of: "… (5000 bytes)", with: "") - XCTAssertEqual(hexPart.count, OracleCellFormatting.maxHexBytes * 2) + #expect(hexPart.count == OracleCellFormatting.maxHexBytes * 2) } - func testUnsupportedPlaceholderEmbedsTypeName() { - XCTAssertEqual( - OracleCellFormatting.unsupportedPlaceholder(typeName: "interval year to month"), - "" - ) + @Test func testUnsupportedPlaceholderEmbedsTypeName() { + #expect(OracleCellFormatting.unsupportedPlaceholder(typeName: "interval year to month") == "") } } diff --git a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleCloseRecordTests.swift b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleCloseRecordTests.swift index 6c1deb63e3..6db270df9c 100644 --- a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleCloseRecordTests.swift +++ b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleCloseRecordTests.swift @@ -1,70 +1,71 @@ +import Foundation @testable import TableProOracleCore -import XCTest +import Testing /// Two closers reach one channel: whoever decided to close it, and the statement that was on the /// wire when it went. The second arrives as `clientClosedConnection` and knows nothing about the /// first, so it must not be able to talk the connection into redialing after the app let it go. -final class OracleCloseRecordTests: XCTestCase { - func testAFreshRecordPermitsBothRecoveries() { +struct OracleCloseRecordTests { + @Test func testAFreshRecordPermitsBothRecoveries() { var record = OracleCloseRecord() - XCTAssertTrue(record.allowsReconnect) - XCTAssertFalse(record.allowsSessionSetupReplay) + #expect(record.allowsReconnect) + #expect(!record.allowsSessionSetupReplay) record.record(.pingTimedOut) - XCTAssertTrue(record.allowsReconnect) - XCTAssertTrue(record.allowsSessionSetupReplay) + #expect(record.allowsReconnect) + #expect(record.allowsSessionSetupReplay) } - func testTheDyingStatementCannotOverwriteADeliberateTeardown() { + @Test func testTheDyingStatementCannotOverwriteADeliberateTeardown() { var record = OracleCloseRecord() record.record(.userRequested) record.record(.channelAlreadyClosed) - XCTAssertEqual(record.reason, .userRequested) - XCTAssertFalse(record.allowsSessionSetupReplay) - XCTAssertFalse(record.allowsReconnect) + #expect(record.reason == .userRequested) + #expect(!record.allowsSessionSetupReplay) + #expect(!record.allowsReconnect) } - func testATeardownArrivingSecondStillFinishesTheConnection() { + @Test func testATeardownArrivingSecondStillFinishesTheConnection() { var record = OracleCloseRecord() record.record(.transportError) record.record(.userRequested) - XCTAssertEqual(record.reason, .transportError) - XCTAssertFalse(record.allowsSessionSetupReplay) - XCTAssertFalse(record.allowsReconnect) + #expect(record.reason == .transportError) + #expect(!record.allowsSessionSetupReplay) + #expect(!record.allowsReconnect) } - func testADyingProbeCannotOverwriteAConnectDeadlineTeardown() { + @Test func testADyingProbeCannotOverwriteAConnectDeadlineTeardown() { var record = OracleCloseRecord() record.record(.connectTimedOut) record.record(.channelAlreadyClosed) - XCTAssertEqual(record.reason, .connectTimedOut) - XCTAssertFalse(record.allowsSessionSetupReplay) - XCTAssertFalse(record.allowsReconnect) + #expect(record.reason == .connectTimedOut) + #expect(!record.allowsSessionSetupReplay) + #expect(!record.allowsReconnect) } - func testCancellingAQueryDoesNotFinishTheConnection() { + @Test func testCancellingAQueryDoesNotFinishTheConnection() { var record = OracleCloseRecord() record.record(.queryCancelled) - XCTAssertTrue(record.allowsReconnect) - XCTAssertFalse(record.allowsSessionSetupReplay) + #expect(record.allowsReconnect) + #expect(!record.allowsSessionSetupReplay) } /// A connect clears the reason so the next failure reads its own, and leaves `isFinished` /// alone: the plugin never reuses a connection the app disconnected. - func testConnectingClearsTheReasonButNotTheTeardown() { + @Test func testConnectingClearsTheReasonButNotTheTeardown() { var record = OracleCloseRecord() record.record(.transportError) record.clearOnConnect() - XCTAssertNil(record.reason) - XCTAssertTrue(record.allowsReconnect) - XCTAssertFalse(record.allowsSessionSetupReplay) + #expect(record.reason == nil) + #expect(record.allowsReconnect) + #expect(!record.allowsSessionSetupReplay) record.record(.userRequested) record.clearOnConnect() - XCTAssertFalse(record.allowsReconnect) + #expect(!record.allowsReconnect) } } diff --git a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleColumnDefaultTests.swift b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleColumnDefaultTests.swift index 738c67e06c..5bd055c326 100644 --- a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleColumnDefaultTests.swift +++ b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleColumnDefaultTests.swift @@ -1,9 +1,10 @@ +import Foundation @testable import TableProOracleCore -import XCTest +import Testing /// The stored texts here are the ones `USER_TAB_COLS.DATA_DEFAULT` returned on Oracle 23ai for the DDL named beside /// each, read through OracleNIO. `scripts/probes/check-oracle-column-defaults.sh` checks the same shapes against a live server. -final class OracleColumnDefaultTests: XCTestCase { +struct OracleColumnDefaultTests { private func clause( _ storedText: String?, isIdentity: Bool = false, @@ -14,43 +15,43 @@ final class OracleColumnDefaultTests: XCTestCase { } /// `DEFAULT 7` followed by a newline and the indentation of the next line. - func testTrailingWhitespaceTheDDLLeftBehindIsTrimmed() { - XCTAssertEqual(clause("7\n "), "7") - XCTAssertEqual(clause("7 "), "7") - XCTAssertEqual(clause("''\n"), "''") + @Test func testTrailingWhitespaceTheDDLLeftBehindIsTrimmed() { + #expect(clause("7\n ") == "7") + #expect(clause("7 ") == "7") + #expect(clause("''\n") == "''") } - func testNoDefaultIsNil() { - XCTAssertNil(clause(nil)) - XCTAssertNil(clause("")) - XCTAssertNil(clause(" \n ")) + @Test func testNoDefaultIsNil() { + #expect(clause(nil) == nil) + #expect(clause("") == nil) + #expect(clause(" \n ") == nil) } /// `DEFAULT NULL` is stored as typed, so `default null` reads `null`. It is a default, unlike SQL NULL. - func testAnExplicitNullDefaultReadsAsNullWhateverItsCase() { - XCTAssertEqual(clause("null"), "NULL") - XCTAssertEqual(clause("NULL"), "NULL") - XCTAssertEqual(clause("Null\n "), "NULL") + @Test func testAnExplicitNullDefaultReadsAsNullWhateverItsCase() { + #expect(clause("null") == "NULL") + #expect(clause("NULL") == "NULL") + #expect(clause("Null\n ") == "NULL") } - func testLiteralsAndExpressionsKeepTheirSpelling() { - XCTAssertEqual(clause("'it''s'"), "'it''s'") - XCTAssertEqual(clause("sys_guid()"), "sys_guid()") - XCTAssertEqual(clause("current_timestamp"), "current_timestamp") - XCTAssertEqual(clause("-1.5"), "-1.5") - XCTAssertEqual(clause("(1 + 2)"), "(1 + 2)") - XCTAssertEqual(clause("'null'"), "'null'") + @Test func testLiteralsAndExpressionsKeepTheirSpelling() { + #expect(clause("'it''s'") == "'it''s'") + #expect(clause("sys_guid()") == "sys_guid()") + #expect(clause("current_timestamp") == "current_timestamp") + #expect(clause("-1.5") == "-1.5") + #expect(clause("(1 + 2)") == "(1 + 2)") + #expect(clause("'null'") == "'null'") } /// `GENERATED BY DEFAULT AS IDENTITY` stores the sequence it draws from. - func testAnIdentityColumnHasNoDefault() { - XCTAssertNil(clause("\"PROBE\".\"ISEQ$$_73292\".nextval", isIdentity: true)) - XCTAssertNil(clause("\"PROBE\".\"ISEQ$$_73292\".nextval", isIdentity: true, onNull: .onInsert)) + @Test func testAnIdentityColumnHasNoDefault() { + #expect(clause("\"PROBE\".\"ISEQ$$_73292\".nextval", isIdentity: true) == nil) + #expect(clause("\"PROBE\".\"ISEQ$$_73292\".nextval", isIdentity: true, onNull: .onInsert) == nil) } /// `GENERATED ALWAYS AS (c_zero + 1) VIRTUAL` stores its expression. - func testAVirtualColumnHasNoDefault() { - XCTAssertNil(clause("\"C_ZERO\"+1", isVirtual: true)) + @Test func testAVirtualColumnHasNoDefault() { + #expect(clause("\"C_ZERO\"+1", isVirtual: true) == nil) } private func generationExpression(_ storedText: String?, isIdentity: Bool = false, isVirtual: Bool) -> String? { @@ -59,65 +60,65 @@ final class OracleColumnDefaultTests: XCTestCase { } /// `a + 1 /* c */` is stored rewritten as `"A"+1`, and `UPPER('x' || TO_CHAR(a))` as `UPPER('x'||TO_CHAR("A"))`. - func testAVirtualColumnReadsItsExpression() { - XCTAssertEqual(generationExpression("\"A\"+1", isVirtual: true), "\"A\"+1") - XCTAssertEqual(generationExpression("UPPER('x'||TO_CHAR(\"A\"))\n ", isVirtual: true), "UPPER('x'||TO_CHAR(\"A\"))") - XCTAssertEqual(generationExpression("\"A\"+1 -- note\n", isVirtual: true), "\"A\"+1") - XCTAssertNil(generationExpression(" \n", isVirtual: true)) - XCTAssertNil(generationExpression(nil, isVirtual: true)) + @Test func testAVirtualColumnReadsItsExpression() { + #expect(generationExpression("\"A\"+1", isVirtual: true) == "\"A\"+1") + #expect(generationExpression("UPPER('x'||TO_CHAR(\"A\"))\n ", isVirtual: true) == "UPPER('x'||TO_CHAR(\"A\"))") + #expect(generationExpression("\"A\"+1 -- note\n", isVirtual: true) == "\"A\"+1") + #expect(generationExpression(" \n", isVirtual: true) == nil) + #expect(generationExpression(nil, isVirtual: true) == nil) } - func testOnlyAVirtualColumnHasAnExpression() { - XCTAssertNil(generationExpression("5", isVirtual: false)) - XCTAssertNil(generationExpression("\"PROBE\".\"ISEQ$$_73292\".nextval", isIdentity: true, isVirtual: false)) + @Test func testOnlyAVirtualColumnHasAnExpression() { + #expect(generationExpression("5", isVirtual: false) == nil) + #expect(generationExpression("\"PROBE\".\"ISEQ$$_73292\".nextval", isIdentity: true, isVirtual: false) == nil) } /// A sequence named in an ordinary `DEFAULT` is a default like any other. - func testASequenceDefaultOnAnOrdinaryColumnIsKept() { - XCTAssertEqual(clause("\"PROBE\".\"MY_SEQ\".\"NEXTVAL\""), "\"PROBE\".\"MY_SEQ\".\"NEXTVAL\"") + @Test func testASequenceDefaultOnAnOrdinaryColumnIsKept() { + #expect(clause("\"PROBE\".\"MY_SEQ\".\"NEXTVAL\"") == "\"PROBE\".\"MY_SEQ\".\"NEXTVAL\"") } /// Restating `'x'` alone on a `DEFAULT ON NULL` column turned the semantics off and the column nullable, measured. - func testADefaultOnNullKeepsItsOnNull() { - XCTAssertEqual(clause("'x'", onNull: .onInsert), "ON NULL 'x'") - XCTAssertEqual(clause("'u'\n ", onNull: .onInsertAndUpdate), "ON NULL FOR INSERT AND UPDATE 'u'") + @Test func testADefaultOnNullKeepsItsOnNull() { + #expect(clause("'x'", onNull: .onInsert) == "ON NULL 'x'") + #expect(clause("'u'\n ", onNull: .onInsertAndUpdate) == "ON NULL FOR INSERT AND UPDATE 'u'") } /// `default 9 -- trailing comment` with the comma on the next line keeps the comment and its newline, and the /// newline is what stops it commenting out the rest of a statement the value is written into. - func testATrailingLineCommentGoesWithTheWhitespace() { - XCTAssertEqual(clause("9 -- trailing comment\n "), "9") - XCTAssertEqual(clause("'--' -- dashes\n "), "'--'") + @Test func testATrailingLineCommentGoesWithTheWhitespace() { + #expect(clause("9 -- trailing comment\n ") == "9") + #expect(clause("'--' -- dashes\n ") == "'--'") } - func testABlockCommentGoes() { - XCTAssertEqual(clause("'A' /* active */\n "), "'A'") - XCTAssertEqual(clause("null /* none */"), "NULL") + @Test func testABlockCommentGoes() { + #expect(clause("'A' /* active */\n ") == "'A'") + #expect(clause("null /* none */") == "NULL") } /// `default 1 -- one` then `+ 2` on the next line. - func testACommentInsideAMultiLineDefaultKeepsItsNewline() { - XCTAssertEqual(clause("1 -- one\n + 2\n "), "1 \n + 2") + @Test func testACommentInsideAMultiLineDefaultKeepsItsNewline() { + #expect(clause("1 -- one\n + 2\n ") == "1 \n + 2") } - func testCommentMarkersInsideQuotesAreText() { - XCTAssertEqual(clause("q'[it's -- x]' -- real\n "), "q'[it's -- x]'") - XCTAssertEqual(clause("nq'{a''b}'\n"), "nq'{a''b}'") - XCTAssertEqual(clause("Q'!/* x */!'"), "Q'!/* x */!'") - XCTAssertEqual(clause("'a /* b */ c'"), "'a /* b */ c'") - XCTAssertEqual(clause("\"COL--NAME\" -- note\n"), "\"COL--NAME\"") + @Test func testCommentMarkersInsideQuotesAreText() { + #expect(clause("q'[it's -- x]' -- real\n ") == "q'[it's -- x]'") + #expect(clause("nq'{a''b}'\n") == "nq'{a''b}'") + #expect(clause("Q'!/* x */!'") == "Q'!/* x */!'") + #expect(clause("'a /* b */ c'") == "'a /* b */ c'") + #expect(clause("\"COL--NAME\" -- note\n") == "\"COL--NAME\"") } /// `q'` opens an alternative quote only at the start of a word; inside a longer name the quote is ordinary, so /// `'it''s'` still reads its doubled quote as an escape. - func testAQInsideANameDoesNotOpenAnAlternativeQuote() { - XCTAssertEqual(OracleSQLCommentStripper("seq'a''b -- c' -- d").stripped(), "seq'a''b -- c' ") + @Test func testAQInsideANameDoesNotOpenAnAlternativeQuote() { + #expect(OracleSQLCommentStripper("seq'a''b -- c' -- d").stripped() == "seq'a''b -- c' ") } - func testOnNullFollowsBothFlags() { - XCTAssertEqual(OracleDefaultOnNull(onInsert: false, onUpdate: false), .never) - XCTAssertEqual(OracleDefaultOnNull(onInsert: true, onUpdate: false), .onInsert) - XCTAssertEqual(OracleDefaultOnNull(onInsert: true, onUpdate: true), .onInsertAndUpdate) - XCTAssertEqual(OracleDefaultOnNull(onInsert: false, onUpdate: true), .never) + @Test func testOnNullFollowsBothFlags() { + #expect(OracleDefaultOnNull(onInsert: false, onUpdate: false) == .never) + #expect(OracleDefaultOnNull(onInsert: true, onUpdate: false) == .onInsert) + #expect(OracleDefaultOnNull(onInsert: true, onUpdate: true) == .onInsertAndUpdate) + #expect(OracleDefaultOnNull(onInsert: false, onUpdate: true) == .never) } } diff --git a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleColumnTypeNameTests.swift b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleColumnTypeNameTests.swift index 6d37af41c0..7dfd6df705 100644 --- a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleColumnTypeNameTests.swift +++ b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleColumnTypeNameTests.swift @@ -1,54 +1,55 @@ +import Foundation import OracleNIO @testable import TableProOracleCore -import XCTest +import Testing -final class OracleColumnTypeNameTests: XCTestCase { +struct OracleColumnTypeNameTests { /// An SDO_GEOMETRY or any other object column used to be labelled "unknown" in the grid header /// and in its cell placeholder. - func testObjectColumnIsNamed() { - XCTAssertEqual(OracleCellDecoding.typeName(of: .object), "object") - XCTAssertEqual(OracleCellFormatting.unsupportedPlaceholder(typeName: "object"), "") + @Test func testObjectColumnIsNamed() { + #expect(OracleCellDecoding.typeName(of: .object) == "object") + #expect(OracleCellFormatting.unsupportedPlaceholder(typeName: "object") == "") } - func testCursorColumnIsNamed() { - XCTAssertEqual(OracleCellDecoding.typeName(of: .cursor), "cursor") + @Test func testCursorColumnIsNamed() { + #expect(OracleCellDecoding.typeName(of: .cursor) == "cursor") } - func testNCLOBFetchedAsLongIsNamed() { - XCTAssertEqual(OracleCellDecoding.typeName(of: .longNVarchar), "nclob") + @Test func testNCLOBFetchedAsLongIsNamed() { + #expect(OracleCellDecoding.typeName(of: .longNVarchar) == "nclob") } /// The header names a LOB by its declared type; the driver fetches it as LONG, which named a CLOB /// `long` and a BLOB `long raw`. - func testLOBsAreNamedByTheirDeclaredType() { - XCTAssertEqual(OracleCellDecoding.typeName(of: .clob), "clob") - XCTAssertEqual(OracleCellDecoding.typeName(of: .blob), "blob") - XCTAssertEqual(OracleCellDecoding.typeName(of: .nCLOB), "nclob") + @Test func testLOBsAreNamedByTheirDeclaredType() { + #expect(OracleCellDecoding.typeName(of: .clob) == "clob") + #expect(OracleCellDecoding.typeName(of: .blob) == "blob") + #expect(OracleCellDecoding.typeName(of: .nCLOB) == "nclob") } - func testEveryTypeTheDriverDescribesHasAName() { + @Test func testEveryTypeTheDriverDescribesHasAName() { let described: [OracleDataType] = [ .bFile, .binaryDouble, .binaryFloat, .binaryInteger, .blob, .boolean, .char, .clob, .cursor, .date, .intervalDS, .intervalYM, .json, .long, .longNVarchar, .longRAW, .nChar, .nCLOB, .number, .nVarchar, .object, .raw, .ref, .rowID, .timestamp, .timestampLTZ, .timestampTZ, .uRowID, .varchar, .vector, ] for type in described { - XCTAssertNotEqual(OracleCellDecoding.typeName(of: type), "unknown", "\(type)") + #expect(OracleCellDecoding.typeName(of: type) != "unknown", "\(type)") } - XCTAssertEqual(OracleCellDecoding.typeName(of: .uRowID), "urowid") - XCTAssertEqual(OracleCellDecoding.typeName(of: .ref), "ref") + #expect(OracleCellDecoding.typeName(of: .uRowID) == "urowid") + #expect(OracleCellDecoding.typeName(of: .ref) == "ref") } /// A datetime column's scale is its fractional-second digits; the describe reports -127 for a column /// that has no scale, which is not a datetime scale. - func testFractionDigitsComeFromTheScale() { - XCTAssertEqual(OracleResultColumn(name: "TS", dataType: .timestamp, scale: 6).fractionDigits, 6) - XCTAssertEqual(OracleResultColumn(name: "TS", dataType: .timestamp, scale: 0).fractionDigits, 0) - XCTAssertEqual(OracleResultColumn(name: "N", dataType: .number, scale: -127).fractionDigits, 9) + @Test func testFractionDigitsComeFromTheScale() { + #expect(OracleResultColumn(name: "TS", dataType: .timestamp, scale: 6).fractionDigits == 6) + #expect(OracleResultColumn(name: "TS", dataType: .timestamp, scale: 0).fractionDigits == 0) + #expect(OracleResultColumn(name: "N", dataType: .number, scale: -127).fractionDigits == 9) } - func testDescriptorUsesTheDeclaredTypeName() { + @Test func testDescriptorUsesTheDeclaredTypeName() { let column = OracleResultColumn(name: "NOTE", dataType: .clob, scale: 0) - XCTAssertEqual(column.descriptor, OracleColumnDescriptor(name: "NOTE", typeName: "clob")) + #expect(column.descriptor == OracleColumnDescriptor(name: "NOTE", typeName: "clob")) } } diff --git a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleColumnTypeSpellingTests.swift b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleColumnTypeSpellingTests.swift index 631a7b9f0e..a8e890516e 100644 --- a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleColumnTypeSpellingTests.swift +++ b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleColumnTypeSpellingTests.swift @@ -1,9 +1,10 @@ +import Foundation @testable import TableProOracleCore -import XCTest +import Testing /// Each dictionary row here is what `USER_TAB_COLS` reported on Oracle 23ai (AL32UTF8, AL16UTF16) for the column /// declared beside it, and each expected spelling recreated that same row through `CREATE TABLE` (measured). -final class OracleColumnTypeSpellingTests: XCTestCase { +struct OracleColumnTypeSpellingTests { private struct Shape { let declared: String let row: OracleColumnTypeSpelling @@ -35,13 +36,13 @@ final class OracleColumnTypeSpellingTests: XCTestCase { ) } - private func assertShapes(_ shapes: [Shape], file: StaticString = #filePath, line: UInt = #line) { + private func assertShapes(_ shapes: [Shape], sourceLocation: SourceLocation = #_sourceLocation) { for shape in shapes { - XCTAssertEqual(shape.row.declaration, shape.spelling, "declared \(shape.declared)", file: file, line: line) + #expect(shape.row.declaration == shape.spelling, "declared \(shape.declared)", sourceLocation: sourceLocation) } } - func testCharacterTypesSpellTheirDeclaredLength() { + @Test func testCharacterTypesSpellTheirDeclaredLength() { assertShapes([ Shape(declared: "NVARCHAR2(100)", row: row("NVARCHAR2", length: 200, charLength: 100, charUsed: "C"), spelling: "NVARCHAR2(100)"), Shape(declared: "NCHAR(10)", row: row("NCHAR", length: 20, charLength: 10, charUsed: "C"), spelling: "NCHAR(10)"), @@ -57,7 +58,7 @@ final class OracleColumnTypeSpellingTests: XCTestCase { } /// `NUMBER(*,s)` and `INTEGER` report no precision and keep their scale; a negative scale is kept. - func testNumericTypesKeepStarPrecisionAndNegativeScale() { + @Test func testNumericTypesKeepStarPrecisionAndNegativeScale() { assertShapes([ Shape(declared: "NUMBER", row: row("NUMBER", length: 22, charLength: 0), spelling: "NUMBER"), Shape(declared: "NUMBER(*)", row: row("NUMBER", length: 22, charLength: 0), spelling: "NUMBER"), @@ -77,7 +78,7 @@ final class OracleColumnTypeSpellingTests: XCTestCase { } /// `DATA_TYPE` already carries the fractional and leading precisions, with the defaults filled in. - func testDatetimeAndIntervalTypesAreTheirDataTypeVerbatim() { + @Test func testDatetimeAndIntervalTypesAreTheirDataTypeVerbatim() { assertShapes([ Shape(declared: "DATE", row: row("DATE", length: 7), spelling: "DATE"), Shape(declared: "TIMESTAMP", row: row("TIMESTAMP(6)", length: 11, scale: 6), spelling: "TIMESTAMP(6)"), @@ -105,7 +106,7 @@ final class OracleColumnTypeSpellingTests: XCTestCase { ]) } - func testBinaryRowidAndLargeObjectTypes() { + @Test func testBinaryRowidAndLargeObjectTypes() { assertShapes([ Shape(declared: "RAW(16)", row: row("RAW", length: 16), spelling: "RAW(16)"), Shape(declared: "UROWID", row: row("UROWID", length: 4_000), spelling: "UROWID"), @@ -124,7 +125,7 @@ final class OracleColumnTypeSpellingTests: XCTestCase { } /// `VECTOR_INFO` spells every part; `DENSE` and trailing `*` are the defaults a declaration leaves out. - func testVectorSpellsItsVectorInfo() { + @Test func testVectorSpellsItsVectorInfo() { let vector = { (info: String, dimensions: Int) in self.row("VECTOR", length: 8_200, charLength: dimensions, vectorInfo: info) } assertShapes([ Shape(declared: "VECTOR", row: vector("VECTOR(*,*,DENSE)", 0), spelling: "VECTOR"), @@ -144,19 +145,19 @@ final class OracleColumnTypeSpellingTests: XCTestCase { } /// Before 23.4 the query projects `NULL` for `VECTOR_INFO`, and `CHAR_LENGTH` still carries the dimension count. - func testVectorWithoutVectorInfoKeepsTheDimensionCount() { - XCTAssertEqual(row("VECTOR", length: 8_200, charLength: 3).declaration, "VECTOR(3, *)") - XCTAssertEqual(row("VECTOR", length: 8_200, charLength: 0).declaration, "VECTOR") - XCTAssertEqual(row("VECTOR", length: 8_200).declaration, "VECTOR") + @Test func testVectorWithoutVectorInfoKeepsTheDimensionCount() { + #expect(row("VECTOR", length: 8_200, charLength: 3).declaration == "VECTOR(3, *)") + #expect(row("VECTOR", length: 8_200, charLength: 0).declaration == "VECTOR") + #expect(row("VECTOR", length: 8_200).declaration == "VECTOR") } - func testAnUnreadableVectorInfoFallsBackToTheDimensionCount() { - XCTAssertEqual(row("VECTOR", charLength: 3, vectorInfo: "VECTOR").declaration, "VECTOR(3, *)") - XCTAssertEqual(row("VECTOR", charLength: 3, vectorInfo: "VECTOR(3,,DENSE)").declaration, "VECTOR(3, *)") + @Test func testAnUnreadableVectorInfoFallsBackToTheDimensionCount() { + #expect(row("VECTOR", charLength: 3, vectorInfo: "VECTOR").declaration == "VECTOR(3, *)") + #expect(row("VECTOR", charLength: 3, vectorInfo: "VECTOR(3,,DENSE)").declaration == "VECTOR(3, *)") } /// `PUBLIC` owns `XMLTYPE` declared plainly; `SYS.XMLTYPE` and `SYS.ANYDATA` report `SYS`. - func testATypeOfAnotherSchemaIsQualifiedWithItsOwner() { + @Test func testATypeOfAnotherSchemaIsQualifiedWithItsOwner() { assertShapes([ Shape(declared: "XMLTYPE", row: row("XMLTYPE", length: 2_000, owner: "PUBLIC"), spelling: "XMLTYPE"), Shape(declared: "SYS.XMLTYPE", row: row("XMLTYPE", length: 2_000, owner: "SYS"), spelling: "\"SYS\".\"XMLTYPE\""), @@ -167,11 +168,11 @@ final class OracleColumnTypeSpellingTests: XCTestCase { ]) } - func testAnOwnerOrTypeNameWithAQuoteIsEscaped() { - XCTAssertEqual(row("T\"X", length: 1, owner: "O\"WN").declaration, "\"O\"\"WN\".\"T\"\"X\"") + @Test func testAnOwnerOrTypeNameWithAQuoteIsEscaped() { + #expect(row("T\"X", length: 1, owner: "O\"WN").declaration == "\"O\"\"WN\".\"T\"\"X\"") } - func testARefNamesTheTypeItPointsTo() { + @Test func testARefNamesTheTypeItPointsTo() { assertShapes([ Shape(declared: "REF S_OBJ", row: row("S_OBJ", length: 50, owner: "PROBE", modifier: "REF"), spelling: "REF S_OBJ"), Shape(declared: "REF HR.ADDRESS_T", row: row("ADDRESS_T", length: 50, owner: "HR", modifier: "REF"), spelling: "REF \"HR\".\"ADDRESS_T\""), @@ -181,55 +182,55 @@ final class OracleColumnTypeSpellingTests: XCTestCase { /// A type created under a quoted mixed-case name only resolves under that exact name, so it is never case-folded /// and is quoted when an unquoted name would read as another. - func testATypeNameIsNeverCaseFolded() { - XCTAssertEqual(row("S_Mixed", length: 1, owner: "PROBE").declaration, "\"S_Mixed\"") - XCTAssertEqual(row("S_Mixed", length: 1, owner: "PROBE").dataType, "S_Mixed") - XCTAssertEqual(row("TYPE WITH SPACE", length: 1, owner: "PROBE").declaration, "\"TYPE WITH SPACE\"") + @Test func testATypeNameIsNeverCaseFolded() { + #expect(row("S_Mixed", length: 1, owner: "PROBE").declaration == "\"S_Mixed\"") + #expect(row("S_Mixed", length: 1, owner: "PROBE").dataType == "S_Mixed") + #expect(row("TYPE WITH SPACE", length: 1, owner: "PROBE").declaration == "\"TYPE WITH SPACE\"") } /// A built-in name is a keyword, so a lowercase one is still recognised, and the case it came in is kept. - func testABuiltInNameIsRecognisedInAnyCase() { - XCTAssertEqual(row("number", precision: 10, scale: 2).declaration, "number(10,2)") - XCTAssertEqual(row("nvarchar2", length: 20, charLength: 10, charUsed: "C").declaration, "nvarchar2(10)") + @Test func testABuiltInNameIsRecognisedInAnyCase() { + #expect(row("number", precision: 10, scale: 2).declaration == "number(10,2)") + #expect(row("nvarchar2", length: 20, charLength: 10, charUsed: "C").declaration == "nvarchar2(10)") } - func testMissingLengthsLeaveTheBareTypeName() { - XCTAssertEqual(row("VARCHAR2").declaration, "VARCHAR2") - XCTAssertEqual(row("NVARCHAR2").declaration, "NVARCHAR2") - XCTAssertEqual(row("RAW").declaration, "RAW") - XCTAssertEqual(row("FLOAT").declaration, "FLOAT") - XCTAssertEqual(row("UROWID").declaration, "UROWID") + @Test func testMissingLengthsLeaveTheBareTypeName() { + #expect(row("VARCHAR2").declaration == "VARCHAR2") + #expect(row("NVARCHAR2").declaration == "NVARCHAR2") + #expect(row("RAW").declaration == "RAW") + #expect(row("FLOAT").declaration == "FLOAT") + #expect(row("UROWID").declaration == "UROWID") } /// `DATA_LENGTH` is the byte count, so it stands in for a missing `CHAR_LENGTH` only under byte semantics. - func testDataLengthStandsInForCharLengthOnlyUnderByteSemantics() { - XCTAssertEqual(row("VARCHAR2", length: 50, charUsed: "B").declaration, "VARCHAR2(50 BYTE)") - XCTAssertEqual(row("VARCHAR2", length: 200, charUsed: "C").declaration, "VARCHAR2") + @Test func testDataLengthStandsInForCharLengthOnlyUnderByteSemantics() { + #expect(row("VARCHAR2", length: 50, charUsed: "B").declaration == "VARCHAR2(50 BYTE)") + #expect(row("VARCHAR2", length: 200, charUsed: "C").declaration == "VARCHAR2") } - func testClassificationTypeName() { - XCTAssertEqual(row("DATE", length: 7).classificationTypeName, "TIMESTAMP(0)") - XCTAssertEqual(row("XMLTYPE", owner: "SYS").classificationTypeName, "XMLTYPE") - XCTAssertEqual(row("SDO_GEOMETRY", owner: "MDSYS").classificationTypeName, "SDO_GEOMETRY") - XCTAssertEqual(row("S_OBJ", owner: "PROBE", modifier: "REF").classificationTypeName, "S_OBJ") - XCTAssertEqual(row("S_Mixed", owner: "PROBE").classificationTypeName, "S_Mixed") - XCTAssertNil(row("XMLTYPE", owner: "PUBLIC").classificationTypeName) - XCTAssertNil(row("S_OBJ", owner: "PROBE").classificationTypeName) - XCTAssertNil(row("TIMESTAMP(0)", scale: 0).classificationTypeName) - XCTAssertNil(row("NUMBER", precision: 10, scale: 0).classificationTypeName) - XCTAssertNil(row("NVARCHAR2", length: 200, charLength: 100, charUsed: "C").classificationTypeName) + @Test func testClassificationTypeName() { + #expect(row("DATE", length: 7).classificationTypeName == "TIMESTAMP(0)") + #expect(row("XMLTYPE", owner: "SYS").classificationTypeName == "XMLTYPE") + #expect(row("SDO_GEOMETRY", owner: "MDSYS").classificationTypeName == "SDO_GEOMETRY") + #expect(row("S_OBJ", owner: "PROBE", modifier: "REF").classificationTypeName == "S_OBJ") + #expect(row("S_Mixed", owner: "PROBE").classificationTypeName == "S_Mixed") + #expect(row("XMLTYPE", owner: "PUBLIC").classificationTypeName == nil) + #expect(row("S_OBJ", owner: "PROBE").classificationTypeName == nil) + #expect(row("TIMESTAMP(0)", scale: 0).classificationTypeName == nil) + #expect(row("NUMBER", precision: 10, scale: 0).classificationTypeName == nil) + #expect(row("NVARCHAR2", length: 200, charLength: 100, charUsed: "C").classificationTypeName == nil) } /// `CHAR_LENGTH` is also a `VECTOR`'s dimension count and 0 for a `NUMBER`, neither of which is a length. - func testCharacterLengthIsOnlyForTheFourCharacterTypes() { - XCTAssertEqual(row("VARCHAR2", length: 50, charLength: 50, charUsed: "B").characterLength, 50) - XCTAssertEqual(row("VARCHAR2", length: 200, charLength: 50, charUsed: "C").characterLength, 50) - XCTAssertEqual(row("CHAR", length: 40, charLength: 10, charUsed: "C").characterLength, 10) - XCTAssertEqual(row("NVARCHAR2", length: 200, charLength: 100, charUsed: "C").characterLength, 100) - XCTAssertEqual(row("NCHAR", length: 2, charLength: 1, charUsed: "C").characterLength, 1) - XCTAssertNil(row("VECTOR", length: 8_200, charLength: 3, vectorInfo: "VECTOR(3,FLOAT32,DENSE)").characterLength) - XCTAssertNil(row("NUMBER", length: 22, charLength: 0).characterLength) - XCTAssertNil(row("RAW", length: 16).characterLength) - XCTAssertNil(row("CLOB", length: 4_000).characterLength) + @Test func testCharacterLengthIsOnlyForTheFourCharacterTypes() { + #expect(row("VARCHAR2", length: 50, charLength: 50, charUsed: "B").characterLength == 50) + #expect(row("VARCHAR2", length: 200, charLength: 50, charUsed: "C").characterLength == 50) + #expect(row("CHAR", length: 40, charLength: 10, charUsed: "C").characterLength == 10) + #expect(row("NVARCHAR2", length: 200, charLength: 100, charUsed: "C").characterLength == 100) + #expect(row("NCHAR", length: 2, charLength: 1, charUsed: "C").characterLength == 1) + #expect(row("VECTOR", length: 8_200, charLength: 3, vectorInfo: "VECTOR(3,FLOAT32,DENSE)").characterLength == nil) + #expect(row("NUMBER", length: 22, charLength: 0).characterLength == nil) + #expect(row("RAW", length: 16).characterLength == nil) + #expect(row("CLOB", length: 4_000).characterLength == nil) } } diff --git a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleConnectDeadlineTests.swift b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleConnectDeadlineTests.swift index 810eb93b4a..528ae0bc60 100644 --- a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleConnectDeadlineTests.swift +++ b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleConnectDeadlineTests.swift @@ -1,26 +1,18 @@ import Foundation @testable import TableProOracleCore -import XCTest +import Testing -final class OracleConnectDeadlineTests: XCTestCase { - func testEverySetupPhaseReadsTheSameAbsoluteDeadline() { +struct OracleConnectDeadlineTests { + @Test func testEverySetupPhaseReadsTheSameAbsoluteDeadline() { let started: ContinuousClock.Instant = .now let deadline = OracleConnectDeadline(seconds: 60, now: started) - XCTAssertEqual(deadline.remainingSeconds(at: started), 60, accuracy: 1e-6) - XCTAssertEqual( - deadline.remainingSeconds(at: started.advanced(by: .seconds(7))), - 53, - accuracy: 1e-6 - ) - XCTAssertEqual( - deadline.remainingSeconds(at: started.advanced(by: .seconds(19))), - 41, - accuracy: 1e-6 - ) + #expect(abs(deadline.remainingSeconds(at: started) - (60)) <= 1e-6) + #expect(abs((deadline.remainingSeconds(at: started.advanced(by: .seconds(7)))) - (53)) <= 1e-6) + #expect(abs((deadline.remainingSeconds(at: started.advanced(by: .seconds(19)))) - (41)) <= 1e-6) } - func testAnExpiredDeadlineRefusesToStartAnotherProbe() async { + @Test func testAnExpiredDeadlineRefusesToStartAnotherProbe() async { let started: ContinuousClock.Instant = .now let deadline = OracleConnectDeadline(seconds: 1, now: started) let probes = InvocationCounter() @@ -39,11 +31,11 @@ final class OracleConnectDeadlineTests: XCTestCase { probes.increment() return "should not run" } - XCTFail("an expired connect deadline should refuse the next probe") + Issue.record("an expired connect deadline should refuse the next probe") } catch { - XCTAssertEqual(error as? OracleCoreError, .connectTimedOut) + #expect(error as? OracleCoreError == .connectTimedOut) } - XCTAssertEqual(probes.value, 0) + #expect(probes.value == 0) } } diff --git a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleConnectErrorClassifierTests.swift b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleConnectErrorClassifierTests.swift index 7fcdcaeb73..7741fa5978 100644 --- a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleConnectErrorClassifierTests.swift +++ b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleConnectErrorClassifierTests.swift @@ -1,98 +1,93 @@ +import Foundation @testable import TableProOracleCore -import XCTest +import Testing -final class OracleConnectErrorClassifierTests: XCTestCase { - func testVerifierPrefixIsClassifiedWithItsFlag() { +struct OracleConnectErrorClassifierTests { + @Test func testVerifierPrefixIsClassifiedWithItsFlag() { let failure = OracleConnectErrorClassifier.classify("unsupportedVerifierType(0x12)") - XCTAssertEqual(failure, .verifierUnsupported(flag: "unsupportedVerifierType(0x12)")) + #expect(failure == .verifierUnsupported(flag: "unsupportedVerifierType(0x12)")) } - func testKnownCodesMapToTheirFailures() { - XCTAssertEqual(OracleConnectErrorClassifier.classify("uncleanShutdown"), .connectionDropped) - XCTAssertEqual(OracleConnectErrorClassifier.classify("serverVersionNotSupported"), .versionNotSupported) - XCTAssertEqual(OracleConnectErrorClassifier.classify("advancedNegotiationFailed"), .advancedNegotiationFailed) - XCTAssertEqual( - OracleConnectErrorClassifier.classify("advancedNegotiationRequired"), - .advancedNegotiationRequired - ) - XCTAssertEqual( - OracleConnectErrorClassifier.classify("loginHandshakeTimedOut"), - .loginHandshakeTimedOut - ) + @Test func testKnownCodesMapToTheirFailures() { + #expect(OracleConnectErrorClassifier.classify("uncleanShutdown") == .connectionDropped) + #expect(OracleConnectErrorClassifier.classify("serverVersionNotSupported") == .versionNotSupported) + #expect(OracleConnectErrorClassifier.classify("advancedNegotiationFailed") == .advancedNegotiationFailed) + #expect(OracleConnectErrorClassifier.classify("advancedNegotiationRequired") == .advancedNegotiationRequired) + #expect(OracleConnectErrorClassifier.classify("loginHandshakeTimedOut") == .loginHandshakeTimedOut) } - func testStalledLoginIsNotBlamedOnEncryption() { - XCTAssertFalse(OracleConnectErrorClassifier.isLikelyNativeEncryptionFailure( + @Test func testStalledLoginIsNotBlamedOnEncryption() { + #expect(!OracleConnectErrorClassifier.isLikelyNativeEncryptionFailure( failure: .loginHandshakeTimedOut, nativeNetworkEncryptionEnabled: true, timedOut: true )) } - func testRequiredNegotiationIsAnEncryptionSignal() { - XCTAssertTrue(OracleConnectErrorClassifier.isLikelyNativeEncryptionFailure( + @Test func testRequiredNegotiationIsAnEncryptionSignal() { + #expect(OracleConnectErrorClassifier.isLikelyNativeEncryptionFailure( failure: .advancedNegotiationRequired, nativeNetworkEncryptionEnabled: true, timedOut: false )) } - func testUnknownCodeFallsBackToConnectionFailed() { - XCTAssertEqual(OracleConnectErrorClassifier.classify("somethingElse"), .connectionFailed) + @Test func testUnknownCodeFallsBackToConnectionFailed() { + #expect(OracleConnectErrorClassifier.classify("somethingElse") == .connectionFailed) } - func testAdvancedNegotiationIsAlwaysANativeEncryptionSignal() { - XCTAssertTrue(OracleConnectErrorClassifier.isLikelyNativeEncryptionFailure( + @Test func testAdvancedNegotiationIsAlwaysANativeEncryptionSignal() { + #expect(OracleConnectErrorClassifier.isLikelyNativeEncryptionFailure( failure: .advancedNegotiationFailed, nativeNetworkEncryptionEnabled: true, timedOut: false )) } - func testDroppedConnectionCountsOnlyWhenItTimedOut() { - XCTAssertFalse(OracleConnectErrorClassifier.isLikelyNativeEncryptionFailure( + @Test func testDroppedConnectionCountsOnlyWhenItTimedOut() { + #expect(!OracleConnectErrorClassifier.isLikelyNativeEncryptionFailure( failure: .connectionDropped, nativeNetworkEncryptionEnabled: true, timedOut: false )) - XCTAssertTrue(OracleConnectErrorClassifier.isLikelyNativeEncryptionFailure( + #expect(OracleConnectErrorClassifier.isLikelyNativeEncryptionFailure( failure: .connectionDropped, nativeNetworkEncryptionEnabled: true, timedOut: true )) } - func testAuthFailuresAreNeverEncryptionFailures() { - XCTAssertFalse(OracleConnectErrorClassifier.isLikelyNativeEncryptionFailure( + @Test func testAuthFailuresAreNeverEncryptionFailures() { + #expect(!OracleConnectErrorClassifier.isLikelyNativeEncryptionFailure( failure: .versionNotSupported, nativeNetworkEncryptionEnabled: true, timedOut: true )) - XCTAssertFalse(OracleConnectErrorClassifier.isLikelyNativeEncryptionFailure( + #expect(!(OracleConnectErrorClassifier.isLikelyNativeEncryptionFailure( failure: .verifierUnsupported(flag: "x"), nativeNetworkEncryptionEnabled: true, timedOut: true - )) + ))) } - func testNothingCountsWhenEncryptionIsDisabled() { - XCTAssertFalse(OracleConnectErrorClassifier.isLikelyNativeEncryptionFailure( + @Test func testNothingCountsWhenEncryptionIsDisabled() { + #expect(!OracleConnectErrorClassifier.isLikelyNativeEncryptionFailure( failure: .advancedNegotiationFailed, nativeNetworkEncryptionEnabled: false, timedOut: true )) } - func testChannelFatalCodesResetTheConnection() { - XCTAssertTrue(OracleChannelFatalCode.isChannelFatal("connectionError")) - XCTAssertTrue(OracleChannelFatalCode.isChannelFatal("messageDecodingFailure")) - XCTAssertTrue(OracleChannelFatalCode.isChannelFatal("unexpectedBackendMessage")) - XCTAssertFalse(OracleChannelFatalCode.isChannelFatal("statementError")) + @Test func testChannelFatalCodesResetTheConnection() { + #expect(OracleChannelFatalCode.isChannelFatal("connectionError")) + #expect(OracleChannelFatalCode.isChannelFatal("messageDecodingFailure")) + #expect(OracleChannelFatalCode.isChannelFatal("unexpectedBackendMessage")) + #expect(!OracleChannelFatalCode.isChannelFatal("statementError")) } /// The table mirrors OracleNIO's own `ConnectionStateMachine.shouldCloseConnection(reason:)`, /// which is internal and so cannot be called. Every case it names is pinned here. - func testChannelFatalTableMirrorsOracleNIO() { + @Test func testChannelFatalTableMirrorsOracleNIO() { for code in [ "clientClosesConnection", "clientClosedConnection", @@ -112,43 +107,40 @@ final class OracleConnectErrorClassifierTests: XCTestCase { "loginHandshakeTimedOut", "nationalCharsetNotSupported" ] { - XCTAssertTrue(OracleChannelFatalCode.isChannelFatal(code), code) + #expect(OracleChannelFatalCode.isChannelFatal(code), "\(code)") } for code in ["statementCancelled", "missingStatement", "malformedStatement"] { - XCTAssertFalse(OracleChannelFatalCode.isChannelFatal(code), code) + #expect(!OracleChannelFatalCode.isChannelFatal(code), "\(code)") } } /// ORA-28 is the session being killed and ORA-600 an internal error; OracleNIO closes the /// channel on both and on no other server error. - func testServerErrorsAreFatalOnlyForKilledSessions() { - XCTAssertTrue(OracleChannelFatalCode.isChannelFatal("server", serverErrorNumber: 28)) - XCTAssertTrue(OracleChannelFatalCode.isChannelFatal("server", serverErrorNumber: 600)) - XCTAssertFalse(OracleChannelFatalCode.isChannelFatal("server", serverErrorNumber: 942)) - XCTAssertFalse(OracleChannelFatalCode.isChannelFatal("server")) + @Test func testServerErrorsAreFatalOnlyForKilledSessions() { + #expect(OracleChannelFatalCode.isChannelFatal("server", serverErrorNumber: 28)) + #expect(OracleChannelFatalCode.isChannelFatal("server", serverErrorNumber: 600)) + #expect(!OracleChannelFatalCode.isChannelFatal("server", serverErrorNumber: 942)) + #expect(!OracleChannelFatalCode.isChannelFatal("server")) } /// A lost socket must not be reported as the server sending something the driver could not /// read: `uncleanShutdown` and `connectionError` are the transport going away, and /// `OracleConnectErrorClassifier` already calls the first of them a dropped connection. - func testClosuresAreToldApartByWhatTookTheChannel() { - XCTAssertEqual(OracleChannelFatalCode.closureKind("clientClosedConnection"), .clientClose) - XCTAssertEqual(OracleChannelFatalCode.closureKind("clientClosesConnection"), .clientClose) - XCTAssertEqual(OracleChannelFatalCode.closureKind("uncleanShutdown"), .transportLoss) - XCTAssertEqual(OracleChannelFatalCode.closureKind("connectionError"), .transportLoss) - XCTAssertEqual(OracleChannelFatalCode.closureKind("messageDecodingFailure"), .protocolFailure) - XCTAssertEqual(OracleChannelFatalCode.closureKind("unexpectedBackendMessage"), .protocolFailure) + @Test func testClosuresAreToldApartByWhatTookTheChannel() { + #expect(OracleChannelFatalCode.closureKind("clientClosedConnection") == .clientClose) + #expect(OracleChannelFatalCode.closureKind("clientClosesConnection") == .clientClose) + #expect(OracleChannelFatalCode.closureKind("uncleanShutdown") == .transportLoss) + #expect(OracleChannelFatalCode.closureKind("connectionError") == .transportLoss) + #expect(OracleChannelFatalCode.closureKind("messageDecodingFailure") == .protocolFailure) + #expect(OracleChannelFatalCode.closureKind("unexpectedBackendMessage") == .protocolFailure) } - func testTLSClassifierRecognizesOracleWalletAndCipherErrors() { - XCTAssertEqual(OracleSSLClassifier.classifyTLSFailure("ORA-28759: failure to open file"), .clientCertRequired) - XCTAssertEqual(OracleSSLClassifier.classifyTLSFailure("ORA-29024: Certificate validation failure"), .cipherMismatch) - XCTAssertEqual(OracleSSLClassifier.classifyTLSFailure("ORA-28860: Fatal SSL error"), .cipherMismatch) - XCTAssertEqual( - OracleSSLClassifier.classifyTLSFailure("certificate verify failed: untrusted root"), - .untrustedCertificate - ) - XCTAssertNil(OracleSSLClassifier.classifyTLSFailure("connection reset by peer")) + @Test func testTLSClassifierRecognizesOracleWalletAndCipherErrors() { + #expect(OracleSSLClassifier.classifyTLSFailure("ORA-28759: failure to open file") == .clientCertRequired) + #expect(OracleSSLClassifier.classifyTLSFailure("ORA-29024: Certificate validation failure") == .cipherMismatch) + #expect(OracleSSLClassifier.classifyTLSFailure("ORA-28860: Fatal SSL error") == .cipherMismatch) + #expect(OracleSSLClassifier.classifyTLSFailure("certificate verify failed: untrusted root") == .untrustedCertificate) + #expect(OracleSSLClassifier.classifyTLSFailure("connection reset by peer") == nil) } } diff --git a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleConnectionOptionsTests.swift b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleConnectionOptionsTests.swift index 1c3ac42fc3..8714ef6a71 100644 --- a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleConnectionOptionsTests.swift +++ b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleConnectionOptionsTests.swift @@ -1,74 +1,69 @@ +import Foundation @testable import TableProOracleCore -import XCTest +import Testing -final class OracleConnectionOptionsTests: XCTestCase { - func testAdditionalFieldKeysMatchTheMacKeysExactly() { - XCTAssertEqual(OracleConnectionOptions.AdditionalFieldKey.connectionType, "oracleConnectionType") - XCTAssertEqual(OracleConnectionOptions.AdditionalFieldKey.serviceName, "oracleServiceName") - XCTAssertEqual(OracleConnectionOptions.AdditionalFieldKey.sid, "oracleSID") +struct OracleConnectionOptionsTests { + @Test func testAdditionalFieldKeysMatchTheMacKeysExactly() { + #expect(OracleConnectionOptions.AdditionalFieldKey.connectionType == "oracleConnectionType") + #expect(OracleConnectionOptions.AdditionalFieldKey.serviceName == "oracleServiceName") + #expect(OracleConnectionOptions.AdditionalFieldKey.sid == "oracleSID") } - func testIdentifierModeRawValuesMatchTheMacValues() { - XCTAssertEqual(OracleConnectionOptions.IdentifierMode.service.rawValue, "service") - XCTAssertEqual(OracleConnectionOptions.IdentifierMode.sid.rawValue, "sid") + @Test func testIdentifierModeRawValuesMatchTheMacValues() { + #expect(OracleConnectionOptions.IdentifierMode.service.rawValue == "service") + #expect(OracleConnectionOptions.IdentifierMode.sid.rawValue == "sid") } - func testIdentifierModeDefaultsToServiceWhenAbsent() { - XCTAssertEqual(OracleConnectionOptions.identifierMode(from: [:]), .service) + @Test func testIdentifierModeDefaultsToServiceWhenAbsent() { + #expect(OracleConnectionOptions.identifierMode(from: [:]) == .service) } - func testIdentifierModeReadsSID() { + @Test func testIdentifierModeReadsSID() { let fields = [OracleConnectionOptions.AdditionalFieldKey.connectionType: "sid"] - XCTAssertEqual(OracleConnectionOptions.identifierMode(from: fields), .sid) + #expect(OracleConnectionOptions.identifierMode(from: fields) == .sid) } - func testIdentifierModeFallsBackToServiceForUnknownValue() { + @Test func testIdentifierModeFallsBackToServiceForUnknownValue() { let fields = [OracleConnectionOptions.AdditionalFieldKey.connectionType: "tns"] - XCTAssertEqual(OracleConnectionOptions.identifierMode(from: fields), .service) + #expect(OracleConnectionOptions.identifierMode(from: fields) == .service) } - func testIdentifierUsesServiceNameInServiceMode() { + @Test func testIdentifierUsesServiceNameInServiceMode() { let options = makeOptions(identifierMode: .service, serviceName: "ORCL", sid: "XE", database: "DB") - XCTAssertEqual(options.identifier, "ORCL") + #expect(options.identifier == "ORCL") } - func testIdentifierUsesSIDInSIDMode() { + @Test func testIdentifierUsesSIDInSIDMode() { let options = makeOptions(identifierMode: .sid, serviceName: "ORCL", sid: "XE", database: "DB") - XCTAssertEqual(options.identifier, "XE") + #expect(options.identifier == "XE") } - func testIdentifierFallsBackToDatabaseWhenChosenFieldIsEmpty() { + @Test func testIdentifierFallsBackToDatabaseWhenChosenFieldIsEmpty() { let serviceMode = makeOptions(identifierMode: .service, serviceName: "", sid: "XE", database: "DB") - XCTAssertEqual(serviceMode.identifier, "DB") + #expect(serviceMode.identifier == "DB") let sidMode = makeOptions(identifierMode: .sid, serviceName: "ORCL", sid: "", database: "DB") - XCTAssertEqual(sidMode.identifier, "DB") + #expect(sidMode.identifier == "DB") } - func testDefaultPortIsOracleListenerPort() { - XCTAssertEqual(OracleConnectionOptions.defaultPort, 1_521) + @Test func testDefaultPortIsOracleListenerPort() { + #expect(OracleConnectionOptions.defaultPort == 1_521) } - func testRemainingMillisecondConnectBudgetTakesPrecedence() { + @Test func testRemainingMillisecondConnectBudgetTakesPrecedence() { let fields = [ OracleConnectionOptions.AdditionalFieldKey.connectTimeoutMilliseconds: "1250", OracleConnectionOptions.AdditionalFieldKey.connectTimeoutSeconds: "30" ] - XCTAssertEqual(OracleConnectionOptions.loginTimeoutSeconds(from: fields), 1.25) + #expect(OracleConnectionOptions.loginTimeoutSeconds(from: fields) == 1.25) } - func testConfiguredConnectSecondsAndOldHostDefault() { - XCTAssertEqual( - OracleConnectionOptions.loginTimeoutSeconds(from: [ - OracleConnectionOptions.AdditionalFieldKey.connectTimeoutSeconds: "2.5" - ]), - 2.5 - ) - XCTAssertEqual( - OracleConnectionOptions.loginTimeoutSeconds(from: [:]), - OracleConnectionOptions.defaultLoginTimeoutSeconds - ) + @Test func testConfiguredConnectSecondsAndOldHostDefault() { + #expect(OracleConnectionOptions.loginTimeoutSeconds(from: [ + OracleConnectionOptions.AdditionalFieldKey.connectTimeoutSeconds: "2.5" + ]) == 2.5) + #expect(OracleConnectionOptions.loginTimeoutSeconds(from: [:]) == OracleConnectionOptions.defaultLoginTimeoutSeconds) } private func makeOptions( diff --git a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleCoreErrorMessageTests.swift b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleCoreErrorMessageTests.swift index 3c57412a32..bb39f0277e 100644 --- a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleCoreErrorMessageTests.swift +++ b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleCoreErrorMessageTests.swift @@ -1,10 +1,11 @@ +import Foundation @testable import TableProOracleCore -import XCTest +import Testing /// OracleNIO documents its own errors as unfit to show: "These errors should not be forwareded to /// the end user, as they may leak sensitive information." One reached an alert as /// `OracleSQLError(code: clientClosedConnection, triggeredFromRequestInFile: ...)`. -final class OracleCoreErrorMessageTests: XCTestCase { +struct OracleCoreErrorMessageTests { private let cases: [OracleCoreError] = [ .notConnected, .connectionFailed("listener refused the connection"), @@ -27,30 +28,30 @@ final class OracleCoreErrorMessageTests: XCTestCase { .certificateAuthorityRequired ] - func testNoMessageLeaksTheDriversOwnErrorStruct() { + @Test func testNoMessageLeaksTheDriversOwnErrorStruct() { for error in cases { let message = error.errorDescription ?? "" - XCTAssertFalse(message.contains("OracleSQLError("), message) - XCTAssertFalse(message.contains("triggeredFromRequestInFile"), message) - XCTAssertFalse(message.isEmpty) + #expect(!(message.contains("OracleSQLError(")), "\(message)") + #expect(!message.contains("triggeredFromRequestInFile"), "\(message)") + #expect(!message.isEmpty) } } - func testAClosedChannelSaysSoAndSaysWhatToDo() { + @Test func testAClosedChannelSaysSoAndSaysWhatToDo() { let message = OracleCoreError.connectionClosed.errorDescription ?? "" - XCTAssertTrue(message.contains("closed"), message) - XCTAssertTrue(message.contains("again"), message) + #expect(message.contains("closed"), "\(message)") + #expect(message.contains("again"), "\(message)") } - func testTheMissingCARefusalNamesNoScreenOnlyOnePlatformHas() { + @Test func testTheMissingCARefusalNamesNoScreenOnlyOnePlatformHas() { let message = OracleCoreError.certificateAuthorityRequired.errorDescription ?? "" - XCTAssertTrue(message.contains("CA certificate"), message) - XCTAssertFalse(message.localizedCaseInsensitiveContains("tab"), message) + #expect(message.contains("CA certificate"), "\(message)") + #expect(!message.localizedCaseInsensitiveContains("tab"), "\(message)") } - func testADriverErrorWithNoServerMessageNamesItsCode() { + @Test func testADriverErrorWithNoServerMessageNamesItsCode() { let message = String(format: OracleCoreError.driverErrorFormat, "malformedStatement") - XCTAssertTrue(message.contains("malformedStatement"), message) - XCTAssertFalse(message.contains("OracleSQLError("), message) + #expect(message.contains("malformedStatement"), "\(message)") + #expect(!(message.contains("OracleSQLError(")), "\(message)") } } diff --git a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDictionaryQualificationTests.swift b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDictionaryQualificationTests.swift index 3852cb0358..83de0c5bbe 100644 --- a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDictionaryQualificationTests.swift +++ b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDictionaryQualificationTests.swift @@ -1,10 +1,11 @@ +import Foundation @testable import TableProOracleCore -import XCTest +import Testing /// Every data dictionary object the shared Oracle core names in SQL is owner-qualified, so a table, view or package a /// user plants in the current schema, or in a schema the reader switched into, cannot shadow it. Measured on Oracle /// 23ai: a bare `ALL_TABLES` in the current schema captured the tables read, and `SYS.ALL_TABLES` did not. -final class OracleDictionaryQualificationTests: XCTestCase { +struct OracleDictionaryQualificationTests { /// A dictionary token that must never appear without its `SYS` owner in front of it. The queries give their /// tables lowercase aliases, so a name after one (`c.USER_GENERATED`, a column of `ALL_TAB_COLS`) is a column and /// names no dictionary object, while an uppercase owner in front of a view is still caught. @@ -13,14 +14,14 @@ final class OracleDictionaryQualificationTests: XCTestCase { ) /// Strips every `SYS.`-qualified reference, then fails if any dictionary token is left bare. - private func assertFullyQualified(_ sql: String, _ label: String, file: StaticString = #filePath, line: UInt = #line) { + private func assertFullyQualified(_ sql: String, _ label: String, sourceLocation: SourceLocation = #_sourceLocation) { let stripped = sql.replacingOccurrences( of: #"SYS\.[A-Z_$0-9]+"#, with: " ", options: .regularExpression ) let range = NSRange(stripped.startIndex..., in: stripped) let match = Self.bareDictionary.firstMatch(in: stripped, range: range) if let match, let matchRange = Range(match.range, in: stripped) { - XCTFail("\(label) names \(stripped[matchRange]) without its SYS owner:\n\(sql)", file: file, line: line) + Issue.record("\(label) names \(stripped[matchRange]) without its SYS owner:\n\(sql)", sourceLocation: sourceLocation) } } @@ -54,37 +55,33 @@ final class OracleDictionaryQualificationTests: XCTestCase { ] } - func testEverySchemaQueryIsOwnerQualified() { + @Test func testEverySchemaQueryIsOwnerQualified() { for (label, sql) in everySchemaQuery { assertFullyQualified(sql, label) } } - func testThePerformanceViewUsesTheUnderscoreForm() { - XCTAssertEqual(OracleDictionary.versionView, "SYS.V_$VERSION") - XCTAssertEqual(OracleDictionary.performanceView("V$SESSION"), "SYS.V_$SESSION") - XCTAssertEqual(OracleDictionary.performanceView("GV$SESSION"), "SYS.GV_$SESSION") - XCTAssertTrue(OracleSchemaQueries.serverVersion.contains("SYS.V_$VERSION")) - XCTAssertFalse(OracleSchemaQueries.serverVersion.contains("V$VERSION")) + @Test func testThePerformanceViewUsesTheUnderscoreForm() { + #expect(OracleDictionary.versionView == "SYS.V_$VERSION") + #expect(OracleDictionary.performanceView("V$SESSION") == "SYS.V_$SESSION") + #expect(OracleDictionary.performanceView("GV$SESSION") == "SYS.GV_$SESSION") + #expect(OracleSchemaQueries.serverVersion.contains("SYS.V_$VERSION")) + #expect(!OracleSchemaQueries.serverVersion.contains("V$VERSION")) } - func testDualAndTheSegmentViewsAreQualified() { - XCTAssertEqual(OracleDictionary.dual, "SYS.DUAL") - XCTAssertTrue(OracleSchemaQueries.ping.contains("SYS.DUAL")) - XCTAssertTrue( - OracleSchemaQueries.segmentSize(schema: "HR", table: "T", ownedByCurrentSchema: true) - .contains("SYS.USER_SEGMENTS") - ) - XCTAssertTrue( - OracleSchemaQueries.segmentSize(schema: "HR", table: "T", ownedByCurrentSchema: false) - .contains("SYS.DBA_SEGMENTS") - ) + @Test func testDualAndTheSegmentViewsAreQualified() { + #expect(OracleDictionary.dual == "SYS.DUAL") + #expect(OracleSchemaQueries.ping.contains("SYS.DUAL")) + #expect(OracleSchemaQueries.segmentSize(schema: "HR", table: "T", ownedByCurrentSchema: true) + .contains("SYS.USER_SEGMENTS")) + #expect(OracleSchemaQueries.segmentSize(schema: "HR", table: "T", ownedByCurrentSchema: false) + .contains("SYS.DBA_SEGMENTS")) } /// `ALL_SEGMENTS` does not exist on Oracle (ORA-00942 even as `SYSTEM`), so no query may name it. - func testNoQueryNamesTheNonexistentAllSegments() { + @Test func testNoQueryNamesTheNonexistentAllSegments() { for (label, sql) in everySchemaQuery { - XCTAssertFalse(sql.contains("ALL_SEGMENTS"), "\(label) names ALL_SEGMENTS, which does not exist") + #expect(!sql.contains("ALL_SEGMENTS"), "\(label) names ALL_SEGMENTS, which does not exist") } } } diff --git a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDisconnectReasonTests.swift b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDisconnectReasonTests.swift index ea94a82e66..0c5395f68c 100644 --- a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDisconnectReasonTests.swift +++ b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDisconnectReasonTests.swift @@ -1,49 +1,50 @@ +import Foundation @testable import TableProOracleCore -import XCTest +import Testing /// A statement may only be sent again across a close that took the channel away from a session /// somebody still wants. Replaying across a deliberate teardown opens a socket nobody owns, and /// reports a switch the caller has already abandoned as having succeeded. -final class OracleDisconnectReasonTests: XCTestCase { - func testADeliberateTeardownIsNeverReplayedAcross() { - XCTAssertFalse(OracleDisconnectReason.userRequested.allowsReplay) - XCTAssertFalse(OracleDisconnectReason.queryCancelled.allowsReplay) - XCTAssertFalse(OracleDisconnectReason.abandonedLoginAttempt.allowsReplay) - XCTAssertFalse(OracleDisconnectReason.connectTimedOut.allowsReplay) - XCTAssertFalse(OracleDisconnectReason.connectCancelled.allowsReplay) +struct OracleDisconnectReasonTests { + @Test func testADeliberateTeardownIsNeverReplayedAcross() { + #expect(!OracleDisconnectReason.userRequested.allowsReplay) + #expect(!OracleDisconnectReason.queryCancelled.allowsReplay) + #expect(!OracleDisconnectReason.abandonedLoginAttempt.allowsReplay) + #expect(!OracleDisconnectReason.connectTimedOut.allowsReplay) + #expect(!OracleDisconnectReason.connectCancelled.allowsReplay) } - func testAChannelTakenFromALiveSessionMayBeReplayedAcross() { - XCTAssertTrue(OracleDisconnectReason.pingTimedOut.allowsReplay) - XCTAssertTrue(OracleDisconnectReason.queryTimedOut.allowsReplay) - XCTAssertTrue(OracleDisconnectReason.wedgedStatement.allowsReplay) - XCTAssertTrue(OracleDisconnectReason.channelAlreadyClosed.allowsReplay) - XCTAssertTrue(OracleDisconnectReason.transportError.allowsReplay) - XCTAssertTrue(OracleDisconnectReason.fatalProtocolError.allowsReplay) + @Test func testAChannelTakenFromALiveSessionMayBeReplayedAcross() { + #expect(OracleDisconnectReason.pingTimedOut.allowsReplay) + #expect(OracleDisconnectReason.queryTimedOut.allowsReplay) + #expect(OracleDisconnectReason.wedgedStatement.allowsReplay) + #expect(OracleDisconnectReason.channelAlreadyClosed.allowsReplay) + #expect(OracleDisconnectReason.transportError.allowsReplay) + #expect(OracleDisconnectReason.fatalProtocolError.allowsReplay) } /// A connection attempt that never completed is also finished for good. Cancelling a query /// ends one statement, and an abandoned login attempt closes only its own late handle. - func testAConnectAttemptTeardownEndsTheConnection() { - XCTAssertTrue(OracleDisconnectReason.userRequested.endsConnection) - XCTAssertTrue(OracleDisconnectReason.connectTimedOut.endsConnection) - XCTAssertTrue(OracleDisconnectReason.connectCancelled.endsConnection) + @Test func testAConnectAttemptTeardownEndsTheConnection() { + #expect(OracleDisconnectReason.userRequested.endsConnection) + #expect(OracleDisconnectReason.connectTimedOut.endsConnection) + #expect(OracleDisconnectReason.connectCancelled.endsConnection) for reason in [ OracleDisconnectReason.queryCancelled, .queryTimedOut, .pingTimedOut, .wedgedStatement, .channelAlreadyClosed, .fatalProtocolError, .transportError, .abandonedLoginAttempt ] { - XCTAssertFalse(reason.endsConnection, String(describing: reason)) + #expect(!reason.endsConnection, "\(String(describing: reason))") } } - func testEveryReasonSaysSomethingTheLogCanUse() { + @Test func testEveryReasonSaysSomethingTheLogCanUse() { let reasons: [OracleDisconnectReason] = [ .userRequested, .queryCancelled, .queryTimedOut, .pingTimedOut, .wedgedStatement, .channelAlreadyClosed, .fatalProtocolError, .transportError, .abandonedLoginAttempt, .connectTimedOut, .connectCancelled ] for reason in reasons { - XCTAssertFalse(reason.logDescription.isEmpty, String(describing: reason)) + #expect(!reason.logDescription.isEmpty, "\(String(describing: reason))") } } } diff --git a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleListenerRefusalTests.swift b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleListenerRefusalTests.swift index 951d3ab87e..4cbb2c96f1 100644 --- a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleListenerRefusalTests.swift +++ b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleListenerRefusalTests.swift @@ -1,43 +1,26 @@ +import Foundation @testable import TableProOracleCore -import XCTest +import Testing -final class OracleListenerRefusalTests: XCTestCase { - func testKnownCodesMapToHumanReasonWithORACode() { - XCTAssertEqual( - OracleListenerRefusal.detail(code: 12_514), - "The listener does not know the requested service name (ORA-12514)." - ) - XCTAssertEqual( - OracleListenerRefusal.detail(code: 12_505), - "The listener does not know the requested SID (ORA-12505)." - ) - XCTAssertEqual( - OracleListenerRefusal.detail(code: 12_528), - "The listener is blocking new connections to the requested service (ORA-12528)." - ) +struct OracleListenerRefusalTests { + @Test func testKnownCodesMapToHumanReasonWithORACode() { + #expect(OracleListenerRefusal.detail(code: 12_514) == "The listener does not know the requested service name (ORA-12514).") + #expect(OracleListenerRefusal.detail(code: 12_505) == "The listener does not know the requested SID (ORA-12505).") + #expect(OracleListenerRefusal.detail(code: 12_528) == "The listener is blocking new connections to the requested service (ORA-12528).") } - func testHandlerUnavailableCodesShareOneReason() { + @Test func testHandlerUnavailableCodesShareOneReason() { for code in [12_516, 12_519, 12_520] { - XCTAssertEqual( - OracleListenerRefusal.reason(forCode: code), - "The listener has no handler available for the requested service" - ) + #expect(OracleListenerRefusal.reason(forCode: code) == "The listener has no handler available for the requested service") } } - func testUnknownCodeFallsBackToGenericMessage() { - XCTAssertNil(OracleListenerRefusal.reason(forCode: 9_999)) - XCTAssertEqual( - OracleListenerRefusal.detail(code: 9_999), - "The Oracle listener refused the connection (ORA-9999)." - ) + @Test func testUnknownCodeFallsBackToGenericMessage() { + #expect(OracleListenerRefusal.reason(forCode: 9_999) == nil) + #expect(OracleListenerRefusal.detail(code: 9_999) == "The Oracle listener refused the connection (ORA-9999).") } - func testMissingCodeFallsBackToGenericMessage() { - XCTAssertEqual( - OracleListenerRefusal.detail(code: nil), - "The Oracle listener refused the connection." - ) + @Test func testMissingCodeFallsBackToGenericMessage() { + #expect(OracleListenerRefusal.detail(code: nil) == "The Oracle listener refused the connection.") } } diff --git a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleNetworkEncryptionTests.swift b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleNetworkEncryptionTests.swift index 179aa0ff6b..217b798ea9 100644 --- a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleNetworkEncryptionTests.swift +++ b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleNetworkEncryptionTests.swift @@ -1,62 +1,60 @@ +import Foundation import OracleNIO @testable import TableProOracleCore -import XCTest +import Testing -final class OracleNetworkEncryptionTests: XCTestCase { - func testNetworkEncryptionKeyMatchesTheMacKey() { - XCTAssertEqual( - OracleConnectionOptions.AdditionalFieldKey.networkEncryption, - "oracleNetworkEncryption" - ) +struct OracleNetworkEncryptionTests { + @Test func testNetworkEncryptionKeyMatchesTheMacKey() { + #expect(OracleConnectionOptions.AdditionalFieldKey.networkEncryption == "oracleNetworkEncryption") } - func testLevelRawValuesAreStableWireStrings() { - XCTAssertEqual(OracleConnectionOptions.NetworkEncryption.rejected.rawValue, "rejected") - XCTAssertEqual(OracleConnectionOptions.NetworkEncryption.accepted.rawValue, "accepted") - XCTAssertEqual(OracleConnectionOptions.NetworkEncryption.requested.rawValue, "requested") - XCTAssertEqual(OracleConnectionOptions.NetworkEncryption.required.rawValue, "required") + @Test func testLevelRawValuesAreStableWireStrings() { + #expect(OracleConnectionOptions.NetworkEncryption.rejected.rawValue == "rejected") + #expect(OracleConnectionOptions.NetworkEncryption.accepted.rawValue == "accepted") + #expect(OracleConnectionOptions.NetworkEncryption.requested.rawValue == "requested") + #expect(OracleConnectionOptions.NetworkEncryption.required.rawValue == "required") } - func testLevelDefaultsToAcceptedWhenAbsent() { - XCTAssertEqual(OracleConnectionOptions.networkEncryption(from: [:]), .accepted) + @Test func testLevelDefaultsToAcceptedWhenAbsent() { + #expect(OracleConnectionOptions.networkEncryption(from: [:]) == .accepted) } - func testLevelDefaultsToAcceptedForAnUnknownValue() { + @Test func testLevelDefaultsToAcceptedForAnUnknownValue() { let fields = [OracleConnectionOptions.AdditionalFieldKey.networkEncryption: "maybe"] - XCTAssertEqual(OracleConnectionOptions.networkEncryption(from: fields), .accepted) + #expect(OracleConnectionOptions.networkEncryption(from: fields) == .accepted) } - func testLevelIsReadFromAdditionalFields() { + @Test func testLevelIsReadFromAdditionalFields() { for level in OracleConnectionOptions.NetworkEncryption.allCases { let fields = [OracleConnectionOptions.AdditionalFieldKey.networkEncryption: level.rawValue] - XCTAssertEqual(OracleConnectionOptions.networkEncryption(from: fields), level) + #expect(OracleConnectionOptions.networkEncryption(from: fields) == level) } } - func testEveryLevelMapsToTheMatchingDriverLevel() { - XCTAssertEqual(OracleCoreConnection.encryptionLevel(for: .rejected), .rejected) - XCTAssertEqual(OracleCoreConnection.encryptionLevel(for: .accepted), .accepted) - XCTAssertEqual(OracleCoreConnection.encryptionLevel(for: .requested), .requested) - XCTAssertEqual(OracleCoreConnection.encryptionLevel(for: .required), .required) + @Test func testEveryLevelMapsToTheMatchingDriverLevel() { + #expect(OracleCoreConnection.encryptionLevel(for: .rejected) == .rejected) + #expect(OracleCoreConnection.encryptionLevel(for: .accepted) == .accepted) + #expect(OracleCoreConnection.encryptionLevel(for: .requested) == .requested) + #expect(OracleCoreConnection.encryptionLevel(for: .required) == .required) } - func testOptionsDefaultToAccepted() { + @Test func testOptionsDefaultToAccepted() { let options = OracleConnectionOptions(host: "localhost", user: "scott", password: "tiger") - XCTAssertEqual(options.networkEncryption, .accepted) + #expect(options.networkEncryption == .accepted) } - func testStalledLoginNamesTheStepItStalledIn() { + @Test func testStalledLoginNamesTheStepItStalledIn() { let error = OracleCoreError.loginHandshakeStalled(phase: "advancedNegotiation") let description = error.errorDescription ?? "" - XCTAssertTrue(description.contains("network encryption"), description) + #expect(description.contains("network encryption"), "\(description)") } - func testStalledLoginWithoutAPhaseStillReads() { + @Test func testStalledLoginWithoutAPhaseStillReads() { let error = OracleCoreError.loginHandshakeStalled(phase: nil) - XCTAssertFalse((error.errorDescription ?? "").isEmpty) + #expect(!((error.errorDescription ?? "").isEmpty)) } - func testAnUnknownPhaseFallsBackToItsRawLabel() { - XCTAssertEqual(OracleCoreError.handshakePhaseName("somethingNew"), "somethingNew") + @Test func testAnUnknownPhaseFallsBackToItsRawLabel() { + #expect(OracleCoreError.handshakePhaseName("somethingNew") == "somethingNew") } } diff --git a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OraclePLSQLUnitTests.swift b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OraclePLSQLUnitTests.swift index 02fadad55a..0b3c5ede3b 100644 --- a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OraclePLSQLUnitTests.swift +++ b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OraclePLSQLUnitTests.swift @@ -1,10 +1,11 @@ +import Foundation @testable import TableProOracleCore -import XCTest +import Testing /// Oracle stores a unit that does not compile and reports success, so the plugin reads the unit's errors back by the /// owner, name and type the `CREATE` header names. Reading the wrong name reports a broken unit as fine. -final class OraclePLSQLUnitTests: XCTestCase { - func testReadsTheUnitEachHeaderDefines() { +struct OraclePLSQLUnitTests { + @Test func testReadsTheUnitEachHeaderDefines() { let cases: [(sql: String, unit: OraclePLSQLUnit)] = [ ("CREATE PROCEDURE p IS BEGIN NULL; END;", OraclePLSQLUnit(type: "PROCEDURE", owner: nil, name: "P")), ( @@ -26,11 +27,11 @@ final class OraclePLSQLUnitTests: XCTestCase { ("CREATE NONEDITIONABLE PROCEDURE x$y#z IS BEGIN NULL; END;", OraclePLSQLUnit(type: "PROCEDURE", owner: nil, name: "X$Y#Z")), ] for example in cases { - XCTAssertEqual(OraclePLSQLUnit.definition(in: example.sql), example.unit, example.sql) + #expect(OraclePLSQLUnit.definition(in: example.sql) == example.unit, "\(example.sql)") } } - func testStatementsThatDefineNoUnit() { + @Test func testStatementsThatDefineNoUnit() { for sql in [ "CREATE TABLE t (a NUMBER)", "CREATE OR REPLACE VIEW v AS SELECT 1 FROM dual", @@ -38,45 +39,45 @@ final class OraclePLSQLUnitTests: XCTestCase { "DROP PROCEDURE p", "SELECT 'CREATE PROCEDURE p' FROM dual", ] { - XCTAssertNil(OraclePLSQLUnit.definition(in: sql), sql) + #expect(OraclePLSQLUnit.definition(in: sql) == nil, "\(sql)") } } - func testErrorsQueryEscapesAndFallsBackToTheSessionSchema() { + @Test func testErrorsQueryEscapesAndFallsBackToTheSessionSchema() { let named = OraclePLSQLUnit(type: "PACKAGE BODY", owner: "O'NEIL", name: "IT'S") - XCTAssertTrue(named.errorsQuery.contains("OWNER = 'O''NEIL'")) - XCTAssertTrue(named.errorsQuery.contains("NAME = 'IT''S'")) - XCTAssertTrue(named.errorsQuery.contains("TYPE = 'PACKAGE BODY'")) - XCTAssertTrue(named.errorsQuery.contains("ATTRIBUTE = 'ERROR'")) + #expect(named.errorsQuery.contains("OWNER = 'O''NEIL'")) + #expect(named.errorsQuery.contains("NAME = 'IT''S'")) + #expect(named.errorsQuery.contains("TYPE = 'PACKAGE BODY'")) + #expect(named.errorsQuery.contains("ATTRIBUTE = 'ERROR'")) let unqualified = OraclePLSQLUnit(type: "PROCEDURE", owner: nil, name: "P") - XCTAssertTrue(unqualified.errorsQuery.contains("OWNER = SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA')")) + #expect(unqualified.errorsQuery.contains("OWNER = SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA')")) } - func testFailureMessageListsEveryError() { + @Test func testFailureMessageListsEveryError() { let unit = OraclePLSQLUnit(type: "PROCEDURE", owner: nil, name: "P") let message = unit.compilationFailureMessage(errors: [ OracleCompilationError(line: 5, position: 3, text: "PLS-00103: Encountered the symbol \"end-of-file\"\n"), OracleCompilationError(line: 7, position: 1, text: "PL/SQL: Statement ignored"), ]) - XCTAssertEqual(message, """ + #expect(message == """ PROCEDURE P was created with compilation errors: Line 5, column 3: PLS-00103: Encountered the symbol "end-of-file" Line 7, column 1: PL/SQL: Statement ignored """) } - func testCompilationErrorReadsAnErrorsRow() { + @Test func testCompilationErrorReadsAnErrorsRow() { let error = OracleCompilationError(row: [.string("5"), .string("3"), .string("PLS-00103: x")]) - XCTAssertEqual(error, OracleCompilationError(line: 5, position: 3, text: "PLS-00103: x")) - XCTAssertNil(OracleCompilationError(row: [.null, .string("3"), .string("x")])) + #expect(error == OracleCompilationError(line: 5, position: 3, text: "PLS-00103: x")) + #expect(OracleCompilationError(row: [.null, .string("3"), .string("x")]) == nil) } - func testAnonymousBlocksAreRecognisedPastLabelsAndComments() { - XCTAssertTrue(OraclePLSQLUnit.isAnonymousBlock("BEGIN NULL; END;")) - XCTAssertTrue(OraclePLSQLUnit.isAnonymousBlock("declare v number; begin null; end;")) - XCTAssertTrue(OraclePLSQLUnit.isAnonymousBlock("-- c\n<>\n<> BEGIN NULL; END;")) - XCTAssertFalse(OraclePLSQLUnit.isAnonymousBlock("SELECT 1 FROM dual")) - XCTAssertFalse(OraclePLSQLUnit.isAnonymousBlock("CALL p()")) + @Test func testAnonymousBlocksAreRecognisedPastLabelsAndComments() { + #expect(OraclePLSQLUnit.isAnonymousBlock("BEGIN NULL; END;")) + #expect(OraclePLSQLUnit.isAnonymousBlock("declare v number; begin null; end;")) + #expect(OraclePLSQLUnit.isAnonymousBlock("-- c\n<>\n<> BEGIN NULL; END;")) + #expect(!OraclePLSQLUnit.isAnonymousBlock("SELECT 1 FROM dual")) + #expect(!(OraclePLSQLUnit.isAnonymousBlock("CALL p()"))) } } diff --git a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OraclePingDecisionTests.swift b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OraclePingDecisionTests.swift index de6ec2e4df..38950d9037 100644 --- a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OraclePingDecisionTests.swift +++ b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OraclePingDecisionTests.swift @@ -1,28 +1,29 @@ +import Foundation @testable import TableProOracleCore -import XCTest +import Testing /// #3053. A health check used to wrap the whole of `executeQuery` in a ten second deadline, and /// `executeQuery` opens by waiting on the query gate, so the deadline covered the queue rather /// than the round trip. A probe that never got a turn closed the channel anyway, and OracleNIO /// reports that to the statement that was running as `clientClosedConnection`. -final class OraclePingDecisionTests: XCTestCase { - func testAProbeThatOwnsTheChannelMayRun() { - XCTAssertEqual(OraclePingDecision.of(.acquired, wedgedAfter: .seconds(300)), .probe) +struct OraclePingDecisionTests { + @Test func testAProbeThatOwnsTheChannelMayRun() { + #expect(OraclePingDecision.of(.acquired, wedgedAfter: .seconds(300)) == .probe) } - func testABusyChannelIsReportedAliveRatherThanClosed() { - XCTAssertEqual(OraclePingDecision.of(.busy(for: .seconds(11)), wedgedAfter: .seconds(300)), .reportAlive) - XCTAssertEqual(OraclePingDecision.of(.busy(for: .seconds(299)), wedgedAfter: .seconds(300)), .reportAlive) + @Test func testABusyChannelIsReportedAliveRatherThanClosed() { + #expect(OraclePingDecision.of(.busy(for: .seconds(11)), wedgedAfter: .seconds(300)) == .reportAlive) + #expect(OraclePingDecision.of(.busy(for: .seconds(299)), wedgedAfter: .seconds(300)) == .reportAlive) } - func testAChannelHeldPastTheStalenessLimitIsReportedWedged() { - XCTAssertEqual(OraclePingDecision.of(.busy(for: .seconds(301)), wedgedAfter: .seconds(300)), .reportWedged) + @Test func testAChannelHeldPastTheStalenessLimitIsReportedWedged() { + #expect(OraclePingDecision.of(.busy(for: .seconds(301)), wedgedAfter: .seconds(300)) == .reportWedged) } /// The limit is the app's own `max(queryTimeout, 300)` rule, so a long query timeout moves it /// and the escape valve still fires past it. - func testTheStalenessLimitFollowsTheQueryTimeout() { - XCTAssertEqual(OraclePingDecision.of(.busy(for: .seconds(400)), wedgedAfter: .seconds(600)), .reportAlive) - XCTAssertEqual(OraclePingDecision.of(.busy(for: .seconds(601)), wedgedAfter: .seconds(600)), .reportWedged) + @Test func testTheStalenessLimitFollowsTheQueryTimeout() { + #expect(OraclePingDecision.of(.busy(for: .seconds(400)), wedgedAfter: .seconds(600)) == .reportAlive) + #expect(OraclePingDecision.of(.busy(for: .seconds(601)), wedgedAfter: .seconds(600)) == .reportWedged) } } diff --git a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleQueryGateTests.swift b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleQueryGateTests.swift index ee32ffea37..a20acb91c4 100644 --- a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleQueryGateTests.swift +++ b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleQueryGateTests.swift @@ -1,23 +1,25 @@ +import Foundation @testable import TableProOracleCore -import XCTest +import Testing /// The gate is what gives a task the right to close the channel, so a probe that cannot take a /// turn must be told so rather than being allowed to act (#3053). -final class OracleQueryGateTests: XCTestCase { - func testATurnIsRefusedWhileTheChannelIsHeld() async { +struct OracleQueryGateTests { + @Test func testATurnIsRefusedWhileTheChannelIsHeld() async { let gate = QueryGate() await gate.acquire() guard case .busy = await gate.takeTurnIfFree() else { - return XCTFail("A held gate must not hand out a second turn") + Issue.record("A held gate must not hand out a second turn") + return } await gate.release() let afterRelease = await gate.takeTurnIfFree() - XCTAssertEqual(afterRelease, .acquired) + #expect(afterRelease == .acquired) } - func testAQueuedStatementKeepsTheChannelBusy() async { + @Test func testAQueuedStatementKeepsTheChannelBusy() async { let gate = QueryGate() await gate.acquire() @@ -25,14 +27,16 @@ final class OracleQueryGateTests: XCTestCase { try? await Task.sleep(for: .milliseconds(50)) guard case .busy = await gate.takeTurnIfFree() else { - return XCTFail("A gate with a waiter must not hand out a turn") + Issue.record("A gate with a waiter must not hand out a turn") + return } await gate.release() await queued.value guard case .busy = await gate.takeTurnIfFree() else { - return XCTFail("The waiter now holds the gate") + Issue.record("The waiter now holds the gate") + return } await gate.release() } @@ -40,24 +44,26 @@ final class OracleQueryGateTests: XCTestCase { /// Compared against the first holder's own age rather than a wall-clock bound: time only moves /// forward, so without the reset the second reading could never be the smaller one, whatever a /// loaded CI worker does to the scheduling in between. - func testAHandoverRestartsTheHoldingClock() async { + @Test func testAHandoverRestartsTheHoldingClock() async { let gate = QueryGate() await gate.acquire() let queued = Task { await gate.acquire() } try? await Task.sleep(for: .milliseconds(120)) guard case .busy(let beforeHandover) = await gate.takeTurnIfFree() else { - return XCTFail("The first holder still holds the gate") + Issue.record("The first holder still holds the gate") + return } await gate.release() await queued.value guard case .busy(let afterHandover) = await gate.takeTurnIfFree() else { - return XCTFail("The waiter now holds the gate") + Issue.record("The waiter now holds the gate") + return } - XCTAssertGreaterThan(beforeHandover, .zero) - XCTAssertLessThan(afterHandover, beforeHandover) + #expect(beforeHandover > .zero) + #expect(afterHandover < beforeHandover) await gate.release() } } diff --git a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleQueryTimeoutTests.swift b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleQueryTimeoutTests.swift index a05a3cffa3..4dd543f5a9 100644 --- a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleQueryTimeoutTests.swift +++ b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleQueryTimeoutTests.swift @@ -1,16 +1,17 @@ +import Foundation @testable import TableProOracleCore -import XCTest +import Testing -final class OracleQueryTimeoutTests: XCTestCase { - func testTimeoutBoundsCannotOverflowNativeUnits() { +struct OracleQueryTimeoutTests { + @Test func testTimeoutBoundsCannotOverflowNativeUnits() { let maximum = OracleQueryTimeout.maximumSeconds - XCTAssertEqual(OracleQueryTimeout.boundedSeconds(maximum), maximum) - XCTAssertEqual(OracleQueryTimeout.boundedSeconds(maximum + 1), maximum) - XCTAssertEqual(OracleQueryTimeout.boundedSeconds(Int.max), maximum) - XCTAssertEqual(OracleQueryTimeout.boundedSeconds(-1), 0) - XCTAssertEqual(OracleQueryTimeout.nanoseconds(Double(maximum)), UInt64(maximum) * 1_000_000_000) - XCTAssertEqual(OracleQueryTimeout.nanoseconds(.infinity), UInt64(maximum) * 1_000_000_000) - XCTAssertEqual(OracleQueryTimeout.nanoseconds(.nan), 0) + #expect(OracleQueryTimeout.boundedSeconds(maximum) == maximum) + #expect(OracleQueryTimeout.boundedSeconds(maximum + 1) == maximum) + #expect(OracleQueryTimeout.boundedSeconds(Int.max) == maximum) + #expect(OracleQueryTimeout.boundedSeconds(-1) == 0) + #expect(OracleQueryTimeout.nanoseconds(Double(maximum)) == UInt64(maximum) * 1_000_000_000) + #expect(OracleQueryTimeout.nanoseconds(.infinity) == UInt64(maximum) * 1_000_000_000) + #expect(OracleQueryTimeout.nanoseconds(.nan) == 0) } } diff --git a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleRoleTests.swift b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleRoleTests.swift index e397d520f1..f9dfb851ab 100644 --- a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleRoleTests.swift +++ b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleRoleTests.swift @@ -1,50 +1,42 @@ +import Foundation @testable import TableProOracleCore -import XCTest +import Testing -final class OracleRoleTests: XCTestCase { - func testRoleKeyMatchesTheMacKey() { - XCTAssertEqual(OracleConnectionOptions.AdditionalFieldKey.role, "oracleRole") +struct OracleRoleTests { + @Test func testRoleKeyMatchesTheMacKey() { + #expect(OracleConnectionOptions.AdditionalFieldKey.role == "oracleRole") } - func testRoleRawValuesAreStableWireStrings() { - XCTAssertEqual(OracleConnectionOptions.Role.normal.rawValue, "normal") - XCTAssertEqual(OracleConnectionOptions.Role.sysdba.rawValue, "sysdba") - XCTAssertEqual(OracleConnectionOptions.Role.sysoper.rawValue, "sysoper") + @Test func testRoleRawValuesAreStableWireStrings() { + #expect(OracleConnectionOptions.Role.normal.rawValue == "normal") + #expect(OracleConnectionOptions.Role.sysdba.rawValue == "sysdba") + #expect(OracleConnectionOptions.Role.sysoper.rawValue == "sysoper") } - func testRoleDefaultsToNormalWhenAbsent() { - XCTAssertEqual(OracleConnectionOptions.role(from: [:]), .normal) + @Test func testRoleDefaultsToNormalWhenAbsent() { + #expect(OracleConnectionOptions.role(from: [:]) == .normal) } - func testRoleDefaultsToNormalForAnUnknownValue() { + @Test func testRoleDefaultsToNormalForAnUnknownValue() { let fields = [OracleConnectionOptions.AdditionalFieldKey.role: "sysasm"] - XCTAssertEqual(OracleConnectionOptions.role(from: fields), .normal) + #expect(OracleConnectionOptions.role(from: fields) == .normal) } - func testRoleIsReadFromAdditionalFields() { + @Test func testRoleIsReadFromAdditionalFields() { for role in OracleConnectionOptions.Role.allCases { let fields = [OracleConnectionOptions.AdditionalFieldKey.role: role.rawValue] - XCTAssertEqual(OracleConnectionOptions.role(from: fields), role) + #expect(OracleConnectionOptions.role(from: fields) == role) } } - func testEveryRoleMapsToTheMatchingOracleAuthenticationMode() { - XCTAssertEqual( - OracleCoreConnection.authenticationMode(for: .normal).description, - "DEFAULT" - ) - XCTAssertEqual( - OracleCoreConnection.authenticationMode(for: .sysdba).description, - "SYSDBA" - ) - XCTAssertEqual( - OracleCoreConnection.authenticationMode(for: .sysoper).description, - "SYSOPER" - ) + @Test func testEveryRoleMapsToTheMatchingOracleAuthenticationMode() { + #expect(OracleCoreConnection.authenticationMode(for: .normal).description == "DEFAULT") + #expect(OracleCoreConnection.authenticationMode(for: .sysdba).description == "SYSDBA") + #expect(OracleCoreConnection.authenticationMode(for: .sysoper).description == "SYSOPER") } - func testOptionsDefaultToANormalLogon() { + @Test func testOptionsDefaultToANormalLogon() { let options = OracleConnectionOptions(host: "localhost", user: "scott", password: "tiger") - XCTAssertEqual(options.role, .normal) + #expect(options.role == .normal) } } diff --git a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleSchemaQueriesTests.swift b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleSchemaQueriesTests.swift index f0426dbb2f..53d1372be5 100644 --- a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleSchemaQueriesTests.swift +++ b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleSchemaQueriesTests.swift @@ -1,296 +1,284 @@ +import Foundation @testable import TableProOracleCore -import XCTest +import Testing -final class OracleSchemaQueriesTests: XCTestCase { +struct OracleSchemaQueriesTests { private let release23ai = OracleServerRelease(major: 23, update: 4) - func testSchemaOwnerIsEscapedInEveryQuery() { + @Test func testSchemaOwnerIsEscapedInEveryQuery() { let owner = "O'BRIEN" - XCTAssertTrue(OracleSchemaQueries.tables(schema: owner).contains("owner = 'O''BRIEN'")) - XCTAssertTrue( - OracleSchemaQueries.columns(schema: owner, table: "T", release: release23ai).contains("c.OWNER = 'O''BRIEN'") - ) - XCTAssertTrue( - OracleSchemaQueries.allColumns(schema: owner, release: release23ai).contains("c.OWNER = 'O''BRIEN'") - ) - XCTAssertTrue(OracleSchemaQueries.indexes(schema: owner, table: "T").contains("i.OWNER = 'O''BRIEN'")) - XCTAssertTrue(OracleSchemaQueries.foreignKeys(schema: owner, table: "T").contains("ac.OWNER = 'O''BRIEN'")) + #expect(OracleSchemaQueries.tables(schema: owner).contains("owner = 'O''BRIEN'")) + #expect(OracleSchemaQueries.columns(schema: owner, table: "T", release: release23ai).contains("c.OWNER = 'O''BRIEN'")) + #expect(OracleSchemaQueries.allColumns(schema: owner, release: release23ai).contains("c.OWNER = 'O''BRIEN'")) + #expect(OracleSchemaQueries.indexes(schema: owner, table: "T").contains("i.OWNER = 'O''BRIEN'")) + #expect(OracleSchemaQueries.foreignKeys(schema: owner, table: "T").contains("ac.OWNER = 'O''BRIEN'")) } - func testTableNameIsEscaped() { + @Test func testTableNameIsEscaped() { let sql = OracleSchemaQueries.columns(schema: "HR", table: "IT'S", release: release23ai) - XCTAssertTrue(sql.contains("c.TABLE_NAME = 'IT''S'")) - XCTAssertFalse(sql.contains("'IT'S'")) + #expect(sql.contains("c.TABLE_NAME = 'IT''S'")) + #expect(!sql.contains("'IT'S'")) } - func testSetCurrentSchemaQuotesAndEscapesIdentifier() { - XCTAssertEqual( - OracleSchemaQueries.setCurrentSchema("HR"), - "ALTER SESSION SET CURRENT_SCHEMA = \"HR\"" - ) - XCTAssertEqual( - OracleSchemaQueries.setCurrentSchema("WEIRD\"NAME"), - "ALTER SESSION SET CURRENT_SCHEMA = \"WEIRD\"\"NAME\"" - ) + @Test func testSetCurrentSchemaQuotesAndEscapesIdentifier() { + #expect(OracleSchemaQueries.setCurrentSchema("HR") == "ALTER SESSION SET CURRENT_SCHEMA = \"HR\"") + #expect(OracleSchemaQueries.setCurrentSchema("WEIRD\"NAME") == "ALTER SESSION SET CURRENT_SCHEMA = \"WEIRD\"\"NAME\"") } - func testParseTableRowDistinguishesViews() { + @Test func testParseTableRowDistinguishesViews() { let table = OracleSchemaQueries.parseTableRow([.string("EMPLOYEES"), .string("BASE TABLE")]) - XCTAssertEqual(table, OracleTableRow(name: "EMPLOYEES", isView: false)) + #expect(table == OracleTableRow(name: "EMPLOYEES", isView: false)) let view = OracleSchemaQueries.parseTableRow([.string("EMP_VIEW"), .string("VIEW")]) - XCTAssertEqual(view, OracleTableRow(name: "EMP_VIEW", isView: true)) + #expect(view == OracleTableRow(name: "EMP_VIEW", isView: true)) } - func testParseTableRowReadsPartitioningSeparatelyFromTheCount() { + @Test func testParseTableRowReadsPartitioningSeparatelyFromTheCount() { let counted = OracleSchemaQueries.parseTableRow([ .string("ORDERS"), .string("BASE TABLE"), .string("Y"), .string("12") ]) - XCTAssertEqual(counted, OracleTableRow(name: "ORDERS", isView: false, isPartitioned: true, partitionCount: 12)) + #expect(counted == OracleTableRow(name: "ORDERS", isView: false, isPartitioned: true, partitionCount: 12)) let plain = OracleSchemaQueries.parseTableRow([ .string("EMPLOYEES"), .string("BASE TABLE"), .string("N"), .null ]) - XCTAssertEqual(plain, OracleTableRow(name: "EMPLOYEES", isView: false)) + #expect(plain == OracleTableRow(name: "EMPLOYEES", isView: false)) } /// An interval-partitioned table reports a count that means the range the server will extend /// into rather than the partitions it holds, so the query omits it. The table is still /// partitioned, and reading the missing count as "not partitioned" would hide its partitions. - func testIntervalPartitionedTableStaysPartitionedWithoutACount() { + @Test func testIntervalPartitionedTableStaysPartitionedWithoutACount() { let interval = OracleSchemaQueries.parseTableRow([ .string("EVENTS"), .string("BASE TABLE"), .string("Y"), .null ]) - XCTAssertEqual(interval?.isPartitioned, true) - XCTAssertNil(interval?.partitionCount) + #expect(interval?.isPartitioned == true) + #expect(interval?.partitionCount == nil) } - func testTableListingReadsPartitioningFromAllPartTables() { + @Test func testTableListingReadsPartitioningFromAllPartTables() { let sql = OracleSchemaQueries.tables(schema: "HR") - XCTAssertTrue(sql.contains("LEFT JOIN SYS.ALL_PART_TABLES pt")) - XCTAssertTrue(sql.contains("CASE WHEN pt.table_name IS NULL THEN 'N' ELSE 'Y' END")) - XCTAssertTrue(sql.contains("CASE WHEN pt.interval IS NULL THEN pt.partition_count END")) + #expect(sql.contains("LEFT JOIN SYS.ALL_PART_TABLES pt")) + #expect(sql.contains("CASE WHEN pt.table_name IS NULL THEN 'N' ELSE 'Y' END")) + #expect(sql.contains("CASE WHEN pt.interval IS NULL THEN pt.partition_count END")) } /// `HIGH_VALUE` is a LONG column OracleNIO cannot decode, so it must never be selected. - func testPartitionQueriesNeverReadHighValue() { + @Test func testPartitionQueriesNeverReadHighValue() { let partitions = OracleSchemaQueries.partitions(schema: "HR", table: "ORDERS") let subpartitions = OracleSchemaQueries.subpartitions(schema: "HR", table: "ORDERS") - XCTAssertFalse(partitions.lowercased().contains("high_value")) - XCTAssertFalse(subpartitions.lowercased().contains("high_value")) - XCTAssertTrue(partitions.contains("p.table_owner = 'HR'")) - XCTAssertTrue(subpartitions.contains("s.table_name = 'ORDERS'")) + #expect(!(partitions.lowercased().contains("high_value"))) + #expect(!(subpartitions.lowercased().contains("high_value"))) + #expect(partitions.contains("p.table_owner = 'HR'")) + #expect(subpartitions.contains("s.table_name = 'ORDERS'")) } /// Every subpartition of the table in one statement, carrying its parent's name so the rows /// group in memory. Asking per partition was one round trip each, and one timeout among /// hundreds discarded the whole answer. - func testSubpartitionsAreFetchedForTheWholeTableAtOnce() { + @Test func testSubpartitionsAreFetchedForTheWholeTableAtOnce() { let sql = OracleSchemaQueries.subpartitions(schema: "HR", table: "ORDERS") - XCTAssertTrue(sql.contains("s.partition_name")) - XCTAssertFalse(sql.contains("s.partition_name = '")) - XCTAssertTrue(sql.contains("ORDER BY s.partition_name, s.subpartition_position")) + #expect(sql.contains("s.partition_name")) + #expect(!sql.contains("s.partition_name = '")) + #expect(sql.contains("ORDER BY s.partition_name, s.subpartition_position")) } - func testParseSubpartitionRowCarriesItsParentName() { + @Test func testParseSubpartitionRowCarriesItsParentName() { let parsed = OracleSchemaQueries.parseSubpartitionRow([ .string("P1"), .string("P1_SP2"), .string("2"), .string("40") ]) - XCTAssertEqual(parsed?.parent, "P1") - XCTAssertEqual(parsed?.row.name, "P1_SP2") - XCTAssertEqual(parsed?.row.position, 2) - XCTAssertEqual(parsed?.row.rowCount, 40) - XCTAssertEqual(parsed?.row.isSubpartitioned, false) + #expect(parsed?.parent == "P1") + #expect(parsed?.row.name == "P1_SP2") + #expect(parsed?.row.position == 2) + #expect(parsed?.row.rowCount == 40) + #expect(parsed?.row.isSubpartitioned == false) } - func testParseSubpartitionRowNeedsBothNames() { - XCTAssertNil(OracleSchemaQueries.parseSubpartitionRow([.string("P1")])) - XCTAssertNil(OracleSchemaQueries.parseSubpartitionRow([.null, .string("P1_SP1")])) + @Test func testParseSubpartitionRowNeedsBothNames() { + #expect(OracleSchemaQueries.parseSubpartitionRow([.string("P1")]) == nil) + #expect(OracleSchemaQueries.parseSubpartitionRow([.null, .string("P1_SP1")]) == nil) } - func testParsePartitionRowReadsSubpartitionCount() { + @Test func testParsePartitionRowReadsSubpartitionCount() { let composite = OracleSchemaQueries.parsePartitionRow([ .string("P1"), .string("2"), .string("500"), .string("4") ]) - XCTAssertEqual(composite?.isSubpartitioned, true) - XCTAssertEqual(composite?.position, 2) - XCTAssertEqual(composite?.rowCount, 500) + #expect(composite?.isSubpartitioned == true) + #expect(composite?.position == 2) + #expect(composite?.rowCount == 500) let leaf = OracleSchemaQueries.parsePartitionRow([ .string("P2"), .string("3"), .null, .string("0") ]) - XCTAssertEqual(leaf?.isSubpartitioned, false) - XCTAssertNil(leaf?.rowCount) + #expect(leaf?.isSubpartitioned == false) + #expect(leaf?.rowCount == nil) } - func testParseTableRowReturnsNilWithoutAName() { - XCTAssertNil(OracleSchemaQueries.parseTableRow([.null, .string("VIEW")])) - XCTAssertNil(OracleSchemaQueries.parseTableRow([])) + @Test func testParseTableRowReturnsNilWithoutAName() { + #expect(OracleSchemaQueries.parseTableRow([.null, .string("VIEW")]) == nil) + #expect(OracleSchemaQueries.parseTableRow([]) == nil) } - func testParseColumnRowReadsFlagsAndNullPrimaryKeyJoin() { + @Test func testParseColumnRowReadsFlagsAndNullPrimaryKeyJoin() { let row: [OracleRawCell] = [ .string("SALARY"), .string("NUMBER"), .string("22"), .string("10"), .string("2"), .string("N"), .string("N") ] let parsed = OracleSchemaQueries.parseColumnRow(row) - XCTAssertEqual(parsed?.name, "SALARY") - XCTAssertEqual(parsed?.dataType, "NUMBER") - XCTAssertEqual(parsed?.isNullable, false) - XCTAssertEqual(parsed?.isPrimaryKey, false) - XCTAssertEqual(parsed?.displayType, "NUMBER(10,2)") + #expect(parsed?.name == "SALARY") + #expect(parsed?.dataType == "NUMBER") + #expect(parsed?.isNullable == false) + #expect(parsed?.isPrimaryKey == false) + #expect(parsed?.displayType == "NUMBER(10,2)") } - func testParseColumnRowReadsTheDefault() { + @Test func testParseColumnRowReadsTheDefault() { let row: [OracleRawCell] = [ .string("CREATED"), .string("DATE"), .string("7"), .null, .null, .string("Y"), .string("N"), .string("sysdate\n "), .string("NO"), .string("NO"), .string("NO"), .string("NO") ] - XCTAssertEqual(OracleSchemaQueries.parseColumnRow(row)?.defaultValue, "sysdate") + #expect(OracleSchemaQueries.parseColumnRow(row)?.defaultValue == "sysdate") } - func testParseColumnRowWithoutADefaultReadsNil() { + @Test func testParseColumnRowWithoutADefaultReadsNil() { let row: [OracleRawCell] = [ .string("NOTE"), .string("VARCHAR2"), .string("20"), .null, .null, .string("Y"), .string("N"), .null, .string("NO"), .string("NO"), .string("NO"), .string("NO") ] - XCTAssertNil(OracleSchemaQueries.parseColumnRow(row)?.defaultValue) + #expect(OracleSchemaQueries.parseColumnRow(row)?.defaultValue == nil) } /// Position 12 is `GENERATION_TYPE` from `ALL_TAB_IDENTITY_COLS`, which only an identity column has. - func testParseColumnRowReadsHowAnIdentityIsGeneratedAndWhetherAColumnIsVirtual() { + @Test func testParseColumnRowReadsHowAnIdentityIsGeneratedAndWhetherAColumnIsVirtual() { let always: [OracleRawCell] = [ .string("ID"), .string("NUMBER"), .string("22"), .null, .null, .string("N"), .string("Y"), .string("\"HR\".\"ISEQ$$_1\".nextval"), .string("NO"), .string("YES"), .string("NO"), .string("NO"), .string("ALWAYS") ] - XCTAssertEqual(OracleSchemaQueries.parseColumnRow(always)?.identityGeneration, .always) - XCTAssertEqual(OracleSchemaQueries.parseColumnRow(always)?.isVirtual, false) + #expect(OracleSchemaQueries.parseColumnRow(always)?.identityGeneration == .always) + #expect(OracleSchemaQueries.parseColumnRow(always)?.isVirtual == false) let byDefault: [OracleRawCell] = [ .string("ID"), .string("NUMBER"), .string("22"), .null, .null, .string("N"), .string("Y"), .string("\"HR\".\"ISEQ$$_2\".nextval"), .string("NO"), .string("YES"), .string("NO"), .string("NO"), .string("BY DEFAULT") ] - XCTAssertEqual(OracleSchemaQueries.parseColumnRow(byDefault)?.identityGeneration, .byDefault) + #expect(OracleSchemaQueries.parseColumnRow(byDefault)?.identityGeneration == .byDefault) let virtual: [OracleRawCell] = [ .string("V"), .string("NUMBER"), .string("22"), .null, .null, .string("Y"), .string("N"), .string("LENGTH(\"NAME\")"), .string("YES"), .string("NO"), .string("NO"), .string("NO"), .null ] - XCTAssertNil(OracleSchemaQueries.parseColumnRow(virtual)?.identityGeneration) - XCTAssertEqual(OracleSchemaQueries.parseColumnRow(virtual)?.isVirtual, true) + #expect(OracleSchemaQueries.parseColumnRow(virtual)?.identityGeneration == nil) + #expect(OracleSchemaQueries.parseColumnRow(virtual)?.isVirtual == true) } - func testColumnsQueryReadsTheIdentityGenerationOnlyWhereTheReleaseHasIdentity() { + @Test func testColumnsQueryReadsTheIdentityGenerationOnlyWhereTheReleaseHasIdentity() { let modern = OracleSchemaQueries.columns(schema: "HR", table: "T", release: OracleServerRelease(major: 23)) - XCTAssertTrue(modern.contains("i.GENERATION_TYPE FROM SYS.ALL_TAB_IDENTITY_COLS i")) + #expect(modern.contains("i.GENERATION_TYPE FROM SYS.ALL_TAB_IDENTITY_COLS i")) let legacy = OracleSchemaQueries.columns(schema: "HR", table: "T", release: OracleServerRelease(major: 11)) - XCTAssertTrue(legacy.contains("NULL AS GENERATION_TYPE")) - XCTAssertFalse(legacy.contains("ALL_TAB_IDENTITY_COLS")) + #expect(legacy.contains("NULL AS GENERATION_TYPE")) + #expect(!legacy.contains("ALL_TAB_IDENTITY_COLS")) } /// Positions 8 and 9 are `VIRTUAL_COLUMN` and `IDENTITY_COLUMN`, in the order the projection writes them. - func testParseColumnRowDropsTheDefaultOfAnIdentityOrVirtualColumn() { + @Test func testParseColumnRowDropsTheDefaultOfAnIdentityOrVirtualColumn() { let identity: [OracleRawCell] = [ .string("ID"), .string("NUMBER"), .string("22"), .null, .null, .string("N"), .string("Y"), .string("\"HR\".\"ISEQ$$_73292\".nextval"), .string("NO"), .string("YES"), .string("NO"), .string("NO") ] - XCTAssertNil(OracleSchemaQueries.parseColumnRow(identity)?.defaultValue) + #expect(OracleSchemaQueries.parseColumnRow(identity)?.defaultValue == nil) let virtual: [OracleRawCell] = [ .string("TOTAL"), .string("NUMBER"), .string("22"), .null, .null, .string("Y"), .string("N"), .string("\"C_ZERO\"+1"), .string("YES"), .string("NO"), .string("NO"), .string("NO") ] - XCTAssertNil(OracleSchemaQueries.parseColumnRow(virtual)?.defaultValue) + #expect(OracleSchemaQueries.parseColumnRow(virtual)?.defaultValue == nil) } /// Positions 10 and 11 are `DEFAULT_ON_NULL` and `DEFAULT_ON_NULL_UPD`. - func testParseColumnRowReadsDefaultOnNull() { + @Test func testParseColumnRowReadsDefaultOnNull() { let onInsert: [OracleRawCell] = [ .string("STATUS"), .string("VARCHAR2"), .string("10"), .null, .null, .string("N"), .string("N"), .string("'x'"), .string("NO"), .string("NO"), .string("YES"), .string("NO") ] - XCTAssertEqual(OracleSchemaQueries.parseColumnRow(onInsert)?.defaultValue, "ON NULL 'x'") + #expect(OracleSchemaQueries.parseColumnRow(onInsert)?.defaultValue == "ON NULL 'x'") let onUpdate: [OracleRawCell] = [ .string("STATUS"), .string("VARCHAR2"), .string("10"), .null, .null, .string("N"), .string("N"), .string("'u'"), .string("NO"), .string("NO"), .string("YES"), .string("YES") ] - XCTAssertEqual( - OracleSchemaQueries.parseColumnRow(onUpdate)?.defaultValue, - "ON NULL FOR INSERT AND UPDATE 'u'" - ) + #expect(OracleSchemaQueries.parseColumnRow(onUpdate)?.defaultValue == "ON NULL FOR INSERT AND UPDATE 'u'") } - func testParseTableColumnRowReadsTheTableThenTheSameColumnShape() { + @Test func testParseTableColumnRowReadsTheTableThenTheSameColumnShape() { let row: [OracleRawCell] = [ .string("ORDERS"), .string("QTY"), .string("NUMBER"), .string("22"), .string("5"), .string("0"), .string("N"), .string("N"), .string("0"), .string("NO"), .string("NO"), .string("NO"), .string("NO") ] let parsed = OracleSchemaQueries.parseTableColumnRow(row) - XCTAssertEqual(parsed?.table, "ORDERS") - XCTAssertEqual(parsed?.column.name, "QTY") - XCTAssertEqual(parsed?.column.displayType, "NUMBER(5)") - XCTAssertEqual(parsed?.column.isNullable, false) - XCTAssertEqual(parsed?.column.defaultValue, "0") - XCTAssertNil(OracleSchemaQueries.parseTableColumnRow([.null, .string("QTY")])) + #expect(parsed?.table == "ORDERS") + #expect(parsed?.column.name == "QTY") + #expect(parsed?.column.displayType == "NUMBER(5)") + #expect(parsed?.column.isNullable == false) + #expect(parsed?.column.defaultValue == "0") + #expect(OracleSchemaQueries.parseTableColumnRow([.null, .string("QTY")]) == nil) } /// 11g has no `IDENTITY_COLUMN`, `DEFAULT_ON_NULL` or `USER_GENERATED`, and naming any of them fails the statement /// with ORA-00904. Its `ALL_TAB_COLUMNS` is `ALL_TAB_COLS` without the hidden columns. - func testAnElevenGServerIsNeverAskedForAColumnItDoesNotHave() { + @Test func testAnElevenGServerIsNeverAskedForAColumnItDoesNotHave() { let release = OracleServerRelease(major: 11) for sql in [ OracleSchemaQueries.columns(schema: "HR", table: "T", release: release), OracleSchemaQueries.allColumns(schema: "HR", release: release) ] { - XCTAssertFalse(sql.contains("c.IDENTITY_COLUMN"), sql) - XCTAssertFalse(sql.contains("c.DEFAULT_ON_NULL"), sql) - XCTAssertFalse(sql.contains("USER_GENERATED"), sql) - XCTAssertTrue(sql.contains("c.HIDDEN_COLUMN = 'NO'"), sql) - XCTAssertTrue(sql.contains("'NO' AS IDENTITY_COLUMN"), sql) - XCTAssertTrue(sql.contains("c.VIRTUAL_COLUMN"), sql) - XCTAssertTrue(sql.contains("c.DATA_DEFAULT"), sql) + #expect(!sql.contains("c.IDENTITY_COLUMN"), "\(sql)") + #expect(!sql.contains("c.DEFAULT_ON_NULL"), "\(sql)") + #expect(!sql.contains("USER_GENERATED"), "\(sql)") + #expect(sql.contains("c.HIDDEN_COLUMN = 'NO'"), "\(sql)") + #expect(sql.contains("'NO' AS IDENTITY_COLUMN"), "\(sql)") + #expect(sql.contains("c.VIRTUAL_COLUMN"), "\(sql)") + #expect(sql.contains("c.DATA_DEFAULT"), "\(sql)") } } /// From 12.1 `ALL_TAB_COLUMNS` is `ALL_TAB_COLS` where `USER_GENERATED = 'YES'`, which keeps invisible columns; /// filtering on `HIDDEN_COLUMN` there would drop them. - func testATwelveCServerReadsTheFlagsAndKeepsInvisibleColumns() { + @Test func testATwelveCServerReadsTheFlagsAndKeepsInvisibleColumns() { let release = OracleServerRelease(major: 19) for sql in [ OracleSchemaQueries.columns(schema: "HR", table: "T", release: release), OracleSchemaQueries.allColumns(schema: "HR", release: release) ] { - XCTAssertTrue(sql.contains("c.USER_GENERATED = 'YES'"), sql) - XCTAssertFalse(sql.contains("HIDDEN_COLUMN"), sql) - XCTAssertTrue(sql.contains("c.IDENTITY_COLUMN AS IDENTITY_COLUMN"), sql) - XCTAssertTrue(sql.contains("c.DEFAULT_ON_NULL AS DEFAULT_ON_NULL"), sql) - XCTAssertTrue(sql.contains("'NO' AS DEFAULT_ON_NULL_UPD"), sql) - XCTAssertFalse(sql.contains("c.DEFAULT_ON_NULL_UPD"), sql) + #expect(sql.contains("c.USER_GENERATED = 'YES'"), "\(sql)") + #expect(!sql.contains("HIDDEN_COLUMN"), "\(sql)") + #expect(sql.contains("c.IDENTITY_COLUMN AS IDENTITY_COLUMN"), "\(sql)") + #expect(sql.contains("c.DEFAULT_ON_NULL AS DEFAULT_ON_NULL"), "\(sql)") + #expect(sql.contains("'NO' AS DEFAULT_ON_NULL_UPD"), "\(sql)") + #expect(!sql.contains("c.DEFAULT_ON_NULL_UPD"), "\(sql)") } } - func testA23aiServerReadsDefaultOnNullForUpdate() { + @Test func testA23aiServerReadsDefaultOnNullForUpdate() { for sql in [ OracleSchemaQueries.columns(schema: "HR", table: "T", release: release23ai), OracleSchemaQueries.allColumns(schema: "HR", release: release23ai) ] { - XCTAssertTrue(sql.contains("c.DEFAULT_ON_NULL_UPD AS DEFAULT_ON_NULL_UPD"), sql) + #expect(sql.contains("c.DEFAULT_ON_NULL_UPD AS DEFAULT_ON_NULL_UPD"), "\(sql)") } } /// The bulk read is parsed by dropping its first cell, so after `TABLE_NAME` its projection has to be the /// per-table one, column for column, and the parser reads by position, so every release projects the same count. - func testTheBulkReadProjectsTheTableNameThenThePerTableColumns() { + @Test func testTheBulkReadProjectsTheTableNameThenThePerTableColumns() { for release in Self.releases { let single = projection(of: OracleSchemaQueries.columns(schema: "HR", table: "T", release: release)) let bulk = projection(of: OracleSchemaQueries.allColumns(schema: "HR", release: release)) - XCTAssertEqual(bulk.first, "c.TABLE_NAME") - XCTAssertEqual(Array(bulk.dropFirst()), single, "release \(release)") - XCTAssertEqual(single.count, 19, "release \(release)") - XCTAssertEqual(single.last, "c.OWNER", "release \(release)") + #expect(bulk.first == "c.TABLE_NAME") + #expect(Array(bulk.dropFirst()) == single, "release \(release)") + #expect(single.count == 19, "release \(release)") + #expect(single.last == "c.OWNER", "release \(release)") } } @@ -306,7 +294,7 @@ final class OracleSchemaQueriesTests: XCTestCase { /// The `ALL_TAB_COLS` columns each release lacks, from the Database Reference of 11.2, 12.1, 19c, 21c and 23ai. /// `VECTOR_INFO` is not documented before 23.4, so 23.3 is treated as lacking it. - func testNoReleaseIsAskedForATypeColumnItDoesNotHave() { + @Test func testNoReleaseIsAskedForATypeColumnItDoesNotHave() { let lacking: [(OracleServerRelease, [String])] = [ (OracleServerRelease(major: 11), ["IDENTITY_COLUMN", "DEFAULT_ON_NULL", "DEFAULT_ON_NULL_UPD", "USER_GENERATED", "VECTOR_INFO"]), (OracleServerRelease(major: 12), ["DEFAULT_ON_NULL_UPD", "VECTOR_INFO"]), @@ -320,20 +308,20 @@ final class OracleSchemaQueriesTests: XCTestCase { OracleSchemaQueries.allColumns(schema: "HR", release: release) ] { for column in columns { - XCTAssertFalse(names(sql, column), "release \(release) names \(column)") + #expect(!names(sql, column), "release \(release) names \(column)") } for column in ["CHAR_LENGTH", "CHAR_USED", "DATA_TYPE_OWNER", "DATA_TYPE_MOD"] { - XCTAssertTrue(names(sql, column), "release \(release) does not name \(column)") + #expect(names(sql, column), "release \(release) does not name \(column)") } } } } - func testVectorInfoIsALiteralNullWhereTheReleaseLacksIt() { + @Test func testVectorInfoIsALiteralNullWhereTheReleaseLacksIt() { let legacy = OracleSchemaQueries.columns(schema: "HR", table: "T", release: OracleServerRelease(major: 23, update: 3)) - XCTAssertTrue(legacy.contains("NULL AS VECTOR_INFO"), legacy) + #expect(legacy.contains("NULL AS VECTOR_INFO"), "\(legacy)") let modern = OracleSchemaQueries.columns(schema: "HR", table: "T", release: release23ai) - XCTAssertTrue(modern.contains("c.VECTOR_INFO AS VECTOR_INFO"), modern) + #expect(modern.contains("c.VECTOR_INFO AS VECTOR_INFO"), "\(modern)") } private func names(_ sql: String, _ column: String) -> Bool { @@ -373,126 +361,117 @@ final class OracleSchemaQueriesTests: XCTestCase { } /// `NVARCHAR2(100)` reports `DATA_LENGTH` 200 in AL16UTF16, which the app used to show as `nvarchar2(200)`. - func testParseColumnRowReadsTheCharacterLengthAndSemantics() { + @Test func testParseColumnRowReadsTheCharacterLengthAndSemantics() { let national = OracleSchemaQueries.parseColumnRow( columnRow("NOTE", type: "NVARCHAR2", length: "200", charLength: "100", charUsed: "C") ) - XCTAssertEqual(national?.displayType, "NVARCHAR2(100)") - XCTAssertEqual(national?.charLength, 100) - XCTAssertNil(national?.classificationTypeName) + #expect(national?.displayType == "NVARCHAR2(100)") + #expect(national?.charLength == 100) + #expect(national?.classificationTypeName == nil) let characters = OracleSchemaQueries.parseColumnRow( columnRow("NAME", type: "VARCHAR2", length: "200", charLength: "50", charUsed: "C") ) - XCTAssertEqual(characters?.displayType, "VARCHAR2(50 CHAR)") - XCTAssertEqual(characters?.dataLength, "200") + #expect(characters?.displayType == "VARCHAR2(50 CHAR)") + #expect(characters?.dataLength == "200") } - func testParseColumnRowQualifiesATypeFromAnotherSchema() { + @Test func testParseColumnRowQualifiesATypeFromAnotherSchema() { let geometry = OracleSchemaQueries.parseColumnRow( columnRow("SHAPE", type: "SDO_GEOMETRY", length: "1", typeOwner: "MDSYS") ) - XCTAssertEqual(geometry?.displayType, "\"MDSYS\".\"SDO_GEOMETRY\"") - XCTAssertEqual(geometry?.classificationTypeName, "SDO_GEOMETRY") + #expect(geometry?.displayType == "\"MDSYS\".\"SDO_GEOMETRY\"") + #expect(geometry?.classificationTypeName == "SDO_GEOMETRY") let reference = OracleSchemaQueries.parseColumnRow( columnRow("ADDR", type: "ADDRESS_T", length: "50", typeOwner: "HR", typeModifier: "REF") ) - XCTAssertEqual(reference?.displayType, "REF ADDRESS_T") - XCTAssertEqual(reference?.classificationTypeName, "ADDRESS_T") + #expect(reference?.displayType == "REF ADDRESS_T") + #expect(reference?.classificationTypeName == "ADDRESS_T") } - func testParseColumnRowKeepsTheCaseOfAQuotedTypeName() { + @Test func testParseColumnRowKeepsTheCaseOfAQuotedTypeName() { let mixed = OracleSchemaQueries.parseColumnRow( columnRow("M", type: "Mixed_T", length: "1", typeOwner: "HR") ) - XCTAssertEqual(mixed?.dataType, "Mixed_T") - XCTAssertEqual(mixed?.displayType, "\"Mixed_T\"") + #expect(mixed?.dataType == "Mixed_T") + #expect(mixed?.displayType == "\"Mixed_T\"") } - func testParseColumnRowReadsVectorInfo() { + @Test func testParseColumnRowReadsVectorInfo() { let vector = OracleSchemaQueries.parseColumnRow( columnRow("EMBEDDING", type: "VECTOR", length: "8200", charLength: "3", vectorInfo: "VECTOR(3,FLOAT32,DENSE)") ) - XCTAssertEqual(vector?.displayType, "VECTOR(3, FLOAT32)") - XCTAssertNil(vector?.charLength) + #expect(vector?.displayType == "VECTOR(3, FLOAT32)") + #expect(vector?.charLength == nil) } /// Oracle stores a virtual column's expression rewritten, `"A"+1` for `a + 1`, in `DATA_DEFAULT` (measured). - func testParseColumnRowReadsTheExpressionOfAVirtualColumn() { + @Test func testParseColumnRowReadsTheExpressionOfAVirtualColumn() { let virtual = OracleSchemaQueries.parseColumnRow( columnRow("V", type: "NUMBER", length: "22", storedDefault: "\"A\"+1\n ", isVirtual: true) ) - XCTAssertEqual(virtual?.generationExpression, "\"A\"+1") - XCTAssertNil(virtual?.defaultValue) + #expect(virtual?.generationExpression == "\"A\"+1") + #expect(virtual?.defaultValue == nil) let plain = OracleSchemaQueries.parseColumnRow( columnRow("D", type: "NUMBER", length: "22", storedDefault: "5 /* five */") ) - XCTAssertNil(plain?.generationExpression) - XCTAssertEqual(plain?.defaultValue, "5") + #expect(plain?.generationExpression == nil) + #expect(plain?.defaultValue == "5") } - func testParseColumnRowClassifiesADateAsATimestamp() { + @Test func testParseColumnRowClassifiesADateAsATimestamp() { let date = OracleSchemaQueries.parseColumnRow(columnRow("CREATED", type: "DATE", length: "7")) - XCTAssertEqual(date?.displayType, "DATE") - XCTAssertEqual(date?.classificationTypeName, "TIMESTAMP(0)") + #expect(date?.displayType == "DATE") + #expect(date?.classificationTypeName == "TIMESTAMP(0)") } - func testTheBulkRowReadsTheSameTypeColumns() { + @Test func testTheBulkRowReadsTheSameTypeColumns() { let row = [OracleRawCell.string("NOTES")] + columnRow("NOTE", type: "NVARCHAR2", length: "200", charLength: "100", charUsed: "C") - XCTAssertEqual(OracleSchemaQueries.parseTableColumnRow(row)?.column.displayType, "NVARCHAR2(100)") + #expect(OracleSchemaQueries.parseTableColumnRow(row)?.column.displayType == "NVARCHAR2(100)") } - func testParseColumnRowTreatsMissingTypeAsVarchar2() { + @Test func testParseColumnRowTreatsMissingTypeAsVarchar2() { let parsed = OracleSchemaQueries.parseColumnRow([.string("C"), .null, .null, .null, .null, .string("Y"), .string("Y")]) - XCTAssertEqual(parsed?.dataType, "VARCHAR2") - XCTAssertEqual(parsed?.isNullable, true) - XCTAssertEqual(parsed?.isPrimaryKey, true) + #expect(parsed?.dataType == "VARCHAR2") + #expect(parsed?.isNullable == true) + #expect(parsed?.isPrimaryKey == true) } - func testParseIndexRowReadsUniquenessAndPrimaryFlag() { + @Test func testParseIndexRowReadsUniquenessAndPrimaryFlag() { let row: [OracleRawCell] = [.string("PK_EMP"), .string("UNIQUE"), .string("EMP_ID"), .string("Y")] - XCTAssertEqual( - OracleSchemaQueries.parseIndexRow(row), - OracleIndexRow(name: "PK_EMP", isUnique: true, columnName: "EMP_ID", isPrimary: true) - ) + #expect(OracleSchemaQueries.parseIndexRow(row) == OracleIndexRow(name: "PK_EMP", isUnique: true, columnName: "EMP_ID", isPrimary: true)) let nonUnique: [OracleRawCell] = [.string("IX_DEPT"), .string("NONUNIQUE"), .string("DEPT_ID"), .string("N")] - XCTAssertEqual( - OracleSchemaQueries.parseIndexRow(nonUnique), - OracleIndexRow(name: "IX_DEPT", isUnique: false, columnName: "DEPT_ID", isPrimary: false) - ) + #expect(OracleSchemaQueries.parseIndexRow(nonUnique) == OracleIndexRow(name: "IX_DEPT", isUnique: false, columnName: "DEPT_ID", isPrimary: false)) } - func testParseIndexRowReturnsNilWithoutAColumn() { - XCTAssertNil(OracleSchemaQueries.parseIndexRow([.string("IX"), .string("UNIQUE"), .null, .string("N")])) + @Test func testParseIndexRowReturnsNilWithoutAColumn() { + #expect(OracleSchemaQueries.parseIndexRow([.string("IX"), .string("UNIQUE"), .null, .string("N")]) == nil) } - func testParseForeignKeyRowReadsReferenceAndDeleteRule() { + @Test func testParseForeignKeyRowReadsReferenceAndDeleteRule() { let row: [OracleRawCell] = [ .string("FK_EMP_DEPT"), .string("DEPT_ID"), .string("DEPARTMENTS"), .string("ID"), .string("CASCADE"), .string("HR") ] - XCTAssertEqual( - OracleSchemaQueries.parseForeignKeyRow(row), - OracleForeignKeyRow( + #expect(OracleSchemaQueries.parseForeignKeyRow(row) == OracleForeignKeyRow( constraintName: "FK_EMP_DEPT", columnName: "DEPT_ID", referencedTable: "DEPARTMENTS", referencedColumn: "ID", referencedSchema: "HR", deleteRule: "CASCADE" - ) - ) + )) } - func testParseForeignKeyRowDefaultsDeleteRule() { + @Test func testParseForeignKeyRowDefaultsDeleteRule() { let row: [OracleRawCell] = [ .string("FK"), .string("C"), .string("T"), .string("ID"), .null, .null ] - XCTAssertEqual(OracleSchemaQueries.parseForeignKeyRow(row)?.deleteRule, "NO ACTION") - XCTAssertNil(OracleSchemaQueries.parseForeignKeyRow(row)?.referencedSchema) + #expect(OracleSchemaQueries.parseForeignKeyRow(row)?.deleteRule == "NO ACTION") + #expect(OracleSchemaQueries.parseForeignKeyRow(row)?.referencedSchema == nil) } } diff --git a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleServerOutputTests.swift b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleServerOutputTests.swift index 5ca3f5e927..a86c0581a1 100644 --- a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleServerOutputTests.swift +++ b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleServerOutputTests.swift @@ -1,16 +1,17 @@ +import Foundation @testable import TableProOracleCore -import XCTest +import Testing /// A `DBMS_OUTPUT` line can be 32767 bytes and a SQL value on a `MAX_STRING_SIZE=STANDARD` database only 4000, so the /// driver reads each line as fixed-width frames (a marker, the piece length, the padded piece) and rejoins them here. -final class OracleServerOutputTests: XCTestCase { +struct OracleServerOutputTests { private func frame(_ marker: Character, _ content: String) -> String { let length = String(format: "%03d", content.count) let padded = content.padding(toLength: OracleServerOutput.pieceLength, withPad: " ", startingAt: 0) return "\(marker)\(length)\(padded)" } - func testFramesJoinIntoTheLinesTheyCameFrom() { + @Test func testFramesJoinIntoTheLinesTheyCameFrom() { let output = OracleServerOutput.read( pieces: [ frame("N", "Hello"), @@ -22,76 +23,76 @@ final class OracleServerOutputTests: XCTestCase { reportedCount: 4, cap: 10 ) - XCTAssertEqual(output, OracleServerOutput( + #expect(output == OracleServerOutput( lines: ["Hello", "", "first half second half", "last"], isTruncated: false )) } - func testTrailingSpacesInAPieceSurviveThePadding() { + @Test func testTrailingSpacesInAPieceSurviveThePadding() { let output = OracleServerOutput.read(pieces: [frame("N", "value ")], reportedCount: 1, cap: 10) - XCTAssertEqual(output.lines, ["value "]) + #expect(output.lines == ["value "]) } - func testMoreLinesThanTheCapReportsTruncation() { + @Test func testMoreLinesThanTheCapReportsTruncation() { let output = OracleServerOutput.read( pieces: [frame("N", "a"), frame("N", "b"), frame("N", "c")], reportedCount: 4, cap: 3 ) - XCTAssertEqual(output, OracleServerOutput(lines: ["a", "b", "c"], isTruncated: true)) + #expect(output == OracleServerOutput(lines: ["a", "b", "c"], isTruncated: true)) } - func testAnEmptyBufferReadsAsNoOutput() { - XCTAssertEqual(OracleServerOutput.read(pieces: [], reportedCount: 0, cap: 10), .empty) + @Test func testAnEmptyBufferReadsAsNoOutput() { + #expect(OracleServerOutput.read(pieces: [], reportedCount: 0, cap: 10) == .empty) } - func testAFrameWithoutAHeaderIsSkipped() { + @Test func testAFrameWithoutAHeaderIsSkipped() { let output = OracleServerOutput.read(pieces: [nil, "", frame("N", "kept")], reportedCount: 1, cap: 10) - XCTAssertEqual(output.lines, ["kept"]) + #expect(output.lines == ["kept"]) } - func testAFrameIsShortEnoughForAStandardVarchar() { - XCTAssertLessThanOrEqual(OracleServerOutput.frameWidth * 4, 4_000) + @Test func testAFrameIsShortEnoughForAStandardVarchar() { + #expect(OracleServerOutput.frameWidth * 4 <= 4_000) } /// The drain reaches `DBMS_OUTPUT` only through a `CALL` inside `EXECUTE IMMEDIATE`, so its name resolves at SQL /// level and a package named `SYS` in a schema the session switched into cannot capture it. Measured on 23ai: the /// `BEGIN SYS.DBMS_OUTPUT... END;` form was captured across schemas, the `CALL` form was not. - func testDbmsOutputIsReachedOnlyThroughASqlLevelCall() { + @Test func testDbmsOutputIsReachedOnlyThroughASqlLevelCall() { for sql in [OracleServerOutput.enableStatement, OracleServerOutput.drainBlock(maxLines: 10)] { let mentions = sql.components(separatedBy: "DBMS_OUTPUT").count - 1 let throughCall = sql.components(separatedBy: "CALL SYS.DBMS_OUTPUT").count - 1 - XCTAssertEqual(mentions, throughCall, "DBMS_OUTPUT is named outside a CALL in:\n\(sql)") + #expect(mentions == throughCall, "DBMS_OUTPUT is named outside a CALL in:\n\(sql)") } } /// The drain declares no variable of a `SYS` collection type: naming one in PL/SQL resolves it through a package /// named `SYS` in the current schema, and a locally declared collection type cannot feed the `TABLE` operator, so /// the block reads its pieces back out of a CLOB at SQL level instead. - func testTheDrainNamesNoSysCollectionType() { + @Test func testTheDrainNamesNoSysCollectionType() { let block = OracleServerOutput.drainBlock(maxLines: 10) for type in ["DBMSOUTPUT_LINESARRAY", "ODCIVARCHAR2LIST"] { - XCTAssertFalse(block.contains(type), "\(type) is named in the drain block:\n\(block)") + #expect(!block.contains(type), "\(type) is named in the drain block:\n\(block)") } } - func testEnableIsASqlLevelCall() { - XCTAssertEqual(OracleServerOutput.enableStatement, "CALL SYS.DBMS_OUTPUT.ENABLE(NULL)") + @Test func testEnableIsASqlLevelCall() { + #expect(OracleServerOutput.enableStatement == "CALL SYS.DBMS_OUTPUT.ENABLE(NULL)") } - func testTheBindsAppearInTheOrderTheyAreAppended() throws { + @Test func testTheBindsAppearInTheOrderTheyAreAppended() throws { let block = OracleServerOutput.drainBlock(maxLines: 10) - let lineCount = try XCTUnwrap(block.range(of: ":" + OracleServerOutput.lineCountBindName)) - let pieceCount = try XCTUnwrap(block.range(of: ":" + OracleServerOutput.pieceCountBindName)) - let pieces = try XCTUnwrap(block.range(of: ":" + OracleServerOutput.piecesBindName)) - XCTAssertLessThan(lineCount.lowerBound, pieceCount.lowerBound) - XCTAssertLessThan(pieceCount.lowerBound, pieces.lowerBound) + let lineCount = try #require(block.range(of: ":" + OracleServerOutput.lineCountBindName)) + let pieceCount = try #require(block.range(of: ":" + OracleServerOutput.pieceCountBindName)) + let pieces = try #require(block.range(of: ":" + OracleServerOutput.piecesBindName)) + #expect(lineCount.lowerBound < pieceCount.lowerBound) + #expect(pieceCount.lowerBound < pieces.lowerBound) } - func testTheBlockStopsOneLinePastTheCap() { + @Test func testTheBlockStopsOneLinePastTheCap() { let block = OracleServerOutput.drainBlock(maxLines: 250) - XCTAssertTrue(block.contains("l_cap CONSTANT PLS_INTEGER := 250;")) - XCTAssertTrue(block.contains("IF l_count > l_cap THEN")) + #expect(block.contains("l_cap CONSTANT PLS_INTEGER := 250;")) + #expect(block.contains("IF l_count > l_cap THEN")) } } diff --git a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleServerReleaseTests.swift b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleServerReleaseTests.swift index 0d4bba94b0..79d29c4102 100644 --- a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleServerReleaseTests.swift +++ b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleServerReleaseTests.swift @@ -1,31 +1,32 @@ +import Foundation @testable import TableProOracleCore -import XCTest +import Testing -final class OracleServerReleaseTests: XCTestCase { - func testIdentityColumnsArriveInTwelveC() { - XCTAssertFalse(OracleServerRelease(major: 11).hasIdentityColumns) - XCTAssertTrue(OracleServerRelease(major: 12).hasIdentityColumns) - XCTAssertTrue(OracleServerRelease(major: 23).hasIdentityColumns) +struct OracleServerReleaseTests { + @Test func testIdentityColumnsArriveInTwelveC() { + #expect(!OracleServerRelease(major: 11).hasIdentityColumns) + #expect(OracleServerRelease(major: 12).hasIdentityColumns) + #expect(OracleServerRelease(major: 23).hasIdentityColumns) } - func testDefaultOnNullForUpdateArrivesIn23ai() { - XCTAssertFalse(OracleServerRelease(major: 12).hasDefaultOnNullForUpdate) - XCTAssertFalse(OracleServerRelease(major: 21).hasDefaultOnNullForUpdate) - XCTAssertTrue(OracleServerRelease(major: 23).hasDefaultOnNullForUpdate) + @Test func testDefaultOnNullForUpdateArrivesIn23ai() { + #expect(!OracleServerRelease(major: 12).hasDefaultOnNullForUpdate) + #expect(!OracleServerRelease(major: 21).hasDefaultOnNullForUpdate) + #expect(OracleServerRelease(major: 23).hasDefaultOnNullForUpdate) } - func testTheUpdateDefaultsToZero() { - XCTAssertEqual(OracleServerRelease(major: 19).update, 0) - XCTAssertEqual(OracleServerRelease(major: 23, update: 26).update, 26) + @Test func testTheUpdateDefaultsToZero() { + #expect(OracleServerRelease(major: 19).update == 0) + #expect(OracleServerRelease(major: 23, update: 26).update == 26) } /// 26ai reports itself as release 23, update 26 (`23.26.3.0.0`, measured). - func testVectorInfoArrivesIn23ai4() { - XCTAssertFalse(OracleServerRelease(major: 21).hasVectorInfo) - XCTAssertFalse(OracleServerRelease(major: 23).hasVectorInfo) - XCTAssertFalse(OracleServerRelease(major: 23, update: 3).hasVectorInfo) - XCTAssertTrue(OracleServerRelease(major: 23, update: 4).hasVectorInfo) - XCTAssertTrue(OracleServerRelease(major: 23, update: 26).hasVectorInfo) - XCTAssertTrue(OracleServerRelease(major: 26).hasVectorInfo) + @Test func testVectorInfoArrivesIn23ai4() { + #expect(!OracleServerRelease(major: 21).hasVectorInfo) + #expect(!OracleServerRelease(major: 23).hasVectorInfo) + #expect(!OracleServerRelease(major: 23, update: 3).hasVectorInfo) + #expect(OracleServerRelease(major: 23, update: 4).hasVectorInfo) + #expect(OracleServerRelease(major: 23, update: 26).hasVectorInfo) + #expect(OracleServerRelease(major: 26).hasVectorInfo) } } diff --git a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleSessionTransactionTests.swift b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleSessionTransactionTests.swift index d12aa1aee1..c5a325377d 100644 --- a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleSessionTransactionTests.swift +++ b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleSessionTransactionTests.swift @@ -1,141 +1,142 @@ +import Foundation @testable import TableProOracleCore -import XCTest +import Testing /// Oracle commits nothing on its own, so a write the driver runs without the commit flag stays invisible to every /// other session, and holds its locks, until something commits it. These pin when the flag is sent. -final class OracleSessionTransactionTests: XCTestCase { - func testAWriteOutsideATransactionCommitsAsItRuns() throws { +struct OracleSessionTransactionTests { + @Test func testAWriteOutsideATransactionCommitsAsItRuns() throws { var transaction = OracleSessionTransaction() - XCTAssertTrue(try transaction.admit(.other, on: 1)) - XCTAssertFalse(transaction.isOpen) + #expect(try transaction.admit(.other, on: 1)) + #expect(!transaction.isOpen) } - func testAQueryNeverCarriesACommit() throws { + @Test func testAQueryNeverCarriesACommit() throws { var transaction = OracleSessionTransaction() - XCTAssertFalse(try transaction.admit(.query, on: 1)) + #expect(!(try transaction.admit(.query, on: 1))) transaction.open() - XCTAssertFalse(try transaction.admit(.query, on: 1)) + #expect(!(try transaction.admit(.query, on: 1))) } - func testNothingCommitsInsideATransactionUntilItEnds() throws { + @Test func testNothingCommitsInsideATransactionUntilItEnds() throws { var transaction = OracleSessionTransaction() transaction.open() - XCTAssertFalse(try transaction.admit(.other, on: 1)) - XCTAssertFalse(try transaction.admit(.other, on: 1)) + #expect(!(try transaction.admit(.other, on: 1))) + #expect(!(try transaction.admit(.other, on: 1))) - XCTAssertFalse(try transaction.admit(.endsTransaction, on: 1)) + #expect(!(try transaction.admit(.endsTransaction, on: 1))) transaction.statementSucceeded(.endsTransaction, on: 1) - XCTAssertFalse(transaction.isOpen) - XCTAssertTrue(try transaction.admit(.other, on: 1)) + #expect(!transaction.isOpen) + #expect(try transaction.admit(.other, on: 1)) } - func testACommitTheServerRolledBackEndsTheTransaction() throws { + @Test func testACommitTheServerRolledBackEndsTheTransaction() throws { var transaction = OracleSessionTransaction() transaction.open() _ = try transaction.admit(.other, on: 1) _ = try transaction.admit(.endsTransaction, on: 1) transaction.statementFailed(.endsTransaction, serverHoldsTransaction: false) - XCTAssertFalse(transaction.isOpen) + #expect(!transaction.isOpen) } - func testAMalformedCommitLeavesTheTransactionOpen() throws { + @Test func testAMalformedCommitLeavesTheTransactionOpen() throws { var transaction = OracleSessionTransaction() transaction.open() _ = try transaction.admit(.other, on: 1) _ = try transaction.admit(.endsTransaction, on: 1) transaction.statementFailed(.endsTransaction, serverHoldsTransaction: true) - XCTAssertTrue(transaction.isOpen) - XCTAssertFalse(try transaction.admit(.other, on: 1)) + #expect(transaction.isOpen) + #expect(!(try transaction.admit(.other, on: 1))) } - func testACommitWithNoConnectionToAskLeavesTheTransactionToTheNextWrite() throws { + @Test func testACommitWithNoConnectionToAskLeavesTheTransactionToTheNextWrite() throws { var transaction = OracleSessionTransaction() transaction.open() _ = try transaction.admit(.other, on: 1) _ = try transaction.admit(.endsTransaction, on: 1) transaction.statementFailed(.endsTransaction, serverHoldsTransaction: nil) - XCTAssertTrue(transaction.isOpen) - XCTAssertThrowsError(try transaction.admit(.other, on: 2)) { error in - XCTAssertEqual(error as? OracleCoreError, .transactionLost) + #expect(transaction.isOpen) + if let error = #expect(throws: (any Error).self, performing: { try transaction.admit(.other, on: 2) }) { + #expect(error as? OracleCoreError == .transactionLost) } } - func testAFailedWriteLeavesTheTransactionOpen() throws { + @Test func testAFailedWriteLeavesTheTransactionOpen() throws { var transaction = OracleSessionTransaction() transaction.open() _ = try transaction.admit(.other, on: 1) transaction.statementFailed(.other, serverHoldsTransaction: false) - XCTAssertTrue(transaction.isOpen) + #expect(transaction.isOpen) } - func testACommitWithNoTransactionOpenIsSentAsIs() throws { + @Test func testACommitWithNoTransactionOpenIsSentAsIs() throws { var transaction = OracleSessionTransaction() - XCTAssertFalse(try transaction.admit(.endsTransaction, on: 1)) + #expect(!(try transaction.admit(.endsTransaction, on: 1))) transaction.statementSucceeded(.endsTransaction, on: 1) - XCTAssertFalse(transaction.isOpen) + #expect(!transaction.isOpen) } - func testASavepointOpensATransactionOnceOracleAcceptsIt() throws { + @Test func testASavepointOpensATransactionOnceOracleAcceptsIt() throws { var transaction = OracleSessionTransaction() - XCTAssertFalse(try transaction.admit(.opensTransaction, on: 1)) - XCTAssertFalse(transaction.isOpen) + #expect(!(try transaction.admit(.opensTransaction, on: 1))) + #expect(!transaction.isOpen) transaction.statementSucceeded(.opensTransaction, on: 1) - XCTAssertTrue(transaction.isOpen) - XCTAssertFalse(try transaction.admit(.other, on: 1)) + #expect(transaction.isOpen) + #expect(!(try transaction.admit(.other, on: 1))) } - func testARefusedSavepointOpensNothing() throws { + @Test func testARefusedSavepointOpensNothing() throws { var transaction = OracleSessionTransaction() _ = try transaction.admit(.opensTransaction, on: 1) transaction.statementFailed(.opensTransaction, serverHoldsTransaction: nil) - XCTAssertFalse(transaction.isOpen) - XCTAssertTrue(try transaction.admit(.other, on: 1)) + #expect(!transaction.isOpen) + #expect(try transaction.admit(.other, on: 1)) } - func testAWriteOnAReplacedConnectionReportsTheTransactionLost() throws { + @Test func testAWriteOnAReplacedConnectionReportsTheTransactionLost() throws { var transaction = OracleSessionTransaction() transaction.open() _ = try transaction.admit(.other, on: 1) - XCTAssertThrowsError(try transaction.admit(.other, on: 2)) { error in - XCTAssertEqual(error as? OracleCoreError, .transactionLost) + if let error = #expect(throws: (any Error).self, performing: { try transaction.admit(.other, on: 2) }) { + #expect(error as? OracleCoreError == .transactionLost) } - XCTAssertFalse(transaction.isOpen) - XCTAssertTrue(try transaction.admit(.other, on: 2)) + #expect(!transaction.isOpen) + #expect(try transaction.admit(.other, on: 2)) } - func testACommitOnAReplacedConnectionReportsTheTransactionLost() throws { + @Test func testACommitOnAReplacedConnectionReportsTheTransactionLost() throws { var transaction = OracleSessionTransaction() transaction.open() _ = try transaction.admit(.other, on: 1) - XCTAssertThrowsError(try transaction.admit(.endsTransaction, on: 2)) { error in - XCTAssertEqual(error as? OracleCoreError, .transactionLost) + if let error = #expect(throws: (any Error).self, performing: { try transaction.admit(.endsTransaction, on: 2) }) { + #expect(error as? OracleCoreError == .transactionLost) } - XCTAssertFalse(transaction.isOpen) + #expect(!transaction.isOpen) } - func testATransactionBindsToTheConnectionOfItsFirstWriteNotItsFirstRead() throws { + @Test func testATransactionBindsToTheConnectionOfItsFirstWriteNotItsFirstRead() throws { var transaction = OracleSessionTransaction() transaction.open() _ = try transaction.admit(.query, on: 1) - XCTAssertFalse(try transaction.admit(.other, on: 2)) - XCTAssertThrowsError(try transaction.admit(.other, on: 3)) + #expect(!(try transaction.admit(.other, on: 2))) + #expect(throws: (any Error).self) { try transaction.admit(.other, on: 3) } } - func testAQueryOnAReplacedConnectionStillRuns() throws { + @Test func testAQueryOnAReplacedConnectionStillRuns() throws { var transaction = OracleSessionTransaction() transaction.open() _ = try transaction.admit(.other, on: 1) - XCTAssertFalse(try transaction.admit(.query, on: 2)) - XCTAssertTrue(transaction.isOpen) + #expect(!(try transaction.admit(.query, on: 2))) + #expect(transaction.isOpen) } - func testOpeningAnOpenTransactionKeepsItsConnection() throws { + @Test func testOpeningAnOpenTransactionKeepsItsConnection() throws { var transaction = OracleSessionTransaction() transaction.open() _ = try transaction.admit(.other, on: 1) transaction.open() - XCTAssertThrowsError(try transaction.admit(.other, on: 2)) + #expect(throws: (any Error).self) { try transaction.admit(.other, on: 2) } } } diff --git a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleTLSMapperTests.swift b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleTLSMapperTests.swift index a3fc1020a0..be6823ac42 100644 --- a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleTLSMapperTests.swift +++ b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleTLSMapperTests.swift @@ -1,113 +1,113 @@ +import Foundation @testable import TableProOracleCore -import XCTest +import Testing -final class OracleTLSMapperTests: XCTestCase { +struct OracleTLSMapperTests { private let missingPath = "/nonexistent/tablepro-oracle-tests/ca.pem" - func testDisabledModeNeverConsultsTheFilesystem() throws { + @Test func testDisabledModeNeverConsultsTheFilesystem() throws { let description = OracleTLSDescription( mode: .disabled, caCertificatePath: missingPath, clientCertificatePath: missingPath, clientKeyPath: missingPath ) - XCTAssertNoThrow(try OracleTLSMapper.tls(for: description)) + #expect(throws: Never.self) { try OracleTLSMapper.tls(for: description) } } - func testRequiredModeWithNoCertificatePathsUsesSystemTrustRoots() { - XCTAssertNoThrow(try OracleTLSMapper.tls(for: OracleTLSDescription(mode: .required))) + @Test func testRequiredModeWithNoCertificatePathsUsesSystemTrustRoots() { + #expect(throws: Never.self) { try OracleTLSMapper.tls(for: OracleTLSDescription(mode: .required)) } } - func testRequiredModeIgnoresCAPathBecauseItDoesNotVerify() { + @Test func testRequiredModeIgnoresCAPathBecauseItDoesNotVerify() { let description = OracleTLSDescription(mode: .required, caCertificatePath: missingPath) - XCTAssertNoThrow(try OracleTLSMapper.tls(for: description)) + #expect(throws: Never.self) { try OracleTLSMapper.tls(for: description) } } - func testUnreadableCAPathThrowsCertificateUnavailable() { + @Test func testUnreadableCAPathThrowsCertificateUnavailable() { let description = OracleTLSDescription(mode: .verifyCA, caCertificatePath: missingPath) assertCertificateUnavailable(for: description, field: .certificateAuthority) } - func testUnreadableClientCertificateThrowsCertificateUnavailable() { + @Test func testUnreadableClientCertificateThrowsCertificateUnavailable() { let description = OracleTLSDescription(mode: .required, clientCertificatePath: missingPath) assertCertificateUnavailable(for: description, field: .clientCertificate) } - func testUnreadableClientKeyThrowsCertificateUnavailable() { + @Test func testUnreadableClientKeyThrowsCertificateUnavailable() { let description = OracleTLSDescription(mode: .required, clientKeyPath: missingPath) assertCertificateUnavailable(for: description, field: .clientKey) } - func testEmptyPathsAreTreatedAsAbsent() { + @Test func testEmptyPathsAreTreatedAsAbsent() { let description = OracleTLSDescription( mode: .verifyIdentity, caCertificatePath: "", clientCertificatePath: "", clientKeyPath: "" ) - XCTAssertNil(description.caCertificatePath) - XCTAssertNil(description.clientCertificatePath) - XCTAssertNil(description.clientKeyPath) - XCTAssertNoThrow(try OracleTLSMapper.tls(for: description)) + #expect(description.caCertificatePath == nil) + #expect(description.clientCertificatePath == nil) + #expect(description.clientKeyPath == nil) + #expect(throws: Never.self) { try OracleTLSMapper.tls(for: description) } } - func testVerifyCAWithNoCAPathThrowsCertificateAuthorityRequired() { + @Test func testVerifyCAWithNoCAPathThrowsCertificateAuthorityRequired() { for path in [nil, "", " "] { let description = OracleTLSDescription(mode: .verifyCA, caCertificatePath: path) - XCTAssertThrowsError(try OracleTLSMapper.tls(for: description)) { error in - XCTAssertEqual(error as? OracleCoreError, .certificateAuthorityRequired) + if let error = #expect(throws: (any Error).self, performing: { try OracleTLSMapper.tls(for: description) }) { + #expect(error as? OracleCoreError == .certificateAuthorityRequired) } } } - func testVerifyCANoCARefusalWinsOverUnreadableClientMaterial() { + @Test func testVerifyCANoCARefusalWinsOverUnreadableClientMaterial() { let description = OracleTLSDescription( mode: .verifyCA, clientCertificatePath: missingPath, clientKeyPath: missingPath ) - XCTAssertThrowsError(try OracleTLSMapper.tls(for: description)) { error in - XCTAssertEqual(error as? OracleCoreError, .certificateAuthorityRequired) + if let error = #expect(throws: (any Error).self, performing: { try OracleTLSMapper.tls(for: description) }) { + #expect(error as? OracleCoreError == .certificateAuthorityRequired) } } - func testVerifyIdentityWithNoCAPathStillConnectsOverTLS() throws { + @Test func testVerifyIdentityWithNoCAPathStillConnectsOverTLS() throws { let description = OracleTLSDescription(mode: .verifyIdentity) - XCTAssertNotNil(try OracleTLSMapper.tls(for: description).sslContext) + #expect(try OracleTLSMapper.tls(for: description).sslContext != nil) } - func testWhitespaceOnlyPathsAreTreatedAsAbsent() { + @Test func testWhitespaceOnlyPathsAreTreatedAsAbsent() { let description = OracleTLSDescription( mode: .verifyIdentity, caCertificatePath: " ", clientCertificatePath: " ", clientKeyPath: "\t" ) - XCTAssertNil(description.caCertificatePath) - XCTAssertNil(description.clientCertificatePath) - XCTAssertNil(description.clientKeyPath) + #expect(description.caCertificatePath == nil) + #expect(description.clientCertificatePath == nil) + #expect(description.clientKeyPath == nil) } - func testCertificateVerificationPerMode() { - XCTAssertEqual(OracleTLSMapper.certificateVerification(for: .verifyIdentity), .fullVerification) - XCTAssertEqual(OracleTLSMapper.certificateVerification(for: .verifyCA), .noHostnameVerification) - XCTAssertEqual(OracleTLSMapper.certificateVerification(for: .required), .none) - XCTAssertEqual(OracleTLSMapper.certificateVerification(for: .disabled), .none) + @Test func testCertificateVerificationPerMode() { + #expect(OracleTLSMapper.certificateVerification(for: .verifyIdentity) == .fullVerification) + #expect(OracleTLSMapper.certificateVerification(for: .verifyCA) == .noHostnameVerification) + #expect(OracleTLSMapper.certificateVerification(for: .required) == .none) + #expect(OracleTLSMapper.certificateVerification(for: .disabled) == .none) } private func assertCertificateUnavailable( for description: OracleTLSDescription, field: OracleCertificateField, - file: StaticString = #filePath, - line: UInt = #line + sourceLocation: SourceLocation = #_sourceLocation ) { - XCTAssertThrowsError(try OracleTLSMapper.tls(for: description), file: file, line: line) { error in + if let error = #expect(throws: (any Error).self, sourceLocation: sourceLocation, performing: { try OracleTLSMapper.tls(for: description) }) { guard case .certificateUnavailable(let thrownField, let path) = error as? OracleCoreError else { - XCTFail("expected certificateUnavailable, got \(error)", file: file, line: line) + Issue.record("expected certificateUnavailable, got \(error)", sourceLocation: sourceLocation) return } - XCTAssertEqual(thrownField, field, file: file, line: line) - XCTAssertEqual(path, missingPath, file: file, line: line) + #expect(thrownField == field, sourceLocation: sourceLocation) + #expect(path == missingPath, sourceLocation: sourceLocation) } } } diff --git a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleTransactionRoleTests.swift b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleTransactionRoleTests.swift index b79c3b2fdc..e54664037a 100644 --- a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleTransactionRoleTests.swift +++ b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleTransactionRoleTests.swift @@ -1,10 +1,11 @@ +import Foundation @testable import TableProOracleCore -import XCTest +import Testing /// Whether a statement commits as it runs is decided from its first words, so a misread one either leaves a write /// uncommitted or commits away a savepoint or a lock the user meant to hold. -final class OracleTransactionRoleTests: XCTestCase { - func testQueriesAreReadsWhateverLeadsThem() { +struct OracleTransactionRoleTests { + @Test func testQueriesAreReadsWhateverLeadsThem() { for sql in [ "SELECT * FROM emp", "select 1 from dual", @@ -13,11 +14,11 @@ final class OracleTransactionRoleTests: XCTestCase { "-- latest hires\nSELECT * FROM emp", "/* report */ SELECT 1 FROM dual", ] { - XCTAssertEqual(OracleTransactionRole(of: sql), .query, sql) + #expect(OracleTransactionRole(of: sql) == .query, "\(sql)") } } - func testStatementsThatOnlyMeanSomethingInsideATransactionOpenOne() { + @Test func testStatementsThatOnlyMeanSomethingInsideATransactionOpenOne() { for sql in [ "SAVEPOINT before_raise", "savepoint a", @@ -27,11 +28,11 @@ final class OracleTransactionRoleTests: XCTestCase { "LOCK TABLE emp IN EXCLUSIVE MODE", "-- hold it\nLOCK TABLE emp IN SHARE MODE NOWAIT", ] { - XCTAssertEqual(OracleTransactionRole(of: sql), .opensTransaction, sql) + #expect(OracleTransactionRole(of: sql) == .opensTransaction, "\(sql)") } } - func testCommitAndAFullRollbackEndTheTransaction() { + @Test func testCommitAndAFullRollbackEndTheTransaction() { for sql in [ "COMMIT", "commit work", @@ -41,29 +42,29 @@ final class OracleTransactionRoleTests: XCTestCase { "rollback work", "/* undo */ ROLLBACK", ] { - XCTAssertEqual(OracleTransactionRole(of: sql), .endsTransaction, sql) + #expect(OracleTransactionRole(of: sql) == .endsTransaction, "\(sql)") } } - func testARollbackToASavepointKeepsTheTransactionOpen() { + @Test func testARollbackToASavepointKeepsTheTransactionOpen() { for sql in ["ROLLBACK TO before_raise", "ROLLBACK TO SAVEPOINT a", "rollback work to savepoint a"] { - XCTAssertEqual(OracleTransactionRole(of: sql), .other, sql) + #expect(OracleTransactionRole(of: sql) == .other, "\(sql)") } } - func testSettlingAnInDoubtDistributedTransactionLeavesTheSessionsOwnAlone() { + @Test func testSettlingAnInDoubtDistributedTransactionLeavesTheSessionsOwnAlone() { for sql in ["COMMIT FORCE '1.2.3'", "commit work force '1.2.3', 42", "ROLLBACK FORCE '1.2.3'", "ROLLBACK WORK FORCE '1.2.3'"] { - XCTAssertEqual(OracleTransactionRole(of: sql), .other, sql) + #expect(OracleTransactionRole(of: sql) == .other, "\(sql)") } } - func testAVeryLongStatementIsReadFromItsHead() { + @Test func testAVeryLongStatementIsReadFromItsHead() { let values = Array(repeating: "(1)", count: 500_000).joined(separator: ", ") - XCTAssertEqual(OracleTransactionRole(of: "INSERT INTO t VALUES \(values)"), .other) - XCTAssertEqual(OracleTransactionRole(of: "SELECT \(values) FROM dual"), .query) + #expect(OracleTransactionRole(of: "INSERT INTO t VALUES \(values)") == .other) + #expect(OracleTransactionRole(of: "SELECT \(values) FROM dual") == .query) } - func testEverythingElseIsOrdinaryWork() { + @Test func testEverythingElseIsOrdinaryWork() { for sql in [ "INSERT INTO emp VALUES (1)", "UPDATE emp SET sal = sal * 2", @@ -80,7 +81,7 @@ final class OracleTransactionRoleTests: XCTestCase { "", " ", ] { - XCTAssertEqual(OracleTransactionRole(of: sql), .other, sql) + #expect(OracleTransactionRole(of: sql) == .other, "\(sql)") } } } diff --git a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleViewDefinitionTests.swift b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleViewDefinitionTests.swift index 94e7519648..1af7dbb929 100644 --- a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleViewDefinitionTests.swift +++ b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleViewDefinitionTests.swift @@ -1,28 +1,29 @@ +import Foundation @testable import TableProOracleCore -import XCTest +import Testing /// `ALL_VIEWS.TEXT_VC` is `VARCHAR2(4000)` and cuts a longer view text mid-identifier, which Show DDL and a dump then /// handed out as the whole view. -final class OracleViewDefinitionTests: XCTestCase { - func testReadsTheStoredAndReturnedLengthsBesideTheText() { +struct OracleViewDefinitionTests { + @Test func testReadsTheStoredAndReturnedLengthsBesideTheText() { let sql = OracleSchemaQueries.viewDefinition(schema: "HR", view: "V") - XCTAssertTrue(sql.hasPrefix("SELECT TEXT_VC, TEXT_LENGTH, LENGTHB(TEXT_VC) FROM")) - XCTAssertTrue(sql.contains("VIEW_NAME = 'V' AND OWNER = 'HR'")) + #expect(sql.hasPrefix("SELECT TEXT_VC, TEXT_LENGTH, LENGTHB(TEXT_VC) FROM")) + #expect(sql.contains("VIEW_NAME = 'V' AND OWNER = 'HR'")) } /// Measured on Oracle 23 Free: a 160-column view read `TEXT_LENGTH` 6892 against `LENGTHB(TEXT_VC)` 4000. - func testALongerStoredTextIsTruncated() { - XCTAssertTrue(OracleSchemaQueries.isViewTextTruncated(storedLength: "6892", returnedLength: "4000")) + @Test func testALongerStoredTextIsTruncated() { + #expect(OracleSchemaQueries.isViewTextTruncated(storedLength: "6892", returnedLength: "4000")) } /// Measured: a 58-byte Vietnamese text of 48 characters read 58 for both. - func testAWholeTextIsNotTruncated() { - XCTAssertFalse(OracleSchemaQueries.isViewTextTruncated(storedLength: "58", returnedLength: "58")) - XCTAssertFalse(OracleSchemaQueries.isViewTextTruncated(storedLength: " 29", returnedLength: "29 ")) + @Test func testAWholeTextIsNotTruncated() { + #expect(!OracleSchemaQueries.isViewTextTruncated(storedLength: "58", returnedLength: "58")) + #expect(!OracleSchemaQueries.isViewTextTruncated(storedLength: " 29", returnedLength: "29 ")) } - func testAnUnreadableLengthIsNotReportedAsTruncation() { - XCTAssertFalse(OracleSchemaQueries.isViewTextTruncated(storedLength: nil, returnedLength: "4000")) - XCTAssertFalse(OracleSchemaQueries.isViewTextTruncated(storedLength: "6892", returnedLength: nil)) + @Test func testAnUnreadableLengthIsNotReportedAsTruncation() { + #expect(!OracleSchemaQueries.isViewTextTruncated(storedLength: nil, returnedLength: "4000")) + #expect(!OracleSchemaQueries.isViewTextTruncated(storedLength: "6892", returnedLength: nil)) } } diff --git a/Plugins/ClickHouseDriverTests/ClickHouseGeneratedColumnClassificationTests.swift b/Plugins/ClickHouseDriverTests/ClickHouseGeneratedColumnClassificationTests.swift index c8758fd985..9ede137660 100644 --- a/Plugins/ClickHouseDriverTests/ClickHouseGeneratedColumnClassificationTests.swift +++ b/Plugins/ClickHouseDriverTests/ClickHouseGeneratedColumnClassificationTests.swift @@ -6,14 +6,9 @@ import Testing struct ClickHouseGeneratedColumnClassificationTests { - @Test("MATERIALIZED columns are generated") - func materialized() { - #expect(clickhouseColumnIsGenerated(defaultKind: "MATERIALIZED")) - } - - @Test("ALIAS columns are generated") - func alias() { - #expect(clickhouseColumnIsGenerated(defaultKind: "ALIAS")) + @Test("MATERIALIZED and ALIAS columns are generated", arguments: ["MATERIALIZED", "ALIAS"]) + func generatedKinds(_ defaultKind: String) { + #expect(clickhouseColumnIsGenerated(defaultKind: defaultKind)) } @Test("DEFAULT columns stay insertable") diff --git a/Plugins/FormatPluginTests/JSONImportSkipTests.swift b/Plugins/FormatPluginTests/JSONImportSkipTests.swift index dbfbdc623b..2909c3e92a 100644 --- a/Plugins/FormatPluginTests/JSONImportSkipTests.swift +++ b/Plugins/FormatPluginTests/JSONImportSkipTests.swift @@ -238,10 +238,15 @@ struct JSONImportSkipTests { @Test("An import finishes while another reader in the process waits on a quiet pipe") func importIgnoresABlockedAsyncBytesReader() async throws { let pipe = Pipe() + let (reading, signalReading) = AsyncStream.makeStream() let blocker = Task { - for try await _ in pipe.fileHandleForReading.bytes {} + for try await _ in pipe.fileHandleForReading.bytes { + signalReading.yield() + } } - try await Task.sleep(nanoseconds: 100_000_000) + // Reading this byte puts the reader on the shared queue, and its next read parks there on the quiet pipe. + try pipe.fileHandleForWriting.write(contentsOf: Data([0x0A])) + for await _ in reading { break } let finished = await withTaskGroup(of: Bool.self) { group in group.addTask { diff --git a/Plugins/FormatPluginTests/SQLExportDDLRewriterTests.swift b/Plugins/FormatPluginTests/SQLExportDDLRewriterTests.swift index 25317090f6..4377738ffc 100644 --- a/Plugins/FormatPluginTests/SQLExportDDLRewriterTests.swift +++ b/Plugins/FormatPluginTests/SQLExportDDLRewriterTests.swift @@ -133,9 +133,11 @@ struct SQLExportDDLRewriterTests { #expect(Self.keeping.rewrite(Self.createView) == Self.createView) } - @Test("An unterminated quote is copied rather than parsed past") - func unterminatedQuoteIsCopied() { - let ddl = ") ENGINE=InnoDB COMMENT 'oops AUTO_INCREMENT=4" + @Test("An unterminated quote, and definer text outside a CREATE header, are copied rather than parsed past", arguments: [ + ") ENGINE=InnoDB COMMENT 'oops AUTO_INCREMENT=4", + "ALTER TABLE t ADD COLUMN c int; UPDATE t SET definer = 'a'@'b'", + ]) + func unparsedTextIsCopied(_ ddl: String) { #expect(Self.stripping.rewrite(ddl) == ddl) } @@ -158,12 +160,6 @@ struct SQLExportDDLRewriterTests { == "CREATE SQL SECURITY DEFINER VIEW v AS select 1 where definer = CURRENT_USER") } - @Test("A statement that never opened a CREATE header keeps its definer text") - func definerOutsideAHeaderSurvives() { - let ddl = "ALTER TABLE t ADD COLUMN c int; UPDATE t SET definer = 'a'@'b'" - #expect(Self.stripping.rewrite(ddl) == ddl) - } - /// Under `ANSI_QUOTES` the server quotes identifiers with `"` instead of a backtick. It still /// writes a literal backslash doubled and an embedded quote doubled, measured on MariaDB 12.3, /// so a name carrying a clause spelling reaches the scanner inside a quoted run either way. diff --git a/Plugins/MySQLDriverTests/MySQLCompareCapabilityTests.swift b/Plugins/MySQLDriverTests/MySQLCompareCapabilityTests.swift new file mode 100644 index 0000000000..1ba143da24 --- /dev/null +++ b/Plugins/MySQLDriverTests/MySQLCompareCapabilityTests.swift @@ -0,0 +1,20 @@ +import TableProPluginKit +import Testing + +struct MySQLCompareCapabilityTests { + @Test("The driver declares both compare capabilities Compare & Sync is gated on") + func declaresCompareCapabilities() { + let driver = MySQLPluginDriver(config: DriverConnectionConfig( + host: "", port: 0, username: "", password: "", database: "" + )) + + #expect( + driver.capabilities.contains(.schemaCompare), + "Without .schemaCompare, Compare & Sync refuses every structure comparison" + ) + #expect( + driver.capabilities.contains(.dataCompare), + "Without .dataCompare, Compare & Sync refuses every data comparison" + ) + } +} diff --git a/Plugins/MySQLDriverTests/MySQLForeignKeyClauseTests.swift b/Plugins/MySQLDriverTests/MySQLForeignKeyClauseTests.swift index e98bea383a..90d774f15e 100644 --- a/Plugins/MySQLDriverTests/MySQLForeignKeyClauseTests.swift +++ b/Plugins/MySQLDriverTests/MySQLForeignKeyClauseTests.swift @@ -156,14 +156,12 @@ struct MySQLForeignKeyClauseTests { #expect(parse(ddl).isEmpty) } - @Test("A table with no constraint at all yields no keys") - func noConstraints() { - #expect(parse("CREATE TABLE `t` (`id` int NOT NULL, PRIMARY KEY (`id`))").isEmpty) - } - - @Test("A CHECK constraint is not read as a foreign key") - func checkConstraint() { - #expect(parse("CREATE TABLE `t` (`a` int, CONSTRAINT `c` CHECK (`a` > 0))").isEmpty) + @Test("A table with no constraint, or only a CHECK constraint, yields no keys", arguments: [ + "CREATE TABLE `t` (`id` int NOT NULL, PRIMARY KEY (`id`))", + "CREATE TABLE `t` (`a` int, CONSTRAINT `c` CHECK (`a` > 0))", + ]) + func noForeignKeyConstraint(_ ddl: String) { + #expect(parse(ddl).isEmpty) } } diff --git a/Plugins/MySQLDriverTests/MySQLGeneratedColumnClassificationTests.swift b/Plugins/MySQLDriverTests/MySQLGeneratedColumnClassificationTests.swift index d48501ef4f..d31df81ccc 100644 --- a/Plugins/MySQLDriverTests/MySQLGeneratedColumnClassificationTests.swift +++ b/Plugins/MySQLDriverTests/MySQLGeneratedColumnClassificationTests.swift @@ -7,14 +7,9 @@ import TableProPluginKit import Testing struct MySQLGeneratedColumnClassificationTests { - @Test("STORED GENERATED is generated") - func storedGenerated() { - #expect(mysqlColumnIsGenerated(extra: "STORED GENERATED")) - } - - @Test("VIRTUAL GENERATED is generated") - func virtualGenerated() { - #expect(mysqlColumnIsGenerated(extra: "VIRTUAL GENERATED")) + @Test("STORED GENERATED and VIRTUAL GENERATED are generated", arguments: ["STORED GENERATED", "VIRTUAL GENERATED"]) + func generatedExtras(_ extra: String) { + #expect(mysqlColumnIsGenerated(extra: extra)) } @Test("Lowercase variants are handled") diff --git a/Plugins/MySQLDriverTests/MySQLStatementClassificationTests.swift b/Plugins/MySQLDriverTests/MySQLStatementClassificationTests.swift index 0fc2baa93e..93149ccc16 100644 --- a/Plugins/MySQLDriverTests/MySQLStatementClassificationTests.swift +++ b/Plugins/MySQLDriverTests/MySQLStatementClassificationTests.swift @@ -7,14 +7,12 @@ import TableProPluginKit import Testing struct MySQLStatementClassificationTests { - @Test("SELECT is read-only") - func selectIsReadOnly() { - #expect(mysqlStatementIsReadOnly("SELECT * FROM users")) - } - - @Test("Leading whitespace and lowercase are handled") - func whitespaceAndCase() { - #expect(mysqlStatementIsReadOnly(" \n select 1")) + @Test("SELECT is read-only, with leading whitespace and in lowercase too", arguments: [ + "SELECT * FROM users", + " \n select 1", + ]) + func selectIsReadOnly(_ statement: String) { + #expect(mysqlStatementIsReadOnly(statement)) } @Test("SHOW, DESCRIBE, and DESC are read-only") diff --git a/Plugins/PostgreSQLDriverTests/PostgreSQLCompareCapabilityTests.swift b/Plugins/PostgreSQLDriverTests/PostgreSQLCompareCapabilityTests.swift new file mode 100644 index 0000000000..30a5bcfcaf --- /dev/null +++ b/Plugins/PostgreSQLDriverTests/PostgreSQLCompareCapabilityTests.swift @@ -0,0 +1,20 @@ +import TableProPluginKit +import Testing + +struct PostgreSQLCompareCapabilityTests { + @Test("The driver declares both compare capabilities Compare & Sync is gated on") + func declaresCompareCapabilities() { + let driver = PostgreSQLPluginDriver(config: DriverConnectionConfig( + host: "", port: 0, username: "", password: "", database: "" + )) + + #expect( + driver.capabilities.contains(.schemaCompare), + "Without .schemaCompare, Compare & Sync refuses every structure comparison" + ) + #expect( + driver.capabilities.contains(.dataCompare), + "Without .dataCompare, Compare & Sync refuses every data comparison" + ) + } +} diff --git a/Plugins/RedisDriverTests/RedisBinaryValueTests.swift b/Plugins/RedisDriverTests/RedisBinaryValueTests.swift index d5f617ffbc..166db0cbd6 100644 --- a/Plugins/RedisDriverTests/RedisBinaryValueTests.swift +++ b/Plugins/RedisDriverTests/RedisBinaryValueTests.swift @@ -48,16 +48,8 @@ struct RedisWriteRoundTripTests { #expect(parsedValue(of: statements[0].statement) == Data("hello world".utf8)) } - @Test("quotes, backslashes, and newlines round-trip") - func specialCharactersRoundTrip() throws { - let value = "a\"b\\c\nd" - let statements = try updateStatements(newValue: .text(value)) - #expect(parsedValue(of: statements[0].statement) == Data(value.utf8)) - } - - @Test("non-ASCII text round-trips") - func unicodeRoundTrips() throws { - let value = "café ☕" + @Test("quotes, backslashes, newlines and non-ASCII text round-trip", arguments: ["a\"b\\c\nd", "café ☕"]) + func textRoundTrips(_ value: String) throws { let statements = try updateStatements(newValue: .text(value)) #expect(parsedValue(of: statements[0].statement) == Data(value.utf8)) } diff --git a/Plugins/RedisDriverTests/RedisCommandParserTests.swift b/Plugins/RedisDriverTests/RedisCommandParserTests.swift index 264e7131b3..7e6221bfea 100644 --- a/Plugins/RedisDriverTests/RedisCommandParserTests.swift +++ b/Plugins/RedisDriverTests/RedisCommandParserTests.swift @@ -21,10 +21,16 @@ struct RedisCommandParserKeyCommandTests { #expect(key == "mykey") } - @Test("GET missing key throws") - func getMissingKey() { + @Test("A key command missing an argument, or given a non-integer where it needs one, throws", arguments: [ + "GET", + "SET mykey", + "DEL", + "EXPIRE mykey abc", + "SCAN 0 COUNT abc", + ]) + func malformedKeyCommandThrows(_ command: String) { #expect(throws: RedisParseError.self) { - try RedisCommandParser.parse("GET") + try RedisCommandParser.parse(command) } } @@ -60,13 +66,6 @@ struct RedisCommandParserKeyCommandTests { #expect(options?.nx == true) } - @Test("SET missing value throws") - func setMissingValue() { - #expect(throws: RedisParseError.self) { - try RedisCommandParser.parse("SET mykey") - } - } - @Test("DEL parses single key") func delSingleKey() throws { let op = try RedisCommandParser.parse("DEL mykey") @@ -87,13 +86,6 @@ struct RedisCommandParserKeyCommandTests { #expect(keys == ["key1", "key2", "key3"]) } - @Test("DEL missing key throws") - func delMissingKey() { - #expect(throws: RedisParseError.self) { - try RedisCommandParser.parse("DEL") - } - } - @Test("KEYS parses pattern") func keysCommand() throws { let op = try RedisCommandParser.parse("KEYS user:*") @@ -161,13 +153,6 @@ struct RedisCommandParserKeyCommandTests { #expect(seconds == 300) } - @Test("EXPIRE with non-integer seconds throws") - func expireInvalidSeconds() { - #expect(throws: RedisParseError.self) { - try RedisCommandParser.parse("EXPIRE mykey abc") - } - } - @Test("RENAME parses key and newKey") func renameCommand() throws { let op = try RedisCommandParser.parse("RENAME oldkey newkey") @@ -241,13 +226,6 @@ struct RedisCommandParserKeyCommandTests { #expect(cursor == "18446744073709551615") } - @Test("A SCAN COUNT that is not an integer throws") - func scanWithInvalidCountThrows() { - #expect(throws: RedisParseError.self) { - try RedisCommandParser.parse("SCAN 0 COUNT abc") - } - } - @Test("SCAN carries its TYPE along with MATCH and COUNT") func scanWithType() throws { let op = try RedisCommandParser.parse("SCAN 0 MATCH u:* TYPE hash COUNT 5") @@ -474,10 +452,13 @@ struct RedisCommandParserSortedSetTests { #expect(flags == ["BYSCORE", "LIMIT", "0", "10", "WITHSCORES"]) } - @Test("ZRANGE with a LIMIT missing its count throws") - func zrangeWithShortLimitThrows() { + @Test("ZRANGE with a LIMIT missing its count, and ZADD with a non-numeric score, throw", arguments: [ + "ZRANGE z 0 -1 LIMIT 0", + "ZADD myzset notanumber member", + ]) + func malformedSortedSetCommandThrows(_ command: String) { #expect(throws: RedisParseError.self) { - try RedisCommandParser.parse("ZRANGE z 0 -1 LIMIT 0") + try RedisCommandParser.parse(command) } } @@ -511,13 +492,6 @@ struct RedisCommandParserSortedSetTests { #expect(scoreMembers[1].1 == Data("b".utf8)) } - @Test("ZADD with non-numeric score throws") - func zaddInvalidScore() { - #expect(throws: RedisParseError.self) { - try RedisCommandParser.parse("ZADD myzset notanumber member") - } - } - @Test("ZREM parses key and members") func zremCommand() throws { let op = try RedisCommandParser.parse("ZREM myzset a b") @@ -717,17 +691,10 @@ struct RedisCommandParserServerTests { // MARK: - Error Cases struct RedisCommandParserErrorTests { - @Test("Empty input throws emptySyntax") - func emptyInput() { - #expect(throws: RedisParseError.self) { - try RedisCommandParser.parse("") - } - } - - @Test("Whitespace-only input throws emptySyntax") - func whitespaceOnly() { + @Test("Empty and whitespace-only input throw emptySyntax", arguments: ["", " "]) + func emptyInput(_ input: String) { #expect(throws: RedisParseError.self) { - try RedisCommandParser.parse(" ") + try RedisCommandParser.parse(input) } } @@ -746,20 +713,12 @@ struct RedisCommandParserErrorTests { // MARK: - Tokenizer struct RedisCommandParserTokenizerTests { - @Test("Double-quoted strings are parsed correctly") - func doubleQuotedString() throws { - let op = try RedisCommandParser.parse("SET mykey \"hello world\"") - guard case .set(let key, let value, _) = op else { - Issue.record("Expected .set") - return - } - #expect(key == "mykey") - #expect(value == Data("hello world".utf8)) - } - - @Test("Single-quoted strings are parsed correctly") - func singleQuotedString() throws { - let op = try RedisCommandParser.parse("SET mykey 'hello world'") + @Test("Double- and single-quoted strings are parsed correctly", arguments: [ + "SET mykey \"hello world\"", + "SET mykey 'hello world'", + ]) + func quotedString(_ command: String) throws { + let op = try RedisCommandParser.parse(command) guard case .set(let key, let value, _) = op else { Issue.record("Expected .set") return @@ -809,17 +768,13 @@ struct RedisCommandParserTokenizerTests { #expect(value == Data("a\\b'c".utf8)) } - @Test("Text right after a closing quote is refused, as redis-cli refuses it") - func textAfterClosingQuoteThrows() { - #expect(throws: RedisParseError.self) { - try RedisCommandParser.parse("SET k a\"b c\"d") - } - } - - @Test("An unbalanced quote is refused") - func unbalancedQuoteThrows() { + @Test("Text right after a closing quote, as redis-cli refuses it, and an unbalanced quote are refused", arguments: [ + "SET k a\"b c\"d", + "SET k \"abc", + ]) + func malformedQuotingThrows(_ command: String) { #expect(throws: RedisParseError.self) { - try RedisCommandParser.parse("SET k \"abc") + try RedisCommandParser.parse(command) } } diff --git a/Plugins/SQLiteDriverTests/SQLiteCompareCapabilityTests.swift b/Plugins/SQLiteDriverTests/SQLiteCompareCapabilityTests.swift new file mode 100644 index 0000000000..7a309134f3 --- /dev/null +++ b/Plugins/SQLiteDriverTests/SQLiteCompareCapabilityTests.swift @@ -0,0 +1,20 @@ +import TableProPluginKit +import Testing + +struct SQLiteCompareCapabilityTests { + @Test("The driver declares both compare capabilities Compare & Sync is gated on") + func declaresCompareCapabilities() { + let driver = SQLitePluginDriver(config: DriverConnectionConfig( + host: "", port: 0, username: "", password: "", database: "" + )) + + #expect( + driver.capabilities.contains(.schemaCompare), + "Without .schemaCompare, Compare & Sync refuses every structure comparison" + ) + #expect( + driver.capabilities.contains(.dataCompare), + "Without .dataCompare, Compare & Sync refuses every data comparison" + ) + } +} diff --git a/Plugins/SQLiteDriverTests/SQLiteTypelessColumnTests.swift b/Plugins/SQLiteDriverTests/SQLiteTypelessColumnTests.swift new file mode 100644 index 0000000000..bb5e768449 --- /dev/null +++ b/Plugins/SQLiteDriverTests/SQLiteTypelessColumnTests.swift @@ -0,0 +1,37 @@ +// +// SQLiteTypelessColumnTests.swift +// SQLiteDriverTests +// + +import Foundation +import TableProPluginKit +import Testing + +/// SQLite reads `body` in `CREATE TABLE notes (tag TEXT, body)` with an empty type, and a structure save on such a +/// table used to be refused. The driver's half: the drop it is handed runs, whichever column goes. +struct SQLiteTypelessColumnTests { + @Test("A column drops from a table that holds a typeless column", arguments: ["tag", "body"]) + func dropBesideATypelessColumn(column: String) async throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("sqlite-typeless-\(UUID().uuidString).sqlite") + defer { try? FileManager.default.removeItem(at: url) } + let driver = SQLitePluginDriver(config: DriverConnectionConfig( + host: "", port: 0, username: "", password: "", database: url.path + )) + try await driver.connect() + defer { driver.disconnect() } + _ = try await driver.execute(query: "CREATE TABLE notes (tag TEXT, body)") + _ = try await driver.execute(query: "INSERT INTO notes VALUES ('a', 1)") + let before = try await driver.fetchColumns(table: "notes", schema: nil) + try #require(before.map(\.name) == ["tag", "body"]) + #expect(before[1].dataType.isEmpty, "body is declared without a type") + + let drop = try #require(driver.generateDropColumnSQL(table: "notes", columnName: column)) + _ = try await driver.execute(query: drop) + + let after = try await driver.fetchColumns(table: "notes", schema: nil) + #expect(after.map(\.name) == ["tag", "body"].filter { $0 != column }) + let count = try await driver.execute(query: "SELECT count(*) FROM notes") + #expect(count.rows.first?.first?.asText == "1", "The row stays") + } +} diff --git a/TablePro/Core/Database/DatabaseManager+OpenTransactions.swift b/TablePro/Core/Database/DatabaseManager+OpenTransactions.swift index 2da91e3d77..b40772d738 100644 --- a/TablePro/Core/Database/DatabaseManager+OpenTransactions.swift +++ b/TablePro/Core/Database/DatabaseManager+OpenTransactions.swift @@ -13,7 +13,8 @@ extension DatabaseManager { /// bring its earlier work back to a commit. internal func databasesHoldingTransaction( for connectionId: UUID, - among databases: Set? = nil + among databases: Set? = nil, + clock: any Clock = ContinuousClock() ) async -> [String] { guard let session = activeSessions[connectionId] else { return [] } var candidates: [(database: String, driver: DatabaseDriver)] = [] @@ -30,7 +31,10 @@ extension DatabaseManager { let reads = candidates .filter { databases?.contains($0.database) ?? true } .map { candidate in - (database: candidate.database, state: Task { await Self.boundedTransactionState(of: candidate.driver) }) + ( + database: candidate.database, + state: Task { await Self.boundedTransactionState(of: candidate.driver, clock: clock) } + ) } var holding: [String] = [] for read in reads { @@ -45,14 +49,17 @@ extension DatabaseManager { /// A task group is no bound here: it waits for its child, and the read queues behind any /// statement on the connection's serial queue, which ignores cancellation. The late answer is /// dropped, never cancelled: a driver's cancel can stop the other work queued on the connection. - private static func boundedTransactionState(of driver: DatabaseDriver) async -> PluginSessionTransactionState { + private static func boundedTransactionState( + of driver: DatabaseDriver, + clock: any Clock + ) async -> PluginSessionTransactionState { let gate = ConnectionSingleResumeGate() Task { gate.resume(with: .success(await driver.heldSessionTransactionState())) } let deadline = Task.detached { do { - try await Task.sleep(for: .seconds(1)) + try await clock.sleep(for: .seconds(1)) } catch { return } diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index b282fd9e31..2290dfa69d 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -184,11 +184,14 @@ final class PluginManager: ObservableObject { // Until `loadPlugins()` runs, no load is in flight and a wait could only time out. private var hasStartedInitialLoad = false + private let initialLoadClock: any Clock + func waitForInitialLoad() async { guard hasStartedInitialLoad, !hasFinishedInitialLoad else { return } let waiterId = UUID() + let clock = initialLoadClock let timeoutTask = Task { [weak self] in - try? await Task.sleep(for: .seconds(10)) + try? await clock.sleep(for: .seconds(10)) self?.resumeWaiter(id: waiterId) } defer { timeoutTask.cancel() } @@ -246,10 +249,12 @@ final class PluginManager: ObservableObject { init( userDefaults: UserDefaults = AppStorageEnvironment.shared.defaults, builtInPluginsURL: URL? = Bundle.main.builtInPlugInsURL, - userPluginsDir: URL = PluginManager.defaultUserPluginsDir() + userPluginsDir: URL = PluginManager.defaultUserPluginsDir(), + initialLoadClock: any Clock = ContinuousClock() ) { self.builtInPluginsURL = builtInPluginsURL self.userPluginsDir = userPluginsDir + self.initialLoadClock = initialLoadClock Self.clearLegacyNeedsRestartKey(in: userDefaults) } diff --git a/TablePro/Core/SOCKS/SOCKSProxyManager.swift b/TablePro/Core/SOCKS/SOCKSProxyManager.swift index 999aca8a47..1a108342ae 100644 --- a/TablePro/Core/SOCKS/SOCKSProxyManager.swift +++ b/TablePro/Core/SOCKS/SOCKSProxyManager.swift @@ -62,9 +62,11 @@ actor SOCKSProxyManager: TunnelManaging { private var tunnels: [UUID: TunnelState] = [:] private var appNapActivity: NSObjectProtocol? private let connectTimeout: TimeInterval + private let now: @Sendable () -> ContinuousClock.Instant - init(connectTimeout: TimeInterval = 15) { + init(connectTimeout: TimeInterval = 15, now: @escaping @Sendable () -> ContinuousClock.Instant = { .now }) { self.connectTimeout = connectTimeout + self.now = now } func createTunnel( @@ -81,7 +83,7 @@ actor SOCKSProxyManager: TunnelManaging { password: password, targetHost: targetHost, targetPort: targetPort, - deadline: ConnectionDeadline(configuredSeconds: Int(connectTimeout.rounded(.up))) + deadline: ConnectionDeadline(configuredSeconds: Int(connectTimeout.rounded(.up)), startedAt: now()) ) } catch is ConnectionTimeoutError { throw SOCKSProxyError.connectTimedOut(proxyHost: config.host, proxyPort: config.port) @@ -100,7 +102,7 @@ actor SOCKSProxyManager: TunnelManaging { throw SOCKSProxyError.invalidConfiguration } let timeoutEndpoint = ConnectionTimeoutEndpoint.proxy("\(config.host):\(config.port)") - try deadline.check(endpoint: timeoutEndpoint) + try deadline.check(endpoint: timeoutEndpoint, at: now()) if tunnels[connectionId] != nil { try await closeTunnel(connectionId: connectionId) @@ -120,7 +122,7 @@ actor SOCKSProxyManager: TunnelManaging { deadline: deadline, timeoutEndpoint: timeoutEndpoint ) - try deadline.check(endpoint: timeoutEndpoint) + try deadline.check(endpoint: timeoutEndpoint, at: now()) let listener = try makeListener() listener.newConnectionHandler = { [weak self] inbound in @@ -147,10 +149,11 @@ actor SOCKSProxyManager: TunnelManaging { let localPort = try await Self.startListener( listener, deadline: deadline, - timeoutEndpoint: timeoutEndpoint + timeoutEndpoint: timeoutEndpoint, + now: now ) do { - try deadline.check(endpoint: timeoutEndpoint) + try deadline.check(endpoint: timeoutEndpoint, at: now()) } catch { listener.stateUpdateHandler = nil listener.cancel() @@ -227,7 +230,8 @@ actor SOCKSProxyManager: TunnelManaging { deadline: deadline, timeoutEndpoint: timeoutEndpoint, proxyHost: config.host, - proxyPort: config.port + proxyPort: config.port, + now: now ) } @@ -246,7 +250,7 @@ actor SOCKSProxyManager: TunnelManaging { let relayId = UUID() let outbound = Self.makeProxiedConnection(privacyContext: privacyContext, targetHost: targetHost, targetPort: targetPort) - guard let relayBudget = tunnels[connectionId]?.relayDeadlines.next() else { + guard let relayBudget = tunnels[connectionId]?.relayDeadlines.next(startedAt: now()) else { inbound.cancel() return } @@ -262,14 +266,15 @@ actor SOCKSProxyManager: TunnelManaging { } inbound.start(queue: Self.networkQueue) - Task { [byteCounter] in + Task { [byteCounter, now] in do { try await Self.waitUntilReady( outbound, deadline: relayBudget.deadline, timeoutEndpoint: .proxy("\(config.host):\(config.port)"), proxyHost: config.host, - proxyPort: config.port + proxyPort: config.port, + now: now ) outbound.stateUpdateHandler = { [weak self] state in switch state { @@ -377,9 +382,11 @@ actor SOCKSProxyManager: TunnelManaging { private static func startListener( _ listener: NWListener, deadline: ConnectionDeadline, - timeoutEndpoint: ConnectionTimeoutEndpoint + timeoutEndpoint: ConnectionTimeoutEndpoint, + now: @Sendable () -> ContinuousClock.Instant ) async throws -> Int { - try deadline.check(endpoint: timeoutEndpoint) + try deadline.check(endpoint: timeoutEndpoint, at: now()) + let remainingMilliseconds = deadline.remainingMilliseconds(at: now()) return try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in let resumed = OSAllocatedUnfairLock(initialState: false) @@ -412,7 +419,7 @@ actor SOCKSProxyManager: TunnelManaging { break } } - networkQueue.asyncAfter(deadline: .now() + .milliseconds(deadline.remainingMilliseconds)) { + networkQueue.asyncAfter(deadline: .now() + .milliseconds(remainingMilliseconds)) { let timedOut = resumed.withLock { done -> Bool in guard !done else { return false } done = true @@ -435,12 +442,13 @@ actor SOCKSProxyManager: TunnelManaging { deadline: ConnectionDeadline, timeoutEndpoint: ConnectionTimeoutEndpoint, proxyHost: String, - proxyPort: Int + proxyPort: Int, + now: @Sendable () -> ContinuousClock.Instant ) async throws { - try deadline.check(endpoint: timeoutEndpoint) + try deadline.check(endpoint: timeoutEndpoint, at: now()) try await waitUntilReady( connection, - timeoutMilliseconds: deadline.remainingMilliseconds, + timeoutMilliseconds: deadline.remainingMilliseconds(at: now()), timeoutError: deadline.timeoutError(for: timeoutEndpoint), proxyHost: proxyHost, proxyPort: proxyPort diff --git a/TablePro/Core/Services/Infrastructure/AppActivationPolicy.swift b/TablePro/Core/Services/Infrastructure/AppActivationPolicy.swift index 3fc6c22613..45d0520955 100644 --- a/TablePro/Core/Services/Infrastructure/AppActivationPolicy.swift +++ b/TablePro/Core/Services/Infrastructure/AppActivationPolicy.swift @@ -86,7 +86,6 @@ internal extension LaunchIntent { .openDatabaseURL, .installPlugin, .reopenClosedTab, - .openSampleDatabase, .openSettings: return true } diff --git a/TablePro/Core/Services/Infrastructure/LaunchIntent.swift b/TablePro/Core/Services/Infrastructure/LaunchIntent.swift index 92e639bdc3..c7cb643940 100644 --- a/TablePro/Core/Services/Infrastructure/LaunchIntent.swift +++ b/TablePro/Core/Services/Infrastructure/LaunchIntent.swift @@ -30,7 +30,6 @@ internal enum LaunchIntent: @unchecked Sendable { case openDatabaseURL(URL) case installPlugin(URL) case reopenClosedTab(RecentlyClosedTabEntry) - case openSampleDatabase internal var targetConnectionId: UUID? { switch self { @@ -41,8 +40,7 @@ internal enum LaunchIntent: @unchecked Sendable { return id case .reopenClosedTab(let entry): return entry.connectionId - case .openSampleDatabase, - .openDatabaseURL, + case .openDatabaseURL, .openDatabaseFile, .openDataFile, .openSQLFile, diff --git a/TablePro/Core/Services/Infrastructure/LaunchIntentRouter.swift b/TablePro/Core/Services/Infrastructure/LaunchIntentRouter.swift index 411ef06b7c..ffb2448528 100644 --- a/TablePro/Core/Services/Infrastructure/LaunchIntentRouter.swift +++ b/TablePro/Core/Services/Infrastructure/LaunchIntentRouter.swift @@ -49,9 +49,6 @@ internal final class LaunchIntentRouter { case .installPlugin(let url): try await installPlugin(url) - - case .openSampleDatabase: - SampleDatabaseLauncher.open() } } catch let error where error.isUserCancellation { Self.logger.info("Intent cancelled: \(error.publicLogShape, privacy: .public)") @@ -148,11 +145,8 @@ internal final class LaunchIntentRouter { title = String(localized: "Pairing Failed") case .installPlugin: title = String(localized: "Plugin Installation Failed") - /// `openSampleDatabase` presents its own failure through `SampleDatabaseLauncher` and never - /// throws out of `route`, so this arm exists to keep the switch exhaustive rather than to - /// be reached. Grouped with the connection cases because that is what it opens. case .openConnection, .openTable, .openQuery, .openAgentSession, .openDatabaseURL, - .openDatabaseFile, .reopenClosedTab, .openSampleDatabase: + .openDatabaseFile, .reopenClosedTab: title = String(localized: "Connection Failed") case .openSQLFile, .openDataFile: title = String(localized: "Could Not Open File") diff --git a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift index d81765b1a4..a5adafcf9f 100644 --- a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift +++ b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift @@ -25,7 +25,7 @@ internal final class TabPersistenceCoordinator: ObservableObject { nonisolated internal static let logger = Logger(subsystem: "com.TablePro", category: "NativeTabLifecycle") let connectionId: UUID - private var saveTask: Task? + private(set) var saveTask: Task? /// Whether this window has ever had a tab of its own. Only a window that held tabs can report /// that the user closed them all; one that never saw any is not evidence of anything. A window diff --git a/TablePro/Core/Services/Operations/OperationCompletionReporter.swift b/TablePro/Core/Services/Operations/OperationCompletionReporter.swift index 5f1f09a016..08da5eef14 100644 --- a/TablePro/Core/Services/Operations/OperationCompletionReporter.swift +++ b/TablePro/Core/Services/Operations/OperationCompletionReporter.swift @@ -53,7 +53,8 @@ internal final class OperationCompletionReporter { } } - internal func report(_ completion: OperationCompletion) { + @discardableResult + internal func report(_ completion: OperationCompletion) -> Task? { let visibility = resolveVisibility(completion.owner, completion.connectionId) let decision = OperationCompletionPolicy.decide( completion: completion, @@ -65,9 +66,9 @@ internal final class OperationCompletionReporter { markUnseen(completion.owner) } - guard let plan = decision.notification else { return } + guard let plan = decision.notification else { return nil } - Task { [weak self] in + return Task { [weak self] in await self?.deliver(plan, for: completion) } } diff --git a/TablePro/Core/Storage/KeychainHelper.swift b/TablePro/Core/Storage/KeychainHelper.swift index 2d537f804b..0ee970aa03 100644 --- a/TablePro/Core/Storage/KeychainHelper.swift +++ b/TablePro/Core/Storage/KeychainHelper.swift @@ -36,7 +36,7 @@ final class KeychainHelper: KeychainStoring { static let shared = KeychainHelper() static let passwordSyncEnabledKey = "com.TablePro.keychainPasswordSyncEnabled" - private let service = "com.TablePro" + private let service: String private let accessGroup: String? = KeychainHelper.resolveAccessGroup() private static let logger = Logger(subsystem: "com.TablePro", category: "KeychainHelper") @@ -58,7 +58,9 @@ final class KeychainHelper: KeychainStoring { AppStorageEnvironment.shared.defaults.bool(forKey: Self.passwordSyncEnabledKey) } - private init() {} + init(service: String = "com.TablePro") { + self.service = service + } // MARK: - Data API diff --git a/TablePro/Core/Storage/SplitViewAutosaveSweep.swift b/TablePro/Core/Storage/SplitViewAutosaveSweep.swift index 6678740484..c4b0ed4723 100644 --- a/TablePro/Core/Storage/SplitViewAutosaveSweep.swift +++ b/TablePro/Core/Storage/SplitViewAutosaveSweep.swift @@ -6,6 +6,7 @@ import AppKit import Foundation import os +import TableProUtilities /// What one `NSSplitView` autosave record describes. /// @@ -21,16 +22,11 @@ internal enum SplitViewAutosaveRecord: Equatable { /// lifetime it cannot reason about, and guessing costs a reader their saved layout. case unrecognised - /// Undocumented by Apple, so `SplitViewAutosaveSweepTests` derives it from a real `NSSplitView` - /// rather than trusting this transcription. - internal static let keyPrefix = "NSSplitView Subview Frames " - private static let uuidLength = 36 - /// Parses a defaults key, or returns nil when the key is not an autosave record at all. + /// Nil when the key is not an autosave record at all. internal static func parse(key: String) -> SplitViewAutosaveRecord? { - guard key.hasPrefix(keyPrefix) else { return nil } - let name = String(key.dropFirst(keyPrefix.count)) + guard let name = key.dropPrefixIfPresent(SplitViewAutosaveKey.prefix) else { return nil } guard let rest = name.dropPrefixIfPresent(SplitViewAutosaveName.querySplitPrefix) else { return .unrecognised } diff --git a/TablePro/Core/Testing/UITestLaunchEnvironment.swift b/TablePro/Core/Testing/UITestLaunchEnvironment.swift index dab2b085fc..da33e3320b 100644 --- a/TablePro/Core/Testing/UITestLaunchEnvironment.swift +++ b/TablePro/Core/Testing/UITestLaunchEnvironment.swift @@ -1,25 +1,8 @@ -// -// UITestLaunchEnvironment.swift -// TablePro -// - import Foundation import os -/// The launch intents a UI test can ask for without driving the menu bar to get them. -/// -/// A test that needs a connected window spends its whole budget reaching one. Clicking -/// `Help > Open Sample Database` costs XCUITest a menu traversal it fails on the first attempt and -/// recovers from ten seconds later, every time, on every test that opens the sample: 66 of the 85 -/// cases and 11 of the 39 minutes the UI suite used to take. Asking for the sample at launch is -/// both faster and the thing those tests actually mean, since none of them is testing the Help menu. -/// `SingleWindowMenuContractUITests` still covers that menu item the way a person reaches it. -/// -/// The read is gated on the storage sandbox, the same as `ScreenshotEnvironment`. In a shipped -/// build `isIsolated` is false and this variable does nothing; a build that sets -/// `TABLEPRO_UI_TESTING` without a sandbox has already refused to launch. +// Read only under the storage sandbox, so a shipped build ignores every variable here. internal enum UITestLaunchEnvironment { - internal static let sampleDatabaseVariable = "TABLEPRO_UI_TEST_OPEN_SAMPLE" internal static let welcomeSheetVariable = "TABLEPRO_UI_TEST_SHOW_WELCOME_SHEET" internal static let dataFileVariable = "TABLEPRO_UI_TEST_OPEN_FILE" internal static let connectionShareVariable = "TABLEPRO_UI_TEST_OPEN_CONNECTION_SHARE" @@ -31,15 +14,9 @@ internal enum UITestLaunchEnvironment { isSet(welcomeSheetVariable) } - /// Delivered through the ordinary intent path rather than opened directly, so the launch - /// counts as one somebody asked for. `runStartupBehaviorIfNeeded(skipping:)` skips a launch - /// that carries intents, which is what stops the startup behaviour from racing the sample - /// window onto the screen 150ms later. + // Delivered as intents, so `runStartupBehaviorIfNeeded(skipping:)` skips the startup behaviour instead of racing it. @MainActor internal static var launchIntents: [LaunchIntent] { var intents: [LaunchIntent] = [] - if isSet(sampleDatabaseVariable) { - intents.append(.openSampleDatabase) - } if let dataFileURL = fileURL(in: dataFileVariable) { intents.append(.openDataFile(dataFileURL)) } @@ -52,8 +29,7 @@ internal enum UITestLaunchEnvironment { return intents } - /// Classified like a URL macOS hands the app, without going through LaunchServices, which - /// would deliver it to whichever TablePro is registered for the scheme. + // Not through LaunchServices, which would deliver it to whichever TablePro is registered for the scheme. @MainActor private static var openURLIntent: LaunchIntent? { guard let raw = value(of: openURLVariable), let url = URL(string: raw) else { return nil } switch URLClassifier.classify(url) { diff --git a/TablePro/Core/Utilities/Connection/PasswordSourceResolver.swift b/TablePro/Core/Utilities/Connection/PasswordSourceResolver.swift index ee49e19589..20bb461281 100644 --- a/TablePro/Core/Utilities/Connection/PasswordSourceResolver.swift +++ b/TablePro/Core/Utilities/Connection/PasswordSourceResolver.swift @@ -68,12 +68,15 @@ enum PasswordSourceResolver { } } - static func resolve(_ source: PasswordSource) async throws -> String { + static func resolve( + _ source: PasswordSource, + environment: [String: String] = ProcessInfo.processInfo.environment + ) async throws -> String { switch source { case let .file(path): return try resolveFile(path: path) case let .env(variable): - return try resolveEnvironment(variable: variable) + return try resolveEnvironment(variable: variable, in: environment) case let .command(shell): return try await resolveCommand(shell: shell, timeoutSeconds: commandTimeoutSeconds) case .onePassword, .vault: @@ -141,14 +144,18 @@ enum PasswordSourceResolver { return try nonEmpty(contents.trimmingCharacters(in: .whitespacesAndNewlines)) } - private static func resolveEnvironment(variable: String) throws -> String { - guard let value = ProcessInfo.processInfo.environment[variable] else { + private static func resolveEnvironment(variable: String, in environment: [String: String]) throws -> String { + guard let value = environment[variable] else { throw ResolutionError.environmentVariableNotSet(name: variable) } return try nonEmpty(value.trimmingCharacters(in: .whitespacesAndNewlines)) } - static func resolveCommand(shell: String, timeoutSeconds: UInt64) async throws -> String { + static func resolveCommand( + shell: String, + timeoutSeconds: UInt64, + clock: any Clock = ContinuousClock() + ) async throws -> String { let output = try await Task.detached(priority: .userInitiated) { () async throws -> String in let process = Process() process.executableURL = URL(fileURLWithPath: "/bin/bash") @@ -186,7 +193,7 @@ enum PasswordSourceResolver { let didTimeout = AtomicFlag() let timeoutTask = Task.detached { - try await Task.sleep(nanoseconds: timeoutSeconds * 1_000_000_000) + try await clock.sleep(for: .seconds(timeoutSeconds)) if process.isRunning { didTimeout.set() process.terminate() diff --git a/TablePro/ViewModels/ERDiagramViewModel.swift b/TablePro/ViewModels/ERDiagramViewModel.swift index dce9de84ad..4aa76e760b 100644 --- a/TablePro/ViewModels/ERDiagramViewModel.swift +++ b/TablePro/ViewModels/ERDiagramViewModel.swift @@ -115,16 +115,24 @@ final class ERDiagramViewModel: ObservableObject { private var nodeIdToName: [UUID: String] = [:] private let services: AppServices + private let autoPanClock: any Clock private var loadTask: Task? // MARK: - Initialization - init(connectionId: UUID, databaseName: String, schemaKey: String, services: AppServices = .live) { + init( + connectionId: UUID, + databaseName: String, + schemaKey: String, + services: AppServices = .live, + autoPanClock: any Clock = ContinuousClock() + ) { self.connectionId = connectionId self.databaseName = databaseName self.schemaKey = schemaKey self.schemaName = Self.resolveSchemaName(fromSchemaKey: schemaKey, databaseName: databaseName) self.services = services + self.autoPanClock = autoPanClock } deinit { @@ -463,10 +471,10 @@ final class ERDiagramViewModel: ObservableObject { autoPanVelocity = velocity if velocity != .zero && autoPanTask == nil { - autoPanTask = Task { [weak self] in + autoPanTask = Task { [weak self, autoPanClock] in while !Task.isCancelled { self?.autoPanTick() - try? await Task.sleep(for: .milliseconds(16)) + try? await autoPanClock.sleep(for: .milliseconds(16)) } } } else if velocity == .zero && autoPanTask != nil { diff --git a/TablePro/ViewModels/HistoryPanelViewModel.swift b/TablePro/ViewModels/HistoryPanelViewModel.swift index 79d40b20bb..cc842e0523 100644 --- a/TablePro/ViewModels/HistoryPanelViewModel.swift +++ b/TablePro/ViewModels/HistoryPanelViewModel.swift @@ -24,6 +24,7 @@ final class HistoryPanelViewModel: ObservableObject { private let history: QueryHistoryReading private let connectionDirectory: HistoryConnectionDirectory private let pageSize: Int + private let clock: any Clock @Published private var entries: [QueryHistoryEntry] = [] @Published private var nextCursor: QueryHistoryCursor? @@ -37,12 +38,14 @@ final class HistoryPanelViewModel: ObservableObject { connectionId: UUID, history: QueryHistoryReading, connectionDirectory: HistoryConnectionDirectory = .live, - pageSize: Int = HistoryPanelViewModel.pageSize + pageSize: Int = HistoryPanelViewModel.pageSize, + clock: any Clock = ContinuousClock() ) { self.connectionId = connectionId self.history = history self.connectionDirectory = connectionDirectory self.pageSize = pageSize + self.clock = clock self.state = HistoryPanelState.forConnection(connectionId) } @@ -105,8 +108,9 @@ final class HistoryPanelViewModel: ObservableObject { /// single user action. private func scheduleLiveRefresh() { liveRefresh?.cancel() + let clock = clock liveRefresh = Task { [weak self] in - try? await Task.sleep(for: .milliseconds(200)) + try? await clock.sleep(for: .milliseconds(200)) guard !Task.isCancelled, let self else { return } await reload(preservingLoadedWindow: true) } @@ -169,8 +173,9 @@ final class HistoryPanelViewModel: ObservableObject { func scheduleSearchReload() { searchDebounce?.cancel() + let clock = clock searchDebounce = Task { [weak self] in - try? await Task.sleep(for: .milliseconds(150)) + try? await clock.sleep(for: .milliseconds(150)) guard !Task.isCancelled, let self else { return } await reload() } diff --git a/TablePro/ViewModels/SidebarViewModel.swift b/TablePro/ViewModels/SidebarViewModel.swift index 2045cbabee..02b7d3be14 100644 --- a/TablePro/ViewModels/SidebarViewModel.swift +++ b/TablePro/ViewModels/SidebarViewModel.swift @@ -14,7 +14,7 @@ final class SidebarViewModel: ObservableObject { private var searchTextObservation: AnyCancellable? private static let logger = Logger(subsystem: "com.TablePro", category: "SidebarViewModel") private static var registry: [UUID: SidebarViewModel] = [:] - private static let searchDebounceNanoseconds: UInt64 = 150_000_000 + private static let searchDebounce: Duration = .milliseconds(150) static func shared( connectionId: UUID, @@ -124,7 +124,7 @@ final class SidebarViewModel: ObservableObject { } @Published var isRedisKeysExpanded: Bool { didSet { - AppStorageEnvironment.shared.defaults.set( + defaults.set( isRedisKeysExpanded, forKey: SidebarPersistenceKey.redisKeysExpanded(connectionId: connectionId) ) @@ -132,7 +132,7 @@ final class SidebarViewModel: ObservableObject { } @Published var isRecentsExpanded: Bool { didSet { - AppStorageEnvironment.shared.defaults.set( + defaults.set( isRecentsExpanded, forKey: SidebarPersistenceKey.recentsExpanded(connectionId: connectionId) ) @@ -140,7 +140,7 @@ final class SidebarViewModel: ObservableObject { } @Published var isFoldersExpanded: Bool { didSet { - AppStorageEnvironment.shared.defaults.set( + defaults.set( isFoldersExpanded, forKey: SidebarPersistenceKey.foldersExpanded(connectionId: connectionId) ) @@ -161,6 +161,8 @@ final class SidebarViewModel: ObservableObject { // MARK: - Dependencies private let connectionId: UUID + private let defaults: UserDefaults + private let clock: any Clock /// The single connection-scoped state holder. Search text and the Redis key /// tree live here so this view model and the sidebar views share one source. @@ -201,7 +203,9 @@ final class SidebarViewModel: ObservableObject { pendingDeletes: Binding>, tableOperationOptions: Binding<[DatabaseTreeTableRef: TableOperationOptions]>, databaseType: DatabaseType, - connectionId: UUID + connectionId: UUID, + defaults: UserDefaults = AppStorageEnvironment.shared.defaults, + clock: any Clock = ContinuousClock() ) { self.selectedTablesBinding = selectedTables self.pendingTruncatesBinding = pendingTruncates @@ -209,20 +213,25 @@ final class SidebarViewModel: ObservableObject { self.tableOperationOptionsBinding = tableOperationOptions self.databaseType = databaseType self.connectionId = connectionId + self.defaults = defaults + self.clock = clock self.sharedState = SharedSidebarState.forConnection(connectionId) - self.expanded = Self.loadInitialExpansion(connectionId: connectionId) + self.expanded = Self.loadInitialExpansion(connectionId: connectionId, defaults: defaults) self.isRedisKeysExpanded = Self.loadExpansion( perConnectionKey: SidebarPersistenceKey.redisKeysExpanded(connectionId: connectionId), legacyKey: SidebarPersistenceKey.legacyRedisKeysExpanded, - defaultValue: true + defaultValue: true, + defaults: defaults ) self.isRecentsExpanded = Self.loadExpansion( perConnectionKey: SidebarPersistenceKey.recentsExpanded(connectionId: connectionId), - defaultValue: true + defaultValue: true, + defaults: defaults ) self.isFoldersExpanded = Self.loadExpansion( perConnectionKey: SidebarPersistenceKey.foldersExpanded(connectionId: connectionId), - defaultValue: true + defaultValue: true, + defaults: defaults ) /// Seeded once, at creation, from whatever the field already holds. Doing it from a view's /// initializer instead ran on every view-graph pass. @@ -238,16 +247,15 @@ final class SidebarViewModel: ObservableObject { ] } - private static func loadInitialExpansion(connectionId: UUID) -> ExpansionState { + private static func loadInitialExpansion(connectionId: UUID, defaults: UserDefaults) -> ExpansionState { var values: [SidebarObjectKind: Bool] = [:] for kind in SidebarObjectKind.allCases { - values[kind] = loadKindExpansion(connectionId: connectionId, kind: kind) + values[kind] = loadKindExpansion(connectionId: connectionId, kind: kind, defaults: defaults) } return ExpansionState(values: values) } - private static func loadKindExpansion(connectionId: UUID, kind: SidebarObjectKind) -> Bool { - let defaults = AppStorageEnvironment.shared.defaults + private static func loadKindExpansion(connectionId: UUID, kind: SidebarObjectKind, defaults: UserDefaults) -> Bool { let perKindKey = SidebarPersistenceKey.expanded(connectionId: connectionId, kind: kind) if defaults.object(forKey: perKindKey) != nil { return defaults.bool(forKey: perKindKey) @@ -271,9 +279,9 @@ final class SidebarViewModel: ObservableObject { private static func loadExpansion( perConnectionKey: String, legacyKey: String? = nil, - defaultValue: Bool + defaultValue: Bool, + defaults: UserDefaults ) -> Bool { - let defaults = AppStorageEnvironment.shared.defaults if defaults.object(forKey: perConnectionKey) != nil { return defaults.bool(forKey: perConnectionKey) } @@ -286,7 +294,6 @@ final class SidebarViewModel: ObservableObject { } private func persistExpansion(oldValue: ExpansionState) { - let defaults = AppStorageEnvironment.shared.defaults for kind in SidebarObjectKind.allCases where oldValue[kind] != expanded[kind] { defaults.set( expanded[kind], @@ -615,8 +622,9 @@ final class SidebarViewModel: ObservableObject { return } filterDebounceTask?.cancel() + let clock = clock filterDebounceTask = Task { @MainActor [weak self] in - try? await Task.sleep(nanoseconds: Self.searchDebounceNanoseconds) + try? await clock.sleep(for: Self.searchDebounce) guard !Task.isCancelled else { return } guard let self else { return } self.filterQuery = self.searchText diff --git a/TablePro/Views/Editor/QueryDiagnosticsController.swift b/TablePro/Views/Editor/QueryDiagnosticsController.swift index b229b096dc..a8bc35f755 100644 --- a/TablePro/Views/Editor/QueryDiagnosticsController.swift +++ b/TablePro/Views/Editor/QueryDiagnosticsController.swift @@ -23,11 +23,13 @@ final class QueryDiagnosticsController { } private static let debounce: Duration = .milliseconds(500) + private let clock: any Clock nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "QueryDiagnostics") - init(databaseType: DatabaseType?) { + init(databaseType: DatabaseType?, clock: any Clock = ContinuousClock()) { self.producer = QueryDiagnosticsFactory.make(for: databaseType) + self.clock = clock } func configure(databaseType: DatabaseType?) { @@ -55,9 +57,10 @@ final class QueryDiagnosticsController { return } + let clock = clock pendingTask = Task { [weak self, weak controller] in do { - try await Task.sleep(for: Self.debounce) + try await clock.sleep(for: Self.debounce) } catch { return } diff --git a/TablePro/Views/Editor/SQLEditorCoordinator.swift b/TablePro/Views/Editor/SQLEditorCoordinator.swift index 5051e9ba41..5c71b5231c 100644 --- a/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -27,8 +27,10 @@ final class SQLEditorCoordinator: ObservableObject, TextViewCoordinator, TextVie private static let languageServiceLengthLimit = EditorHighlighting.maxHighlightableCharacters weak var controller: TextViewController? + private let diagnosticsClock: any Clock private lazy var diagnosticsController = QueryDiagnosticsController( - databaseType: databaseType + databaseType: databaseType, + clock: diagnosticsClock ) private let statementRunController = StatementRunController() /// Shared schema provider for inline AI suggestions (avoids duplicate schema fetches) @@ -63,6 +65,10 @@ final class SQLEditorCoordinator: ObservableObject, TextViewCoordinator, TextVie var pendingCursorRestore: NSRange? { cursorRestorePending } + init(diagnosticsClock: any Clock = ContinuousClock()) { + self.diagnosticsClock = diagnosticsClock + } + func scheduleEditorFocusClaim() { focusClaimPending = true } diff --git a/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift index e5c9398dc2..46192efe26 100644 --- a/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift +++ b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift @@ -9,7 +9,7 @@ import UniformTypeIdentifiers struct InstalledPluginsView: View { @ObservedObject private var pluginManager = PluginManager.shared - @ObservedObject private var registryClient = RegistryClient.shared + @ObservedObject private var registryClient: RegistryClient @ObservedObject private var installTracker = PluginInstallTracker.shared @ObservedObject private var navigation = PluginsSettingsNavigation.shared @@ -21,6 +21,10 @@ struct InstalledPluginsView: View { @State private var dismissedRestartBanner = false @State private var dismissedRejectedBanner = false + init(registryClient: RegistryClient = .shared) { + self.registryClient = registryClient + } + private var filteredPlugins: [PluginEntry] { if searchText.isEmpty { return pluginManager.plugins } return pluginManager.plugins.filter { $0.name.localizedCaseInsensitiveContains(searchText) } diff --git a/TablePro/Views/Settings/SettingsWindowController.swift b/TablePro/Views/Settings/SettingsWindowController.swift index edf8834e0f..16d069c0c3 100644 --- a/TablePro/Views/Settings/SettingsWindowController.swift +++ b/TablePro/Views/Settings/SettingsWindowController.swift @@ -27,8 +27,8 @@ internal final class SettingsWindowController: NSWindowController { contentViewController as? SettingsPaneTabViewController } - internal convenience init() { - let panes = SettingsPaneTabViewController(nibName: nil, bundle: nil) + internal convenience init(registryClient: RegistryClient = .shared) { + let panes = SettingsPaneTabViewController(registryClient: registryClient) /// `NSWindow(contentViewController:)` binds the window title to the content view /// controller's own title, so the selected pane publishes the title and nothing writes @@ -62,6 +62,17 @@ internal final class SettingsPaneTabViewController: NSTabViewController { private static let logger = Logger(subsystem: "com.TablePro", category: "SettingsWindow") + private let registryClient: RegistryClient + + internal init(registryClient: RegistryClient = .shared) { + self.registryClient = registryClient + super.init(nibName: nil, bundle: nil) + } + + internal required init?(coder: NSCoder) { + fatalError("SettingsPaneTabViewController does not support NSCoder init") + } + private var persistedPane: SettingsPane { let stored = AppStorageEnvironment.shared.defaults.string(forKey: PreferenceKeys.selectedSettingsPane.name) guard let stored, let pane = SettingsPane(rawValue: stored) else { return .general } @@ -104,7 +115,7 @@ internal final class SettingsPaneTabViewController: NSTabViewController { } private func makeTabViewItem(for pane: SettingsPane) -> NSTabViewItem { - let content = SettingsPaneContent(pane: pane) + let content = SettingsPaneContent(pane: pane, registryClient: registryClient) .frame( minWidth: Self.paneSize.width, maxWidth: .infinity, @@ -131,9 +142,11 @@ private struct SettingsPaneContent: View { @ObservedObject private var settingsManager = AppSettingsManager.shared private let pane: SettingsPane + private let registryClient: RegistryClient - fileprivate init(pane: SettingsPane) { + fileprivate init(pane: SettingsPane, registryClient: RegistryClient) { self.pane = pane + self.registryClient = registryClient } fileprivate var body: some View { @@ -170,7 +183,7 @@ private struct SettingsPaneContent: View { case .mcp: MCPSettingsView(settings: $settingsManager.mcp) case .plugins: - InstalledPluginsView() + InstalledPluginsView(registryClient: registryClient) .frame(maxWidth: .infinity, maxHeight: .infinity) case .sync: SyncSettingsView() diff --git a/TableProTests/AWS/AWSDiscoverySessionTests.swift b/TableProTests/AWS/AWSDiscoverySessionTests.swift new file mode 100644 index 0000000000..4573163a03 --- /dev/null +++ b/TableProTests/AWS/AWSDiscoverySessionTests.swift @@ -0,0 +1,40 @@ +// +// AWSDiscoverySessionTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +/// What Import from AWS's Continue is gated on. The discovery behind it needs a live account. +@MainActor +struct AWSDiscoverySessionTests { + /// No config file on any machine defines it, so it resolves no region of its own. + private let profileWithoutARegion = "tablepro-tests-\(UUID().uuidString)" + + @Test("Continue needs both a profile and a region") + func continueNeedsAProfileAndARegion() { + let session = AWSDiscoverySession() + #expect(!session.canStart) + + session.profileName = profileWithoutARegion + #expect(!session.canStart, "AWS has no default region, so a profile alone cannot start") + + session.selectedRegionIds = ["us-east-1"] + #expect(session.canStart) + + session.profileName = " " + #expect(!session.canStart, "A blank profile is no profile") + } + + /// The sheet preselects the profile's own region, so a profile without one opens on nothing. + @Test("A profile with no region of its own leaves Continue disabled") + func profileWithoutARegionLeavesContinueDisabled() { + let session = AWSDiscoverySession(profileName: profileWithoutARegion) + + #expect(session.defaultRegionForProfile() == nil) + #expect(session.selectedRegionIds.isEmpty) + #expect(!session.canStart) + } +} diff --git a/TableProTests/CloudSQL/CloudSQLProxyManagerTests.swift b/TableProTests/CloudSQL/CloudSQLProxyManagerTests.swift index 599d5a5686..e60486ce50 100644 --- a/TableProTests/CloudSQL/CloudSQLProxyManagerTests.swift +++ b/TableProTests/CloudSQL/CloudSQLProxyManagerTests.swift @@ -1,8 +1,3 @@ -// -// CloudSQLProxyManagerTests.swift -// TableProTests -// - import CryptoKit import Darwin import Foundation @@ -139,18 +134,6 @@ struct CloudSQLProxyManagerTests { CloudSQLProxyConfiguration(instanceConnectionName: instance, localPort: localPort, binaryPath: "/bin/echo") } - private func waitUntil( - timeout: Duration = .seconds(2), - _ condition: @escaping @Sendable () -> Bool - ) async -> Bool { - let deadline = ContinuousClock.now.advanced(by: timeout) - while ContinuousClock.now < deadline { - if condition() { return true } - try? await Task.sleep(for: .milliseconds(5)) - } - return condition() - } - /// The managed binary is ad-hoc signed and sits in a user-writable directory, so the launch /// path has to obtain it through the checksum-verifying accessor rather than by checking that /// a file exists. It did the latter, which left the pinned checksum unread before exec. @@ -274,7 +257,7 @@ struct CloudSQLProxyManagerTests { fake.stop() } - try #require(await waitUntil { fake.startCallCount > 0 }) + try await waitUntil { fake.startCallCount > 0 } let result = try #require(await BoundedCall.result(within: .seconds(2)) { await creation.result }) diff --git a/TableProTests/Cloudflare/CloudflareTunnelManagerTests.swift b/TableProTests/Cloudflare/CloudflareTunnelManagerTests.swift index d17705b1f8..2d5f70f5a8 100644 --- a/TableProTests/Cloudflare/CloudflareTunnelManagerTests.swift +++ b/TableProTests/Cloudflare/CloudflareTunnelManagerTests.swift @@ -1,8 +1,3 @@ -// -// CloudflareTunnelManagerTests.swift -// TableProTests -// - import Darwin import Foundation import Testing @@ -146,18 +141,6 @@ struct CloudflareTunnelManagerTests { CloudflareConfiguration(accessHostname: hostname, localPort: localPort, binaryPath: "/bin/echo") } - private func waitUntil( - timeout: Duration = .seconds(2), - _ condition: @escaping @Sendable () -> Bool - ) async -> Bool { - let deadline = ContinuousClock.now.advanced(by: timeout) - while ContinuousClock.now < deadline { - if condition() { return true } - try? await Task.sleep(for: .milliseconds(5)) - } - return condition() - } - @Test("createTunnel returns the allocated port once cloudflared is listening") func readinessSucceeds() async throws { let fake = FakeCloudflaredRunner(behavior: .ready) @@ -216,7 +199,7 @@ struct CloudflareTunnelManagerTests { fake.stop() } - try #require(await waitUntil { fake.startCallCount > 0 }) + try await waitUntil { fake.startCallCount > 0 } let result = try #require(await BoundedCall.result(within: .seconds(2)) { await creation.result }) diff --git a/TableProTests/Core/AI/AIConnectionAccessGateTests.swift b/TableProTests/Core/AI/AIConnectionAccessGateTests.swift index 26ab8d84ec..a7dfbf7940 100644 --- a/TableProTests/Core/AI/AIConnectionAccessGateTests.swift +++ b/TableProTests/Core/AI/AIConnectionAccessGateTests.swift @@ -94,8 +94,10 @@ internal struct AIConnectionAccessGateTests { internal struct AIConnectionAccessGateSavedPolicyTests { private let storage: ConnectionStorage private let fileURL: URL + private let defaults: EphemeralDefaults + private let syncDefaults: EphemeralDefaults - init() { + init() throws { let unique = UUID().uuidString let fileURL = FileManager.default.temporaryDirectory .appendingPathComponent("tablepro-tests") @@ -104,16 +106,15 @@ internal struct AIConnectionAccessGateSavedPolicyTests { at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true ) - guard let defaults = UserDefaults(suiteName: "com.TablePro.tests.AIAccessGate.\(unique)"), - let syncDefaults = UserDefaults(suiteName: "com.TablePro.tests.AIAccessGate.Sync.\(unique)") - else { - fatalError("Failed to create isolated test user defaults") - } - let tracker = SyncChangeTracker(metadataStorage: SyncMetadataStorage(userDefaults: syncDefaults)) + let defaults = try EphemeralDefaults() + let syncDefaults = try EphemeralDefaults() + let tracker = SyncChangeTracker(metadataStorage: SyncMetadataStorage(userDefaults: syncDefaults.defaults)) self.fileURL = fileURL + self.defaults = defaults + self.syncDefaults = syncDefaults self.storage = ConnectionStorage( fileURL: fileURL, - userDefaults: defaults, + userDefaults: defaults.defaults, syncTracker: tracker, keychain: InMemoryKeychain() ) diff --git a/TableProTests/Core/AI/Chat/ToolApprovalOrderingTests.swift b/TableProTests/Core/AI/Chat/ToolApprovalOrderingTests.swift index 438e002a2a..423a980b2a 100644 --- a/TableProTests/Core/AI/Chat/ToolApprovalOrderingTests.swift +++ b/TableProTests/Core/AI/Chat/ToolApprovalOrderingTests.swift @@ -1,8 +1,3 @@ -// -// ToolApprovalOrderingTests.swift -// TableProTests -// - import Foundation @testable import TablePro import Testing @@ -22,7 +17,7 @@ struct ToolApprovalOrderingTests { center.resolve(sessionId: session, toolUseId: "second", decision: .run) let first = Task { await center.awaitDecision(sessionId: session, toolUseId: "first") } - await Task.yield() + await runQueuedMainActorWork() center.resolve(sessionId: session, toolUseId: "first", decision: .cancel) if case .cancel = await first.value {} else { Issue.record("first should cancel") } @@ -36,7 +31,7 @@ struct ToolApprovalOrderingTests { center.expect(sessionId: session, toolUseIds: ["stray"]) let waiter = Task { await center.awaitDecision(sessionId: session, toolUseId: "stray") } - await Task.yield() + await runQueuedMainActorWork() center.resolve(sessionId: session, toolUseId: "stray", decision: .cancel) if case .cancel = await waiter.value {} else { Issue.record("the stray answer should not have survived") } } @@ -53,7 +48,7 @@ struct ToolApprovalOrderingTests { center.expect(sessionId: session, toolUseIds: ["call_0"]) let waiter = Task { await center.awaitDecision(sessionId: session, toolUseId: "call_0") } - await Task.yield() + await runQueuedMainActorWork() center.resolve(sessionId: session, toolUseId: "call_0", decision: .cancel) if case .cancel = await waiter.value {} else { Issue.record("the previous turn's answer was spent on this one") @@ -69,7 +64,7 @@ struct ToolApprovalOrderingTests { center.expect(sessionId: session, toolUseIds: ["first", "second"]) let firstWaiter = Task { await center.awaitDecision(sessionId: session, toolUseId: "first") } - await Task.yield() + await runQueuedMainActorWork() center.cancelAll(sessionId: session) if case .cancel = await firstWaiter.value {} else { Issue.record("first should cancel") } @@ -88,8 +83,14 @@ struct ToolApprovalOrderingTests { center.expect(sessionId: session, toolUseIds: ["pending"]) let waiter = Task { await center.awaitDecision(sessionId: session, toolUseId: "pending") } - await Task.yield() + await runQueuedMainActorWork() center.resolve(sessionId: session, toolUseId: "pending", decision: .cancel) if case .cancel = await waiter.value {} else { Issue.record("a cleared answer came back") } } + + /// The main queue runs its jobs in order, so a job queued now runs after every waiter the case + /// started has reached its await. + private func runQueuedMainActorWork() async { + await Task { @MainActor in }.value + } } diff --git a/TableProTests/Core/AI/CopilotIdleStopControllerTests.swift b/TableProTests/Core/AI/CopilotIdleStopControllerTests.swift index 2599c47bc6..9adc4eaf2e 100644 --- a/TableProTests/Core/AI/CopilotIdleStopControllerTests.swift +++ b/TableProTests/Core/AI/CopilotIdleStopControllerTests.swift @@ -1,13 +1,7 @@ -// -// CopilotIdleStopControllerTests.swift -// TableProTests -// -// Verifies the deferred-stop state machine extracted from CopilotService. -// - import Foundation @testable import TablePro import TableProPluginKit +import TableProTestSupport import Testing @MainActor @@ -22,116 +16,6 @@ private final class TestState { } } -/// Time moves only when a case advances it, so no case races the wall clock or a busy main actor. -private final class ManualClock: Clock, @unchecked Sendable { - struct Instant: InstantProtocol { - let offset: Swift.Duration - - func advanced(by duration: Swift.Duration) -> Instant { - Instant(offset: offset + duration) - } - - func duration(to other: Instant) -> Swift.Duration { - other.offset - offset - } - - static func < (lhs: Instant, rhs: Instant) -> Bool { - lhs.offset < rhs.offset - } - } - - private struct Sleeper { - let id: UUID - let deadline: Instant - let continuation: CheckedContinuation - } - - private let lock = NSLock() - private var current = Instant(offset: .zero) - private var sleepers: [Sleeper] = [] - private var sleeperWaiters: [CheckedContinuation] = [] - - var now: Instant { - lock.withLock { current } - } - - var minimumResolution: Swift.Duration { - .zero - } - - var pendingSleeperCount: Int { - lock.withLock { sleepers.count } - } - - func sleep(until deadline: Instant, tolerance: Swift.Duration?) async throws { - let id = UUID() - try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - enqueue(Sleeper(id: id, deadline: deadline, continuation: continuation)) - } - } onCancel: { - removeSleeper(id: id)?.continuation.resume(throwing: CancellationError()) - } - } - - func waitUntilSleeping() async { - await withCheckedContinuation { (continuation: CheckedContinuation) in - let isSleeping = lock.withLock { - guard sleepers.isEmpty else { return true } - sleeperWaiters.append(continuation) - return false - } - if isSleeping { continuation.resume() } - } - } - - func advance(by duration: Swift.Duration) { - let due = lock.withLock { - current = current.advanced(by: duration) - let reached = current - let woken = sleepers.filter { $0.deadline <= reached } - sleepers.removeAll { $0.deadline <= reached } - return woken - } - for sleeper in due { - sleeper.continuation.resume() - } - } - - private func enqueue(_ sleeper: Sleeper) { - enum Admission { - case wake - case cancel - case wait([CheckedContinuation]) - } - let admission: Admission = lock.withLock { - if Task.isCancelled { return .cancel } - if sleeper.deadline <= current { return .wake } - sleepers.append(sleeper) - let waiters = sleeperWaiters - sleeperWaiters.removeAll() - return .wait(waiters) - } - switch admission { - case .wake: - sleeper.continuation.resume() - case .cancel: - sleeper.continuation.resume(throwing: CancellationError()) - case .wait(let waiters): - for waiter in waiters { - waiter.resume() - } - } - } - - private func removeSleeper(id: UUID) -> Sleeper? { - lock.withLock { - guard let index = sleepers.firstIndex(where: { $0.id == id }) else { return nil } - return sleepers.remove(at: index) - } - } -} - @MainActor struct CopilotIdleStopControllerTests { private static let timeout: Duration = .seconds(300) diff --git a/TableProTests/Core/AI/InlineSuggestionManagerCompositionTests.swift b/TableProTests/Core/AI/InlineSuggestionManagerCompositionTests.swift index f52b0b6e1c..0bde999635 100644 --- a/TableProTests/Core/AI/InlineSuggestionManagerCompositionTests.swift +++ b/TableProTests/Core/AI/InlineSuggestionManagerCompositionTests.swift @@ -1,12 +1,8 @@ -// -// InlineSuggestionManagerCompositionTests.swift -// TableProTests -// - import AppKit import Carbon.HIToolbox @testable import TablePro import TableProEditorKit +import TableProTestSupport import TableProTextEngine import Testing @@ -76,27 +72,19 @@ internal struct InlineSuggestionManagerCompositionTests { return Harness(window: window, editor: editor, manager: manager, source: source) } - private func waitUntil(_ condition: () -> Bool) async { - var attempts = 0 - while !condition(), attempts < 500 { - attempts += 1 - await Task.yield() - } - } - - private func drainMainActor() async { - for _ in 0..<50 { - await Task.yield() - } + /// The main queue runs its jobs in order, so a job queued now runs after the request task the + /// case started, or resumed, has run to its end. + private func runQueuedMainActorWork() async { + await Task { @MainActor in }.value } @Test("A settled line requests and shows a suggestion") - func settledLineShowsSuggestion() async { + func settledLineShowsSuggestion() async throws { let harness = makeHarness() defer { harness.manager.uninstall() } harness.manager.requestSuggestion() - await waitUntil { !harness.source.shown.isEmpty } + try await waitUntil { !harness.source.shown.isEmpty } #expect(harness.source.requests.count == 1) #expect(harness.source.shown.count == 1) @@ -109,37 +97,37 @@ internal struct InlineSuggestionManagerCompositionTests { EditorControllerFixture.beginComposition("le", in: harness.editor.textView) harness.manager.requestSuggestion() - await drainMainActor() + await runQueuedMainActorWork() #expect(harness.source.requests.isEmpty) #expect(harness.source.shown.isEmpty) } @Test("A composition the input method empties no longer holds suggestions back") - func emptiedCompositionRequestsAgain() async { + func emptiedCompositionRequestsAgain() async throws { let harness = makeHarness() defer { harness.manager.uninstall() } EditorControllerFixture.beginComposition("le", in: harness.editor.textView) EditorControllerFixture.emptyComposition(in: harness.editor.textView) harness.manager.requestSuggestion() - await waitUntil { !harness.source.shown.isEmpty } + try await waitUntil { !harness.source.shown.isEmpty } #expect(harness.source.requests.count == 1) #expect(harness.source.shown.count == 1) } @Test("A reply that arrives after a composition begins is not shown") - func replyDuringCompositionIsDropped() async { + func replyDuringCompositionIsDropped() async throws { let harness = makeHarness() defer { harness.manager.uninstall() } harness.source.holdsReplies = true harness.manager.requestSuggestion() - await waitUntil { harness.source.heldReplyCount == 1 } + try await waitUntil { harness.source.heldReplyCount == 1 } EditorControllerFixture.beginComposition("le", in: harness.editor.textView) harness.source.releaseReplies() - await drainMainActor() + await runQueuedMainActorWork() #expect(harness.source.requests.count == 1) #expect(harness.source.shown.isEmpty) @@ -150,7 +138,7 @@ internal struct InlineSuggestionManagerCompositionTests { let harness = makeHarness() defer { harness.manager.uninstall() } harness.manager.requestSuggestion() - await waitUntil { !harness.source.shown.isEmpty } + try await waitUntil { !harness.source.shown.isEmpty } try #require(harness.source.shown.count == 1) EditorControllerFixture.beginComposition("le", in: harness.editor.textView) @@ -177,7 +165,7 @@ internal struct InlineSuggestionManagerCompositionTests { let harness = makeHarness() defer { harness.manager.uninstall() } harness.manager.requestSuggestion() - await waitUntil { !harness.source.shown.isEmpty } + try await waitUntil { !harness.source.shown.isEmpty } try #require(harness.source.shown.count == 1, "\(chord.name)") let event = try #require(harness.tab(modifiers: chord.modifiers)) @@ -192,7 +180,7 @@ internal struct InlineSuggestionManagerCompositionTests { let harness = makeHarness() defer { harness.manager.uninstall() } harness.manager.requestSuggestion() - await waitUntil { !harness.source.shown.isEmpty } + try await waitUntil { !harness.source.shown.isEmpty } try #require(harness.source.shown.count == 1) let tab = try #require(harness.tab()) diff --git a/TableProTests/Core/AI/ToolApprovalCenterTests.swift b/TableProTests/Core/AI/ToolApprovalCenterTests.swift index bd1f71f15e..1e309c3db8 100644 --- a/TableProTests/Core/AI/ToolApprovalCenterTests.swift +++ b/TableProTests/Core/AI/ToolApprovalCenterTests.swift @@ -1,8 +1,3 @@ -// -// ToolApprovalCenterTests.swift -// TableProTests -// - import Foundation import TableProPluginKit @testable import TablePro @@ -18,7 +13,7 @@ struct ToolApprovalCenterTests { let waiter = Task { await center.awaitDecision(sessionId: session, toolUseId: "tool-1") } - await Task.yield() + await runQueuedMainActorWork() center.resolve(sessionId: session, toolUseId: "tool-1", decision: .run) let decision = await waiter.value if case .run = decision { @@ -40,7 +35,7 @@ struct ToolApprovalCenterTests { let center = ToolApprovalCenter() let firstWaiter = Task { await center.awaitDecision(sessionId: session, toolUseId: "a") } let secondWaiter = Task { await center.awaitDecision(sessionId: session, toolUseId: "b") } - await Task.yield() + await runQueuedMainActorWork() center.cancelAll(sessionId: session) let firstDecision = await firstWaiter.value let secondDecision = await secondWaiter.value @@ -53,9 +48,9 @@ struct ToolApprovalCenterTests { func duplicateAwaitCancelsPrior() async { let center = ToolApprovalCenter() let firstWaiter = Task { await center.awaitDecision(sessionId: session, toolUseId: "tool-1") } - await Task.yield() + await runQueuedMainActorWork() let secondWaiter = Task { await center.awaitDecision(sessionId: session, toolUseId: "tool-1") } - await Task.yield() + await runQueuedMainActorWork() let firstDecision = await firstWaiter.value if case .cancel = firstDecision {} else { Issue.record("first should auto-cancel when overwritten, got \(firstDecision)") @@ -72,7 +67,7 @@ struct ToolApprovalCenterTests { let center = ToolApprovalCenter() #expect(center.hasPending == false) let waiter = Task { await center.awaitDecision(sessionId: session, toolUseId: "tool-1") } - await Task.yield() + await runQueuedMainActorWork() #expect(center.hasPending == true) center.resolve(sessionId: session, toolUseId: "tool-1", decision: .run) _ = await waiter.value @@ -88,7 +83,7 @@ struct ToolApprovalCenterTests { let other = UUID() let mine = Task { await center.awaitDecision(sessionId: session, toolUseId: "call_0") } let theirs = Task { await center.awaitDecision(sessionId: other, toolUseId: "call_0") } - await Task.yield() + await runQueuedMainActorWork() center.resolve(sessionId: session, toolUseId: "call_0", decision: .run) if case .run = await mine.value {} else { Issue.record("this session's answer went missing") } @@ -105,7 +100,7 @@ struct ToolApprovalCenterTests { let other = UUID() let survivor = Task { await center.awaitDecision(sessionId: other, toolUseId: "call_0") } let victim = Task { await center.awaitDecision(sessionId: session, toolUseId: "call_0") } - await Task.yield() + await runQueuedMainActorWork() center.cancelAll(sessionId: session) if case .cancel = await victim.value {} else { Issue.record("the stopped session should cancel") } @@ -121,7 +116,7 @@ struct ToolApprovalCenterTests { let other = UUID() let mine = Task { await center.awaitDecision(sessionId: session, toolUseId: "call_0") } let theirs = Task { await center.awaitDecision(sessionId: other, toolUseId: "call_0") } - await Task.yield() + await runQueuedMainActorWork() center.cancelEverything() @@ -129,4 +124,10 @@ struct ToolApprovalCenterTests { if case .cancel = await theirs.value {} else { Issue.record("second should cancel") } #expect(center.hasPending == false) } + + /// The main queue runs its jobs in order, so a job queued now runs after every waiter the case + /// started has reached its await. + private func runQueuedMainActorWork() async { + await Task { @MainActor in }.value + } } diff --git a/TableProTests/Core/Autocomplete/CompletionEngineFilterTests.swift b/TableProTests/Core/Autocomplete/CompletionEngineFilterTests.swift index d6fe7ea80e..7e9c4af696 100644 --- a/TableProTests/Core/Autocomplete/CompletionEngineFilterTests.swift +++ b/TableProTests/Core/Autocomplete/CompletionEngineFilterTests.swift @@ -80,7 +80,6 @@ private final class MockFilterDriver: DatabaseDriver, @unchecked Sendable { func rollbackTransaction() async throws {} } -@Suite("Completion Engine Filter Completions", .serialized) @MainActor struct CompletionEngineFilterTests { private func makeEngine() async -> CompletionEngine { diff --git a/TableProTests/Core/Autocomplete/CompletionEngineTests.swift b/TableProTests/Core/Autocomplete/CompletionEngineTests.swift index db7fdb017f..f0cfced0fd 100644 --- a/TableProTests/Core/Autocomplete/CompletionEngineTests.swift +++ b/TableProTests/Core/Autocomplete/CompletionEngineTests.swift @@ -11,7 +11,6 @@ import TableProPluginKit import Testing @testable import TablePro -@Suite("Completion Engine", .serialized) struct CompletionEngineTests { private let schemaProvider: SQLSchemaProvider private let engine: CompletionEngine diff --git a/TableProTests/Core/Autocomplete/SQLSchemaProviderFallbackTests.swift b/TableProTests/Core/Autocomplete/SQLSchemaProviderFallbackTests.swift index 89fb639f90..414aacc08c 100644 --- a/TableProTests/Core/Autocomplete/SQLSchemaProviderFallbackTests.swift +++ b/TableProTests/Core/Autocomplete/SQLSchemaProviderFallbackTests.swift @@ -105,7 +105,6 @@ private func populateCache( // MARK: - Tests -@Suite("SQLSchemaProvider Fallback Columns", .serialized) @MainActor struct SQLSchemaProviderFallbackTests { // MARK: - allColumnsFromCachedTables diff --git a/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift b/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift index 934b46cfac..d75b10582f 100644 --- a/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift +++ b/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift @@ -9,10 +9,9 @@ import Foundation @testable import TablePro @testable import TableProAutocomplete import TableProPluginKit +import TableProTestSupport import Testing -// MARK: - Mock Driver - final class MockDatabaseDriver: DatabaseDriver, SchemaSwitchable, @unchecked Sendable { let connection: DatabaseConnection var status: ConnectionStatus = .connected @@ -39,7 +38,7 @@ final class MockDatabaseDriver: DatabaseDriver, SchemaSwitchable, @unchecked Sen var disconnectCallCount = 0 var pingCallCount = 0 var pingError: Error? - var pingDelaySeconds: Double = 0 + var pingGate: AsyncGate? var connectDelaySeconds: Double = 0 var switchSchemaDelaySeconds: Double = 0 var onSwitchSchema: (@Sendable () async -> Void)? @@ -61,16 +60,18 @@ final class MockDatabaseDriver: DatabaseDriver, SchemaSwitchable, @unchecked Sen } var sessionTransactionStateToReturn: PluginSessionTransactionState = .unknown - var sessionTransactionStateDelaySeconds: Double = 0 + var sessionTransactionStateGate: AsyncGate? var sessionTransactionStateReadWasCancelled = false + private(set) var finishedSessionTransactionStateReads = 0 /// libpq reports no transaction state once a check has found the socket closed. var pingFailureForgetsTransactionState = false func sessionTransactionState() async -> PluginSessionTransactionState { - if sessionTransactionStateDelaySeconds > 0 { - try? await Task.sleep(nanoseconds: UInt64(sessionTransactionStateDelaySeconds * 1_000_000_000)) + if let sessionTransactionStateGate { + await sessionTransactionStateGate.wait() if Task.isCancelled { sessionTransactionStateReadWasCancelled = true } } + finishedSessionTransactionStateReads += 1 return sessionTransactionStateToReturn } @@ -111,8 +112,8 @@ final class MockDatabaseDriver: DatabaseDriver, SchemaSwitchable, @unchecked Sen func ping() async throws { pingCallCount += 1 - if pingDelaySeconds > 0 { - try? await Task.sleep(nanoseconds: UInt64(pingDelaySeconds * 1_000_000_000)) + if let pingGate { + await pingGate.wait() } if let pingError { if pingFailureForgetsTransactionState { diff --git a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorSchemaTests.swift b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorSchemaTests.swift index 4f4280c185..88ca3484d7 100644 --- a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorSchemaTests.swift +++ b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorSchemaTests.swift @@ -7,11 +7,12 @@ // schema keeps the unqualified name it has always produced. // +import Foundation @testable import TablePro import TableProPluginKit -import XCTest +import Testing -final class SQLStatementGeneratorSchemaTests: XCTestCase { +struct SQLStatementGeneratorSchemaTests { private func generator(schema: String?) throws -> SQLStatementGenerator { try SQLStatementGenerator( tableName: "orders", @@ -23,48 +24,48 @@ final class SQLStatementGeneratorSchemaTests: XCTestCase { ) } - func testASchemaQualifiesTheInsert() throws { + @Test + func aSchemaQualifiesTheInsert() throws { let statement = try generator(schema: "sales") .insertStatement(columns: ["id"], values: [.text("1")]) - XCTAssertEqual(statement?.sql, "INSERT INTO \"sales\".\"orders\" (\"id\") VALUES ($1)") + #expect(statement?.sql == "INSERT INTO \"sales\".\"orders\" (\"id\") VALUES ($1)") } /// Every existing caller passes no schema, so nothing that worked before changes shape. - func testNoSchemaKeepsTheUnqualifiedName() throws { + @Test + func noSchemaKeepsTheUnqualifiedName() throws { let statement = try generator(schema: nil) .insertStatement(columns: ["id"], values: [.text("1")]) - XCTAssertEqual(statement?.sql, "INSERT INTO \"orders\" (\"id\") VALUES ($1)") + #expect(statement?.sql == "INSERT INTO \"orders\" (\"id\") VALUES ($1)") } - func testAnEmptySchemaIsTreatedAsNoSchema() throws { - XCTAssertEqual(try generator(schema: "").qualifiedTableName, "\"orders\"") + @Test + func anEmptySchemaIsTreatedAsNoSchema() throws { + #expect(try generator(schema: "").qualifiedTableName == "\"orders\"") } - func testTheMultiRowInsertIsQualifiedToo() throws { + @Test + func theMultiRowInsertIsQualifiedToo() throws { let statement = try generator(schema: "sales") .insertStatement(columns: ["id", "total"], rows: [[.text("1"), .text("9")], [.text("2"), .text("8")]]) - XCTAssertEqual( - statement?.sql, - "INSERT INTO \"sales\".\"orders\" (\"id\", \"total\") VALUES ($1, $2), ($3, $4)" - ) + #expect(statement?.sql == "INSERT INTO \"sales\".\"orders\" (\"id\", \"total\") VALUES ($1, $2), ($3, $4)") } /// The copier hands the driver the batch flattened row-major, so the generator's own parameter /// order has to match or values land in the wrong columns. - func testMultiRowParametersAreOrderedRowMajor() throws { + @Test + func multiRowParametersAreOrderedRowMajor() throws { let statement = try generator(schema: nil) .insertStatement(columns: ["id", "total"], rows: [[.text("1"), .text("9")], [.text("2"), .text("8")]]) - XCTAssertEqual(statement?.parameters.map { $0 as? String }, ["1", "9", "2", "8"]) + #expect(statement?.parameters.map { $0 as? String } == ["1", "9", "2", "8"]) } - func testDeleteAllRowsIsQualified() throws { - XCTAssertEqual( - try generator(schema: "sales").deleteAllRowsStatement(), - "DELETE FROM \"sales\".\"orders\"" - ) + @Test + func deleteAllRowsIsQualified() throws { + #expect(try generator(schema: "sales").deleteAllRowsStatement() == "DELETE FROM \"sales\".\"orders\"") } } diff --git a/TableProTests/Core/ChangeTracking/SQLWriteBatchBudgetTests.swift b/TableProTests/Core/ChangeTracking/SQLWriteBatchBudgetTests.swift index 1f7b76b3b5..cb384b23e4 100644 --- a/TableProTests/Core/ChangeTracking/SQLWriteBatchBudgetTests.swift +++ b/TableProTests/Core/ChangeTracking/SQLWriteBatchBudgetTests.swift @@ -7,11 +7,12 @@ // server's max_allowed_packet is rejected outright. // +import Foundation @testable import TablePro import TableProPluginKit -import XCTest +import Testing -final class SQLWriteBatchBudgetTests: XCTestCase { +struct SQLWriteBatchBudgetTests { private func generator(_ databaseType: DatabaseType) throws -> SQLStatementGenerator { try SQLStatementGenerator( tableName: "t", columns: [], primaryKeyColumns: [], databaseType: databaseType) @@ -19,129 +20,138 @@ final class SQLWriteBatchBudgetTests: XCTestCase { // MARK: - Row bounds, ported from ObjectCopyRowCopierTests - func testTheRowCapNeverExceedsTheEnginesBindParameterCeiling() throws { + @Test + func theRowCapNeverExceedsTheEnginesBindParameterCeiling() throws { let mssql = try SQLWriteBatchBudget(columnCount: 100, generator: generator(.mssql)) let sqlite = try SQLWriteBatchBudget(columnCount: 100, generator: generator(.sqlite)) - XCTAssertEqual(mssql.maxRows, 21) - XCTAssertEqual(sqlite.maxRows, 327) + #expect(mssql.maxRows == 21) + #expect(sqlite.maxRows == 327) } /// A one-column table would otherwise put 65,535 rows in one statement, which parses slowly /// everywhere and cannot be cancelled part-way. - func testTheRowCapIsCappedByRowsAsWellAsByParameters() throws { + @Test + func theRowCapIsCappedByRowsAsWellAsByParameters() throws { let mysql = try SQLWriteBatchBudget(columnCount: 1, generator: generator(.mysql)) - XCTAssertEqual(mysql.maxRows, 1_000) + #expect(mysql.maxRows == 1_000) } /// Oracle before 23c rejects `INSERT … VALUES (…), (…)`, which is the only form the generic /// generator emits, so its batches carry one row each however narrow the table is. - func testOracleTakesOneRowPerStatement() throws { + @Test + func oracleTakesOneRowPerStatement() throws { let oracle = try SQLWriteBatchBudget(columnCount: 2, generator: generator(.oracle)) - XCTAssertEqual(oracle.maxRows, 1) + #expect(oracle.maxRows == 1) } /// A table wider than the ceiling still writes one row at a time rather than none. - func testAVeryWideTableStillWritesOneRowPerStatement() throws { + @Test + func aVeryWideTableStillWritesOneRowPerStatement() throws { let mssql = try SQLWriteBatchBudget(columnCount: 5_000, generator: generator(.mssql)) - XCTAssertEqual(mssql.maxRows, 1) + #expect(mssql.maxRows == 1) } /// An engine with no documented multi-row ceiling keeps the flat thousand rather than taking /// its parameter ceiling: a five-column PostgreSQL table would otherwise jump to 13,107. - func testAnEngineWithNoSyntaxCeilingKeepsTheFlatRowCap() throws { + @Test + func anEngineWithNoSyntaxCeilingKeepsTheFlatRowCap() throws { let postgres = try SQLWriteBatchBudget(columnCount: 5, generator: generator(.postgresql)) - XCTAssertEqual(SQLMultiRowInsert.maximumRowsPerStatement(forDatabaseTypeId: "PostgreSQL"), .max) - XCTAssertEqual(postgres.maxRows, 1_000) + #expect(SQLMultiRowInsert.maximumRowsPerStatement(forDatabaseTypeId: "PostgreSQL") == .max) + #expect(postgres.maxRows == 1_000) } // MARK: - Byte bounds /// The reported defect: 500 rows of three 1 MB values sit inside a three-column table's /// 21,845-row parameter ceiling and went out as one ~500 MB statement the server refused. - func testAByteHeavyRowClosesTheBatchLongBeforeTheRowCap() throws { + @Test + func aByteHeavyRowClosesTheBatchLongBeforeTheRowCap() throws { let budget = try SQLWriteBatchBudget(columnCount: 3, generator: generator(.mysql)) let megabyte = budget.byteCount( of: (0 ..< 3).map { _ in PluginCellValue.text(String(repeating: "a", count: 1_048_576)) }) - XCTAssertGreaterThan(budget.maxRows, 500, "the parameter ceiling alone allows far more") - XCTAssertFalse(budget.hasRoom(for: megabyte, inBatchOf: 1, bytes: megabyte)) + #expect(budget.maxRows > 500, "the parameter ceiling alone allows far more") + #expect(!budget.hasRoom(for: megabyte, inBatchOf: 1, bytes: megabyte)) } /// A row cannot be split across two statements, so an empty batch takes it whatever it weighs. - func testAnEmptyBatchTakesARowOverTheWholeBudget() { + @Test + func anEmptyBatchTakesARowOverTheWholeBudget() { let budget = SQLWriteBatchBudget(maxRows: 500) let huge = 10 * SQLWriteBatchBudget.maximumBytes - XCTAssertTrue(budget.hasRoom(for: huge, inBatchOf: 0, bytes: 0)) - XCTAssertFalse(budget.hasRoom(for: 1, inBatchOf: 1, bytes: huge)) + #expect(budget.hasRoom(for: huge, inBatchOf: 0, bytes: 0)) + #expect(!budget.hasRoom(for: 1, inBatchOf: 1, bytes: huge)) } - func testTheRowCapStillClosesABatchOfNarrowRows() { + @Test + func theRowCapStillClosesABatchOfNarrowRows() { let budget = SQLWriteBatchBudget(maxRows: 2) - XCTAssertTrue(budget.hasRoom(for: 10, inBatchOf: 1, bytes: 10)) - XCTAssertFalse(budget.hasRoom(for: 10, inBatchOf: 2, bytes: 20)) + #expect(budget.hasRoom(for: 10, inBatchOf: 1, bytes: 10)) + #expect(!budget.hasRoom(for: 10, inBatchOf: 2, bytes: 20)) } /// Every value carries a length prefix, a type-array entry and a null-bitmap bit, so a row of /// NULLs is not free. Measured against MariaDB 12.3.3, the real per-value cost settles near 6 /// bytes; 12 is the documented upper bound and over-charging is the safe direction. - func testEveryValueCostsItsOverheadEvenWhenItCarriesNothing() { + @Test + func everyValueCostsItsOverheadEvenWhenItCarriesNothing() { let budget = SQLWriteBatchBudget(maxRows: 500) - XCTAssertEqual(budget.byteCount(of: [PluginCellValue.null]), 12) - XCTAssertEqual(budget.byteCount(of: [PluginCellValue.text("abc")]), 15) - XCTAssertEqual( - budget.byteCount(of: [PluginCellValue.bytes(Data(repeating: 0, count: 100))]), 112) - XCTAssertEqual(budget.byteCount(of: [PluginCellValue]()), 0) + #expect(budget.byteCount(of: [PluginCellValue.null]) == 12) + #expect(budget.byteCount(of: [PluginCellValue.text("abc")]) == 15) + #expect(budget.byteCount(of: [PluginCellValue.bytes(Data(repeating: 0, count: 100))]) == 112) + #expect(budget.byteCount(of: [PluginCellValue]()) == 0) } /// Databend's driver inlines a parameter into the SQL, so binary crosses as hex at two /// characters a byte. Charged the bind-parameter rate, the cap bounded nothing on that engine. - func testAnEngineThatInlinesItsValuesIsChargedDouble() throws { + @Test + func anEngineThatInlinesItsValuesIsChargedDouble() throws { let databend = try SQLWriteBatchBudget(columnCount: 1, generator: generator(.databend)) let mysql = try SQLWriteBatchBudget(columnCount: 1, generator: generator(.mysql)) let value = [PluginCellValue.bytes(Data(repeating: 0xAB, count: 400_000))] - XCTAssertTrue(databend.rendersValuesAsLiterals) - XCTAssertFalse(mysql.rendersValuesAsLiterals) - XCTAssertEqual(mysql.byteCount(of: value), 400_012) - XCTAssertEqual(databend.byteCount(of: value), 800_012) - XCTAssertFalse( - databend.hasRoom(for: databend.byteCount(of: value), inBatchOf: 1, - bytes: databend.byteCount(of: value)), - "two of these exceed a mebibyte of rendered SQL, so the second must close the batch" - ) + #expect(databend.rendersValuesAsLiterals) + #expect(!mysql.rendersValuesAsLiterals) + #expect(mysql.byteCount(of: value) == 400_012) + #expect(databend.byteCount(of: value) == 800_012) + #expect(!(databend.hasRoom(for: databend.byteCount(of: value), inBatchOf: 1, + bytes: databend.byteCount(of: value))), + "two of these exceed a mebibyte of rendered SQL, so the second must close the batch") } /// A multi-byte value is charged its UTF-8 length, not its character count: the packet the /// server measures carries bytes. - func testAMultiByteValueIsChargedItsBytes() { + @Test + func aMultiByteValueIsChargedItsBytes() { let emoji = String(repeating: "😀", count: 10) - XCTAssertEqual(emoji.count, 10) - XCTAssertEqual( - SQLWriteBatchBudget(maxRows: 500).byteCount(of: [PluginCellValue.text(emoji)]), 12 + 40) + #expect(emoji.count == 10) + #expect(SQLWriteBatchBudget(maxRows: 500).byteCount(of: [PluginCellValue.text(emoji)]) == 12 + 40) } // MARK: - The filler - func testTheFillerClosesABatchBeforeTheRowThatWouldCrossTheBudget() { + @Test + func theFillerClosesABatchBeforeTheRowThatWouldCrossTheBudget() { var filler = SQLWriteBatchFiller(budget: SQLWriteBatchBudget(maxRows: 100, maxBytes: 100)) - XCTAssertNil(filler.append(1, bytes: 60)) + #expect(filler.append(1, bytes: 60) == nil) let closed = filler.append(2, bytes: 60) - XCTAssertEqual(closed, [1], "the first row goes out alone rather than the pair overflowing") - XCTAssertEqual(filler.take(), [2], "the row that closed the batch is held, never dropped") - XCTAssertNil(filler.take()) + #expect(closed == [1], "the first row goes out alone rather than the pair overflowing") + #expect(filler.take() == [2], "the row that closed the batch is held, never dropped") + #expect(filler.take() == nil) } - func testTheFillerNeverDropsARow() { + @Test + func theFillerNeverDropsARow() { var filler = SQLWriteBatchFiller(budget: SQLWriteBatchBudget(maxRows: 2, maxBytes: 1_000)) var written: [Int] = [] @@ -150,6 +160,6 @@ final class SQLWriteBatchBudgetTests: XCTestCase { } if let batch = filler.take() { written.append(contentsOf: batch) } - XCTAssertEqual(written, [1, 2, 3, 4, 5]) + #expect(written == [1, 2, 3, 4, 5]) } } diff --git a/TableProTests/Core/Compare/CompareDataPlanGroupingTests.swift b/TableProTests/Core/Compare/CompareDataPlanGroupingTests.swift index 6f5f114049..201a7be405 100644 --- a/TableProTests/Core/Compare/CompareDataPlanGroupingTests.swift +++ b/TableProTests/Core/Compare/CompareDataPlanGroupingTests.swift @@ -8,10 +8,11 @@ // list, and that a group header expands to exactly the plans a bulk include should touch. // +import Foundation @testable import TablePro -import XCTest +import Testing -final class CompareDataPlanGroupingTests: XCTestCase { +struct CompareDataPlanGroupingTests { private let comparators = [KeyPathComparator(\CompareDataPlanRow.tableName)] private func plan( @@ -44,31 +45,35 @@ final class CompareDataPlanGroupingTests: XCTestCase { // MARK: - Search - func testSearchMatchesTableNamesCaseInsensitively() { + @Test + func searchMatchesTableNamesCaseInsensitively() { let matched = CompareDataPlanGrouping.matching( [plan("orders"), plan("customers"), plan("order_items")], searchText: "ORDER" ) - XCTAssertEqual(matched.map { $0.id }, ["public.orders", "public.order_items"]) + #expect(matched.map { $0.id } == ["public.orders", "public.order_items"]) } - func testAnEmptySearchKeepsEveryPlan() { + @Test + func anEmptySearchKeepsEveryPlan() { let plans = [plan("orders"), plan("customers")] - XCTAssertEqual(CompareDataPlanGrouping.matching(plans, searchText: " ").count, 2) - XCTAssertEqual(CompareDataPlanGrouping.matching(plans, searchText: "").count, 2) + #expect(CompareDataPlanGrouping.matching(plans, searchText: " ").count == 2) + #expect(CompareDataPlanGrouping.matching(plans, searchText: "").count == 2) } - func testSearchMatchesTheQualifiedName() { + @Test + func searchMatchesTheQualifiedName() { let matched = CompareDataPlanGrouping.matching([plan("orders")], searchText: "public.") - XCTAssertEqual(matched.count, 1) + #expect(matched.count == 1) } // MARK: - Sections - func testGroupingByDifferenceSeparatesEveryOutcome() { + @Test + func groupingByDifferenceSeparatesEveryOutcome() { let groups = CompareDataPlanGrouping.groups( from: [ plan("orders", summary: summary(inserts: 3)), @@ -80,49 +85,53 @@ final class CompareDataPlanGroupingTests: XCTestCase { sortedUsing: comparators ) - XCTAssertEqual(groups.count, 4) - XCTAssertEqual(groups.flatMap { $0.rows }.count, 4) - XCTAssertEqual(groups.map { $0.rows.count }, [1, 1, 1, 1]) + #expect(groups.count == 4) + #expect(groups.flatMap { $0.rows }.count == 4) + #expect(groups.map { $0.rows.count } == [1, 1, 1, 1]) } /// A plan nobody has run and a plan that matched are different answers. Folding them together /// would report a table that was never compared as identical. - func testAPlanThatWasNeverComparedIsNotReportedAsIdentical() { + @Test + func aPlanThatWasNeverComparedIsNotReportedAsIdentical() { let groups = CompareDataPlanGrouping.groups( from: [plan("customers", summary: summary(identical: 4)), plan("audit")], grouping: .byDifference, sortedUsing: comparators ) - XCTAssertEqual(groups.count, 2) - XCTAssertTrue(groups.allSatisfy { $0.rows.count == 1 }, "the two outcomes never share a section") - XCTAssertEqual(Set(groups.map { $0.header.id }).count, 2) + #expect(groups.count == 2) + #expect(groups.allSatisfy { $0.rows.count == 1 }, "the two outcomes never share a section") + #expect(Set(groups.map { $0.header.id }).count == 2) } - func testAnEmptyBucketProducesNoSection() { + @Test + func anEmptyBucketProducesNoSection() { let groups = CompareDataPlanGrouping.groups( from: [plan("orders", summary: summary(inserts: 1))], grouping: .byDifference, sortedUsing: comparators ) - XCTAssertEqual(groups.count, 1) + #expect(groups.count == 1) } /// Every plan is a table, so this grouping is one section rather than none. The control stays /// live and says what it grouped by instead of looking broken in one of the two modes. - func testGroupingByObjectTypeProducesOneSection() { + @Test + func groupingByObjectTypeProducesOneSection() { let groups = CompareDataPlanGrouping.groups( from: [plan("orders"), plan("customers")], grouping: .byObjectType, sortedUsing: comparators ) - XCTAssertEqual(groups.count, 1) - XCTAssertEqual(groups.first?.rows.count, 2) + #expect(groups.count == 1) + #expect(groups.first?.rows.count == 2) } - func testNoGroupingProducesFlatRows() { + @Test + func noGroupingProducesFlatRows() { let rows = CompareDataPlanGrouping.rows( from: [plan("orders"), plan("customers")], sortedUsing: comparators ) @@ -130,14 +139,15 @@ final class CompareDataPlanGroupingTests: XCTestCase { from: [plan("orders"), plan("customers")], grouping: .none, sortedUsing: comparators ) - XCTAssertEqual(rows.map { $0.tableName }, ["public.customers", "public.orders"]) - XCTAssertTrue(rows.allSatisfy { !$0.isGroup }) - XCTAssertTrue(groups.isEmpty) + #expect(rows.map { $0.tableName } == ["public.customers", "public.orders"]) + #expect(rows.allSatisfy { !$0.isGroup }) + #expect(groups.isEmpty) } // MARK: - Bulk include - func testGroupMembersExcludeUnreadablePlans() { + @Test + func groupMembersExcludeUnreadablePlans() { let groups = CompareDataPlanGrouping.groups( from: [plan("orders"), plan("legacy", unavailableReason: "No primary key.")], grouping: .byObjectType, @@ -145,10 +155,11 @@ final class CompareDataPlanGroupingTests: XCTestCase { ) let members = groups.flatMap { $0.header.memberIds } - XCTAssertEqual(members, ["public.orders"]) + #expect(members == ["public.orders"]) } - func testSelectingAGroupHeaderExpandsToItsMembers() { + @Test + func selectingAGroupHeaderExpandsToItsMembers() { let groups = CompareDataPlanGrouping.groups( from: [plan("orders"), plan("customers")], grouping: .byObjectType, @@ -158,27 +169,30 @@ final class CompareDataPlanGroupingTests: XCTestCase { let ids = CompareDataPlanGrouping.planIds(in: [header], groups: groups) - XCTAssertEqual(ids.sorted(), ["public.customers", "public.orders"]) + #expect(ids.sorted() == ["public.customers", "public.orders"]) } - func testSelectingPlansPassesTheirOwnIdentifiersThrough() { + @Test + func selectingPlansPassesTheirOwnIdentifiersThrough() { let ids = CompareDataPlanGrouping.planIds(in: ["public.orders"], groups: []) - XCTAssertEqual(ids, ["public.orders"]) + #expect(ids == ["public.orders"]) } - func testAGroupIdentifierCannotCollideWithAPlanIdentifier() { + @Test + func aGroupIdentifierCannotCollideWithAPlanIdentifier() { let groups = CompareDataPlanGrouping.groups( from: [plan("orders")], grouping: .byObjectType, sortedUsing: comparators ) - XCTAssertTrue(CompareDataPlanGrouping.isGroupIdentifier(groups[0].header.id)) - XCTAssertFalse(CompareDataPlanGrouping.isGroupIdentifier(plan("orders").id)) + #expect(CompareDataPlanGrouping.isGroupIdentifier(groups[0].header.id)) + #expect(!(CompareDataPlanGrouping.isGroupIdentifier(plan("orders").id))) } // MARK: - Header counts - func testAGroupHeaderSumsItsMembersCounts() { + @Test + func aGroupHeaderSumsItsMembersCounts() { let groups = CompareDataPlanGrouping.groups( from: [ plan("orders", summary: summary(inserts: 2, updates: 1, deletes: 0, identical: 5)), @@ -189,15 +203,16 @@ final class CompareDataPlanGroupingTests: XCTestCase { ) let header = groups.first?.header - XCTAssertEqual(header?.insertCount, 5) - XCTAssertEqual(header?.updateCount, 1) - XCTAssertEqual(header?.deleteCount, 4) - XCTAssertEqual(header?.identicalCount, 11) + #expect(header?.insertCount == 5) + #expect(header?.updateCount == 1) + #expect(header?.deleteCount == 4) + #expect(header?.identicalCount == 11) } /// Nothing compared means no number, not a zero: the count columns draw a blank for a table /// nobody ran, and a header has to say the same thing rather than claim it found none. - func testAGroupWithNothingComparedCarriesNoCounts() { + @Test + func aGroupWithNothingComparedCarriesNoCounts() { let groups = CompareDataPlanGrouping.groups( from: [plan("orders"), plan("customers")], grouping: .byObjectType, @@ -205,17 +220,18 @@ final class CompareDataPlanGroupingTests: XCTestCase { ) let header = groups.first?.header - XCTAssertNil(header?.insertCount) - XCTAssertNil(header?.identicalCount) + #expect(header?.insertCount == nil) + #expect(header?.identicalCount == nil) } - func testAPlanRowCarriesItsOwnCounts() { + @Test + func aPlanRowCarriesItsOwnCounts() { let rows = CompareDataPlanGrouping.rows( from: [plan("orders", summary: summary(inserts: 2, identical: 7))], sortedUsing: comparators ) - XCTAssertEqual(rows.first?.insertCount, 2) - XCTAssertEqual(rows.first?.identicalCount, 7) - XCTAssertNotNil(rows.first?.plan) + #expect(rows.first?.insertCount == 2) + #expect(rows.first?.identicalCount == 7) + #expect(rows.first?.plan != nil) } } diff --git a/TableProTests/Core/Compare/CompareDataScopeTests.swift b/TableProTests/Core/Compare/CompareDataScopeTests.swift index f6e8e6a222..d21b5b7088 100644 --- a/TableProTests/Core/Compare/CompareDataScopeTests.swift +++ b/TableProTests/Core/Compare/CompareDataScopeTests.swift @@ -8,31 +8,16 @@ import Foundation import TableProPluginKit -import XCTest +import Testing @testable import TablePro -private final class ScopeQuotingDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class ScopeQuotingDriver: PluginDriverStub, @unchecked Sendable { func quoteIdentifier(_ name: String) -> String { "\"\(name)\"" } - func connect() async throws {} - func disconnect() {} func execute(query: String) async throws -> PluginQueryResult { PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) } - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } private enum ScopeDialect { @@ -54,171 +39,189 @@ private enum ScopeDialect { } } -final class DataTableScopeTests: XCTestCase { +struct DataTableScopeTests { // MARK: - Filters - func testAWhitespaceOnlyFilterIsNoFilter() { + @Test + func aWhitespaceOnlyFilterIsNoFilter() { let scope = DataTableScope(sourceFilter: " \n\t ") - XCTAssertNil(scope.effectiveSourceFilter) - XCTAssertNil(scope.effectiveTargetFilter) - XCTAssertFalse(scope.hasFilter) - XCTAssertNil(scope.filterValidationError) + #expect(scope.effectiveSourceFilter == nil) + #expect(scope.effectiveTargetFilter == nil) + #expect(!scope.hasFilter) + #expect(scope.filterValidationError == nil) } - func testAFilterIsTrimmedBeforeItIsUsed() { + @Test + func aFilterIsTrimmedBeforeItIsUsed() { let scope = DataTableScope(sourceFilter: " status = 'active'\n") - XCTAssertEqual(scope.effectiveSourceFilter, "status = 'active'") + #expect(scope.effectiveSourceFilter == "status = 'active'") } - func testANilTargetFilterReusesTheSourceFilter() { + @Test + func aNilTargetFilterReusesTheSourceFilter() { let scope = DataTableScope(sourceFilter: " status = 'active' ") - XCTAssertTrue(scope.usesSameFilterForTarget) - XCTAssertEqual(scope.effectiveTargetFilter, "status = 'active'") - XCTAssertTrue(scope.hasFilter) + #expect(scope.usesSameFilterForTarget) + #expect(scope.effectiveTargetFilter == "status = 'active'") + #expect(scope.hasFilter) } /// An empty target filter is a choice to read the whole target, not a request to reuse the /// source filter, so the two must stay distinguishable. - func testAnEmptyTargetFilterReadsTheWholeTarget() { + @Test + func anEmptyTargetFilterReadsTheWholeTarget() { let scope = DataTableScope(sourceFilter: "status = 'active'", targetFilter: "") - XCTAssertFalse(scope.usesSameFilterForTarget) - XCTAssertEqual(scope.effectiveSourceFilter, "status = 'active'") - XCTAssertNil(scope.effectiveTargetFilter) - XCTAssertTrue(scope.hasFilter) + #expect(!scope.usesSameFilterForTarget) + #expect(scope.effectiveSourceFilter == "status = 'active'") + #expect(scope.effectiveTargetFilter == nil) + #expect(scope.hasFilter) } - func testATargetOnlyFilterLeavesTheSourceUnfiltered() { + @Test + func aTargetOnlyFilterLeavesTheSourceUnfiltered() { let scope = DataTableScope(sourceFilter: " ", targetFilter: "region = 'eu'") - XCTAssertNil(scope.effectiveSourceFilter) - XCTAssertEqual(scope.effectiveTargetFilter, "region = 'eu'") - XCTAssertTrue(scope.hasFilter) + #expect(scope.effectiveSourceFilter == nil) + #expect(scope.effectiveTargetFilter == "region = 'eu'") + #expect(scope.hasFilter) } - func testAnInvalidSourceFilterIsReported() { + @Test + func anInvalidSourceFilterIsReported() { let scope = DataTableScope(sourceFilter: "(status = 'active'") - XCTAssertNotNil(scope.filterValidationError) + #expect(scope.filterValidationError != nil) } - func testAnInvalidTargetFilterIsReportedEvenWhenTheSourceFilterIsValid() { + @Test + func anInvalidTargetFilterIsReportedEvenWhenTheSourceFilterIsValid() { let scope = DataTableScope(sourceFilter: "status = 'active'", targetFilter: "region = 'eu") - XCTAssertNotNil(scope.filterValidationError) + #expect(scope.filterValidationError != nil) } - func testTwoValidFiltersReportNoError() { + @Test + func twoValidFiltersReportNoError() { let scope = DataTableScope(sourceFilter: "status = 'active'", targetFilter: "region = 'eu'") - XCTAssertNil(scope.filterValidationError) + #expect(scope.filterValidationError == nil) } // MARK: - Row limit - func testSetRowLimitClampsZeroAndNegativeValuesToOne() { + @Test + func setRowLimitClampsZeroAndNegativeValuesToOne() { var scope = DataTableScope() scope.setRowLimit(0) - XCTAssertEqual(scope.rowLimit, 1) + #expect(scope.rowLimit == 1) scope.setRowLimit(-25) - XCTAssertEqual(scope.rowLimit, 1) + #expect(scope.rowLimit == 1) scope.setRowLimit(500) - XCTAssertEqual(scope.rowLimit, 500) + #expect(scope.rowLimit == 500) scope.setRowLimit(nil) - XCTAssertNil(scope.rowLimit) + #expect(scope.rowLimit == nil) } - func testTheInitializerClampsTheRowLimitTheSameWay() { - XCTAssertEqual(DataTableScope(rowLimit: 0).rowLimit, 1) - XCTAssertEqual(DataTableScope(rowLimit: -3).rowLimit, 1) - XCTAssertEqual(DataTableScope(rowLimit: 10).rowLimit, 10) - XCTAssertNil(DataTableScope().rowLimit) + @Test + func theInitializerClampsTheRowLimitTheSameWay() { + #expect(DataTableScope(rowLimit: 0).rowLimit == 1) + #expect(DataTableScope(rowLimit: -3).rowLimit == 1) + #expect(DataTableScope(rowLimit: 10).rowLimit == 10) + #expect(DataTableScope().rowLimit == nil) } // MARK: - Columns - func testExcludedColumnsMatchWithoutRegardToCase() { + @Test + func excludedColumnsMatchWithoutRegardToCase() { var scope = DataTableScope(excludedColumns: ["Updated_At"]) - XCTAssertTrue(scope.isExcluded("updated_at")) - XCTAssertTrue(scope.isExcluded("UPDATED_AT")) + #expect(scope.isExcluded("updated_at")) + #expect(scope.isExcluded("UPDATED_AT")) scope.setExcluded(true, column: "Notes") - XCTAssertTrue(scope.isExcluded("NOTES")) + #expect(scope.isExcluded("NOTES")) scope.setExcluded(false, column: "notes") scope.setExcluded(false, column: "UPDATED_at") - XCTAssertFalse(scope.isExcluded("Notes")) - XCTAssertFalse(scope.isExcluded("updated_at")) - XCTAssertTrue(scope.excludedColumns.isEmpty) + #expect(!scope.isExcluded("Notes")) + #expect(!scope.isExcluded("updated_at")) + #expect(scope.excludedColumns.isEmpty) } - func testExcludingOneColumnInTwoSpellingsKeepsOneEntry() { + @Test + func excludingOneColumnInTwoSpellingsKeepsOneEntry() { var scope = DataTableScope() scope.setExcluded(true, column: "Email") scope.setExcluded(true, column: "EMAIL") - XCTAssertEqual(scope.excludedColumns, ["email"]) + #expect(scope.excludedColumns == ["email"]) } - func testKeyColumnsMatchWithoutRegardToCase() { + @Test + func keyColumnsMatchWithoutRegardToCase() { let scope = DataTableScope(keyColumns: ["Tenant_Id", "id"]) - XCTAssertTrue(scope.isKeyColumn("tenant_id")) - XCTAssertTrue(scope.isKeyColumn("ID")) - XCTAssertFalse(scope.isKeyColumn("name")) + #expect(scope.isKeyColumn("tenant_id")) + #expect(scope.isKeyColumn("ID")) + #expect(!scope.isKeyColumn("name")) } - func testRestrictExclusionsDropsColumnsTheTableNoLongerHas() { + @Test + func restrictExclusionsDropsColumnsTheTableNoLongerHas() { var scope = DataTableScope(excludedColumns: ["name", "dropped_column"]) scope.restrictExclusions(to: ["ID", "Name"]) - XCTAssertEqual(scope.excludedColumns, ["name"]) - XCTAssertTrue(scope.isExcluded("Name")) - XCTAssertFalse(scope.isExcluded("dropped_column")) + #expect(scope.excludedColumns == ["name"]) + #expect(scope.isExcluded("Name")) + #expect(!scope.isExcluded("dropped_column")) } - func testRestrictExclusionsToNoColumnsClearsThemAll() { + @Test + func restrictExclusionsToNoColumnsClearsThemAll() { var scope = DataTableScope(excludedColumns: ["name"]) scope.restrictExclusions(to: []) - XCTAssertTrue(scope.excludedColumns.isEmpty) + #expect(scope.excludedColumns.isEmpty) } // MARK: - Coding - func testDecodingAnEmptyObjectGivesTheDefaults() throws { + @Test + func decodingAnEmptyObjectGivesTheDefaults() throws { let scope = try JSONDecoder().decode(DataTableScope.self, from: Data("{}".utf8)) - XCTAssertEqual(scope.keyColumns, []) - XCTAssertTrue(scope.excludedColumns.isEmpty) - XCTAssertEqual(scope.sourceFilter, "") - XCTAssertNil(scope.targetFilter) - XCTAssertNil(scope.rowLimit) - XCTAssertEqual(scope, DataTableScope()) + #expect(scope.keyColumns == []) + #expect(scope.excludedColumns.isEmpty) + #expect(scope.sourceFilter == "") + #expect(scope.targetFilter == nil) + #expect(scope.rowLimit == nil) + #expect(scope == DataTableScope()) } - func testDecodingLowercasesExclusionsAndClampsTheRowLimit() throws { + @Test + func decodingLowercasesExclusionsAndClampsTheRowLimit() throws { let json = #"{"excludedColumns": ["Updated_At"], "rowLimit": 0}"# let scope = try JSONDecoder().decode(DataTableScope.self, from: Data(json.utf8)) - XCTAssertEqual(scope.excludedColumns, ["updated_at"]) - XCTAssertEqual(scope.rowLimit, 1) + #expect(scope.excludedColumns == ["updated_at"]) + #expect(scope.rowLimit == 1) } - func testEncodingThenDecodingRoundTrips() throws { + @Test + func encodingThenDecodingRoundTrips() throws { let scope = DataTableScope( keyColumns: ["tenant", "id"], excludedColumns: ["updated_at", "notes"], @@ -229,23 +232,24 @@ final class DataTableScopeTests: XCTestCase { let decoded = try JSONDecoder().decode(DataTableScope.self, from: JSONEncoder().encode(scope)) - XCTAssertEqual(decoded, scope) - XCTAssertEqual(decoded.targetFilter, "", "an empty target filter must not come back as the shared filter") - XCTAssertFalse(decoded.usesSameFilterForTarget) + #expect(decoded == scope) + #expect(decoded.targetFilter == "", "an empty target filter must not come back as the shared filter") + #expect(!decoded.usesSameFilterForTarget) } - func testASharedFilterStaysSharedAcrossARoundTrip() throws { + @Test + func aSharedFilterStaysSharedAcrossARoundTrip() throws { let scope = DataTableScope(keyColumns: ["id"], sourceFilter: "status = 'active'") let decoded = try JSONDecoder().decode(DataTableScope.self, from: JSONEncoder().encode(scope)) - XCTAssertNil(decoded.targetFilter) - XCTAssertTrue(decoded.usesSameFilterForTarget) - XCTAssertEqual(decoded, scope) + #expect(decoded.targetFilter == nil) + #expect(decoded.usesSameFilterForTarget) + #expect(decoded == scope) } } -final class CompareRowFilterValidationTests: XCTestCase { +struct CompareRowFilterValidationTests { private let semicolonMessage = String( localized: "A filter is a single condition and cannot contain a semicolon." ) @@ -254,166 +258,194 @@ final class CompareRowFilterValidationTests: XCTestCase { localized: "A quote or parenthesis in this filter is not closed." ) - func testAPlainConditionIsAccepted() { - XCTAssertNil(CompareRowFilter.validationError(for: "status = 'active'")) - XCTAssertNil(CompareRowFilter.validationError(for: "id > 100 AND region = 'eu'")) + @Test + func aPlainConditionIsAccepted() { + #expect(CompareRowFilter.validationError(for: "status = 'active'") == nil) + #expect(CompareRowFilter.validationError(for: "id > 100 AND region = 'eu'") == nil) } - func testAnEmptyFilterIsAccepted() { - XCTAssertNil(CompareRowFilter.validationError(for: "")) - XCTAssertNil(CompareRowFilter.validationError(for: " \n ")) + @Test + func anEmptyFilterIsAccepted() { + #expect(CompareRowFilter.validationError(for: "") == nil) + #expect(CompareRowFilter.validationError(for: " \n ") == nil) } /// A filter is one condition, so a semicolon is refused wherever it sits, literal included. - func testASemicolonInsideALiteralIsStillRefused() { - XCTAssertEqual(CompareRowFilter.validationError(for: "name = 'a;b'"), semicolonMessage) + @Test + func aSemicolonInsideALiteralIsStillRefused() { + #expect(CompareRowFilter.validationError(for: "name = 'a;b'") == semicolonMessage) } - func testAStackedStatementIsRefused() { - XCTAssertEqual(CompareRowFilter.validationError(for: "1=1; DELETE FROM t"), semicolonMessage) + @Test + func aStackedStatementIsRefused() { + #expect(CompareRowFilter.validationError(for: "1=1; DELETE FROM t") == semicolonMessage) } - func testALineCommentIsRefused() { - XCTAssertEqual(CompareRowFilter.validationError(for: "id = 1 -- x"), commentMessage) + @Test + func aLineCommentIsRefused() { + #expect(CompareRowFilter.validationError(for: "id = 1 -- x") == commentMessage) } - func testABlockCommentIsRefused() { - XCTAssertEqual(CompareRowFilter.validationError(for: "a /* b */"), commentMessage) + @Test + func aBlockCommentIsRefused() { + #expect(CompareRowFilter.validationError(for: "a /* b */") == commentMessage) } - func testAnUnclosedParenthesisIsRefused() { - XCTAssertEqual(CompareRowFilter.validationError(for: "(a = 1"), unbalancedMessage) + @Test + func anUnclosedParenthesisIsRefused() { + #expect(CompareRowFilter.validationError(for: "(a = 1") == unbalancedMessage) } - func testAStrayClosingParenthesisIsRefused() { - XCTAssertEqual(CompareRowFilter.validationError(for: "a = 1)"), unbalancedMessage) + @Test + func aStrayClosingParenthesisIsRefused() { + #expect(CompareRowFilter.validationError(for: "a = 1)") == unbalancedMessage) } /// The filter is wrapped in parentheses before it reaches the query, so a filter that closes /// the wrapper and opens its own would escape it. - func testAFilterThatClosesTheWrapperIsRefused() { - XCTAssertEqual(CompareRowFilter.validationError(for: "a = 1) OR (1 = 1"), unbalancedMessage) + @Test + func aFilterThatClosesTheWrapperIsRefused() { + #expect(CompareRowFilter.validationError(for: "a = 1) OR (1 = 1") == unbalancedMessage) } - func testAnUnclosedQuoteIsRefused() { - XCTAssertEqual(CompareRowFilter.validationError(for: "a = 'open"), unbalancedMessage) + @Test + func anUnclosedQuoteIsRefused() { + #expect(CompareRowFilter.validationError(for: "a = 'open") == unbalancedMessage) } - func testAParenthesisInsideALiteralDoesNotCount() { - XCTAssertNil(CompareRowFilter.validationError(for: "a IN (1, 2) AND (b = 'x)')")) + @Test + func aParenthesisInsideALiteralDoesNotCount() { + #expect(CompareRowFilter.validationError(for: "a IN (1, 2) AND (b = 'x)')") == nil) } - func testADoubledQuoteStaysInsideTheLiteral() { - XCTAssertNil(CompareRowFilter.validationError(for: "name = 'O''Hara'")) - XCTAssertEqual(CompareRowFilter.validationError(for: "name = 'O''Hara"), unbalancedMessage) + @Test + func aDoubledQuoteStaysInsideTheLiteral() { + #expect(CompareRowFilter.validationError(for: "name = 'O''Hara'") == nil) + #expect(CompareRowFilter.validationError(for: "name = 'O''Hara") == unbalancedMessage) } - func testQuotedIdentifiersMayHoldParentheses() { - XCTAssertNil(CompareRowFilter.validationError(for: "\"odd)name\" = 1")) - XCTAssertNil(CompareRowFilter.validationError(for: "[odd(name] = 1")) - XCTAssertNil(CompareRowFilter.validationError(for: "`odd(name` = 1")) + @Test + func quotedIdentifiersMayHoldParentheses() { + #expect(CompareRowFilter.validationError(for: "\"odd)name\" = 1") == nil) + #expect(CompareRowFilter.validationError(for: "[odd(name] = 1") == nil) + #expect(CompareRowFilter.validationError(for: "`odd(name` = 1") == nil) } - func testNormalizedTrimsAndTreatsBlankAsNil() { - XCTAssertEqual(CompareRowFilter.normalized(" a = 1 \n"), "a = 1") - XCTAssertNil(CompareRowFilter.normalized(" ")) - XCTAssertNil(CompareRowFilter.normalized(nil)) + @Test + func normalizedTrimsAndTreatsBlankAsNil() { + #expect(CompareRowFilter.normalized(" a = 1 \n") == "a = 1") + #expect(CompareRowFilter.normalized(" ") == nil) + #expect(CompareRowFilter.normalized(nil) == nil) } - func testAConditionIsWrappedInParentheses() { - XCTAssertEqual(CompareRowFilter.condition(for: "a = 1 OR b = 2"), "(a = 1 OR b = 2)") + @Test + func aConditionIsWrappedInParentheses() { + #expect(CompareRowFilter.condition(for: "a = 1 OR b = 2") == "(a = 1 OR b = 2)") } /// `#` is MySQL's line comment, and the filter is spliced into a one-line statement, so it /// would comment out the ordering and the row limit that follow it. - func testAHashCommentIsRefused() { - XCTAssertEqual(CompareRowFilter.validationError(for: "a = 1 # rest"), commentMessage) - XCTAssertEqual(CompareRowFilter.validationError(for: "a = 1#rest"), commentMessage) + @Test + func aHashCommentIsRefused() { + #expect(CompareRowFilter.validationError(for: "a = 1 # rest") == commentMessage) + #expect(CompareRowFilter.validationError(for: "a = 1#rest") == commentMessage) } - func testALineCommentWithNoSpaceBeforeItIsRefused() { - XCTAssertEqual(CompareRowFilter.validationError(for: "id = 1--x"), commentMessage) + @Test + func aLineCommentWithNoSpaceBeforeItIsRefused() { + #expect(CompareRowFilter.validationError(for: "id = 1--x") == commentMessage) } /// MySQL, MariaDB and ClickHouse read a backslash inside a literal as an escape and PostgreSQL /// does not, so a filter only one of them reads as balanced is refused rather than spliced. - func testAFilterThatOnlyBalancesUnderOneBackslashReadingIsRefused() { - XCTAssertEqual(CompareRowFilter.validationError(for: "a = 'x\\' AND (1=1 OR y='"), unbalancedMessage) - XCTAssertEqual(CompareRowFilter.validationError(for: "a = 'x\\'"), unbalancedMessage) + @Test + func aFilterThatOnlyBalancesUnderOneBackslashReadingIsRefused() { + #expect(CompareRowFilter.validationError(for: "a = 'x\\' AND (1=1 OR y='") == unbalancedMessage) + #expect(CompareRowFilter.validationError(for: "a = 'x\\'") == unbalancedMessage) } - func testABackslashThatBalancesUnderBothReadingsIsAccepted() { - XCTAssertNil(CompareRowFilter.validationError(for: "path LIKE 'C:\\\\Users%'")) + @Test + func aBackslashThatBalancesUnderBothReadingsIsAccepted() { + #expect(CompareRowFilter.validationError(for: "path LIKE 'C:\\\\Users%'") == nil) } } -final class SQLRowLimitClauseTests: XCTestCase { +struct SQLRowLimitClauseTests { // MARK: - Style - func testNoDialectMeansLimit() { - XCTAssertEqual(SQLRowLimitClause.style(for: nil), .limit) + @Test + func noDialectMeansLimit() { + #expect(SQLRowLimitClause.style(for: nil) == .limit) } - func testAnOffsetFetchDialectOutranksTop() { + @Test + func anOffsetFetchDialectOutranksTop() { let dialect = ScopeDialect.make(paginationStyle: .offsetFetch, autoLimitStyle: .top) - XCTAssertEqual(SQLRowLimitClause.style(for: dialect), .offsetFetch) + #expect(SQLRowLimitClause.style(for: dialect) == .offsetFetch) } - func testTopIsUsedWhenPaginationIsLimit() { + @Test + func topIsUsedWhenPaginationIsLimit() { let dialect = ScopeDialect.make(paginationStyle: .limit, autoLimitStyle: .top) - XCTAssertEqual(SQLRowLimitClause.style(for: dialect), .top) + #expect(SQLRowLimitClause.style(for: dialect) == .top) } // MARK: - Statements - func testNoDialectEmitsALimitClauseLast() { + @Test + func noDialectEmitsALimitClauseLast() { let sql = SQLRowLimitClause.select( columns: "*", from: "t", where: "a = 1", orderBy: "k", limit: 10, dialect: nil ) - XCTAssertEqual(sql, "SELECT * FROM t WHERE a = 1 ORDER BY k LIMIT 10") + #expect(sql == "SELECT * FROM t WHERE a = 1 ORDER BY k LIMIT 10") } - func testAnOffsetFetchDialectPutsTheFetchAfterTheOrderBy() { + @Test + func anOffsetFetchDialectPutsTheFetchAfterTheOrderBy() { let sql = SQLRowLimitClause.select( columns: "*", from: "t", orderBy: "k", limit: 5, dialect: ScopeDialect.make(paginationStyle: .offsetFetch) ) - XCTAssertEqual(sql, "SELECT * FROM t ORDER BY k OFFSET 0 ROWS FETCH NEXT 5 ROWS ONLY") + #expect(sql == "SELECT * FROM t ORDER BY k OFFSET 0 ROWS FETCH NEXT 5 ROWS ONLY") } /// T-SQL parses OFFSET/FETCH as part of ORDER BY, so a statement with no ordering of its own /// still needs the dialect's filler. - func testAnOffsetFetchDialectWithNoOrderingUsesItsFiller() { + @Test + func anOffsetFetchDialectWithNoOrderingUsesItsFiller() { let sql = SQLRowLimitClause.select( columns: "*", from: "t", limit: 5, dialect: ScopeDialect.make(paginationStyle: .offsetFetch) ) - XCTAssertEqual(sql, "SELECT * FROM t ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT 5 ROWS ONLY") + #expect(sql == "SELECT * FROM t ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT 5 ROWS ONLY") } - func testADialectThatDeclaresNoFillerEmitsTheFetchAlone() { + @Test + func aDialectThatDeclaresNoFillerEmitsTheFetchAlone() { let dialect = ScopeDialect.make(paginationStyle: .offsetFetch, offsetFetchOrderBy: "") let sql = SQLRowLimitClause.select(columns: "*", from: "t", limit: 5, dialect: dialect) - XCTAssertEqual(sql, "SELECT * FROM t OFFSET 0 ROWS FETCH NEXT 5 ROWS ONLY") + #expect(sql == "SELECT * FROM t OFFSET 0 ROWS FETCH NEXT 5 ROWS ONLY") } - func testATopDialectPutsTheLimitAfterSelect() { + @Test + func aTopDialectPutsTheLimitAfterSelect() { let dialect = ScopeDialect.make(paginationStyle: .limit, autoLimitStyle: .top) let sql = SQLRowLimitClause.select( columns: "id", from: "t", where: "a = 1", orderBy: "k", limit: 3, dialect: dialect ) - XCTAssertEqual(sql, "SELECT TOP 3 id FROM t WHERE a = 1 ORDER BY k") + #expect(sql == "SELECT TOP 3 id FROM t WHERE a = 1 ORDER BY k") } - func testNoLimitEmitsNoClauseOnAnyDialect() { + @Test + func noLimitEmitsNoClauseOnAnyDialect() { let dialects: [SQLDialectDescriptor?] = [ nil, ScopeDialect.make(paginationStyle: .offsetFetch), @@ -424,78 +456,75 @@ final class SQLRowLimitClauseTests: XCTestCase { let sql = SQLRowLimitClause.select( columns: "*", from: "t", where: "a = 1", orderBy: "k", dialect: dialect ) - XCTAssertEqual(sql, "SELECT * FROM t WHERE a = 1 ORDER BY k") + #expect(sql == "SELECT * FROM t WHERE a = 1 ORDER BY k") } } - func testAnUnlimitedOffsetFetchStatementCarriesNoFiller() { + @Test + func anUnlimitedOffsetFetchStatementCarriesNoFiller() { let sql = SQLRowLimitClause.select( columns: "*", from: "t", dialect: ScopeDialect.make(paginationStyle: .offsetFetch) ) - XCTAssertEqual(sql, "SELECT * FROM t") + #expect(sql == "SELECT * FROM t") } } -final class KeyOrderedQueryScopeTests: XCTestCase { +struct KeyOrderedQueryScopeTests { private let driver = ScopeQuotingDriver() // MARK: - Reading a side - func testAFilterIsWrappedInParentheses() { + @Test + func aFilterIsWrappedInParentheses() { let sql = KeyOrderedQuery.build( table: "users", schema: nil, columns: ["id", "name"], keyColumns: ["id"], filter: "status = 'active' OR status = 'trial'", driver: driver, databaseType: .postgresql ) - XCTAssertEqual( - sql, - #"SELECT "id", "name" FROM "users" WHERE (status = 'active' OR status = 'trial')"# - + #" ORDER BY "id""# - ) + #expect(sql == #"SELECT "id", "name" FROM "users" WHERE (status = 'active' OR status = 'trial')"# + + #" ORDER BY "id""#) } /// A row limit makes the window the first n keys, and a NULL key has no place in that order, so /// the limited read leaves those rows out rather than spending the window on them. The clause /// asks for one row more than the limit: the provider never hands that row out, and it is what /// separates a side that ends exactly at the limit from one the limit cut short. - func testARowLimitExcludesNullKeysAndAddsTheLimitClause() { + @Test + func aRowLimitExcludesNullKeysAndAddsTheLimitClause() { let sql = KeyOrderedQuery.build( table: "users", schema: nil, columns: ["id", "name"], keyColumns: ["id"], filter: "status = 'active'", rowLimit: 100, driver: driver, databaseType: .postgresql ) - XCTAssertEqual( - sql, - #"SELECT "id", "name" FROM "users" WHERE (status = 'active') AND "id" IS NOT NULL"# - + #" ORDER BY "id" LIMIT 101"# - ) + #expect(sql == #"SELECT "id", "name" FROM "users" WHERE (status = 'active') AND "id" IS NOT NULL"# + + #" ORDER BY "id" LIMIT 101"#) } - func testEveryKeyColumnOfACompositeKeyIsCheckedForNull() { + @Test + func everyKeyColumnOfACompositeKeyIsCheckedForNull() { let sql = KeyOrderedQuery.build( table: "orders", schema: nil, columns: ["tenant", "id", "total"], keyColumns: ["tenant", "id"], rowLimit: 50, driver: driver, databaseType: .postgresql ) - XCTAssertEqual( - sql, - #"SELECT "tenant", "id", "total" FROM "orders" WHERE "tenant" IS NOT NULL AND "id" IS NOT NULL"# - + #" ORDER BY "tenant", "id" LIMIT 51"# - ) + #expect(sql == #"SELECT "tenant", "id", "total" FROM "orders" WHERE "tenant" IS NOT NULL AND "id" IS NOT NULL"# + + #" ORDER BY "tenant", "id" LIMIT 51"#) } - func testWithoutARowLimitNullKeysAreNotFilteredOut() { + @Test + func withoutARowLimitNullKeysAreNotFilteredOut() { let sql = KeyOrderedQuery.build( table: "orders", schema: nil, columns: ["id"], keyColumns: ["id"], filter: "total > 10", driver: driver, databaseType: .postgresql ) - XCTAssertEqual(sql, #"SELECT "id" FROM "orders" WHERE (total > 10) ORDER BY "id""#) - XCTAssertFalse(sql.contains("IS NOT NULL")) + #expect(sql == #"SELECT "id" FROM "orders" WHERE (total > 10) ORDER BY "id""#) + #expect(!sql.contains("IS NOT NULL")) } - func testTheRowLimitFollowsTheDialect() { + @Test + func theRowLimitFollowsTheDialect() { let offsetFetch = KeyOrderedQuery.build( table: "users", schema: nil, columns: ["id"], keyColumns: ["id"], rowLimit: 10, driver: driver, databaseType: .mssql, dialect: ScopeDialect.make(paginationStyle: .offsetFetch) @@ -506,36 +535,39 @@ final class KeyOrderedQueryScopeTests: XCTestCase { dialect: ScopeDialect.make(paginationStyle: .limit, autoLimitStyle: .top) ) - XCTAssertTrue(offsetFetch.hasSuffix(#"ORDER BY "id" OFFSET 0 ROWS FETCH NEXT 11 ROWS ONLY"#), offsetFetch) - XCTAssertTrue(top.hasPrefix(#"SELECT TOP 11 "id" FROM "#), top) - XCTAssertTrue(top.hasSuffix(#"ORDER BY "id""#), top) + #expect(offsetFetch.hasSuffix(#"ORDER BY "id" OFFSET 0 ROWS FETCH NEXT 11 ROWS ONLY"#), Comment(rawValue: offsetFetch)) + #expect(top.hasPrefix(#"SELECT TOP 11 "id" FROM "#), Comment(rawValue: top)) + #expect(top.hasSuffix(#"ORDER BY "id""#), Comment(rawValue: top)) } // MARK: - Looking rows up by key - func testALookupOnOneKeyColumnUsesIn() { + @Test + func aLookupOnOneKeyColumnUsesIn() { let sql = KeyOrderedQuery.lookup( table: "users", schema: nil, columns: ["id", "name"], keyColumns: ["id"], keyTypes: [.integer(rawType: "INT")], keys: [[.text("1")], [.text("2")]], driver: driver, databaseType: .postgresql ) - XCTAssertEqual(sql, #"SELECT "id", "name" FROM "users" WHERE "id" IN (1, 2) ORDER BY "id""#) + #expect(sql == #"SELECT "id", "name" FROM "users" WHERE "id" IN (1, 2) ORDER BY "id""#) } /// A VARCHAR key holding `007` has to stay quoted, or MySQL compares it as 7 and the lookup /// answers with rows that are not the ones asked for. - func testALookupQuotesANumericLookingTextKey() { + @Test + func aLookupQuotesANumericLookingTextKey() { let sql = KeyOrderedQuery.lookup( table: "items", schema: nil, columns: ["code"], keyColumns: ["code"], keyTypes: [.text(rawType: "VARCHAR(10)")], keys: [[.text("007")], [.text("O'Brien")]], driver: driver, databaseType: .mysql ) - XCTAssertEqual(sql, #"SELECT "code" FROM "items" WHERE "code" IN ('007', 'O''Brien') ORDER BY "code""#) + #expect(sql == #"SELECT "code" FROM "items" WHERE "code" IN ('007', 'O''Brien') ORDER BY "code""#) } - func testALookupOnACompositeKeyOrsParenthesisedConjunctions() { + @Test + func aLookupOnACompositeKeyOrsParenthesisedConjunctions() { let sql = KeyOrderedQuery.lookup( table: "orders", schema: nil, columns: ["tenant", "id"], keyColumns: ["tenant", "id"], keyTypes: [.text(rawType: "TEXT"), .integer(rawType: "BIGINT")], @@ -543,64 +575,64 @@ final class KeyOrderedQueryScopeTests: XCTestCase { driver: driver, databaseType: .postgresql ) - XCTAssertEqual( - sql, - #"SELECT "tenant", "id" FROM "orders" WHERE ("tenant" = 'acme' AND "id" = 1)"# - + #" OR ("tenant" = '007' AND "id" = 2) ORDER BY "tenant", "id""# - ) + #expect(sql == #"SELECT "tenant", "id" FROM "orders" WHERE ("tenant" = 'acme' AND "id" = 1)"# + + #" OR ("tenant" = '007' AND "id" = 2) ORDER BY "tenant", "id""#) } - func testALookupQualifiesTheSchema() { + @Test + func aLookupQualifiesTheSchema() { let sql = KeyOrderedQuery.lookup( table: "orders", schema: "app", columns: ["id"], keyColumns: ["id"], keyTypes: [.integer(rawType: "INT")], keys: [[.text("1")]], driver: driver, databaseType: .postgresql ) - XCTAssertEqual(sql, #"SELECT "id" FROM "app"."orders" WHERE "id" IN (1) ORDER BY "id""#) + #expect(sql == #"SELECT "id" FROM "app"."orders" WHERE "id" IN (1) ORDER BY "id""#) } - func testALookupOnSQLServerPrefixesATextKey() { + @Test + func aLookupOnSQLServerPrefixesATextKey() { let sql = KeyOrderedQuery.lookup( table: "customers", schema: nil, columns: ["name"], keyColumns: ["name"], keyTypes: [.text(rawType: "NVARCHAR(20)")], keys: [[.text("日本語")]], driver: driver, databaseType: .mssql ) - XCTAssertTrue(sql.contains(#""name" IN (N'日本語')"#), sql) + #expect(sql.contains(#""name" IN (N'日本語')"#), Comment(rawValue: sql)) } - func testALookupWithNoKeyTypesQuotesTheKey() { + @Test + func aLookupWithNoKeyTypesQuotesTheKey() { let sql = KeyOrderedQuery.lookup( table: "items", schema: nil, columns: ["code"], keyColumns: ["code"], keyTypes: [], keys: [[.text("7")]], driver: driver, databaseType: .postgresql ) - XCTAssertEqual(sql, #"SELECT "code" FROM "items" WHERE "code" IN ('7') ORDER BY "code""#) + #expect(sql == #"SELECT "code" FROM "items" WHERE "code" IN ('7') ORDER BY "code""#) } } -final class CompareSQLLiteralTextTests: XCTestCase { +struct CompareSQLLiteralTextTests { private let driver = ScopeQuotingDriver() - func testNumericLookingTextIsQuotedForATextColumn() { + @Test + func numericLookingTextIsQuotedForATextColumn() { let literal = CompareSQLLiteral.textLiteral( "007", columnType: .text(rawType: "VARCHAR(10)"), databaseType: .mysql, driver: driver ) - XCTAssertEqual(literal, "'007'") + #expect(literal == "'007'") } /// With no type to consult the safe answer is the quoted one: a quoted number still compares /// against a numeric column, while an unquoted string does not compare against a text one. - func testNumericLookingTextIsQuotedWhenTheColumnTypeIsUnknown() { - XCTAssertEqual( - CompareSQLLiteral.textLiteral("42", columnType: nil, databaseType: .postgresql, driver: driver), - "'42'" - ) + @Test + func numericLookingTextIsQuotedWhenTheColumnTypeIsUnknown() { + #expect(CompareSQLLiteral.textLiteral("42", columnType: nil, databaseType: .postgresql, driver: driver) == "'42'") } - func testAnIntegerColumnKeepsANumberBare() { + @Test + func anIntegerColumnKeepsANumberBare() { let positive = CompareSQLLiteral.textLiteral( "42", columnType: .integer(rawType: "INT"), databaseType: .mysql, driver: driver ) @@ -608,111 +640,106 @@ final class CompareSQLLiteralTextTests: XCTestCase { "-17", columnType: .integer(rawType: "INT"), databaseType: .mysql, driver: driver ) - XCTAssertEqual(positive, "42") - XCTAssertEqual(negative, "-17") + #expect(positive == "42") + #expect(negative == "-17") } - func testADecimalColumnKeepsANumberBare() { + @Test + func aDecimalColumnKeepsANumberBare() { let literal = CompareSQLLiteral.textLiteral( "3.50", columnType: .decimal(rawType: "NUMERIC(10,2)"), databaseType: .postgresql, driver: driver ) - XCTAssertEqual(literal, "3.50") + #expect(literal == "3.50") } - func testTextThatIsNotANumberIsQuotedEvenForANumericColumn() { + @Test + func textThatIsNotANumberIsQuotedEvenForANumericColumn() { let literal = CompareSQLLiteral.textLiteral( "abc", columnType: .integer(rawType: "INT"), databaseType: .postgresql, driver: driver ) - XCTAssertEqual(literal, "'abc'") + #expect(literal == "'abc'") } - func testSQLServerKeepsItsPrefixOnQuotedText() { - XCTAssertEqual( - CompareSQLLiteral.textLiteral( + @Test + func sQLServerKeepsItsPrefixOnQuotedText() { + #expect(CompareSQLLiteral.textLiteral( "007", columnType: .text(rawType: "NVARCHAR(10)"), databaseType: .mssql, driver: driver - ), - "N'007'" - ) - XCTAssertEqual( - CompareSQLLiteral.textLiteral("O'Hara", columnType: nil, databaseType: .mssql, driver: driver), - "N'O''Hara'" - ) + ) == "N'007'") + #expect(CompareSQLLiteral.textLiteral("O'Hara", columnType: nil, databaseType: .mssql, driver: driver) == "N'O''Hara'") } - func testSQLServerLeavesABareNumberUnprefixed() { + @Test + func sQLServerLeavesABareNumberUnprefixed() { let literal = CompareSQLLiteral.textLiteral( "42", columnType: .integer(rawType: "INT"), databaseType: .mssql, driver: driver ) - XCTAssertEqual(literal, "42") + #expect(literal == "42") } - func testLiteralRoutesNullAndTextThroughTheSameRules() { - XCTAssertEqual( - CompareSQLLiteral.literal( + @Test + func literalRoutesNullAndTextThroughTheSameRules() { + #expect(CompareSQLLiteral.literal( for: .null, columnType: .text(rawType: "TEXT"), databaseType: .mssql, driver: driver - ), - "NULL" - ) - XCTAssertEqual( - CompareSQLLiteral.literal( + ) == "NULL") + #expect(CompareSQLLiteral.literal( for: .text("007"), columnType: .text(rawType: "CHAR(3)"), databaseType: .mysql, driver: driver - ), - "'007'" - ) + ) == "'007'") } } -final class DataCompareOptionsDecodingTests: XCTestCase { +struct DataCompareOptionsDecodingTests { private func decode(_ json: String) throws -> DataCompareOptions { try JSONDecoder().decode(DataCompareOptions.self, from: Data(json.utf8)) } - func testAnObjectWithOneKeyFillsTheRestWithDefaults() throws { + @Test + func anObjectWithOneKeyFillsTheRestWithDefaults() throws { let options = try decode(#"{"insertMissingRows": false}"#) - XCTAssertFalse(options.insertMissingRows) - XCTAssertTrue(options.updateDifferingRows) - XCTAssertFalse(options.deleteExtraRows) - XCTAssertEqual(options.floatTolerance, 0) - XCTAssertEqual(options.timestampFractionalDigits, 6) - XCTAssertEqual(options.maxRetainedEntries, 5_000) - XCTAssertEqual(options.maxRetainedIdenticalEntries, 1_000) + #expect(!options.insertMissingRows) + #expect(options.updateDifferingRows) + #expect(!options.deleteExtraRows) + #expect(options.floatTolerance == 0) + #expect(options.timestampFractionalDigits == 6) + #expect(options.maxRetainedEntries == 5_000) + #expect(options.maxRetainedIdenticalEntries == 1_000) } - func testAnEmptyObjectDecodesToTheDefaults() throws { - XCTAssertEqual(try decode("{}"), .default) + @Test + func anEmptyObjectDecodesToTheDefaults() throws { + #expect(try decode("{}") == .default) } /// Key columns and excluded columns moved to each table's own scope. A profile written before /// that still carries them, and decoding must step over them rather than fail. - func testRetiredKeysAreIgnored() throws { + @Test + func retiredKeysAreIgnored() throws { let json = #"{"keyColumns": ["id"], "excludedFromComparison": ["updated_at"], "deleteExtraRows": true}"# let options = try decode(json) var expected = DataCompareOptions() expected.deleteExtraRows = true - XCTAssertEqual(options, expected) + #expect(options == expected) } - func testEncodingWritesTheCurrentKeysOnly() throws { + @Test + func encodingWritesTheCurrentKeysOnly() throws { let data = try JSONEncoder().encode(DataCompareOptions.default) - let object = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) - XCTAssertEqual( - Set(object.keys), - [ + #expect(Set(object.keys) == [ "insertMissingRows", "updateDifferingRows", "deleteExtraRows", "floatTolerance", "timestampFractionalDigits", "maxRetainedEntries", "maxRetainedIdenticalEntries" - ] - ) + ]) } - func testEncodingThenDecodingRoundTrips() throws { + @Test + func encodingThenDecodingRoundTrips() throws { var options = DataCompareOptions() options.insertMissingRows = false options.updateDifferingRows = false @@ -724,24 +751,25 @@ final class DataCompareOptionsDecodingTests: XCTestCase { let decoded = try JSONDecoder().decode(DataCompareOptions.self, from: JSONEncoder().encode(options)) - XCTAssertEqual(decoded, options) + #expect(decoded == options) } - func testWritesRowsFollowsTheThreeToggles() { + @Test + func writesRowsFollowsTheThreeToggles() { var options = DataCompareOptions() options.insertMissingRows = true options.updateDifferingRows = false options.deleteExtraRows = true - XCTAssertTrue(options.writesRows(of: .insert)) - XCTAssertFalse(options.writesRows(of: .update)) - XCTAssertTrue(options.writesRows(of: .delete)) - XCTAssertFalse(options.writesRows(of: .identical)) - XCTAssertFalse(options.writesRows(of: .conflict)) + #expect(options.writesRows(of: .insert)) + #expect(!options.writesRows(of: .update)) + #expect(options.writesRows(of: .delete)) + #expect(!options.writesRows(of: .identical)) + #expect(!options.writesRows(of: .conflict)) } } -final class CompareSyncProfileScopeCodingTests: XCTestCase { +struct CompareSyncProfileScopeCodingTests { private let profileId = UUID() private let sourceConnection = UUID() private let targetConnection = UUID() @@ -793,36 +821,39 @@ final class CompareSyncProfileScopeCodingTests: XCTestCase { /// A profile saved before exclusions became per table keeps leaving out the columns it left /// out, so the first comparison after an update does not report every row as different. - func testAProfileSavedBeforeTableScopesKeepsItsExcludedColumns() throws { + @Test + func aProfileSavedBeforeTableScopesKeepsItsExcludedColumns() throws { let decoded = try JSONDecoder().decode( CompareSyncProfile.self, from: legacyProfileJSON(dataOptions: legacyDataOptions) ) - XCTAssertEqual(decoded.id, profileId) - XCTAssertEqual(decoded.name, "Nightly") - XCTAssertEqual(decoded.mode, .data) - XCTAssertEqual(decoded.source.database, "prod") - XCTAssertEqual(decoded.source.schema, "public") - XCTAssertNil(decoded.target.schema) - XCTAssertEqual(decoded.tableScopes, [:]) - XCTAssertEqual(decoded.legacyExcludedColumns, ["updated_at"]) - XCTAssertFalse(decoded.dataOptions.updateDifferingRows) - XCTAssertTrue(decoded.dataOptions.deleteExtraRows) - XCTAssertEqual(decoded.structureOptions, .default) - XCTAssertEqual(decoded.selectedObjects, ["orders"]) - } - - func testAProfileWithNoDataOptionsCarriesNoLegacyExclusions() throws { + #expect(decoded.id == profileId) + #expect(decoded.name == "Nightly") + #expect(decoded.mode == .data) + #expect(decoded.source.database == "prod") + #expect(decoded.source.schema == "public") + #expect(decoded.target.schema == nil) + #expect(decoded.tableScopes == [:]) + #expect(decoded.legacyExcludedColumns == ["updated_at"]) + #expect(!decoded.dataOptions.updateDifferingRows) + #expect(decoded.dataOptions.deleteExtraRows) + #expect(decoded.structureOptions == .default) + #expect(decoded.selectedObjects == ["orders"]) + } + + @Test + func aProfileWithNoDataOptionsCarriesNoLegacyExclusions() throws { let decoded = try JSONDecoder().decode( CompareSyncProfile.self, from: legacyProfileJSON(dataOptions: nil) ) - XCTAssertEqual(decoded.dataOptions, .default) - XCTAssertTrue(decoded.legacyExcludedColumns.isEmpty) - XCTAssertEqual(decoded.tableScopes, [:]) + #expect(decoded.dataOptions == .default) + #expect(decoded.legacyExcludedColumns.isEmpty) + #expect(decoded.tableScopes == [:]) } - func testTableScopesRoundTrip() throws { + @Test + func tableScopesRoundTrip() throws { let key = "public\u{1F}orders" let original = profile(tableScopes: [ key: DataTableScope( @@ -837,24 +868,25 @@ final class CompareSyncProfileScopeCodingTests: XCTestCase { let decoded = try JSONDecoder().decode(CompareSyncProfile.self, from: JSONEncoder().encode(original)) - XCTAssertEqual(decoded.tableScopes, original.tableScopes) - XCTAssertEqual(decoded.tableScopes[key]?.rowLimit, 500) - XCTAssertEqual(decoded.tableScopes[key]?.targetFilter, "") - XCTAssertEqual(decoded, original) + #expect(decoded.tableScopes == original.tableScopes) + #expect(decoded.tableScopes[key]?.rowLimit == 500) + #expect(decoded.tableScopes[key]?.targetFilter == "") + #expect(decoded == original) } /// Saving or deleting any one comparison rewrites the whole stored array, so a comparison the /// user has not opened yet keeps the columns it was told to leave out. They move onto its table /// scopes the first time its tables load, and the session stops writing them from then on. - func testLegacyExcludedColumnsSurviveARewriteUntilTheTablesLoad() throws { + @Test + func legacyExcludedColumnsSurviveARewriteUntilTheTablesLoad() throws { let data = try JSONEncoder().encode(profile(tableScopes: [:], legacy: ["updated_at"])) - let object = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) - XCTAssertEqual(object["legacyExcludedColumns"] as? [String], ["updated_at"]) - let dataOptions = try XCTUnwrap(object["dataOptions"] as? [String: Any]) - XCTAssertNil(dataOptions["excludedFromComparison"], "the option they used to live in is gone") + let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + #expect((object["legacyExcludedColumns"] as? [String]) == ["updated_at"]) + let dataOptions = try #require(object["dataOptions"] as? [String: Any]) + #expect(dataOptions["excludedFromComparison"] == nil, "the option they used to live in is gone") let decoded = try JSONDecoder().decode(CompareSyncProfile.self, from: data) - XCTAssertEqual(decoded.legacyExcludedColumns, ["updated_at"]) + #expect(decoded.legacyExcludedColumns == ["updated_at"]) } } diff --git a/TableProTests/Core/Compare/CompareMetadataReadPlanTests.swift b/TableProTests/Core/Compare/CompareMetadataReadPlanTests.swift index 49cea46052..59f2e4e129 100644 --- a/TableProTests/Core/Compare/CompareMetadataReadPlanTests.swift +++ b/TableProTests/Core/Compare/CompareMetadataReadPlanTests.swift @@ -321,7 +321,7 @@ final class CompareMetadataReadPlanTests: XCTestCase { } } -private final class CountingMetadataDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class CountingMetadataDriver: PluginDriverStub, @unchecked Sendable { private let lock = NSLock() private var calls: [String: Int] = [:] private let bulk: Bool @@ -423,22 +423,12 @@ private final class CountingMetadataDriver: PluginDatabaseDriver, @unchecked Sen // MARK: - Unused - func connect() async throws {} - func disconnect() {} var isConnected: Bool { true } func execute(query: String) async throws -> PluginQueryResult { PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchDatabases() async throws -> [String] { [] } - - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { record("fetchTables") return knownTables.map { PluginTableInfo(name: $0, schema: schema, comment: nil) } diff --git a/TableProTests/Core/Compare/CompareObjectScopeTests.swift b/TableProTests/Core/Compare/CompareObjectScopeTests.swift index ace654aab0..290e9d5c43 100644 --- a/TableProTests/Core/Compare/CompareObjectScopeTests.swift +++ b/TableProTests/Core/Compare/CompareObjectScopeTests.swift @@ -6,50 +6,56 @@ // alongside tables, so a view reached the table paths and produced CREATE TABLE for a view. // +import Foundation @testable import TablePro import TableProPluginKit -import XCTest +import Testing -final class CompareTableKindClassifierTests: XCTestCase { +struct CompareTableKindClassifierTests { private func table(_ type: String) -> PluginTableInfo { PluginTableInfo(name: "orders", type: type, schema: "public", comment: nil) } - func testViewsAreRecognisedHoweverTheEngineSpellsThem() { + @Test + func viewsAreRecognisedHoweverTheEngineSpellsThem() { for spelling in ["VIEW", "view", "SYSTEM VIEW", "BASE VIEW"] { - XCTAssertEqual(CompareTableKindClassifier.kind(of: table(spelling)), .view, spelling) + #expect(CompareTableKindClassifier.kind(of: table(spelling)) == .view, Comment(rawValue: spelling)) } } - func testMaterializedViewsAreTheirOwnKind() { + @Test + func materializedViewsAreTheirOwnKind() { for spelling in ["MATERIALIZED VIEW", "MATERIALIZED_VIEW", "materialized view"] { - XCTAssertEqual(CompareTableKindClassifier.kind(of: table(spelling)), .materializedView, spelling) + #expect(CompareTableKindClassifier.kind(of: table(spelling)) == .materializedView, Comment(rawValue: spelling)) } } /// PostgreSQL reports PARTITIONED TABLE for an ordinary, directly queryable table. Matching the /// literal string TABLE would have silently dropped it from every comparison, which is why the /// rule is subtractive. - func testPartitionedAndForeignTablesStillCompareAsTables() { + @Test + func partitionedAndForeignTablesStillCompareAsTables() { for spelling in ["TABLE", "BASE TABLE", "PARTITIONED TABLE", "FOREIGN TABLE", "LOCAL TEMPORARY"] { - XCTAssertEqual(CompareTableKindClassifier.kind(of: table(spelling)), .table, spelling) + #expect(CompareTableKindClassifier.kind(of: table(spelling)) == .table, Comment(rawValue: spelling)) } } - func testAForeignTableIsFlaggedSeparatelyFromItsKind() { - XCTAssertTrue(CompareTableKindClassifier.isForeign(table("FOREIGN TABLE"))) - XCTAssertFalse(CompareTableKindClassifier.isForeign(table("BASE TABLE"))) + @Test + func aForeignTableIsFlaggedSeparatelyFromItsKind() { + #expect(CompareTableKindClassifier.isForeign(table("FOREIGN TABLE"))) + #expect(!(CompareTableKindClassifier.isForeign(table("BASE TABLE")))) } - func testOnlyATableCarriesRows() { - XCTAssertTrue(CompareObjectKind.table.carriesRows) + @Test + func onlyATableCarriesRows() { + #expect(CompareObjectKind.table.carriesRows) for kind in CompareObjectKind.allCases where kind != .table { - XCTAssertFalse(kind.carriesRows, "\(kind.rawValue) holds no rows a data compare can walk") + #expect(!kind.carriesRows, "\(kind.rawValue) holds no rows a data compare can walk") } } } -final class DataComparePlanColumnTests: XCTestCase { +struct DataComparePlanColumnTests { private func plan( generated: Set, keys: [String] = ["id"], @@ -75,51 +81,59 @@ final class DataComparePlanColumnTests: XCTestCase { /// MySQL rejects an explicit value for a generated column outright and PostgreSQL rejects it /// for a stored one, so it is read and compared but never written. - func testGeneratedColumnsAreReadButNotWritten() { + @Test + func generatedColumnsAreReadButNotWritten() { let plan = plan(generated: ["line_total"]) - XCTAssertEqual(plan.columnNames, ["id", "total", "line_total"]) - XCTAssertEqual(plan.writeColumns, ["id", "total"]) + #expect(plan.columnNames == ["id", "total", "line_total"]) + #expect(plan.writeColumns == ["id", "total"]) } /// An identity column the target always generates takes its value on insert only through the /// engine's override, and never through an UPDATE. - func testAnAlwaysIdentityColumnIsInsertedButNeverUpdated() { + @Test + func anAlwaysIdentityColumnIsInsertedButNeverUpdated() { let plan = plan(generated: [], identity: ["id": .always]) - XCTAssertEqual(plan.writeColumns, ["id", "total", "line_total"]) - XCTAssertEqual(plan.updatableColumns, ["total", "line_total"]) - XCTAssertTrue(plan.insertsIntoIdentityColumn) + #expect(plan.writeColumns == ["id", "total", "line_total"]) + #expect(plan.updatableColumns == ["total", "line_total"]) + #expect(plan.insertsIntoIdentityColumn) } - func testAByDefaultIdentityColumnNeedsNoOverride() { - XCTAssertFalse(plan(generated: [], identity: ["id": .byDefault]).insertsIntoIdentityColumn) + @Test + func aByDefaultIdentityColumnNeedsNoOverride() { + #expect(!(plan(generated: [], identity: ["id": .byDefault]).insertsIntoIdentityColumn)) } - func testExcludedColumnsAreLeftOutOfTheComparisonOnlyForTheirOwnTable() { + @Test + func excludedColumnsAreLeftOutOfTheComparisonOnlyForTheirOwnTable() { var excluded = plan(generated: []) excluded.scope.setExcluded(true, column: "TOTAL") - XCTAssertEqual(excluded.comparedColumns, ["line_total"]) - XCTAssertEqual(plan(generated: []).comparedColumns, ["total", "line_total"]) - XCTAssertEqual(excluded.writeColumns, ["id", "total", "line_total"]) + #expect(excluded.comparedColumns == ["line_total"]) + #expect(plan(generated: []).comparedColumns == ["total", "line_total"]) + #expect(excluded.writeColumns == ["id", "total", "line_total"]) } - func testATableWhoseSharedColumnsAreAllGeneratedIsNotComparable() { + @Test + func aTableWhoseSharedColumnsAreAllGeneratedIsNotComparable() { let allGenerated = plan(generated: ["id", "total", "line_total"]) - XCTAssertNotNil(DataComparePlan.unavailableReason(for: allGenerated)) + #expect(DataComparePlan.unavailableReason(for: allGenerated) != nil) } - func testKeyDescriptorsCarryOnlyTheKeyColumns() { - XCTAssertEqual(plan(generated: []).keyDescriptors.map { $0.name }, ["id"]) + @Test + func keyDescriptorsCarryOnlyTheKeyColumns() { + #expect(plan(generated: []).keyDescriptors.map { $0.name } == ["id"]) } - func testATableWithNoKeyIsNotComparable() { - XCTAssertNotNil(DataComparePlan.unavailableReason(for: plan(generated: [], keys: []))) + @Test + func aTableWithNoKeyIsNotComparable() { + #expect(DataComparePlan.unavailableReason(for: plan(generated: [], keys: [])) != nil) } - func testAKeyMissingFromTheSharedColumnsIsNotComparable() { - XCTAssertNotNil(DataComparePlan.unavailableReason(for: plan(generated: [], keys: ["tenant_id"]))) + @Test + func aKeyMissingFromTheSharedColumnsIsNotComparable() { + #expect(DataComparePlan.unavailableReason(for: plan(generated: [], keys: ["tenant_id"])) != nil) } } diff --git a/TableProTests/Core/Compare/CompareResultGroupingTests.swift b/TableProTests/Core/Compare/CompareResultGroupingTests.swift index 68f9e1e79e..11b289076e 100644 --- a/TableProTests/Core/Compare/CompareResultGroupingTests.swift +++ b/TableProTests/Core/Compare/CompareResultGroupingTests.swift @@ -8,10 +8,11 @@ // should touch. // +import Foundation @testable import TablePro -import XCTest +import Testing -final class CompareResultGroupingTests: XCTestCase { +struct CompareResultGroupingTests { private let comparators = [KeyPathComparator(\CompareResultRow.objectName)] private func result( @@ -29,7 +30,8 @@ final class CompareResultGroupingTests: XCTestCase { // MARK: - Sections - func testGroupingByDifferenceProducesOneSectionPerStatus() { + @Test + func groupingByDifferenceProducesOneSectionPerStatus() { let groups = CompareResultGrouping.groups( from: [ result("a", status: .onlyInSource), @@ -40,11 +42,12 @@ final class CompareResultGroupingTests: XCTestCase { sortedUsing: comparators ) - XCTAssertEqual(groups.count, 3) - XCTAssertEqual(groups.flatMap { $0.rows }.count, 3) + #expect(groups.count == 3) + #expect(groups.flatMap { $0.rows }.count == 3) } - func testGroupingByObjectTypeProducesOneSectionPerKind() { + @Test + func groupingByObjectTypeProducesOneSectionPerKind() { let groups = CompareResultGrouping.groups( from: [ result("orders", kind: .table), @@ -55,11 +58,12 @@ final class CompareResultGroupingTests: XCTestCase { sortedUsing: comparators ) - XCTAssertEqual(groups.count, 3) - XCTAssertEqual(Set(groups.map { $0.header.objectName }).count, 3) + #expect(groups.count == 3) + #expect(Set(groups.map { $0.header.objectName }).count == 3) } - func testTheTwoGroupingsSectionTheSameResultsDifferently() { + @Test + func theTwoGroupingsSectionTheSameResultsDifferently() { let results = [ result("orders", kind: .table, status: .differs), result("reporting", kind: .view, status: .differs) @@ -72,34 +76,37 @@ final class CompareResultGroupingTests: XCTestCase { from: results, grouping: .byObjectType, sortedUsing: comparators ) - XCTAssertEqual(byDifference.count, 1, "both differ, so one difference section") - XCTAssertEqual(byKind.count, 2, "two kinds, so two kind sections") + #expect(byDifference.count == 1, "both differ, so one difference section") + #expect(byKind.count == 2, "two kinds, so two kind sections") } - func testAnEmptyBucketProducesNoSection() { + @Test + func anEmptyBucketProducesNoSection() { let groups = CompareResultGrouping.groups( from: [result("orders", status: .differs)], grouping: .byDifference, sortedUsing: comparators ) - XCTAssertEqual(groups.count, 1) + #expect(groups.count == 1) } - func testNoGroupingProducesFlatRows() { + @Test + func noGroupingProducesFlatRows() { let rows = CompareResultGrouping.rows( from: [result("b"), result("a")], sortedUsing: comparators ) - XCTAssertEqual(rows.map { $0.objectName }, ["public.a", "public.b"]) - XCTAssertTrue(rows.allSatisfy { !$0.isGroup }) + #expect(rows.map { $0.objectName } == ["public.a", "public.b"]) + #expect(rows.allSatisfy { !$0.isGroup }) } // MARK: - Bulk include /// A group header's include checkbox must not reach an identical object, whose only available /// action is skip, nor one that could not be compared at all. - func testGroupMembersExcludeIdenticalAndUncomparableResults() { + @Test + func groupMembersExcludeIdenticalAndUncomparableResults() { let groups = CompareResultGrouping.groups( from: [ result("a", status: .differs), @@ -111,54 +118,59 @@ final class CompareResultGroupingTests: XCTestCase { ) let members = groups.flatMap { $0.header.memberIds } - XCTAssertEqual(members.count, 1) - XCTAssertTrue(members[0].contains("a")) + #expect(members.count == 1) + #expect(members[0].contains("a")) } - func testSelectingAGroupHeaderExpandsToItsMembers() { + @Test + func selectingAGroupHeaderExpandsToItsMembers() { let groups = CompareResultGrouping.groups( from: [result("a"), result("b")], grouping: .byObjectType, sortedUsing: comparators ) - let header = try? XCTUnwrap(groups.first).header + let header = try? #require(groups.first).header let ids = CompareResultGrouping.objectIds(in: [header?.id ?? ""], groups: groups) - XCTAssertEqual(ids.count, 2) + #expect(ids.count == 2) } - func testSelectingObjectsPassesTheirOwnIdentifiersThrough() { + @Test + func selectingObjectsPassesTheirOwnIdentifiersThrough() { let orders = result("orders") let ids = CompareResultGrouping.objectIds(in: [orders.id], groups: []) - XCTAssertEqual(ids, [orders.id]) + #expect(ids == [orders.id]) } /// A group header shares the table's row type, so its identifier has to be one no object can /// produce, or a bulk include would resolve to a nonexistent object. - func testAGroupIdentifierCannotCollideWithAnObjectIdentifier() { + @Test + func aGroupIdentifierCannotCollideWithAnObjectIdentifier() { let groups = CompareResultGrouping.groups( from: [result("a")], grouping: .byObjectType, sortedUsing: comparators ) - XCTAssertTrue(CompareResultGrouping.isGroupIdentifier(groups[0].header.id)) - XCTAssertFalse(CompareResultGrouping.isGroupIdentifier(result("a").id)) + #expect(CompareResultGrouping.isGroupIdentifier(groups[0].header.id)) + #expect(!(CompareResultGrouping.isGroupIdentifier(result("a").id))) } // MARK: - Uncomparable - func testUncomparableResultsGetTheirOwnSection() { + @Test + func uncomparableResultsGetTheirOwnSection() { let group = CompareResultGrouping.uncomparableGroup( from: [result("a", error: "permission denied")], sortedUsing: comparators ) - XCTAssertNotNil(group) - XCTAssertEqual(group?.rows.count, 1) - XCTAssertTrue(group?.header.memberIds.isEmpty ?? false, "nothing unreadable can be included") + #expect(group != nil) + #expect(group?.rows.count == 1) + #expect(group?.header.memberIds.isEmpty ?? false, "nothing unreadable can be included") } - func testNoUncomparableSectionWhenEverythingCompared() { - XCTAssertNil(CompareResultGrouping.uncomparableGroup(from: [], sortedUsing: comparators)) + @Test + func noUncomparableSectionWhenEverythingCompared() { + #expect(CompareResultGrouping.uncomparableGroup(from: [], sortedUsing: comparators) == nil) } } diff --git a/TableProTests/Core/Compare/CompareReviewFindingTests.swift b/TableProTests/Core/Compare/CompareReviewFindingTests.swift index b344a21a5e..1a9de78ffa 100644 --- a/TableProTests/Core/Compare/CompareReviewFindingTests.swift +++ b/TableProTests/Core/Compare/CompareReviewFindingTests.swift @@ -6,11 +6,12 @@ // before the fix, which is the only reason to keep them together in one file. // +import Foundation @testable import TablePro import TableProPluginKit -import XCTest +import Testing -final class CompareRetentionCapTests: XCTestCase { +struct CompareRetentionCapTests { private func engine(limit: Int) -> DataDiffEngine { var options = DataCompareOptions() options.maxRetainedEntries = limit @@ -26,7 +27,8 @@ final class CompareRetentionCapTests: XCTestCase { /// Identical rows used to consume the retained list, so a table with thousands of matches and /// a difference at the end reported the difference in the count and listed nothing. - func testIdenticalRowsDoNotCrowdDifferencesOutOfTheRetainedList() async throws { + @Test + func identicalRowsDoNotCrowdDifferencesOutOfTheRetainedList() async throws { var source: [DataRow] = (1 ... 50).map { row($0, name: "same") } var target: [DataRow] = (1 ... 50).map { row($0, name: "same") } source.append(row(51, name: "changed")) @@ -37,14 +39,15 @@ final class CompareRetentionCapTests: XCTestCase { target: ArrayRowProvider(rows: target) ) - XCTAssertEqual(summary.identicalCount, 50, "matches are still counted exactly") - XCTAssertEqual(summary.updateCount, 1) - XCTAssertEqual(summary.entries.count, 1, "only the difference is retained") - XCTAssertEqual(summary.entries[0].kind, .update) - XCTAssertFalse(summary.truncatedEntries, "one difference is well inside the cap") + #expect(summary.identicalCount == 50, "matches are still counted exactly") + #expect(summary.updateCount == 1) + #expect(summary.entries.count == 1, "only the difference is retained") + #expect(summary.entries[0].kind == .update) + #expect(!summary.truncatedEntries, "one difference is well inside the cap") } - func testTheCapStillAppliesToDifferences() async throws { + @Test + func theCapStillAppliesToDifferences() async throws { let source = (1 ... 20).map { row($0, name: "n") } let summary = try await engine(limit: 5).compare( @@ -52,34 +55,38 @@ final class CompareRetentionCapTests: XCTestCase { target: ArrayRowProvider(rows: []) ) - XCTAssertEqual(summary.insertCount, 20) - XCTAssertEqual(summary.entries.count, 5) - XCTAssertTrue(summary.truncatedEntries) + #expect(summary.insertCount == 20) + #expect(summary.entries.count == 5) + #expect(summary.truncatedEntries) } } -final class CompareKeyIdentityTests: XCTestCase { +struct CompareKeyIdentityTests { /// Excluding one row from a sync keys on this string, so two different composite keys rendering /// the same silently excluded the wrong row. - func testTwoCompositeKeysThatReadAlikeHaveDistinctIdentities() { + @Test + func twoCompositeKeysThatReadAlikeHaveDistinctIdentities() { let first: [PluginCellValue] = [.text("a"), .text("b, c")] let second: [PluginCellValue] = [.text("a, b"), .text("c")] - XCTAssertEqual(KeyOrdering.description(of: first), KeyOrdering.description(of: second)) - XCTAssertNotEqual(KeyOrdering.identity(of: first), KeyOrdering.identity(of: second)) + #expect(KeyOrdering.description(of: first) == KeyOrdering.description(of: second)) + #expect(KeyOrdering.identity(of: first) != KeyOrdering.identity(of: second)) } - func testTheSameKeyAlwaysHasTheSameIdentity() { + @Test + func theSameKeyAlwaysHasTheSameIdentity() { let key: [PluginCellValue] = [.text("tenant"), .text("42")] - XCTAssertEqual(KeyOrdering.identity(of: key), KeyOrdering.identity(of: key)) + #expect(KeyOrdering.identity(of: key) == KeyOrdering.identity(of: key)) } - func testASingleKeyIdentityMatchesItsDescription() { - XCTAssertEqual(KeyOrdering.identity(of: [.text("42")]), KeyOrdering.description(of: [.text("42")])) + @Test + func aSingleKeyIdentityMatchesItsDescription() { + #expect(KeyOrdering.identity(of: [.text("42")]) == KeyOrdering.description(of: [.text("42")])) } - func testAnEntryCarriesBothTheReadableKeyAndItsIdentity() { + @Test + func anEntryCarriesBothTheReadableKeyAndItsIdentity() { let entry = RowDiffEntry( kind: .insert, keyDescription: "a, b", @@ -88,18 +95,19 @@ final class CompareKeyIdentityTests: XCTestCase { targetRow: nil ) - XCTAssertEqual(entry.keyDescription, "a, b") - XCTAssertEqual(entry.keyIdentity, "a\u{1F}b") + #expect(entry.keyDescription == "a, b") + #expect(entry.keyIdentity == "a\u{1F}b") } - func testAnEntryWithNoExplicitIdentityFallsBackToItsDescription() { + @Test + func anEntryWithNoExplicitIdentityFallsBackToItsDescription() { let entry = RowDiffEntry(kind: .insert, keyDescription: "42", sourceRow: nil, targetRow: nil) - XCTAssertEqual(entry.keyIdentity, "42") + #expect(entry.keyIdentity == "42") } } -final class CompareSchemaMatchingTests: XCTestCase { +struct CompareSchemaMatchingTests { private func snapshot(_ name: String, schema: String?) -> TableStructureSnapshot { TableStructureSnapshot( name: name, @@ -126,50 +134,52 @@ final class CompareSchemaMatchingTests: XCTestCase { /// Matching on the bare name collapsed two schemas' same-named tables onto one result, diffed /// both against whichever target arrived first, and never reported the other as target-only. - func testTwoSchemasSharingATableNameAreComparedSeparately() { + @Test + func twoSchemasSharingATableNameAreComparedSeparately() { let report = StructureDiffEngine().compare( source: [snapshot("users", schema: "public"), snapshot("users", schema: "audit")], target: [snapshot("users", schema: "public")] ) - XCTAssertEqual(report.results.count, 2) - XCTAssertEqual(report.count(of: .identical), 1) - XCTAssertEqual(report.count(of: .onlyInSource), 1) + #expect(report.results.count == 2) + #expect(report.count(of: .identical) == 1) + #expect(report.count(of: .onlyInSource) == 1) } - func testATargetOnlyTableInASecondSchemaIsReported() { + @Test + func aTargetOnlyTableInASecondSchemaIsReported() { let report = StructureDiffEngine().compare( source: [snapshot("users", schema: "public")], target: [snapshot("users", schema: "public"), snapshot("users", schema: "audit")] ) - XCTAssertEqual(report.count(of: .onlyInTarget), 1) + #expect(report.count(of: .onlyInTarget) == 1) } - func testASchemalessEngineStillMatchesOnNameAlone() { + @Test + func aSchemalessEngineStillMatchesOnNameAlone() { let report = StructureDiffEngine().compare( source: [snapshot("users", schema: nil)], target: [snapshot("users", schema: nil)] ) - XCTAssertEqual(report.count(of: .identical), 1) + #expect(report.count(of: .identical) == 1) } - func testMatchKeyIgnoresSchemaWhenOnlyOneSideHasOne() { + @Test + func matchKeyIgnoresSchemaWhenOnlyOneSideHasOne() { let options = StructureCompareOptions.default - XCTAssertEqual(options.matchKey(name: "users", schema: nil), options.matchKey("users")) - XCTAssertNotEqual( - options.matchKey(name: "users", schema: "audit"), - options.matchKey(name: "users", schema: "public") - ) + #expect(options.matchKey(name: "users", schema: nil) == options.matchKey("users")) + #expect(options.matchKey(name: "users", schema: "audit") != options.matchKey(name: "users", schema: "public")) } } -final class CompareRunResultTests: XCTestCase { +struct CompareRunResultTests { /// A commit that threw used to propagate past the whole run, so the result was discarded and /// the user got an error with no record of which statements had already executed. - func testACommitFailureIsCarriedOnTheResultRatherThanReplacingIt() { + @Test + func aCommitFailureIsCarriedOnTheResultRatherThanReplacingIt() { let statement = SyncStatement(sql: "A;", objectName: "t", summary: "A") let result = CompareSyncRunResult( outcomes: [SyncStatementOutcome(id: statement.id, statement: statement, error: nil, wasSkipped: false)], @@ -178,21 +188,23 @@ final class CompareRunResultTests: XCTestCase { commitFailure: "deadlock detected" ) - XCTAssertEqual(result.executedCount, 1, "the per-statement record survives a failed commit") - XCTAssertEqual(result.commitFailure, "deadlock detected") + #expect(result.executedCount == 1, "the per-statement record survives a failed commit") + #expect(result.commitFailure == "deadlock detected") } - func testASuccessfulRunCarriesNoCommitFailure() { + @Test + func aSuccessfulRunCarriesNoCommitFailure() { let result = CompareSyncRunResult(outcomes: [], rolledBack: false, cancelled: false) - XCTAssertNil(result.commitFailure) + #expect(result.commitFailure == nil) } } -final class CompareDataPlanSchemaTests: XCTestCase { +struct CompareDataPlanSchemaTests { /// The target used to be read with the source's schema name, so a comparison of `audit.users` /// took `public.users`' columns while reading `audit.users`' rows. - func testAPlanRemembersTheTargetsOwnSchema() { + @Test + func aPlanRemembersTheTargetsOwnSchema() { let plan = DataComparePlan( table: "users", schema: "public", @@ -202,11 +214,12 @@ final class CompareDataPlanSchemaTests: XCTestCase { isEnabled: true ) - XCTAssertEqual(plan.schema, "public") - XCTAssertEqual(plan.targetSchema, "audit") + #expect(plan.schema == "public") + #expect(plan.targetSchema == "audit") } - func testAPlanWithNoTargetSchemaMirrorsTheSource() { + @Test + func aPlanWithNoTargetSchemaMirrorsTheSource() { let plan = DataComparePlan( table: "users", schema: "public", @@ -215,6 +228,6 @@ final class CompareDataPlanSchemaTests: XCTestCase { isEnabled: true ) - XCTAssertEqual(plan.targetSchema, "public") + #expect(plan.targetSchema == "public") } } diff --git a/TableProTests/Core/Compare/CompareRowServiceRefusalTests.swift b/TableProTests/Core/Compare/CompareRowServiceRefusalTests.swift index 1a4bbdec0f..3d66e8c526 100644 --- a/TableProTests/Core/Compare/CompareRowServiceRefusalTests.swift +++ b/TableProTests/Core/Compare/CompareRowServiceRefusalTests.swift @@ -4,12 +4,12 @@ // import Foundation -import XCTest +import Testing @testable import TablePro @MainActor -final class CompareRowServiceRefusalTests: XCTestCase { +final class CompareRowServiceRefusalTests { private let connection = UUID() private func endpoint(database: String, schema: String? = nil, connectionId: UUID? = nil) -> DatabaseEndpoint { @@ -25,14 +25,16 @@ final class CompareRowServiceRefusalTests: XCTestCase { /// Two scopes that are one scope reach one pooled entry, which runs its callers serially. The /// outer scope cannot finish until the inner one does and the inner one queues behind the /// outer, so the comparison would hang with nothing to report and leave the entry wedged. - func testComparingAScopeWithItselfIsRefusedBeforeAnythingOpens() { + @Test + func comparingAScopeWithItselfIsRefusedBeforeAnythingOpens() { let service = CompareRowService() let scope = endpoint(database: "shop") - XCTAssertEqual(service.concurrentReadRefusal(source: scope, target: scope), .sameScope) + #expect(service.concurrentReadRefusal(source: scope, target: scope) == .sameScope) } - func testTwoDatabasesOnOneConnectionAreNotRefusedForBeingTheSameScope() { + @Test + func twoDatabasesOnOneConnectionAreNotRefusedForBeingTheSameScope() { let service = CompareRowService() let refusal = service.concurrentReadRefusal( @@ -40,10 +42,11 @@ final class CompareRowServiceRefusalTests: XCTestCase { target: endpoint(database: "shop_staging") ) - XCTAssertNotEqual(refusal, .sameScope) + #expect(refusal != .sameScope) } - func testTwoSchemasInOneDatabaseAreTwoScopes() { + @Test + func twoSchemasInOneDatabaseAreTwoScopes() { let service = CompareRowService() let refusal = service.concurrentReadRefusal( @@ -51,23 +54,23 @@ final class CompareRowServiceRefusalTests: XCTestCase { target: endpoint(database: "shop", schema: "archive") ) - XCTAssertNotEqual(refusal, .sameScope) + #expect(refusal != .sameScope) } - func testASharedConnectionRefusalKeepsTheComparisonWording() { + @Test + func aSharedConnectionRefusalKeepsTheComparisonWording() { let refusal = ConcurrentReadRefusal.sharedConnection(.duckdb) - XCTAssertTrue(refusal.comparisonMessage.hasPrefix("DuckDB cannot compare")) + #expect(refusal.comparisonMessage.hasPrefix("DuckDB cannot compare")) } - func testTwoConnectionsAreNeverRefused() { + @Test + func twoConnectionsAreNeverRefused() { let service = CompareRowService() - XCTAssertNil( - service.concurrentReadRefusal( + #expect(service.concurrentReadRefusal( source: endpoint(database: "shop"), target: endpoint(database: "shop", connectionId: UUID()) - ) - ) + ) == nil) } } diff --git a/TableProTests/Core/Compare/CompareRunClaimTests.swift b/TableProTests/Core/Compare/CompareRunClaimTests.swift index aa0964db13..0abeff002f 100644 --- a/TableProTests/Core/Compare/CompareRunClaimTests.swift +++ b/TableProTests/Core/Compare/CompareRunClaimTests.swift @@ -20,20 +20,17 @@ final class CompareRunClaimTests: XCTestCase { private let sourceConnection = UUID() private let targetConnection = UUID() private var storage: CompareSyncProfileStorage! - private var defaults: UserDefaults! - private let suiteName = "CompareRunClaimTests" - - override func setUp() { - super.setUp() - UserDefaults(suiteName: suiteName)?.removePersistentDomain(forName: suiteName) - defaults = UserDefaults(suiteName: suiteName) - storage = CompareSyncProfileStorage(defaults: defaults) + private var suite: EphemeralDefaults! + + override func setUpWithError() throws { + try super.setUpWithError() + suite = try EphemeralDefaults() + storage = CompareSyncProfileStorage(defaults: suite.defaults) } override func tearDown() { - UserDefaults(suiteName: suiteName)?.removePersistentDomain(forName: suiteName) storage = nil - defaults = nil + suite = nil super.tearDown() } diff --git a/TableProTests/Core/Compare/CompareSQLLiteralTests.swift b/TableProTests/Core/Compare/CompareSQLLiteralTests.swift index 8aede8ac0b..3df470a03e 100644 --- a/TableProTests/Core/Compare/CompareSQLLiteralTests.swift +++ b/TableProTests/Core/Compare/CompareSQLLiteralTests.swift @@ -8,67 +8,77 @@ // driver overrides `sqlLiteral(for:)`, so the spelling is decided per engine here. // +import Foundation @testable import TablePro -import XCTest +import Testing -final class CompareSQLLiteralTests: XCTestCase { +struct CompareSQLLiteralTests { private let png = Data([0x89, 0x50, 0x4E, 0x47]) - func testBitStringEnginesKeepTheDefaultSpelling() throws { + @Test + func bitStringEnginesKeepTheDefaultSpelling() throws { for type in [DatabaseType.mysql, .mariadb, .tidb, .databend, .oceanbase, .sqlite, .clickhouse, .libsql, .turso, .cloudflareD1] { - let literal = try XCTUnwrap(CompareSQLLiteral.binaryLiteral(for: png, databaseType: type)) - XCTAssertEqual(literal, "X'89504E47'", "\(type.rawValue) uses a bit-string literal") + let literal = try #require(CompareSQLLiteral.binaryLiteral(for: png, databaseType: type)) + #expect(literal == "X'89504E47'", "\(type.rawValue) uses a bit-string literal") } } - func testDuckDBDecodesHexWithUnhex() throws { - let literal = try XCTUnwrap(CompareSQLLiteral.binaryLiteral(for: png, databaseType: .duckdb)) - XCTAssertEqual(literal, "unhex('89504E47')") + @Test + func duckDBDecodesHexWithUnhex() throws { + let literal = try #require(CompareSQLLiteral.binaryLiteral(for: png, databaseType: .duckdb)) + #expect(literal == "unhex('89504E47')") } /// A compare script runs its own statements, so a plain literal on a SQL Server database with /// a non-Unicode collation writes `?` into the target rather than the text it compared. - func testTextLiteralsCarryTheSQLServerPrefix() { - XCTAssertEqual(CompareSQLLiteral.prefixed("'日本語'", databaseType: .mssql), "N'日本語'") - XCTAssertEqual(CompareSQLLiteral.prefixed("'abc'", databaseType: .mysql), "'abc'") + @Test + func textLiteralsCarryTheSQLServerPrefix() { + #expect(CompareSQLLiteral.prefixed("'日本語'", databaseType: .mssql) == "N'日本語'") + #expect(CompareSQLLiteral.prefixed("'abc'", databaseType: .mysql) == "'abc'") } /// `sqlLiteral(for:)` answers `NULL` for a null and passes a number through unquoted. `N` in /// front of either is a syntax error. - func testOnlyQuotedLiteralsTakeThePrefix() { - XCTAssertEqual(CompareSQLLiteral.prefixed("NULL", databaseType: .mssql), "NULL") - XCTAssertEqual(CompareSQLLiteral.prefixed("42", databaseType: .mssql), "42") - XCTAssertEqual(CompareSQLLiteral.prefixed("0x89504E47", databaseType: .mssql), "0x89504E47") + @Test + func onlyQuotedLiteralsTakeThePrefix() { + #expect(CompareSQLLiteral.prefixed("NULL", databaseType: .mssql) == "NULL") + #expect(CompareSQLLiteral.prefixed("42", databaseType: .mssql) == "42") + #expect(CompareSQLLiteral.prefixed("0x89504E47", databaseType: .mssql) == "0x89504E47") } - func testPostgresFamilyUsesAByteaCast() throws { + @Test + func postgresFamilyUsesAByteaCast() throws { for type in [DatabaseType.postgresql, .cockroachdb, .redshift, .pglite] { - let literal = try XCTUnwrap(CompareSQLLiteral.binaryLiteral(for: png, databaseType: type)) - XCTAssertEqual(literal, "'\\x89504e47'::bytea", "\(type.rawValue) uses a bytea literal") + let literal = try #require(CompareSQLLiteral.binaryLiteral(for: png, databaseType: type)) + #expect(literal == "'\\x89504e47'::bytea", "\(type.rawValue) uses a bytea literal") } } - func testSQLServerUsesZeroX() throws { - let literal = try XCTUnwrap(CompareSQLLiteral.binaryLiteral(for: png, databaseType: .mssql)) + @Test + func sQLServerUsesZeroX() throws { + let literal = try #require(CompareSQLLiteral.binaryLiteral(for: png, databaseType: .mssql)) - XCTAssertEqual(literal, "0x89504E47") + #expect(literal == "0x89504E47") } - func testOracleUsesHexToRaw() throws { - let literal = try XCTUnwrap(CompareSQLLiteral.binaryLiteral(for: png, databaseType: .oracle)) + @Test + func oracleUsesHexToRaw() throws { + let literal = try #require(CompareSQLLiteral.binaryLiteral(for: png, databaseType: .oracle)) - XCTAssertEqual(literal, "HEXTORAW('89504E47')") + #expect(literal == "HEXTORAW('89504E47')") } /// An engine this build does not name falls back to the driver's own spelling rather than /// guessing at one, which is why the helper returns nil instead of a default. - func testAnUnnamedEngineHasNoOpinion() { - XCTAssertNil(CompareSQLLiteral.binaryLiteral(for: png, databaseType: DatabaseType(rawValue: "Whatever"))) + @Test + func anUnnamedEngineHasNoOpinion() { + #expect(CompareSQLLiteral.binaryLiteral(for: png, databaseType: DatabaseType(rawValue: "Whatever")) == nil) } - func testEmptyDataStillProducesAValidLiteral() throws { - let literal = try XCTUnwrap(CompareSQLLiteral.binaryLiteral(for: Data(), databaseType: .postgresql)) + @Test + func emptyDataStillProducesAValidLiteral() throws { + let literal = try #require(CompareSQLLiteral.binaryLiteral(for: Data(), databaseType: .postgresql)) - XCTAssertEqual(literal, "'\\x'::bytea") + #expect(literal == "'\\x'::bytea") } } diff --git a/TableProTests/Core/Compare/CompareSourceDefinitionReadTests.swift b/TableProTests/Core/Compare/CompareSourceDefinitionReadTests.swift index cfcbf278fd..7c718bef85 100644 --- a/TableProTests/Core/Compare/CompareSourceDefinitionReadTests.swift +++ b/TableProTests/Core/Compare/CompareSourceDefinitionReadTests.swift @@ -3,11 +3,12 @@ // TableProTests // +import Foundation @testable import TablePro import TableProPluginKit -import XCTest +import Testing -final class CompareSourceDefinitionReadTests: XCTestCase { +struct CompareSourceDefinitionReadTests { private let endpointName = "Reporting / shop" private func trigger( @@ -35,7 +36,8 @@ final class CompareSourceDefinitionReadTests: XCTestCase { ) } - func testAViewTheDriverRefusesCarriesTheDriversReason() async throws { + @Test + func aViewTheDriverRefusesCarriesTheDriversReason() async throws { let driver = SourceDefinitionStubDriver() let refusal = PluginObjectSourceError.insufficientPrivilege("recent") driver.viewDefinitions = ["recent": .failure(refusal), "totals": .failure(refusal)] @@ -46,12 +48,13 @@ final class CompareSourceDefinitionReadTests: XCTestCase { using: driver ) - XCTAssertEqual(reads.map(\.failure), [refusal.errorDescription, refusal.errorDescription]) - XCTAssertEqual(reads.map(\.source), ["", ""]) - XCTAssertEqual(reads.map(\.kind), [.view, .materializedView]) + #expect(reads.map(\.failure) == [refusal.errorDescription, refusal.errorDescription]) + #expect(reads.map(\.source) == ["", ""]) + #expect(reads.map(\.kind) == [.view, .materializedView]) } - func testAReadableViewKeepsItsDefinition() async throws { + @Test + func aReadableViewKeepsItsDefinition() async throws { let driver = SourceDefinitionStubDriver() driver.viewDefinitions = ["recent": .success("CREATE VIEW recent AS SELECT 1")] @@ -61,11 +64,12 @@ final class CompareSourceDefinitionReadTests: XCTestCase { using: driver ) - XCTAssertNil(reads.first?.failure) - XCTAssertEqual(reads.first?.source, "CREATE VIEW recent AS SELECT 1") + #expect(reads.first?.failure == nil) + #expect(reads.first?.source == "CREATE VIEW recent AS SELECT 1") } - func testAMaterializedViewsIndexesTravelWithItsDefinition() async throws { + @Test + func aMaterializedViewsIndexesTravelWithItsDefinition() async throws { let driver = SourceDefinitionStubDriver() driver.viewDefinitions = [ "totals": .success("CREATE MATERIALIZED VIEW shop.totals AS SELECT 1 AS id"), @@ -85,17 +89,20 @@ final class CompareSourceDefinitionReadTests: XCTestCase { ) guard case .read(let indexes)? = reads.first?.indexes else { - return XCTFail("the indexes read with the view must reach its definition read") + Issue.record("the indexes read with the view must reach its definition read") + return } - XCTAssertEqual(indexes.map(\.name), ["totals_id_idx"]) + #expect(indexes.map(\.name) == ["totals_id_idx"]) guard case .failed(let reason)? = reads.dropFirst().first?.indexes else { - return XCTFail("a failed index read must reach the definition read as a failure") + Issue.record("a failed index read must reach the definition read as a failure") + return } - XCTAssertEqual(reason, "permission denied") - XCTAssertNil(reads.last?.indexes) + #expect(reason == "permission denied") + #expect(reads.last?.indexes == nil) } - func testACancelledViewReadIsNotAFailure() async { + @Test + func aCancelledViewReadIsNotAFailure() async { let driver = SourceDefinitionStubDriver() driver.viewDefinitions = ["recent": .failure(CancellationError())] @@ -105,15 +112,16 @@ final class CompareSourceDefinitionReadTests: XCTestCase { schema: "shop", using: driver ) - XCTFail("a cancelled read must not become an unreadable object") + Issue.record("a cancelled read must not become an unreadable object") } catch { - XCTAssertTrue(error is CancellationError, "\(error)") + #expect(error is CancellationError, "\(error)") } } // MARK: - Routines - func testARoutineIsReadThroughItsDDLEvenWhenTheListingCarriesABody() async throws { + @Test + func aRoutineIsReadThroughItsDDLEvenWhenTheListingCarriesABody() async throws { let driver = SourceDefinitionStubDriver() driver.routines = .success([ PluginRoutineInfo(name: "add", kind: .function, schema: "main", argumentSignature: "(a, b)", definition: "(a + b)") @@ -124,11 +132,12 @@ final class CompareSourceDefinitionReadTests: XCTestCase { schema: "main", endpointName: endpointName, using: driver ) - XCTAssertEqual(reads.map(\.source), ["CREATE OR REPLACE MACRO main.add(a, b) AS (a + b);"]) - XCTAssertTrue(driver.recordedCalls.contains("fetchRoutineDDL:add")) + #expect(reads.map(\.source) == ["CREATE OR REPLACE MACRO main.add(a, b) AS (a + b);"]) + #expect(driver.recordedCalls.contains("fetchRoutineDDL:add")) } - func testARoutineWhoseDDLCannotBeReadCarriesTheReason() async throws { + @Test + func aRoutineWhoseDDLCannotBeReadCarriesTheReason() async throws { let driver = SourceDefinitionStubDriver() let refusal = PluginObjectSourceError.insufficientPrivilege("p") driver.routines = .success([PluginRoutineInfo(name: "p", kind: .procedure, schema: "shop")]) @@ -138,13 +147,14 @@ final class CompareSourceDefinitionReadTests: XCTestCase { schema: "shop", endpointName: endpointName, using: driver ) - XCTAssertEqual(reads.count, 1) - XCTAssertEqual(reads.first?.kind, .procedure) - XCTAssertEqual(reads.first?.failure, refusal.errorDescription) - XCTAssertEqual(reads.first?.source, "") + #expect(reads.count == 1) + #expect(reads.first?.kind == .procedure) + #expect(reads.first?.failure == refusal.errorDescription) + #expect(reads.first?.source == "") } - func testARoutineListingThatFailsStopsTheRead() async { + @Test + func aRoutineListingThatFailsStopsTheRead() async { let driver = SourceDefinitionStubDriver() driver.routines = .failure(DefinitionReadStubError(message: "Access denied to information_schema")) @@ -152,19 +162,21 @@ final class CompareSourceDefinitionReadTests: XCTestCase { _ = try await CompareMetadataService.readRoutineDefinitions( schema: "shop", endpointName: endpointName, using: driver ) - XCTFail("an unlisted scope must not read as a scope with no routines") + Issue.record("an unlisted scope must not read as a scope with no routines") } catch { guard case CompareSyncError.readFailed(let message) = error else { - return XCTFail("expected readFailed, got \(error)") + Issue.record("expected readFailed, got \(error)") + return } - XCTAssertTrue(message.contains(endpointName), message) - XCTAssertTrue(message.contains("Access denied to information_schema"), message) + #expect(message.contains(endpointName), Comment(rawValue: message)) + #expect(message.contains("Access denied to information_schema"), Comment(rawValue: message)) } } // MARK: - Triggers - func testATriggerWithoutADefinitionIsReadThroughItsDDLAndNeverItsStatement() async throws { + @Test + func aTriggerWithoutADefinitionIsReadThroughItsDDLAndNeverItsStatement() async throws { let driver = SourceDefinitionStubDriver() driver.wholeSchemaTriggers = .success([ trigger("stamped"), @@ -179,15 +191,16 @@ final class CompareSourceDefinitionReadTests: XCTestCase { tables: ["orders"], schema: "shop", endpointName: endpointName, using: driver ) - XCTAssertEqual(reads.map(\.name), ["stamped", "hidden"]) - XCTAssertEqual(reads[0].source, "CREATE TRIGGER stamped BEFORE INSERT ON orders FOR EACH ROW SET NEW.n = 1") - XCTAssertNil(reads[0].failure) - XCTAssertEqual(reads[1].source, "") - XCTAssertEqual(reads[1].failure, PluginObjectSourceError.insufficientPrivilege("hidden").errorDescription) - XCTAssertEqual(reads.map(\.signature), ["orders", "orders"]) + #expect(reads.map(\.name) == ["stamped", "hidden"]) + #expect(reads[0].source == "CREATE TRIGGER stamped BEFORE INSERT ON orders FOR EACH ROW SET NEW.n = 1") + #expect(reads[0].failure == nil) + #expect(reads[1].source == "") + #expect(reads[1].failure == PluginObjectSourceError.insufficientPrivilege("hidden").errorDescription) + #expect(reads.map(\.signature) == ["orders", "orders"]) } - func testATriggerWithADefinitionIsNotReadAgain() async throws { + @Test + func aTriggerWithADefinitionIsNotReadAgain() async throws { let driver = SourceDefinitionStubDriver() let definition = "CREATE TRIGGER stamped BEFORE INSERT ON orders FOR EACH ROW SET NEW.n = 1" driver.wholeSchemaTriggers = .success([trigger("stamped", definition: definition)]) @@ -196,11 +209,12 @@ final class CompareSourceDefinitionReadTests: XCTestCase { tables: ["orders"], schema: "shop", endpointName: endpointName, using: driver ) - XCTAssertEqual(reads.map(\.source), [definition]) - XCTAssertFalse(driver.recordedCalls.contains("fetchTriggerDDL:stamped")) + #expect(reads.map(\.source) == [definition]) + #expect(!driver.recordedCalls.contains("fetchTriggerDDL:stamped")) } - func testAPerTableTriggerListingThatFailsStopsTheRead() async { + @Test + func aPerTableTriggerListingThatFailsStopsTheRead() async { let driver = SourceDefinitionStubDriver() driver.tableTriggers = [ "customers": .success([]), @@ -211,18 +225,20 @@ final class CompareSourceDefinitionReadTests: XCTestCase { _ = try await CompareMetadataService.readTriggerDefinitions( tables: ["customers", "orders"], schema: "shop", endpointName: endpointName, using: driver ) - XCTFail("a table whose triggers could not be listed must not read as a table with none") + Issue.record("a table whose triggers could not be listed must not read as a table with none") } catch { guard case CompareSyncError.readFailed(let message) = error else { - return XCTFail("expected readFailed, got \(error)") + Issue.record("expected readFailed, got \(error)") + return } - XCTAssertTrue(message.contains("orders"), message) - XCTAssertTrue(message.contains(endpointName), message) - XCTAssertTrue(message.contains("TRIGGER command denied"), message) + #expect(message.contains("orders"), Comment(rawValue: message)) + #expect(message.contains(endpointName), Comment(rawValue: message)) + #expect(message.contains("TRIGGER command denied"), Comment(rawValue: message)) } } - func testAFailedWholeSchemaTriggerReadFallsBackPerTable() async throws { + @Test + func aFailedWholeSchemaTriggerReadFallsBackPerTable() async throws { let driver = SourceDefinitionStubDriver() let definition = "CREATE TRIGGER stamped BEFORE INSERT ON orders FOR EACH ROW SET NEW.n = 1" driver.wholeSchemaTriggers = .failure(DefinitionReadStubError(message: "bulk read refused")) @@ -232,7 +248,7 @@ final class CompareSourceDefinitionReadTests: XCTestCase { tables: ["orders"], schema: "shop", endpointName: endpointName, using: driver ) - XCTAssertEqual(reads.map(\.source), [definition]) - XCTAssertTrue(driver.recordedCalls.contains("fetchTriggers:orders")) + #expect(reads.map(\.source) == [definition]) + #expect(driver.recordedCalls.contains("fetchTriggers:orders")) } } diff --git a/TableProTests/Core/Compare/CompareSyncExecutorScopeTests.swift b/TableProTests/Core/Compare/CompareSyncExecutorScopeTests.swift index e706adb41d..7b248776fc 100644 --- a/TableProTests/Core/Compare/CompareSyncExecutorScopeTests.swift +++ b/TableProTests/Core/Compare/CompareSyncExecutorScopeTests.swift @@ -9,7 +9,7 @@ import XCTest @testable import TablePro -private final class ScopeRecordingDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class ScopeRecordingDriver: PluginDriverStub, @unchecked Sendable { static let beginEvent = "" static let commitEvent = "" static let rollbackEvent = "" @@ -26,10 +26,6 @@ private final class ScopeRecordingDriver: PluginDatabaseDriver, @unchecked Senda var supportsTransactions: Bool { true } var supportsTransactionalDDL: Bool { true } - func connect() async throws {} - - func disconnect() {} - func execute(query: String) async throws -> PluginQueryResult { executed.append(query) events.append(query) @@ -54,20 +50,6 @@ private final class ScopeRecordingDriver: PluginDatabaseDriver, @unchecked Senda func beginTransaction() async throws { events.append(Self.beginEvent) } func commitTransaction() async throws { events.append(Self.commitEvent) } func rollbackTransaction() async throws { events.append(Self.rollbackEvent) } - - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } private enum ScopeFixture { diff --git a/TableProTests/Core/Compare/CompareSyncExecutorTests.swift b/TableProTests/Core/Compare/CompareSyncExecutorTests.swift index ff3000ea9f..0728775f6a 100644 --- a/TableProTests/Core/Compare/CompareSyncExecutorTests.swift +++ b/TableProTests/Core/Compare/CompareSyncExecutorTests.swift @@ -5,11 +5,11 @@ import Foundation import TableProPluginKit -import XCTest +import Testing @testable import TablePro -private final class RecordingDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class RecordingDriver: PluginDriverStub, @unchecked Sendable { var executed: [String] = [] var transactionEvents: [String] = [] var failingStatements: Set = [] @@ -21,10 +21,6 @@ private final class RecordingDriver: PluginDatabaseDriver, @unchecked Sendable { var supportsTransactions: Bool { transactionsSupported } var supportsTransactionalDDL: Bool { transactionalDDLSupported } - func connect() async throws {} - - func disconnect() {} - func execute(query: String) async throws -> PluginQueryResult { executed.append(query) if failingStatements.contains(query) { @@ -36,23 +32,9 @@ private final class RecordingDriver: PluginDatabaseDriver, @unchecked Sendable { func beginTransaction() async throws { transactionEvents.append("begin") } func commitTransaction() async throws { transactionEvents.append("commit") } func rollbackTransaction() async throws { transactionEvents.append("rollback") } - - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } -final class CompareSyncExecutorTests: XCTestCase { +struct CompareSyncExecutorTests { private func endpoint(databaseType: DatabaseType = .mysql) -> DatabaseEndpoint { DatabaseEndpoint( scope: DatabaseScope(connectionId: UUID(), database: "app", schema: nil), @@ -95,7 +77,8 @@ final class CompareSyncExecutorTests: XCTestCase { /// A statement already is the text the driver takes, one call each: on Oracle a unit keeps its own `;` and a /// CALL trigger has none, and on SQL Server a body the generic grammar would cut stays whole. - func testEveryStatementGoesOutExactlyAsBuilt() async throws { + @Test + func everyStatementGoesOutExactlyAsBuilt() async throws { let cases: [(DatabaseType, [String])] = [ (.oracle, [ "CREATE OR REPLACE PROCEDURE p IS BEGIN NULL; END;", @@ -108,24 +91,26 @@ final class CompareSyncExecutorTests: XCTestCase { for (databaseType, sql) in cases { let driver = RecordingDriver() _ = try await run(statements: sql.map { statement($0) }, driver: driver, databaseType: databaseType) - XCTAssertEqual(driver.executed, sql, "\(databaseType.rawValue)") + #expect(driver.executed == sql, "\(databaseType.rawValue)") } } // MARK: - Held back statements - func testRefusedStatementIsNotExecutedUnlessAllowed() async throws { + @Test + func refusedStatementIsNotExecutedUnlessAllowed() async throws { let driver = RecordingDriver() let dropStatement = statement("DROP TABLE t;", refused: true) let result = try await run(statements: [statement("SELECT 1;"), dropStatement], driver: driver) - XCTAssertEqual(driver.executed, ["SELECT 1;"], "a refused statement must not reach the driver") - XCTAssertEqual(result.heldBackCount, 1) - XCTAssertEqual(result.executedCount, 1) + #expect(driver.executed == ["SELECT 1;"], "a refused statement must not reach the driver") + #expect(result.heldBackCount == 1) + #expect(result.executedCount == 1) } - func testAllowingARefusedStatementRunsIt() async throws { + @Test + func allowingARefusedStatementRunsIt() async throws { let driver = RecordingDriver() let dropStatement = statement("DROP TABLE t;", refused: true) var settings = CompareSyncExecutionSettings() @@ -133,31 +118,34 @@ final class CompareSyncExecutorTests: XCTestCase { let result = try await run(statements: [dropStatement], settings: settings, driver: driver) - XCTAssertEqual(driver.executed, ["DROP TABLE t;"]) - XCTAssertEqual(result.heldBackCount, 0) + #expect(driver.executed == ["DROP TABLE t;"]) + #expect(result.heldBackCount == 0) } - func testAllRefusedMeansNothingRunsAndNoTransactionOpens() async throws { + @Test + func allRefusedMeansNothingRunsAndNoTransactionOpens() async throws { let driver = RecordingDriver() let result = try await run(statements: [statement("DROP TABLE t;", refused: true)], driver: driver) - XCTAssertTrue(driver.executed.isEmpty) - XCTAssertTrue(driver.transactionEvents.isEmpty) - XCTAssertEqual(result.heldBackCount, 1) + #expect(driver.executed.isEmpty) + #expect(driver.transactionEvents.isEmpty) + #expect(result.heldBackCount == 1) } // MARK: - Transactions - func testSuccessfulRunCommits() async throws { + @Test + func successfulRunCommits() async throws { let driver = RecordingDriver() _ = try await run(statements: [statement("A;"), statement("B;")], driver: driver) - XCTAssertEqual(driver.transactionEvents, ["begin", "commit"]) + #expect(driver.transactionEvents == ["begin", "commit"]) } - func testFailureRollsBackWithStopAndRollback() async throws { + @Test + func failureRollsBackWithStopAndRollback() async throws { let driver = RecordingDriver() driver.failingStatements = ["B;"] var settings = CompareSyncExecutionSettings() @@ -169,12 +157,13 @@ final class CompareSyncExecutorTests: XCTestCase { driver: driver ) - XCTAssertEqual(driver.transactionEvents, ["begin", "rollback"]) - XCTAssertTrue(result.rolledBack) - XCTAssertEqual(driver.executed, ["A;", "B;"], "execution stops at the first failure") + #expect(driver.transactionEvents == ["begin", "rollback"]) + #expect(result.rolledBack) + #expect(driver.executed == ["A;", "B;"], "execution stops at the first failure") } - func testFailureCommitsWithStopAndCommit() async throws { + @Test + func failureCommitsWithStopAndCommit() async throws { let driver = RecordingDriver() driver.failingStatements = ["B;"] var settings = CompareSyncExecutionSettings() @@ -186,11 +175,12 @@ final class CompareSyncExecutorTests: XCTestCase { driver: driver ) - XCTAssertEqual(driver.transactionEvents, ["begin", "commit"]) - XCTAssertFalse(result.rolledBack) + #expect(driver.transactionEvents == ["begin", "commit"]) + #expect(!result.rolledBack) } - func testSkipAndContinueRunsEverythingAndUsesNoTransaction() async throws { + @Test + func skipAndContinueRunsEverythingAndUsesNoTransaction() async throws { let driver = RecordingDriver() driver.failingStatements = ["B;"] var settings = CompareSyncExecutionSettings() @@ -202,51 +192,55 @@ final class CompareSyncExecutorTests: XCTestCase { driver: driver ) - XCTAssertEqual(driver.executed, ["A;", "B;", "C;"]) - XCTAssertTrue(driver.transactionEvents.isEmpty, "skip and continue must not open a transaction") - XCTAssertEqual(result.failedCount, 1) - XCTAssertEqual(result.executedCount, 2) + #expect(driver.executed == ["A;", "B;", "C;"]) + #expect(driver.transactionEvents.isEmpty, "skip and continue must not open a transaction") + #expect(result.failedCount == 1) + #expect(result.executedCount == 2) } - func testNoTransactionWhenDriverDoesNotSupportOne() async throws { + @Test + func noTransactionWhenDriverDoesNotSupportOne() async throws { let driver = RecordingDriver() driver.transactionsSupported = false driver.transactionalDDLSupported = false _ = try await run(statements: [statement("A;")], driver: driver) - XCTAssertTrue(driver.transactionEvents.isEmpty) + #expect(driver.transactionEvents.isEmpty) } /// A structure sync on MySQL, MariaDB or Oracle must not open a transaction it cannot roll /// back: DDL commits implicitly there, so the ROLLBACK undid nothing while the run reported /// "The target is unchanged." - func testStructureSyncOpensNoTransactionWhenDDLCommitsImplicitly() async throws { + @Test + func structureSyncOpensNoTransactionWhenDDLCommitsImplicitly() async throws { let driver = RecordingDriver() driver.transactionsSupported = true driver.transactionalDDLSupported = false _ = try await run(statements: [statement("ALTER TABLE users DROP COLUMN legacy_id;")], driver: driver) - XCTAssertTrue(driver.transactionEvents.isEmpty) + #expect(driver.transactionEvents.isEmpty) } // MARK: - Gate - func testDeniedAuthorizationRunsNoStatements() async { + @Test + func deniedAuthorizationRunsNoStatements() async { let driver = RecordingDriver() do { _ = try await run(statements: [statement("A;")], driver: driver, gate: AlwaysDenyGate()) - XCTFail("Expected the gate to deny the run") + Issue.record("Expected the gate to deny the run") } catch { - XCTAssertTrue(driver.executed.isEmpty, "nothing may run when the gate denies") + #expect(driver.executed.isEmpty, "nothing may run when the gate denies") } } // MARK: - Progress - func testProgressReachesTotalOnCompletion() async throws { + @Test + func progressReachesTotalOnCompletion() async throws { let driver = RecordingDriver() let progress = Progress() @@ -259,12 +253,12 @@ final class CompareSyncExecutorTests: XCTestCase { progress: progress ) - XCTAssertEqual(progress.totalUnitCount, 3) - XCTAssertEqual(progress.completedUnitCount, 3) + #expect(progress.totalUnitCount == 3) + #expect(progress.completedUnitCount == 3) } } -final class CompareSyncExecutionSettingsTests: XCTestCase { +struct CompareSyncExecutionSettingsTests { private func driver(transactions: Bool, transactionalDDL: Bool) -> RecordingDriver { let driver = RecordingDriver() driver.transactionsSupported = transactions @@ -272,78 +266,85 @@ final class CompareSyncExecutionSettingsTests: XCTestCase { return driver } - func testTransactionIsDisabledForSkipAndContinue() { + @Test + func transactionIsDisabledForSkipAndContinue() { var settings = CompareSyncExecutionSettings() settings.wrapInTransaction = true settings.errorHandling = .skipAndContinue - XCTAssertFalse(settings.usesTransaction( + #expect(!(settings.usesTransaction( for: .data, driver: driver(transactions: true, transactionalDDL: true) - )) + ))) } - func testTransactionRequiresDriverSupport() { + @Test + func transactionRequiresDriverSupport() { var settings = CompareSyncExecutionSettings() settings.wrapInTransaction = true settings.errorHandling = .stopAndRollback - XCTAssertTrue(settings.usesTransaction( + #expect(settings.usesTransaction( for: .data, driver: driver(transactions: true, transactionalDDL: true) )) - XCTAssertFalse(settings.usesTransaction( + #expect(!(settings.usesTransaction( for: .data, driver: driver(transactions: false, transactionalDDL: true) - )) + ))) } /// MySQL, MariaDB and Oracle commit implicitly on every DDL statement, so a structure sync /// wrapped in a transaction reported "The target is unchanged" after a ROLLBACK that undid /// nothing. Structure asks `supportsTransactionalDDL`, data asks `supportsTransactions`. - func testStructureSyncAsksForTransactionalDDLRatherThanTransactions() { + @Test + func structureSyncAsksForTransactionalDDLRatherThanTransactions() { var settings = CompareSyncExecutionSettings() settings.wrapInTransaction = true settings.errorHandling = .stopAndRollback let mysqlLike = driver(transactions: true, transactionalDDL: false) - XCTAssertFalse(settings.usesTransaction(for: .structure, driver: mysqlLike)) - XCTAssertTrue(settings.usesTransaction(for: .data, driver: mysqlLike)) + #expect(!settings.usesTransaction(for: .structure, driver: mysqlLike)) + #expect(settings.usesTransaction(for: .data, driver: mysqlLike)) } - func testStructureSyncUsesATransactionWhenTheEngineSupportsTransactionalDDL() { + @Test + func structureSyncUsesATransactionWhenTheEngineSupportsTransactionalDDL() { var settings = CompareSyncExecutionSettings() settings.wrapInTransaction = true settings.errorHandling = .stopAndRollback - XCTAssertTrue(settings.usesTransaction( + #expect(settings.usesTransaction( for: .structure, driver: driver(transactions: true, transactionalDDL: true) )) } } -final class CompareSyncEligibilityTests: XCTestCase { - func testMissingSchemaCompareBitIsRefused() { +struct CompareSyncEligibilityTests { + @Test + func missingSchemaCompareBitIsRefused() { let driver = RecordingDriver() driver.declaredCapabilities = [] - XCTAssertNotNil(CompareSyncEligibility.refusalReason(for: driver, mode: .structure, endpointName: "db")) + #expect(CompareSyncEligibility.refusalReason(for: driver, mode: .structure, endpointName: "db") != nil) } - func testDeclaredBitPasses() { + @Test + func declaredBitPasses() { let driver = RecordingDriver() driver.declaredCapabilities = [.schemaCompare, .dataCompare] - XCTAssertNil(CompareSyncEligibility.refusalReason(for: driver, mode: .structure, endpointName: "db")) - XCTAssertNil(CompareSyncEligibility.refusalReason(for: driver, mode: .data, endpointName: "db")) + #expect(CompareSyncEligibility.refusalReason(for: driver, mode: .structure, endpointName: "db") == nil) + #expect(CompareSyncEligibility.refusalReason(for: driver, mode: .data, endpointName: "db") == nil) } - func testStructureBitDoesNotGrantDataCompare() { + @Test + func structureBitDoesNotGrantDataCompare() { let driver = RecordingDriver() driver.declaredCapabilities = [.schemaCompare] - XCTAssertNotNil(CompareSyncEligibility.refusalReason(for: driver, mode: .data, endpointName: "db")) + #expect(CompareSyncEligibility.refusalReason(for: driver, mode: .data, endpointName: "db") != nil) } } -final class DatabaseEndpointSafeModeTests: XCTestCase { +struct DatabaseEndpointSafeModeTests { private func endpoint(_ level: SafeModeLevel) -> DatabaseEndpoint { DatabaseEndpoint( scope: DatabaseScope(connectionId: UUID(), database: "prod", schema: nil), @@ -354,17 +355,19 @@ final class DatabaseEndpointSafeModeTests: XCTestCase { ) } - func testReadOnlyEndpointCannotBeWrittenTo() { + @Test + func readOnlyEndpointCannotBeWrittenTo() { let target = endpoint(.readOnly) - XCTAssertFalse(target.canBeWrittenTo) - XCTAssertNotNil(target.ineligibleAsTargetReason) + #expect(!target.canBeWrittenTo) + #expect(target.ineligibleAsTargetReason != nil) } - func testOtherLevelsCanBeWrittenTo() { + @Test + func otherLevelsCanBeWrittenTo() { for level in [SafeModeLevel.silent, .alert, .alertFull, .safeMode, .safeModeFull] { - XCTAssertTrue(endpoint(level).canBeWrittenTo, "\(level) should be a valid target") - XCTAssertNil(endpoint(level).ineligibleAsTargetReason) + #expect(endpoint(level).canBeWrittenTo, "\(level) should be a valid target") + #expect(endpoint(level).ineligibleAsTargetReason == nil) } } } diff --git a/TableProTests/Core/Compare/CompareSyncSessionDataScopeTests.swift b/TableProTests/Core/Compare/CompareSyncSessionDataScopeTests.swift index d6cf0fd5b0..a5bf49b3e3 100644 --- a/TableProTests/Core/Compare/CompareSyncSessionDataScopeTests.swift +++ b/TableProTests/Core/Compare/CompareSyncSessionDataScopeTests.swift @@ -19,20 +19,20 @@ private final class DataScopeConnectionList { final class CompareSyncSessionDataScopeTests: XCTestCase { private let sourceConnection = UUID() private let targetConnection = UUID() - private let suiteName = "CompareSyncSessionDataScopeTests" private let failure = "Timed out reading the target." private var storage: CompareSyncProfileStorage? + private var suite: EphemeralDefaults? - override func setUp() { - super.setUp() - UserDefaults(suiteName: suiteName)?.removePersistentDomain(forName: suiteName) - guard let defaults = UserDefaults(suiteName: suiteName) else { return } - storage = CompareSyncProfileStorage(defaults: defaults) + override func setUpWithError() throws { + try super.setUpWithError() + let suite = try EphemeralDefaults() + self.suite = suite + storage = CompareSyncProfileStorage(defaults: suite.defaults) } override func tearDown() { - UserDefaults(suiteName: suiteName)?.removePersistentDomain(forName: suiteName) storage = nil + suite = nil super.tearDown() } diff --git a/TableProTests/Core/Compare/CompareSyncSessionScriptTextTests.swift b/TableProTests/Core/Compare/CompareSyncSessionScriptTextTests.swift index 917e162a6e..974e35b25a 100644 --- a/TableProTests/Core/Compare/CompareSyncSessionScriptTextTests.swift +++ b/TableProTests/Core/Compare/CompareSyncSessionScriptTextTests.swift @@ -6,11 +6,12 @@ // and buffered the rest without a word, so the trigger it had just dropped never came back. // +import Foundation @testable import TablePro -import XCTest +import Testing @MainActor -final class CompareSyncSessionScriptTextTests: XCTestCase { +final class CompareSyncSessionScriptTextTests { private static let callTrigger = "CREATE OR REPLACE TRIGGER T BEFORE INSERT ON X FOR EACH ROW\nCALL P(:NEW.ID)" private func session(target databaseType: DatabaseType, statements: [SyncStatement]) -> CompareSyncSession { @@ -35,23 +36,25 @@ final class CompareSyncSessionScriptTextTests: XCTestCase { ) } - func testTheSavedScriptIsWrittenForTheTargetsOwnClient() { + @Test + func theSavedScriptIsWrittenForTheTargetsOwnClient() { let oracle = session(target: .oracle, statements: [ statement("DROP TRIGGER \"T\""), statement(Self.callTrigger), ]) - XCTAssertEqual(oracle.scriptText, "DROP TRIGGER \"T\";\n\(Self.callTrigger)\n/") - XCTAssertEqual(oracle.statements.map(\.sql), ["DROP TRIGGER \"T\"", Self.callTrigger]) + #expect(oracle.scriptText == "DROP TRIGGER \"T\";\n\(Self.callTrigger)\n/") + #expect(oracle.statements.map(\.sql) == ["DROP TRIGGER \"T\"", Self.callTrigger]) } - func testTheApplySheetShowsOnlyWhatWillRun() { + @Test + func theApplySheetShowsOnlyWhatWillRun() { let oracle = session(target: .oracle, statements: [ statement("DROP TABLE \"OLD\"", refused: true), statement(Self.callTrigger), ]) - XCTAssertEqual(oracle.runnableScriptText, "\(Self.callTrigger)\n/") - XCTAssertTrue(oracle.scriptText.hasPrefix("DROP TABLE \"OLD\";\n")) + #expect(oracle.runnableScriptText == "\(Self.callTrigger)\n/") + #expect(oracle.scriptText.hasPrefix("DROP TABLE \"OLD\";\n")) } } diff --git a/TableProTests/Core/Compare/CompareSyncSetupRestoreTests.swift b/TableProTests/Core/Compare/CompareSyncSetupRestoreTests.swift index 69d4c002b2..e16e18c503 100644 --- a/TableProTests/Core/Compare/CompareSyncSetupRestoreTests.swift +++ b/TableProTests/Core/Compare/CompareSyncSetupRestoreTests.swift @@ -18,20 +18,17 @@ final class CompareSyncSetupRestoreTests: XCTestCase { private let sourceConnection = UUID() private let targetConnection = UUID() private var storage: CompareSyncProfileStorage! - private var defaults: UserDefaults! - private let suiteName = "CompareSyncSetupRestoreTests" - - override func setUp() { - super.setUp() - UserDefaults(suiteName: suiteName)?.removePersistentDomain(forName: suiteName) - defaults = UserDefaults(suiteName: suiteName) - storage = CompareSyncProfileStorage(defaults: defaults) + private var suite: EphemeralDefaults! + + override func setUpWithError() throws { + try super.setUpWithError() + suite = try EphemeralDefaults() + storage = CompareSyncProfileStorage(defaults: suite.defaults) } override func tearDown() { - UserDefaults(suiteName: suiteName)?.removePersistentDomain(forName: suiteName) storage = nil - defaults = nil + suite = nil super.tearDown() } diff --git a/TableProTests/Core/Compare/CompareSyncStartStateTests.swift b/TableProTests/Core/Compare/CompareSyncStartStateTests.swift index 5cf5af6f6f..9da0cef571 100644 --- a/TableProTests/Core/Compare/CompareSyncStartStateTests.swift +++ b/TableProTests/Core/Compare/CompareSyncStartStateTests.swift @@ -10,12 +10,13 @@ // license, which is why the contract is checked here instead. // +import Foundation @testable import TablePro import TableProPluginKit -import XCTest +import Testing @MainActor -final class CompareSyncStartStateTests: XCTestCase { +final class CompareSyncStartStateTests { private func endpoint(name: String, safeModeLevel: SafeModeLevel = .silent) -> DatabaseEndpoint { DatabaseEndpoint( scope: DatabaseScope(connectionId: UUID(), database: "app", schema: nil), @@ -26,52 +27,58 @@ final class CompareSyncStartStateTests: XCTestCase { ) } - func testNeitherEndpointIsPreselected() { + @Test + func neitherEndpointIsPreselected() { let session = CompareSyncSession() - XCTAssertNil(session.source) - XCTAssertNil(session.target, "the side that gets written to is never chosen for the user") + #expect(session.source == nil) + #expect(session.target == nil, "the side that gets written to is never chosen for the user") } - func testBothPickersReadAsUnchosen() { - XCTAssertEqual(DatabaseEndpointSide.source.placeholderTitle, "Choose Source") - XCTAssertEqual(DatabaseEndpointSide.target.placeholderTitle, "Choose Target") + @Test + func bothPickersReadAsUnchosen() { + #expect(DatabaseEndpointSide.source.placeholderTitle == "Choose Source") + #expect(DatabaseEndpointSide.target.placeholderTitle == "Choose Target") } - func testCompareRefusesUntilBothEndpointsAreChosen() { + @Test + func compareRefusesUntilBothEndpointsAreChosen() { let session = CompareSyncSession() - XCTAssertFalse(session.canCompare) - XCTAssertEqual(session.compareDisabledReason, "Choose a source to compare from.") + #expect(!session.canCompare) + #expect(session.compareDisabledReason == "Choose a source to compare from.") session.source = endpoint(name: "prod") - XCTAssertFalse(session.canCompare, "one endpoint is not a comparison") - XCTAssertEqual(session.compareDisabledReason, "Choose a target to compare against.") + #expect(!session.canCompare, "one endpoint is not a comparison") + #expect(session.compareDisabledReason == "Choose a target to compare against.") session.target = endpoint(name: "staging") - XCTAssertTrue(session.canCompare) - XCTAssertNil(session.compareDisabledReason) + #expect(session.canCompare) + #expect(session.compareDisabledReason == nil) } - func testSwapHasNothingToSwapUntilAnEndpointIsChosen() { + @Test + func swapHasNothingToSwapUntilAnEndpointIsChosen() { let session = CompareSyncSession() - XCTAssertFalse(session.canSwap) + #expect(!session.canSwap) session.target = endpoint(name: "staging") - XCTAssertTrue(session.canSwap, "one endpoint is enough to move it to the other side") + #expect(session.canSwap, "one endpoint is enough to move it to the other side") } - func testTheBannerStatesNothingHasBeenWrittenBeforeAnyRun() { - XCTAssertEqual(CompareSyncSession().bannerText, "Comparing only. Nothing has been written.") + @Test + func theBannerStatesNothingHasBeenWrittenBeforeAnyRun() { + #expect(CompareSyncSession().bannerText == "Comparing only. Nothing has been written.") } /// The window opens on Structure, so a comparison that has not run yet cannot offer a script. - func testNoScriptCanBeGeneratedBeforeAComparisonRuns() { + @Test + func noScriptCanBeGeneratedBeforeAComparisonRuns() { let session = CompareSyncSession() - XCTAssertFalse(session.canBuildScript) - XCTAssertEqual(session.scriptDisabledReason, "Compare the two databases first.") + #expect(!session.canBuildScript) + #expect(session.scriptDisabledReason == "Compare the two databases first.") } } diff --git a/TableProTests/Core/Compare/ConcurrentRefreshIndexRuleTests.swift b/TableProTests/Core/Compare/ConcurrentRefreshIndexRuleTests.swift index 4a764ba7fd..a9d08e764b 100644 --- a/TableProTests/Core/Compare/ConcurrentRefreshIndexRuleTests.swift +++ b/TableProTests/Core/Compare/ConcurrentRefreshIndexRuleTests.swift @@ -3,12 +3,13 @@ // TableProTests // +import Foundation @testable import TablePro -import XCTest +import Testing /// The rule PostgreSQL applies to `REFRESH MATERIALIZED VIEW CONCURRENTLY`, as the index read /// reports it. Each shape was measured on PostgreSQL 17.11. -final class ConcurrentRefreshIndexRuleTests: XCTestCase { +struct ConcurrentRefreshIndexRuleTests { private func index( unique: Bool = true, type: EditableIndexDefinition.IndexType = .btree, @@ -23,22 +24,25 @@ final class ConcurrentRefreshIndexRuleTests: XCTestCase { ) } - func testAPlainUniqueIndexAllowsIt() { - XCTAssertTrue(ConcurrentRefreshIndexRule.isUsable(index())) - XCTAssertTrue(ConcurrentRefreshIndexRule.isUsable(index(columns: ["customer", "id"]))) - XCTAssertTrue(ConcurrentRefreshIndexRule.isUsable(index(includedColumns: ["customer"]))) + @Test + func aPlainUniqueIndexAllowsIt() { + #expect(ConcurrentRefreshIndexRule.isUsable(index())) + #expect(ConcurrentRefreshIndexRule.isUsable(index(columns: ["customer", "id"]))) + #expect(ConcurrentRefreshIndexRule.isUsable(index(includedColumns: ["customer"]))) } - func testAPredicateAnExpressionOrANonUniqueIndexDoesNot() { - XCTAssertFalse(ConcurrentRefreshIndexRule.isUsable(index(whereClause: "id > 0"))) - XCTAssertFalse(ConcurrentRefreshIndexRule.isUsable(index(columns: ["(id + 0)"], expressions: ["(id + 0)"]))) - XCTAssertFalse(ConcurrentRefreshIndexRule.isUsable(index(unique: false))) - XCTAssertFalse(ConcurrentRefreshIndexRule.isUsable(index(type: .gist))) + @Test + func aPredicateAnExpressionOrANonUniqueIndexDoesNot() { + #expect(!(ConcurrentRefreshIndexRule.isUsable(index(whereClause: "id > 0")))) + #expect(!(ConcurrentRefreshIndexRule.isUsable(index(columns: ["(id + 0)"], expressions: ["(id + 0)"])))) + #expect(!(ConcurrentRefreshIndexRule.isUsable(index(unique: false)))) + #expect(!(ConcurrentRefreshIndexRule.isUsable(index(type: .gist)))) } - func testOneUsableIndexIsEnough() { - XCTAssertTrue(ConcurrentRefreshIndexRule.allowsConcurrentRefresh([index(unique: false), index()])) - XCTAssertFalse(ConcurrentRefreshIndexRule.allowsConcurrentRefresh([index(unique: false)])) - XCTAssertFalse(ConcurrentRefreshIndexRule.allowsConcurrentRefresh([])) + @Test + func oneUsableIndexIsEnough() { + #expect(ConcurrentRefreshIndexRule.allowsConcurrentRefresh([index(unique: false), index()])) + #expect(!(ConcurrentRefreshIndexRule.allowsConcurrentRefresh([index(unique: false)]))) + #expect(!ConcurrentRefreshIndexRule.allowsConcurrentRefresh([])) } } diff --git a/TableProTests/Core/Compare/DataSyncScriptBuilderTests.swift b/TableProTests/Core/Compare/DataSyncScriptBuilderTests.swift index a0d9e80396..f6264439ec 100644 --- a/TableProTests/Core/Compare/DataSyncScriptBuilderTests.swift +++ b/TableProTests/Core/Compare/DataSyncScriptBuilderTests.swift @@ -12,7 +12,7 @@ import XCTest /// `sqlLiteral(for:)` lives only in a protocol extension, so it is statically dispatched and /// cannot be overridden here. These tests assert against the shared default, which emits /// numeric-looking text bare unless the target column type says the value is not a number. -private final class PlanQuotingDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class PlanQuotingDriver: PluginDriverStub, @unchecked Sendable { private let opening: String private let closing: String @@ -23,24 +23,9 @@ private final class PlanQuotingDriver: PluginDatabaseDriver, @unchecked Sendable func quoteIdentifier(_ name: String) -> String { "\(opening)\(name)\(closing)" } - func connect() async throws {} - func disconnect() {} func execute(query: String) async throws -> PluginQueryResult { PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) } - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } private enum PlanSyncFixture { diff --git a/TableProTests/Core/Compare/KeyOrderingTests.swift b/TableProTests/Core/Compare/KeyOrderingTests.swift index de3aaa169c..46114b011e 100644 --- a/TableProTests/Core/Compare/KeyOrderingTests.swift +++ b/TableProTests/Core/Compare/KeyOrderingTests.swift @@ -5,69 +5,73 @@ import Foundation import TableProPluginKit -import XCTest +import Testing @testable import TablePro -final class KeyOrderingPolicyTests: XCTestCase { +struct KeyOrderingPolicyTests { private func numeric() -> KeyOrdering { KeyOrdering(orders: [.numeric]) } private func text() -> KeyOrdering { KeyOrdering(orders: [.caseSensitiveText]) } - func testNumericKeysOrderNumerically() { - XCTAssertEqual(numeric().compare([.text("9")], [.text("10")]), .orderedAscending) - XCTAssertEqual(numeric().compare([.text("10")], [.text("9")]), .orderedDescending) + @Test + func numericKeysOrderNumerically() { + #expect(numeric().compare([.text("9")], [.text("10")]) == .orderedAscending) + #expect(numeric().compare([.text("10")], [.text("9")]) == .orderedDescending) } - func testTextKeysOrderByBytesNotByNumericValue() { - XCTAssertEqual( - text().compare([.text("10")], [.text("9")]), .orderedAscending, - "a text key must sort the way a byte-ordered collation sorts, not numerically" - ) + @Test + func textKeysOrderByBytesNotByNumericValue() { + #expect(text().compare([.text("10")], [.text("9")]) == .orderedAscending, + "a text key must sort the way a byte-ordered collation sorts, not numerically") } - func testTextOrderingIsCaseSensitiveByBytes() { - XCTAssertEqual(text().compare([.text("Carol")], [.text("bob")]), .orderedAscending) + @Test + func textOrderingIsCaseSensitiveByBytes() { + #expect(text().compare([.text("Carol")], [.text("bob")]) == .orderedAscending) } - func testEqualKeysReportSame() { - XCTAssertEqual(text().compare([.text("a"), .text("1")], [.text("a"), .text("1")]), .orderedSame) + @Test + func equalKeysReportSame() { + #expect(text().compare([.text("a"), .text("1")], [.text("a"), .text("1")]) == .orderedSame) } - func testOrderingIsAntisymmetric() { + @Test + func orderingIsAntisymmetric() { let pairs: [([PluginCellValue], [PluginCellValue])] = [ ([.text("1")], [.text("2")]), ([.text("b")], [.text("a")]), ([.bytes(Data([1]))], [.bytes(Data([2]))]) ] for (left, right) in pairs { - XCTAssertNotEqual(text().compare(left, right), text().compare(right, left)) + #expect(text().compare(left, right) != text().compare(right, left)) } } - func testCompositeKeyFallsThroughToSecondColumn() { + @Test + func compositeKeyFallsThroughToSecondColumn() { let ordering = KeyOrdering(orders: [.caseSensitiveText, .numeric]) - XCTAssertEqual( - ordering.compare([.text("a"), .text("2")], [.text("a"), .text("10")]), - .orderedAscending - ) + #expect(ordering.compare([.text("a"), .text("2")], [.text("a"), .text("10")]) == .orderedAscending) } // MARK: - Type classification - func testNumericTypesAreClassifiedNumeric() { + @Test + func numericTypesAreClassifiedNumeric() { for type in ["int", "INT(11)", "bigint unsigned", "numeric(10,2)", "double precision", "serial"] { - XCTAssertTrue(KeyOrdering.isNumeric(type), "\(type) should compare numerically") + #expect(KeyOrdering.isNumeric(type), "\(type) should compare numerically") } } - func testTextAndDateTypesAreNotNumeric() { + @Test + func textAndDateTypesAreNotNumeric() { for type in ["varchar(255)", "text", "uuid", "timestamp", "date", "bytea"] { - XCTAssertFalse(KeyOrdering.isNumeric(type), "\(type) should not compare numerically") + #expect(!KeyOrdering.isNumeric(type), "\(type) should not compare numerically") } } - func testOrdersDerivedFromDeclaredColumnTypes() { + @Test + func ordersDerivedFromDeclaredColumnTypes() { let orders = KeyOrdering.orders( for: ["id", "name"], descriptors: [ @@ -76,11 +80,12 @@ final class KeyOrderingPolicyTests: XCTestCase { ] ) - XCTAssertEqual(orders, [.numeric, .caseSensitiveText]) + #expect(orders == [.numeric, .caseSensitiveText]) } - func testUnknownColumnTypeFallsBackToCaseSensitiveText() { - XCTAssertEqual(KeyOrdering.orders(for: ["mystery"], descriptors: []), [.caseSensitiveText]) + @Test + func unknownColumnTypeFallsBackToCaseSensitiveText() { + #expect(KeyOrdering.orders(for: ["mystery"], descriptors: []) == [.caseSensitiveText]) } // MARK: - Exact numeric ordering @@ -88,49 +93,55 @@ final class KeyOrderingPolicyTests: XCTestCase { /// Going through Double collapsed every pair of integers sharing the first 53 bits, so two /// Snowflake ids one apart matched as the same row and the engine emitted an UPDATE that /// overwrote a different row. - func testTwoBigIntegersAboveTwoToTheFiftyThreeAreNotEqual() { + @Test + func twoBigIntegersAboveTwoToTheFiftyThreeAreNotEqual() { let ordering = numeric() let lower: [PluginCellValue] = [.text("1234567890123456789")] let higher: [PluginCellValue] = [.text("1234567890123456790")] - XCTAssertEqual(ordering.compare(lower, higher), .orderedAscending) - XCTAssertEqual(ordering.compare(higher, lower), .orderedDescending) - XCTAssertEqual(ordering.compare(lower, lower), .orderedSame) + #expect(ordering.compare(lower, higher) == .orderedAscending) + #expect(ordering.compare(higher, lower) == .orderedDescending) + #expect(ordering.compare(lower, lower) == .orderedSame) } - func testNumericOrderingIsNotLexicographic() { - XCTAssertEqual(numeric().compare([.text("9")], [.text("10")]), .orderedAscending) + @Test + func numericOrderingIsNotLexicographic() { + #expect(numeric().compare([.text("9")], [.text("10")]) == .orderedAscending) } /// An unparsable numeric key used to collapse onto a shared zero, which made every one of them /// compare equal to every other. - func testUnparsableNumericKeysStayDistinct() { - XCTAssertNotEqual(numeric().compare([.text("n/a")], [.text("unknown")]), .orderedSame) + @Test + func unparsableNumericKeysStayDistinct() { + #expect(numeric().compare([.text("n/a")], [.text("unknown")]) != .orderedSame) } // MARK: - Collation - func testCaseInsensitiveCollationIsDetectedPerEngine() { + @Test + func caseInsensitiveCollationIsDetectedPerEngine() { for collation in ["utf8mb4_general_ci", "NOCASE", "SQL_Latin1_General_CP1_CI_AS"] { - XCTAssertTrue(KeyOrdering.isCaseInsensitive(collation), "\(collation) is case-insensitive") + #expect(KeyOrdering.isCaseInsensitive(collation), "\(collation) is case-insensitive") } for collation in ["utf8mb4_bin", "SQL_Latin1_General_CP1_CS_AS", "C", nil] { - XCTAssertFalse(KeyOrdering.isCaseInsensitive(collation), "\(collation ?? "nil") is case-sensitive") + #expect(!KeyOrdering.isCaseInsensitive(collation), "\(collation ?? "nil") is case-sensitive") } } /// The server's own key constraint treats these as one row, so a byte comparator reported an /// orphan insert for a row the target already had. - func testCaseInsensitiveKeyMatchesRegardlessOfCase() { + @Test + func caseInsensitiveKeyMatchesRegardlessOfCase() { let ordering = KeyOrdering(orders: [.caseInsensitiveText]) - XCTAssertEqual(ordering.compare([.text("Alice")], [.text("ALICE")]), .orderedSame) - XCTAssertEqual(KeyOrdering(orders: [.caseSensitiveText]).compare( + #expect(ordering.compare([.text("Alice")], [.text("ALICE")]) == .orderedSame) + #expect(KeyOrdering(orders: [.caseSensitiveText]).compare( [.text("Alice")], [.text("ALICE")] - ), .orderedDescending) + ) == .orderedDescending) } - func testCollationDecidesTheTextOrder() { + @Test + func collationDecidesTheTextOrder() { let orders = KeyOrdering.orders( for: ["name", "code"], descriptors: [ @@ -139,18 +150,19 @@ final class KeyOrderingPolicyTests: XCTestCase { ] ) - XCTAssertEqual(orders, [.caseInsensitiveText, .caseSensitiveText]) + #expect(orders == [.caseInsensitiveText, .caseSensitiveText]) } // MARK: - NULL keys - func testNullComponentIsDetected() { - XCTAssertTrue(KeyOrdering.hasNullComponent([.text("a"), .null])) - XCTAssertFalse(KeyOrdering.hasNullComponent([.text("a"), .text("b")])) + @Test + func nullComponentIsDetected() { + #expect(KeyOrdering.hasNullComponent([.text("a"), .null])) + #expect(!(KeyOrdering.hasNullComponent([.text("a"), .text("b")]))) } } -final class DataDiffOrderingSafetyTests: XCTestCase { +struct DataDiffOrderingSafetyTests { private func row(_ id: String) -> DataRow { DataRow(values: ["id": .text(id), "name": .text("n")]) } @@ -168,76 +180,79 @@ final class DataDiffOrderingSafetyTests: XCTestCase { } /// The case that previously produced a delete for a row present on both sides. - func testDisagreeingCollationIsReportedInsteadOfDeletingAMatchingRow() async { + @Test + func disagreeingCollationIsReportedInsteadOfDeletingAMatchingRow() async { let source = ArrayRowProvider(rows: [row("Alice"), row("bob"), row("Carol")]) let target = ArrayRowProvider(rows: [row("Alice"), row("Carol")]) do { let summary = try await engine(numericKey: false).compare(source: source, target: target) - XCTAssertEqual( - summary.deleteCount, 0, - "a row present on both sides must never be reported as a delete" - ) + #expect(summary.deleteCount == 0, + "a row present on both sides must never be reported as a delete") } catch let error as CompareSyncError { guard case .streamOutOfOrder = error else { - return XCTFail("Expected an out-of-order report, got \(error)") + Issue.record("Expected an out-of-order report, got \(error)") + return } } catch { - XCTFail("Unexpected error \(error)") + Issue.record("Unexpected error \(error)") } } - func testOutOfOrderTextStreamThrowsRatherThanMismatching() async { + @Test + func outOfOrderTextStreamThrowsRatherThanMismatching() async { let source = ArrayRowProvider(rows: [row("Alice"), row("bob"), row("Carol")]) let target = ArrayRowProvider(rows: [row("Alice")]) do { _ = try await engine(numericKey: false).compare(source: source, target: target) - XCTFail("Expected an out-of-order error for a case-insensitively sorted stream") + Issue.record("Expected an out-of-order error for a case-insensitively sorted stream") } catch let error as CompareSyncError { guard case .streamOutOfOrder(let message) = error else { - return XCTFail("Expected streamOutOfOrder, got \(error)") + Issue.record("Expected streamOutOfOrder, got \(error)") + return } - XCTAssertTrue( - message.contains("Carol"), - "the message must name the key the read stopped at: \(message)" - ) + #expect(message.contains("Carol"), + "the message must name the key the read stopped at: \(message)") } catch { - XCTFail("Unexpected error \(error)") + Issue.record("Unexpected error \(error)") } } - func testInOrderTextStreamComparesNormally() async throws { + @Test + func inOrderTextStreamComparesNormally() async throws { let source = ArrayRowProvider(rows: [row("Alice"), row("Carol"), row("bob")]) let target = ArrayRowProvider(rows: [row("Alice"), row("Carol")]) let summary = try await engine(numericKey: false).compare(source: source, target: target) - XCTAssertEqual(summary.identicalCount, 2) - XCTAssertEqual(summary.insertCount, 1) - XCTAssertEqual(summary.deleteCount, 0) + #expect(summary.identicalCount == 2) + #expect(summary.insertCount == 1) + #expect(summary.deleteCount == 0) } - func testNumericKeysNeedNoOrderCheckAndCompareNumerically() async throws { + @Test + func numericKeysNeedNoOrderCheckAndCompareNumerically() async throws { let source = ArrayRowProvider(rows: [row("2"), row("9"), row("10")]) let target = ArrayRowProvider(rows: [row("2"), row("10")]) let summary = try await engine(numericKey: true).compare(source: source, target: target) - XCTAssertEqual(summary.identicalCount, 2) - XCTAssertEqual(summary.insertCount, 1, "9 is only on the source") - XCTAssertEqual(summary.deleteCount, 0, "10 exists on both sides and must not be deleted") + #expect(summary.identicalCount == 2) + #expect(summary.insertCount == 1, "9 is only on the source") + #expect(summary.deleteCount == 0, "10 exists on both sides and must not be deleted") } - func testRowsWithNullKeysAreSkippedNotGivenAnArbitraryOrder() async throws { + @Test + func rowsWithNullKeysAreSkippedNotGivenAnArbitraryOrder() async throws { let withNullKey = DataRow(values: ["id": .null, "name": .text("n")]) let source = ArrayRowProvider(rows: [withNullKey, row("1"), row("2")]) let target = ArrayRowProvider(rows: [row("1")]) let summary = try await engine(numericKey: true).compare(source: source, target: target) - XCTAssertEqual(summary.skippedNullKeyCount, 1) - XCTAssertEqual(summary.identicalCount, 1) - XCTAssertEqual(summary.insertCount, 1, "only the non-null key rows take part") + #expect(summary.skippedNullKeyCount == 1) + #expect(summary.identicalCount == 1) + #expect(summary.insertCount == 1, "only the non-null key rows take part") } } diff --git a/TableProTests/Core/Compare/MaterializedViewIndexCompareTests.swift b/TableProTests/Core/Compare/MaterializedViewIndexCompareTests.swift index c8096d1025..f2c4b4be52 100644 --- a/TableProTests/Core/Compare/MaterializedViewIndexCompareTests.swift +++ b/TableProTests/Core/Compare/MaterializedViewIndexCompareTests.swift @@ -7,11 +7,12 @@ // dropped and created one again left it without the unique index its concurrent refresh needs. // +import Foundation @testable import TablePro import TableProPluginKit -import XCTest +import Testing -final class MaterializedViewIndexCompareTests: XCTestCase { +struct MaterializedViewIndexCompareTests { private let definition = "CREATE MATERIALIZED VIEW public.mv AS SELECT id, customer FROM orders WITH DATA;" private let uniqueId = PluginIndexInfo(name: "mv_id_idx", columns: ["id"], isUnique: true) @@ -44,52 +45,57 @@ final class MaterializedViewIndexCompareTests: XCTestCase { // MARK: - Differences - func testAnIndexOnlyDifferenceIsADifference() throws { - let result = try XCTUnwrap(compare( + @Test + func anIndexOnlyDifferenceIsADifference() throws { + let result = try #require(compare( source: read(indexes: .read([uniqueId])), target: read(indexes: .read([])) )) - XCTAssertEqual(result.status, .differs) - XCTAssertTrue(result.definitionMatches) - XCTAssertEqual(result.changes.map(\.description), ["Add index 'mv_id_idx'"]) - XCTAssertEqual(result.suggestedAction, .alter) + #expect(result.status == .differs) + #expect(result.definitionMatches) + #expect(result.changes.map(\.description) == ["Add index 'mv_id_idx'"]) + #expect(result.suggestedAction == .alter) } - func testEqualIndexSetsAreIdentical() { + @Test + func equalIndexSetsAreIdentical() { let result = compare( source: read(indexes: .read([uniqueId, customer])), target: read(indexes: .read([customer, uniqueId])) ) - XCTAssertEqual(result?.status, .identical) - XCTAssertEqual(result?.changes.count, 0) + #expect(result?.status == .identical) + #expect(result?.changes.count == 0) } - func testTheSameIndexUnderAnotherNameIsANoteRatherThanAChange() { + @Test + func theSameIndexUnderAnotherNameIsANoteRatherThanAChange() { let renamed = PluginIndexInfo(name: "mv_id_key", columns: ["id"], isUnique: true) let result = compare(source: read(indexes: .read([uniqueId])), target: read(indexes: .read([renamed]))) - XCTAssertEqual(result?.changes.count, 0) - XCTAssertEqual(result?.notes.count, 1) + #expect(result?.changes.count == 0) + #expect(result?.notes.count == 1) } - func testADefinitionDifferenceStillListsTheIndexChanges() throws { - let result = try XCTUnwrap(compare( + @Test + func aDefinitionDifferenceStillListsTheIndexChanges() throws { + let result = try #require(compare( source: read("CREATE MATERIALIZED VIEW public.mv AS SELECT id FROM orders;", indexes: .read([uniqueId])), target: read(indexes: .read([customer])) )) - XCTAssertEqual(result.status, .differs) - XCTAssertFalse(result.definitionMatches) - XCTAssertEqual(result.sourceIndexes?.map(\.name), ["mv_id_idx"]) - XCTAssertEqual(result.targetIndexes?.map(\.name), ["mv_customer_idx"]) + #expect(result.status == .differs) + #expect(!result.definitionMatches) + #expect(result.sourceIndexes?.map(\.name) == ["mv_id_idx"]) + #expect(result.targetIndexes?.map(\.name) == ["mv_customer_idx"]) } // MARK: - Failed reads - func testAFailedIndexReadOnEitherSideIsNotCompared() { + @Test + func aFailedIndexReadOnEitherSideIsNotCompared() { let sourceFailed = compare( source: read(indexes: .failed("permission denied for pg_index")), target: read(indexes: .read([uniqueId])) @@ -100,80 +106,88 @@ final class MaterializedViewIndexCompareTests: XCTestCase { ) for result in [sourceFailed, targetFailed] { - XCTAssertEqual(result?.comparisonError?.contains("permission denied for pg_index"), true) - XCTAssertEqual(result?.suggestedAction, .skip) - XCTAssertEqual(result?.availableActions, [.skip]) - XCTAssertEqual(result?.changes.count, 0, "no DROP INDEX may come from a read that failed") + #expect(result?.comparisonError?.contains("permission denied for pg_index") == true) + #expect(result?.suggestedAction == .skip) + #expect(result?.availableActions == [.skip]) + #expect(result?.changes.count == 0, "no DROP INDEX may come from a read that failed") } - XCTAssertEqual(sourceFailed?.comparisonError?.contains("source"), true) - XCTAssertEqual(targetFailed?.comparisonError?.contains("target"), true) + #expect(sourceFailed?.comparisonError?.contains("source") == true) + #expect(targetFailed?.comparisonError?.contains("target") == true) } - func testAViewWhoseIndexesCouldNotBeReadIsNeverCreated() { + @Test + func aViewWhoseIndexesCouldNotBeReadIsNeverCreated() { let result = compare(source: read(indexes: .failed("denied")), target: nil) - XCTAssertEqual(result?.status, .onlyInSource) - XCTAssertNotNil(result?.comparisonError) - XCTAssertEqual(result?.suggestedAction, .skip) + #expect(result?.status == .onlyInSource) + #expect(result?.comparisonError != nil) + #expect(result?.suggestedAction == .skip) } - func testATargetWhoseIndexesCouldNotBeReadCanStillBeDropped() { + @Test + func aTargetWhoseIndexesCouldNotBeReadCanStillBeDropped() { let result = compare(source: nil, target: read(indexes: .failed("denied"))) - XCTAssertNil(result?.comparisonError) - XCTAssertEqual(result?.suggestedAction, .drop) + #expect(result?.comparisonError == nil) + #expect(result?.suggestedAction == .drop) } // MARK: - Engines that do not carry them - func testATargetThatTakesNoIndexesComparesTheDefinitionAlone() { + @Test + func aTargetThatTakesNoIndexesComparesTheDefinitionAlone() { let result = compare( source: read(indexes: .read([uniqueId])), target: read(indexes: nil), targetCarries: false ) - XCTAssertEqual(result?.status, .identical) - XCTAssertNil(result?.sourceIndexes) - XCTAssertEqual(result?.showsIndexes, false) + #expect(result?.status == .identical) + #expect(result?.sourceIndexes == nil) + #expect(result?.showsIndexes == false) } - func testASourceThatReportsNoIndexesComparesTheDefinitionAlone() { + @Test + func aSourceThatReportsNoIndexesComparesTheDefinitionAlone() { let result = compare(source: read(indexes: nil), target: read(indexes: .read([uniqueId]))) - XCTAssertEqual(result?.status, .identical) - XCTAssertNil(result?.sourceIndexes) + #expect(result?.status == .identical) + #expect(result?.sourceIndexes == nil) } - func testACreateOnATargetThatTakesNoIndexesSaysTheyAreLeftOut() { + @Test + func aCreateOnATargetThatTakesNoIndexesSaysTheyAreLeftOut() { let result = compare(source: read(indexes: .read([uniqueId])), target: nil, targetCarries: false) - XCTAssertEqual(result?.notes, [SourceObjectIndexes.notCarriedByTargetNote]) - XCTAssertNil(result?.sourceIndexes) + #expect(result?.notes == [SourceObjectIndexes.notCarriedByTargetNote]) + #expect(result?.sourceIndexes == nil) } - func testACreateCarriesTheSourcesIndexes() { + @Test + func aCreateCarriesTheSourcesIndexes() { let result = compare(source: read(indexes: .read([uniqueId, customer])), target: nil) - XCTAssertEqual(result?.sourceIndexes?.map(\.name), ["mv_id_idx", "mv_customer_idx"]) - XCTAssertEqual(result?.showsIndexes, true) + #expect(result?.sourceIndexes?.map(\.name) == ["mv_id_idx", "mv_customer_idx"]) + #expect(result?.showsIndexes == true) } // MARK: - What the definitions pane shows - func testTheIndexLinesAreDisplayedApartFromTheDefinition() throws { - let result = try XCTUnwrap(compare( + @Test + func theIndexLinesAreDisplayedApartFromTheDefinition() throws { + let result = try #require(compare( source: read(indexes: .read([uniqueId])), target: read(indexes: .read([])) )) - XCTAssertEqual(result.sourceIndexLines, ["UNIQUE INDEX mv_id_idx (id) USING BTREE"]) - XCTAssertEqual(result.targetIndexLines, []) - XCTAssertFalse(result.sourceDefinition.joined().contains("INDEX")) + #expect(result.sourceIndexLines == ["UNIQUE INDEX mv_id_idx (id) USING BTREE"]) + #expect(result.targetIndexLines == []) + #expect(!(result.sourceDefinition.joined().contains("INDEX"))) } - func testAnIndexOnlyDifferenceCountsItsChangesInTheResultsList() throws { - let result = try XCTUnwrap(compare( + @Test + func anIndexOnlyDifferenceCountsItsChangesInTheResultsList() throws { + let result = try #require(compare( source: read(indexes: .read([uniqueId, customer])), target: read(indexes: .read([])) )) @@ -182,6 +196,6 @@ final class MaterializedViewIndexCompareTests: XCTestCase { from: [result], sortedUsing: [KeyPathComparator(\CompareResultRow.objectName)] ).first - XCTAssertEqual(row?.changeSummary, String(format: String(localized: "%d changes"), 2)) + #expect(row?.changeSummary == String(format: String(localized: "%d changes"), 2)) } } diff --git a/TableProTests/Core/Compare/SchemaSyncScriptBuilderTests.swift b/TableProTests/Core/Compare/SchemaSyncScriptBuilderTests.swift index 514df3b6e8..4ffe1cfa8a 100644 --- a/TableProTests/Core/Compare/SchemaSyncScriptBuilderTests.swift +++ b/TableProTests/Core/Compare/SchemaSyncScriptBuilderTests.swift @@ -9,40 +9,14 @@ import XCTest @testable import TablePro -private final class StubSyncDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class StubSyncDriver: PluginDriverStub, @unchecked Sendable { var createTableStatements: [String]? var modifyColumnSQL: String? - func connect() async throws {} - - func disconnect() {} - func execute(query: String) async throws -> PluginQueryResult { PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) } - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - - func fetchDatabases() async throws -> [String] { [] } - - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } - func generateCreateTableSQL(definition: PluginCreateTableDefinition) -> String? { "CREATE TABLE \(definition.tableName)" } diff --git a/TableProTests/Core/Compare/SourceObjectDiffEngineTests.swift b/TableProTests/Core/Compare/SourceObjectDiffEngineTests.swift index a9624e683b..619018cad0 100644 --- a/TableProTests/Core/Compare/SourceObjectDiffEngineTests.swift +++ b/TableProTests/Core/Compare/SourceObjectDiffEngineTests.swift @@ -8,10 +8,11 @@ // as a real one. // +import Foundation @testable import TablePro -import XCTest +import Testing -final class SourceObjectDiffEngineTests: XCTestCase { +struct SourceObjectDiffEngineTests { private func read( _ name: String, kind: CompareObjectKind = .function, @@ -48,122 +49,131 @@ final class SourceObjectDiffEngineTests: XCTestCase { // MARK: - Status - func testAnObjectOnlyOnTheSourceIsCreated() { + @Test + func anObjectOnlyOnTheSourceIsCreated() { let results = engine().compare( source: [read("audit", source: "CREATE FUNCTION audit() BEGIN END")], target: [] ) - XCTAssertEqual(results.count, 1) - XCTAssertEqual(results[0].status, .onlyInSource) - XCTAssertEqual(results[0].suggestedAction, .create) + #expect(results.count == 1) + #expect(results[0].status == .onlyInSource) + #expect(results[0].suggestedAction == .create) } - func testAnObjectOnlyOnTheTargetIsDropped() { + @Test + func anObjectOnlyOnTheTargetIsDropped() { let results = engine().compare( source: [], target: [read("stale", source: "CREATE FUNCTION stale() BEGIN END")] ) - XCTAssertEqual(results[0].status, .onlyInTarget) - XCTAssertEqual(results[0].suggestedAction, .drop) + #expect(results[0].status == .onlyInTarget) + #expect(results[0].suggestedAction == .drop) } - func testAMatchingDefinitionIsIdentical() { + @Test + func aMatchingDefinitionIsIdentical() { let results = engine().compare( source: [read("audit", source: "CREATE FUNCTION audit()\nBEGIN\n SELECT 1;\nEND")], target: [read("audit", source: "CREATE FUNCTION audit()\nBEGIN\n SELECT 1;\nEND")] ) - XCTAssertEqual(results[0].status, .identical) - XCTAssertEqual(results[0].suggestedAction, .skip) + #expect(results[0].status == .identical) + #expect(results[0].suggestedAction == .skip) } - func testADifferentDefinitionIsAlter() { + @Test + func aDifferentDefinitionIsAlter() { let results = engine().compare( source: [read("audit", source: "CREATE FUNCTION audit() BEGIN SELECT 1; END")], target: [read("audit", source: "CREATE FUNCTION audit() BEGIN SELECT 2; END")] ) - XCTAssertEqual(results[0].status, .differs) - XCTAssertEqual(results[0].suggestedAction, .alter) - XCTAssertFalse(results[0].sourceDefinition.isEmpty) - XCTAssertFalse(results[0].targetDefinition.isEmpty) + #expect(results[0].status == .differs) + #expect(results[0].suggestedAction == .alter) + #expect(!(results[0].sourceDefinition.isEmpty)) + #expect(!(results[0].targetDefinition.isEmpty)) } // MARK: - Normalising - func testTrailingSemicolonsAndLineEndingsAreNotADifference() { + @Test + func trailingSemicolonsAndLineEndingsAreNotADifference() { let results = engine().compare( source: [read("audit", source: "CREATE FUNCTION audit() BEGIN SELECT 1; END;")], target: [read("audit", source: "CREATE FUNCTION audit() BEGIN SELECT 1; END\r\n")] ) - XCTAssertEqual(results[0].status, .identical) + #expect(results[0].status == .identical) } /// Measured on Oracle 23ai: a procedure sent without the `;` after its END is stored INVALID, with it VALID. - func testAnOracleUnitWithoutItsOwnSemicolonIsADifferentObject() { + @Test + func anOracleUnitWithoutItsOwnSemicolonIsADifferentObject() { let valid = "CREATE OR REPLACE PROCEDURE audit IS\nBEGIN\n NULL;\nEND;" let invalid = "CREATE OR REPLACE PROCEDURE audit IS\nBEGIN\n NULL;\nEND" - XCTAssertEqual(status(of: valid, against: invalid, on: .oracle), .differs) - XCTAssertEqual(status(of: valid, against: valid + "\n", on: .oracle), .identical) + #expect(status(of: valid, against: invalid, on: .oracle) == .differs) + #expect(status(of: valid, against: valid + "\n", on: .oracle) == .identical) } /// Folding whitespace first turned the comment's newline into a space, and the comment then ran to the end of /// the text: a body that differed after it compared equal. - func testALineCommentCannotHideTheRestOfTheBody() { + @Test + func aLineCommentCannotHideTheRestOfTheBody() { let source = "CREATE OR REPLACE PROCEDURE audit IS\nBEGIN\n NULL; -- keep\nEND;" let target = "CREATE OR REPLACE PROCEDURE audit IS\nBEGIN\n NULL; -- keep\nEND" - XCTAssertEqual(status(of: source, against: target, on: .oracle), .differs) + #expect(status(of: source, against: target, on: .oracle) == .differs) } - func testAWhenClauseOrADisabledStateIsADifference() { + @Test + func aWhenClauseOrADisabledStateIsADifference() { let plain = "CREATE OR REPLACE TRIGGER audit BEFORE INSERT ON t FOR EACH ROW\nBEGIN NULL; END;" let guarded = "CREATE OR REPLACE TRIGGER audit BEFORE INSERT ON t FOR EACH ROW\nWHEN (NEW.id > 0)\nBEGIN NULL; END;" let disabled = "CREATE OR REPLACE TRIGGER audit BEFORE INSERT ON t FOR EACH ROW\nDISABLE\nBEGIN NULL; END;" - XCTAssertEqual(status(of: plain, against: guarded, on: .oracle), .differs) - XCTAssertEqual(status(of: plain, against: disabled, on: .oracle), .differs) + #expect(status(of: plain, against: guarded, on: .oracle) == .differs) + #expect(status(of: plain, against: disabled, on: .oracle) == .differs) } /// An engine the app has no grammar for accepts a definition with or without its trailing `;`. - func testATrailingSemicolonIsNoDifferenceWhereTheGrammarIsNotTracked() { - XCTAssertEqual( - status(of: "CREATE VIEW audit AS SELECT 1;", against: "CREATE VIEW audit AS SELECT 1", on: .mssql), - .identical - ) + @Test + func aTrailingSemicolonIsNoDifferenceWhereTheGrammarIsNotTracked() { + #expect(status(of: "CREATE VIEW audit AS SELECT 1;", against: "CREATE VIEW audit AS SELECT 1", on: .mssql) == .identical) } - func testWhitespaceIsADifferenceUntilItIsIgnored() { + @Test + func whitespaceIsADifferenceUntilItIsIgnored() { let source = [read("audit", source: "CREATE FUNCTION audit()\nBEGIN\n SELECT 1;\nEND")] let target = [read("audit", source: "CREATE FUNCTION audit() BEGIN SELECT 1; END")] - XCTAssertEqual(engine(strict()).compare(source: source, target: target)[0].status, .differs) + #expect(engine(strict()).compare(source: source, target: target)[0].status == .differs) var lenient = StructureCompareOptions.default lenient.ignoreWhitespaceInText = true - XCTAssertEqual(engine(lenient).compare(source: source, target: target)[0].status, .identical) + #expect(engine(lenient).compare(source: source, target: target)[0].status == .identical) } - func testIdentifierCaseIsADifferenceUntilItIsIgnored() { + @Test + func identifierCaseIsADifferenceUntilItIsIgnored() { let source = [read("audit", source: "CREATE FUNCTION audit() BEGIN SELECT 1; END")] let target = [read("audit", source: "create function audit() begin select 1; end")] - XCTAssertEqual(engine(strict()).compare(source: source, target: target)[0].status, .differs) + #expect(engine(strict()).compare(source: source, target: target)[0].status == .differs) var lenient = StructureCompareOptions.default lenient.ignoreIdentifierCase = true - XCTAssertEqual(engine(lenient).compare(source: source, target: target)[0].status, .identical) + #expect(engine(lenient).compare(source: source, target: target)[0].status == .identical) } // MARK: - Matching /// PostgreSQL and Oracle both allow two routines to share a name, so the signature is part of /// the identity. Without it one overload is compared against the other. - func testTwoOverloadsOfOneNameAreMatchedBySignature() { + @Test + func twoOverloadsOfOneNameAreMatchedBySignature() { let results = engine().compare( source: [ read("area", signature: "(integer)", source: "CREATE FUNCTION area(integer) BEGIN SELECT 1; END"), @@ -174,105 +184,114 @@ final class SourceObjectDiffEngineTests: XCTestCase { ] ) - XCTAssertEqual(results.count, 2) - XCTAssertEqual(results.filter { $0.status == .identical }.count, 1) - XCTAssertEqual(results.filter { $0.status == .onlyInSource }.count, 1) + #expect(results.count == 2) + #expect(results.filter { $0.status == .identical }.count == 1) + #expect(results.filter { $0.status == .onlyInSource }.count == 1) } - func testTwoKindsSharingOneNameAreNotMatched() { + @Test + func twoKindsSharingOneNameAreNotMatched() { let results = engine().compare( source: [read("audit", kind: .function, source: "CREATE FUNCTION audit() BEGIN END")], target: [read("audit", kind: .procedure, source: "CREATE PROCEDURE audit() BEGIN END")] ) - XCTAssertEqual(results.count, 2) - XCTAssertEqual(Set(results.map { $0.status }), [.onlyInSource, .onlyInTarget]) + #expect(results.count == 2) + #expect(Set(results.map { $0.status }) == [.onlyInSource, .onlyInTarget]) } - func testTwoSchemasSharingOneNameAreNotMatched() { + @Test + func twoSchemasSharingOneNameAreNotMatched() { let results = engine().compare( source: [read("audit", schema: "public", source: "CREATE FUNCTION audit() BEGIN END")], target: [read("audit", schema: "sales", source: "CREATE FUNCTION audit() BEGIN END")] ) - XCTAssertEqual(results.count, 2) + #expect(results.count == 2) } // MARK: - Unreadable definitions - func testASourceWhoseReadFailedIsNotComparedAgainstAReadableTarget() { + @Test + func aSourceWhoseReadFailedIsNotComparedAgainstAReadableTarget() { let denied = "SHOW VIEW command denied to user 'reader'@'%' for table 'v'" let results = engine(databaseType: .mysql).compare( source: [read("v", kind: .view, schema: nil, source: "", failure: denied)], target: [read("v", kind: .view, schema: nil, source: "CREATE VIEW v AS SELECT 1")] ) - XCTAssertEqual(results.count, 1) - XCTAssertEqual(results[0].comparisonError, SourceDefinitionDefect.unreadable(denied).reason(on: .source)) - XCTAssertEqual(results[0].suggestedAction, .skip) - XCTAssertEqual(results[0].availableActions, [.skip]) - XCTAssertEqual(results[0].sourceDefinition, []) - XCTAssertEqual(results[0].targetDefinition, ["CREATE VIEW v AS SELECT 1"]) + #expect(results.count == 1) + #expect(results[0].comparisonError == SourceDefinitionDefect.unreadable(denied).reason(on: .source)) + #expect(results[0].suggestedAction == .skip) + #expect(results[0].availableActions == [.skip]) + #expect(results[0].sourceDefinition == []) + #expect(results[0].targetDefinition == ["CREATE VIEW v AS SELECT 1"]) } - func testTwoFailedReadsOfOneObjectAreNotIdentical() { + @Test + func twoFailedReadsOfOneObjectAreNotIdentical() { let results = engine().compare( source: [read("v", kind: .view, source: "", failure: "denied")], target: [read("v", kind: .view, source: "", failure: "denied")] ) - XCTAssertEqual(results.count, 1) - XCTAssertNotEqual(results[0].status, .identical) - XCTAssertFalse(results[0].isComparable) + #expect(results.count == 1) + #expect(results[0].status != .identical) + #expect(!(results[0].isComparable)) } - func testAnUnreadableObjectOnlyOnTheSourceIsNeverCreated() { + @Test + func anUnreadableObjectOnlyOnTheSourceIsNeverCreated() { let results = engine().compare( source: [read("audit", source: "", failure: "permission denied")], target: [] ) - XCTAssertEqual(results[0].comparisonError, SourceDefinitionDefect.unreadable("permission denied").reason(on: .source)) - XCTAssertEqual(results[0].suggestedAction, .skip) - XCTAssertEqual(results[0].availableActions, [.skip]) + #expect(results[0].comparisonError == SourceDefinitionDefect.unreadable("permission denied").reason(on: .source)) + #expect(results[0].suggestedAction == .skip) + #expect(results[0].availableActions == [.skip]) } - func testAnUnreadableObjectOnlyOnTheTargetNamesTheTargetAndIsNeverDropped() { + @Test + func anUnreadableObjectOnlyOnTheTargetNamesTheTargetAndIsNeverDropped() { let results = engine().compare( source: [], target: [read("stale", source: "", failure: "permission denied")] ) - XCTAssertEqual(results[0].comparisonError, SourceDefinitionDefect.unreadable("permission denied").reason(on: .target)) - XCTAssertEqual(results[0].availableActions, [.skip]) + #expect(results[0].comparisonError == SourceDefinitionDefect.unreadable("permission denied").reason(on: .target)) + #expect(results[0].availableActions == [.skip]) } - func testADefinitionWithNothingToRunIsUnreadable() { + @Test + func aDefinitionWithNothingToRunIsUnreadable() { for blank in ["", " \n\t", "-- nothing here", "/* nothing */", "# nothing"] { let results = engine(databaseType: .mysql).compare( source: [read("audit", source: blank)], target: [read("audit", source: "CREATE FUNCTION audit() RETURNS INT RETURN 1")] ) - XCTAssertEqual(results[0].comparisonError, SourceDefinitionDefect.empty.reason(on: .source), blank) - XCTAssertEqual(results[0].availableActions, [.skip], blank) + #expect(results[0].comparisonError == SourceDefinitionDefect.empty.reason(on: .source), Comment(rawValue: blank)) + #expect(results[0].availableActions == [.skip], Comment(rawValue: blank)) } } - func testABodyThatIsNotACreateStatementIsUnreadable() { + @Test + func aBodyThatIsNotACreateStatementIsUnreadable() { for body in ["(a + b)", "SELECT 1 AS x"] { let results = engine(databaseType: .duckdb).compare( source: [read("add", source: body)], target: [] ) - XCTAssertEqual(results[0].comparisonError, SourceDefinitionDefect.notACreateStatement.reason(on: .source), body) - XCTAssertEqual(results[0].availableActions, [.skip], body) - XCTAssertEqual(results[0].sourceDefinition, [body], body) + #expect(results[0].comparisonError == SourceDefinitionDefect.notACreateStatement.reason(on: .source), Comment(rawValue: body)) + #expect(results[0].availableActions == [.skip], Comment(rawValue: body)) + #expect(results[0].sourceDefinition == [body], Comment(rawValue: body)) } } - func testALeadingCommentDoesNotHideTheCreate() { + @Test + func aLeadingCommentDoesNotHideTheCreate() { let definition = "-- Author: ops\n/* audit */\nCREATE PROCEDURE dbo.audit AS SET NOCOUNT ON; SELECT 1;" let results = engine(databaseType: .mssql).compare( @@ -280,11 +299,12 @@ final class SourceObjectDiffEngineTests: XCTestCase { target: [read("audit", schema: "dbo", source: definition)] ) - XCTAssertNil(results[0].comparisonError) - XCTAssertEqual(results[0].status, .identical) + #expect(results[0].comparisonError == nil) + #expect(results[0].status == .identical) } - func testEachSideIsReadInItsOwnEnginesGrammar() { + @Test + func eachSideIsReadInItsOwnEnginesGrammar() { let mysqlDefinition = "# reporting view\nCREATE VIEW v AS SELECT 1" let postgresDefinition = "CREATE VIEW v AS SELECT 1" @@ -301,11 +321,12 @@ final class SourceObjectDiffEngineTests: XCTestCase { target: [read("v", kind: .view, schema: nil, source: mysqlDefinition)] ) - XCTAssertNil(fromMySQL[0].comparisonError) - XCTAssertNil(intoMySQL[0].comparisonError) + #expect(fromMySQL[0].comparisonError == nil) + #expect(intoMySQL[0].comparisonError == nil) } - func testASQLServerModuleStoredAfterAlterOrCreateOrAlterIsReadable() { + @Test + func aSQLServerModuleStoredAfterAlterOrCreateOrAlterIsReadable() { let altered = "/* comment */ CREATE PROCEDURE Test1A AS SELECT 3;" let createdOrAltered = "CrEaTe /*Y*/ PROCEDURE Test1B AS SELECT 2;" @@ -317,19 +338,20 @@ final class SourceObjectDiffEngineTests: XCTestCase { target: [] ) - XCTAssertEqual(results.map(\.comparisonError), [nil, nil]) - XCTAssertEqual(results.map(\.suggestedAction), [.create, .create]) + #expect(results.map(\.comparisonError) == [nil, nil]) + #expect(results.map(\.suggestedAction) == [.create, .create]) } - func testAnUnreadableObjectIsNeitherADifferenceNorSelectable() { + @Test + func anUnreadableObjectIsNeitherADifferenceNorSelectable() { let report = CompareReport(results: engine().compare( source: [read("v", kind: .view, source: "", failure: "denied")], target: [read("v", kind: .view, source: "CREATE VIEW v AS SELECT 1")] )) - XCTAssertEqual(report.uncomparable.count, 1) - XCTAssertTrue(report.comparable.isEmpty) - XCTAssertEqual(report.differenceCount, 0) + #expect(report.uncomparable.count == 1) + #expect(report.comparable.isEmpty) + #expect(report.differenceCount == 0) } private func strict() -> StructureCompareOptions { @@ -340,7 +362,7 @@ final class SourceObjectDiffEngineTests: XCTestCase { } } -final class SourceObjectHazardTests: XCTestCase { +struct SourceObjectHazardTests { private let classifier = SyncSafetyClassifier() private func identity(_ kind: CompareObjectKind) -> CompareObjectIdentity { @@ -349,21 +371,23 @@ final class SourceObjectHazardTests: XCTestCase { /// A view holds no rows, so dropping one is recoverable from the source and only warns. A /// materialized view does hold rows, so it is refused like a table. - func testDroppingAViewIsRefusedButDroppingAMaterializedViewAlsoWarnsAboutItsRows() { + @Test + func droppingAViewIsRefusedButDroppingAMaterializedViewAlsoWarnsAboutItsRows() { let view = classifier.hazards(forDropping: identity(.view), isReplacement: false) let materialized = classifier.hazards(forDropping: identity(.materializedView), isReplacement: false) - XCTAssertTrue(view.contains { $0.severity == .refusedByDefault }) - XCTAssertTrue(materialized.contains { $0.severity == .refusedByDefault }) - XCTAssertGreaterThan(materialized.count, view.count) + #expect(view.contains { $0.severity == .refusedByDefault }) + #expect(materialized.contains { $0.severity == .refusedByDefault }) + #expect(materialized.count > view.count) } /// A replace drops and recreates, so it warns about dependents rather than about losing the /// object: the object comes straight back. - func testAReplacementWarnsAboutDependentsRatherThanBeingRefused() { + @Test + func aReplacementWarnsAboutDependentsRatherThanBeingRefused() { let hazards = classifier.hazards(forDropping: identity(.function), isReplacement: true) - XCTAssertFalse(hazards.contains { $0.severity == .refusedByDefault }) - XCTAssertTrue(hazards.contains { $0.severity == .warning }) + #expect(!(hazards.contains { $0.severity == .refusedByDefault })) + #expect(hazards.contains { $0.severity == .warning }) } } diff --git a/TableProTests/Core/Compare/SourceObjectIndexCarriageTests.swift b/TableProTests/Core/Compare/SourceObjectIndexCarriageTests.swift index 13ad3475d5..29693939ce 100644 --- a/TableProTests/Core/Compare/SourceObjectIndexCarriageTests.swift +++ b/TableProTests/Core/Compare/SourceObjectIndexCarriageTests.swift @@ -3,27 +3,31 @@ // TableProTests // +import Foundation @testable import TablePro -import XCTest +import Testing @MainActor -final class SourceObjectIndexCarriageTests: XCTestCase { - func testTheMatrixDecidesWhichKindsCarryIndexes() { - XCTAssertTrue(SourceObjectIndexes.areCarried(for: .materializedView, by: .postgreSQL)) - XCTAssertFalse(SourceObjectIndexes.areCarried(for: .view, by: .postgreSQL)) - XCTAssertFalse(SourceObjectIndexes.areCarried(for: .materializedView, by: .tablesOnly)) - XCTAssertFalse(SourceObjectIndexes.areCarried(for: .table, by: .postgreSQL)) - XCTAssertFalse(SourceObjectIndexes.areCarried(for: .function, by: .postgreSQL)) +final class SourceObjectIndexCarriageTests { + @Test + func theMatrixDecidesWhichKindsCarryIndexes() { + #expect(SourceObjectIndexes.areCarried(for: .materializedView, by: .postgreSQL)) + #expect(!SourceObjectIndexes.areCarried(for: .view, by: .postgreSQL)) + #expect(!SourceObjectIndexes.areCarried(for: .materializedView, by: .tablesOnly)) + #expect(!SourceObjectIndexes.areCarried(for: .table, by: .postgreSQL)) + #expect(!SourceObjectIndexes.areCarried(for: .function, by: .postgreSQL)) } - func testPostgreSQLAndPGliteCarryAMaterializedViewsIndexes() { - XCTAssertEqual(SourceObjectIndexes.carriedKinds(on: .postgresql), [.materializedView]) - XCTAssertEqual(SourceObjectIndexes.carriedKinds(on: .pglite), [.materializedView]) + @Test + func postgreSQLAndPGliteCarryAMaterializedViewsIndexes() { + #expect(SourceObjectIndexes.carriedKinds(on: .postgresql) == [.materializedView]) + #expect(SourceObjectIndexes.carriedKinds(on: .pglite) == [.materializedView]) } - func testEnginesNobodyHasCuratedCarryNone() { - XCTAssertEqual(SourceObjectIndexes.carriedKinds(on: .cockroachdb), []) - XCTAssertEqual(SourceObjectIndexes.carriedKinds(on: .redshift), []) - XCTAssertEqual(SourceObjectIndexes.carriedKinds(on: DatabaseType(rawValue: "NotARealEngine")), []) + @Test + func enginesNobodyHasCuratedCarryNone() { + #expect(SourceObjectIndexes.carriedKinds(on: .cockroachdb) == []) + #expect(SourceObjectIndexes.carriedKinds(on: .redshift) == []) + #expect(SourceObjectIndexes.carriedKinds(on: DatabaseType(rawValue: "NotARealEngine")) == []) } } diff --git a/TableProTests/Core/Compare/StreamingRowProviderTests.swift b/TableProTests/Core/Compare/StreamingRowProviderTests.swift index dbcc4ccef5..a86d287740 100644 --- a/TableProTests/Core/Compare/StreamingRowProviderTests.swift +++ b/TableProTests/Core/Compare/StreamingRowProviderTests.swift @@ -5,11 +5,11 @@ import Foundation import TableProPluginKit -import XCTest +import Testing @testable import TablePro -final class StreamingRowProviderTests: XCTestCase { +struct StreamingRowProviderTests { private func stream(_ elements: [PluginStreamElement]) -> AsyncThrowingStream { AsyncThrowingStream { continuation in for element in elements { continuation.yield(element) } @@ -21,18 +21,20 @@ final class StreamingRowProviderTests: XCTestCase { .header(PluginStreamHeader(columns: columns, columnTypeNames: columns.map { _ in "text" })) } - func testHeaderColumnsAreUsedToNameValues() async throws { + @Test + func headerColumnsAreUsedToNameValues() async throws { let provider = StreamingRowProvider( stream: stream([header(["id", "name"]), .rows([[.text("1"), .text("a")]])]) ) let row = try await provider.nextRow() - XCTAssertEqual(row?.value(for: "id"), .text("1")) - XCTAssertEqual(row?.value(for: "name"), .text("a")) + #expect(row?.value(for: "id") == .text("1")) + #expect(row?.value(for: "name") == .text("a")) } - func testExplicitColumnsSurviveAMissingHeader() async throws { + @Test + func explicitColumnsSurviveAMissingHeader() async throws { let provider = StreamingRowProvider( stream: stream([.rows([[.text("1"), .text("a")]])]), columns: ["id", "name"] @@ -40,10 +42,11 @@ final class StreamingRowProviderTests: XCTestCase { let row = try await provider.nextRow() - XCTAssertEqual(row?.value(for: "name"), .text("a")) + #expect(row?.value(for: "name") == .text("a")) } - func testRowsAcrossMultipleBatchesAreAllReturnedInOrder() async throws { + @Test + func rowsAcrossMultipleBatchesAreAllReturnedInOrder() async throws { let provider = StreamingRowProvider(stream: stream([ header(["id"]), .rows([[.text("1")], [.text("2")]]), @@ -55,10 +58,11 @@ final class StreamingRowProviderTests: XCTestCase { if case .text(let value) = row.value(for: "id") { seen.append(value) } } - XCTAssertEqual(seen, ["1", "2", "3"]) + #expect(seen == ["1", "2", "3"]) } - func testEmptyBatchesAreSkippedRatherThanEndingTheStream() async throws { + @Test + func emptyBatchesAreSkippedRatherThanEndingTheStream() async throws { let provider = StreamingRowProvider(stream: stream([ header(["id"]), .rows([]), @@ -68,21 +72,23 @@ final class StreamingRowProviderTests: XCTestCase { let row = try await provider.nextRow() - XCTAssertEqual(row?.value(for: "id"), .text("1"), "empty batches must not be mistaken for end of stream") + #expect(row?.value(for: "id") == .text("1"), "empty batches must not be mistaken for end of stream") } - func testExhaustedStreamReturnsNilRepeatedly() async throws { + @Test + func exhaustedStreamReturnsNilRepeatedly() async throws { let provider = StreamingRowProvider(stream: stream([header(["id"]), .rows([[.text("1")]])])) _ = try await provider.nextRow() let firstNil = try await provider.nextRow() let secondNil = try await provider.nextRow() - XCTAssertNil(firstNil) - XCTAssertNil(secondNil) + #expect(firstNil == nil) + #expect(secondNil == nil) } - func testShortRowDoesNotOverrunTheColumnList() async throws { + @Test + func shortRowDoesNotOverrunTheColumnList() async throws { let provider = StreamingRowProvider(stream: stream([ header(["id", "name", "extra"]), .rows([[.text("1")]]) @@ -90,11 +96,12 @@ final class StreamingRowProviderTests: XCTestCase { let row = try await provider.nextRow() - XCTAssertEqual(row?.value(for: "id"), .text("1")) - XCTAssertEqual(row?.value(for: "name"), .null, "a column with no value reads as NULL") + #expect(row?.value(for: "id") == .text("1")) + #expect(row?.value(for: "name") == .null, "a column with no value reads as NULL") } - func testErrorsFromTheStreamPropagate() async { + @Test + func errorsFromTheStreamPropagate() async { struct Boom: Error {} let failing = AsyncThrowingStream { continuation in continuation.yield(self.header(["id"])) @@ -104,69 +111,58 @@ final class StreamingRowProviderTests: XCTestCase { do { _ = try await provider.nextRow() - XCTFail("Expected the stream error to surface") + Issue.record("Expected the stream error to surface") } catch { - XCTAssertTrue(error is Boom) + #expect(error is Boom) } } } -final class KeyOrderedQueryTests: XCTestCase { - private final class Quoting: PluginDatabaseDriver, @unchecked Sendable { +struct KeyOrderedQueryTests { + private final class Quoting: PluginDriverStub, @unchecked Sendable { func quoteIdentifier(_ name: String) -> String { "\"\(name)\"" } - func connect() async throws {} - func disconnect() {} func execute(query: String) async throws -> PluginQueryResult { PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) } - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } - func testQuerySelectsRequestedColumnsOrderedByKey() { + @Test + func querySelectsRequestedColumnsOrderedByKey() { let sql = KeyOrderedQuery.build( table: "users", schema: nil, columns: ["id", "name"], keyColumns: ["id"], driver: Quoting(), databaseType: .postgresql ) - XCTAssertEqual(sql, "SELECT \"id\", \"name\" FROM \"users\" ORDER BY \"id\"") + #expect(sql == "SELECT \"id\", \"name\" FROM \"users\" ORDER BY \"id\"") } - func testCompositeKeyOrdersByEveryKeyColumn() { + @Test + func compositeKeyOrdersByEveryKeyColumn() { let sql = KeyOrderedQuery.build( table: "t", schema: nil, columns: ["a"], keyColumns: ["tenant", "id"], driver: Quoting(), databaseType: .postgresql ) - XCTAssertTrue(sql.hasSuffix("ORDER BY \"tenant\", \"id\""), sql) + #expect(sql.hasSuffix("ORDER BY \"tenant\", \"id\""), Comment(rawValue: sql)) } - func testSchemaIsQualifiedAndQuoted() { + @Test + func schemaIsQualifiedAndQuoted() { let sql = KeyOrderedQuery.build( table: "users", schema: "app", columns: ["id"], keyColumns: ["id"], driver: Quoting(), databaseType: .postgresql ) - XCTAssertTrue(sql.contains("FROM \"app\".\"users\""), sql) + #expect(sql.contains("FROM \"app\".\"users\""), Comment(rawValue: sql)) } - func testTheEngineImplicitSchemaIsLeftUnqualified() { + @Test + func theEngineImplicitSchemaIsLeftUnqualified() { let sql = KeyOrderedQuery.build( table: "users", schema: "(default)", columns: ["id"], keyColumns: ["id"], driver: Quoting(), databaseType: .spanner ) - XCTAssertEqual(sql, "SELECT \"id\" FROM \"users\" ORDER BY \"id\"") + #expect(sql == "SELECT \"id\" FROM \"users\" ORDER BY \"id\"") } } diff --git a/TableProTests/Core/Compare/StructureChangeGuardTests.swift b/TableProTests/Core/Compare/StructureChangeGuardTests.swift index 8052987e91..6cde4e4ccf 100644 --- a/TableProTests/Core/Compare/StructureChangeGuardTests.swift +++ b/TableProTests/Core/Compare/StructureChangeGuardTests.swift @@ -4,12 +4,12 @@ // import Foundation -import XCTest +import Testing @testable import TablePro import TableProPluginKit -final class StructureChangeGuardTests: XCTestCase { +struct StructureChangeGuardTests { private func column( _ name: String, _ dataType: String = "int", @@ -63,70 +63,73 @@ final class StructureChangeGuardTests: XCTestCase { // MARK: - Alter - func testAnAlterWhoseChangesStillMatchIsAllowed() { + @Test + func anAlterWhoseChangesStillMatchIsAllowed() { let result = table(changes: [.addColumn(column("total"))]) - XCTAssertNil( - StructureChangeGuard.refusal( + #expect(StructureChangeGuard.refusal( expected: inputs([result], action: .alter), actual: inputs([result], action: .alter) - ) - ) + ) == nil) } - func testAnAlterWhoseChangeListMovedIsRefused() { + @Test + func anAlterWhoseChangeListMovedIsRefused() { let expected = inputs([table(changes: [.addColumn(column("total"))])], action: .alter) let actual = inputs([table(changes: [.addColumn(column("total", "decimal(10,2)"))])], action: .alter) - XCTAssertNotNil(StructureChangeGuard.refusal(expected: expected, actual: actual)) + #expect(StructureChangeGuard.refusal(expected: expected, actual: actual) != nil) } // MARK: - Create /// `SchemaSyncScriptBuilder` renders the snapshot raw, so the guard has to compare it raw. Two /// tables the comparison calls identical under its own options still produce different DDL. - func testACreateWhoseSnapshotDiffersOnlyByCollationIsRefused() { + @Test + func aCreateWhoseSnapshotDiffersOnlyByCollationIsRefused() { let result = table(status: .onlyInSource) let expected = inputs([result], action: .create, snapshot: snapshot(columns: [column("id")])) let actual = inputs( [result], action: .create, snapshot: snapshot(columns: [column("id")], collation: "utf8mb4_bin") ) - XCTAssertNotNil(StructureChangeGuard.refusal(expected: expected, actual: actual)) + #expect(StructureChangeGuard.refusal(expected: expected, actual: actual) != nil) } - func testACreateWhoseSnapshotDiffersOnlyByAColumnCommentIsRefused() { + @Test + func aCreateWhoseSnapshotDiffersOnlyByAColumnCommentIsRefused() { let result = table(status: .onlyInSource) let expected = inputs([result], action: .create, snapshot: snapshot(columns: [column("id")])) let actual = inputs( [result], action: .create, snapshot: snapshot(columns: [column("id", comment: "the key")]) ) - XCTAssertNotNil(StructureChangeGuard.refusal(expected: expected, actual: actual)) + #expect(StructureChangeGuard.refusal(expected: expected, actual: actual) != nil) } - func testACreateWhoseSourceTableIsGoneIsRefused() { + @Test + func aCreateWhoseSourceTableIsGoneIsRefused() { let result = table(status: .onlyInSource) let expected = inputs([result], action: .create, snapshot: snapshot(columns: [column("id")])) - XCTAssertNotNil( - StructureChangeGuard.refusal(expected: expected, actual: inputs([result], action: .create)) - ) + #expect(StructureChangeGuard.refusal(expected: expected, actual: inputs([result], action: .create)) != nil) } // MARK: - Drop - func testADropOfATableThatCameBackIsRefused() { + @Test + func aDropOfATableThatCameBackIsRefused() { let expected = inputs([table(status: .onlyInTarget)], action: .drop) let actual = inputs([table(status: .differs)], action: .drop) - XCTAssertNotNil(StructureChangeGuard.refusal(expected: expected, actual: actual)) + #expect(StructureChangeGuard.refusal(expected: expected, actual: actual) != nil) } - func testADropOfATableThatIsNoLongerInTheReportIsRefused() { + @Test + func aDropOfATableThatIsNoLongerInTheReportIsRefused() { let expected = inputs([table(status: .onlyInTarget)], action: .drop) - XCTAssertNotNil(StructureChangeGuard.refusal(expected: expected, actual: [:])) + #expect(StructureChangeGuard.refusal(expected: expected, actual: [:]) != nil) } // MARK: - Source-defined objects @@ -134,22 +137,22 @@ final class StructureChangeGuardTests: XCTestCase { /// `SourceObjectSyncBuilder` writes `sourceDefinition` verbatim, while the comparison reads it /// through a normalizer that lowercases the body. A guard built on the normalized text would /// pass a stale CREATE VIEW for an edit that only changed a literal's case. - func testAViewBodyDifferingOnlyInTheCaseOfALiteralIsRefused() { + @Test + func aViewBodyDifferingOnlyInTheCaseOfALiteralIsRefused() { let expected = inputs([view(definition: ["SELECT 'Paid' AS state"])], action: .create) let actual = inputs([view(definition: ["SELECT 'PAID' AS state"])], action: .create) - XCTAssertNotNil(StructureChangeGuard.refusal(expected: expected, actual: actual)) + #expect(StructureChangeGuard.refusal(expected: expected, actual: actual) != nil) } - func testAnUnchangedViewIsAllowed() { + @Test + func anUnchangedViewIsAllowed() { let result = view(definition: ["SELECT 1"]) - XCTAssertNil( - StructureChangeGuard.refusal( + #expect(StructureChangeGuard.refusal( expected: inputs([result], action: .create), actual: inputs([result], action: .create) - ) - ) + ) == nil) } // MARK: - A materialized view's indexes @@ -175,7 +178,8 @@ final class StructureChangeGuardTests: XCTestCase { ) } - func testTwoReadsOfAnUnchangedMaterializedViewAreAllowed() { + @Test + func twoReadsOfAnUnchangedMaterializedViewAreAllowed() { let read = { self.matview( changes: [.addIndex(self.matviewIndex("totals_id_idx"))], @@ -183,22 +187,24 @@ final class StructureChangeGuardTests: XCTestCase { ) } - XCTAssertNil(StructureChangeGuard.refusal( + #expect(StructureChangeGuard.refusal( expected: inputs([read()], action: .alter), actual: inputs([read()], action: .alter) - )) + ) == nil) } - func testASourceIndexAddedAfterComparingRefusesTheScript() { + @Test + func aSourceIndexAddedAfterComparingRefusesTheScript() { let expected = inputs([matview(sourceIndexes: [matviewIndex("totals_id_idx")])], action: .create) let actual = inputs( [matview(sourceIndexes: [matviewIndex("totals_id_idx"), matviewIndex("totals_key", unique: true)])], action: .create ) - XCTAssertNotNil(StructureChangeGuard.refusal(expected: expected, actual: actual)) + #expect(StructureChangeGuard.refusal(expected: expected, actual: actual) != nil) } - func testATargetIndexChangedAfterComparingRefusesAnIndexOnlyAlter() { + @Test + func aTargetIndexChangedAfterComparingRefusesAnIndexOnlyAlter() { let expected = inputs( [matview(changes: [.deleteIndex(matviewIndex("totals_old_idx"))], sourceIndexes: [])], action: .alter ) @@ -206,16 +212,17 @@ final class StructureChangeGuardTests: XCTestCase { [matview(changes: [.deleteIndex(matviewIndex("totals_other_idx"))], sourceIndexes: [])], action: .alter ) - XCTAssertNotNil(StructureChangeGuard.refusal(expected: expected, actual: actual)) + #expect(StructureChangeGuard.refusal(expected: expected, actual: actual) != nil) } /// An index-only alter keeps the view and its rows, and a definition that moved since would /// turn it into a DROP and CREATE the user never reviewed. - func testADefinitionThatStoppedMatchingRefusesAnIndexOnlyAlter() { + @Test + func aDefinitionThatStoppedMatchingRefusesAnIndexOnlyAlter() { let expected = inputs([matview(sourceIndexes: [], definitionMatches: true)], action: .alter) let actual = inputs([matview(sourceIndexes: [], definitionMatches: false)], action: .alter) - XCTAssertNotNil(StructureChangeGuard.refusal(expected: expected, actual: actual)) + #expect(StructureChangeGuard.refusal(expected: expected, actual: actual) != nil) } // MARK: - Two reads of the same tables @@ -307,25 +314,25 @@ final class StructureChangeGuardTests: XCTestCase { ) } - func testACreateFromTwoReadsOfAnUnchangedTableIsAllowed() { + @Test + func aCreateFromTwoReadsOfAnUnchangedTableIsAllowed() { let expected = inputs(comparing: [ordersRead()], with: [], action: .create) let actual = inputs(comparing: [ordersRead()], with: [], action: .create) let snapshot = expected.values.first?.sourceSnapshot - XCTAssertEqual(expected.count, 1) - XCTAssertEqual(snapshot?.columns.count, 5) - XCTAssertEqual(snapshot?.indexes.count, 2) - XCTAssertEqual(snapshot?.foreignKeys.first?.columns, ["region_id", "country"]) - XCTAssertNil(StructureChangeGuard.refusal(expected: expected, actual: actual)) + #expect(expected.count == 1) + #expect(snapshot?.columns.count == 5) + #expect(snapshot?.indexes.count == 2) + #expect(snapshot?.foreignKeys.first?.columns == ["region_id", "country"]) + #expect(StructureChangeGuard.refusal(expected: expected, actual: actual) == nil) } - func testAnAlterFromTwoReadsOfAnUnchangedPairIsAllowed() { + @Test + func anAlterFromTwoReadsOfAnUnchangedPairIsAllowed() { let expected = inputs(comparing: [ordersRead()], with: [legacyOrdersRead()], action: .alter) let actual = inputs(comparing: [ordersRead()], with: [legacyOrdersRead()], action: .alter) - XCTAssertEqual( - expected.values.first?.changes.map(\.description), - [ + #expect(expected.values.first?.changes.map(\.description) == [ "Modify column 'total' to 'total'", "Add column 'shape'", "Delete column 'notes'", @@ -333,12 +340,12 @@ final class StructureChangeGuardTests: XCTestCase { "Delete index 'orders_total_idx'", "Add foreign key 'orders_region_fkey'", "Delete foreign key 'orders_owner_fkey'" - ] - ) - XCTAssertNil(StructureChangeGuard.refusal(expected: expected, actual: actual)) + ]) + #expect(StructureChangeGuard.refusal(expected: expected, actual: actual) == nil) } - func testACreateWhoseSecondReadGainedAColumnIsRefused() { + @Test + func aCreateWhoseSecondReadGainedAColumnIsRefused() { let expected = inputs(comparing: [ordersRead()], with: [], action: .create) let actual = inputs( comparing: [ordersRead(extraColumns: [PluginColumnInfo(name: "discount", dataType: "int")])], @@ -346,21 +353,23 @@ final class StructureChangeGuardTests: XCTestCase { action: .create ) - XCTAssertNotNil(StructureChangeGuard.refusal(expected: expected, actual: actual)) + #expect(StructureChangeGuard.refusal(expected: expected, actual: actual) != nil) } /// The catalog spelling is private state the snapshot carries into `CREATE TABLE`, so it has to /// be compared like every public field. - func testACreateWhoseSecondReadSpellsATypeDifferentlyIsRefused() { + @Test + func aCreateWhoseSecondReadSpellsATypeDifferentlyIsRefused() { let expected = inputs(comparing: [ordersRead()], with: [], action: .create) let actual = inputs( comparing: [ordersRead(shapeSpelling: "public.geometry(Point,3857)")], with: [], action: .create ) - XCTAssertNotNil(StructureChangeGuard.refusal(expected: expected, actual: actual)) + #expect(StructureChangeGuard.refusal(expected: expected, actual: actual) != nil) } - func testACreateWhoseSecondReadSpellsACollationDifferentlyIsRefused() { + @Test + func aCreateWhoseSecondReadSpellsACollationDifferentlyIsRefused() { let collatedRead = { (spelling: String) in self.ordersRead(extraColumns: [ PluginColumnInfo( @@ -374,12 +383,13 @@ final class StructureChangeGuardTests: XCTestCase { let unchanged = inputs(comparing: [collatedRead(#"app."Case Insens""#)], with: [], action: .create) let respelled = inputs(comparing: [collatedRead(#"shared."Case Insens""#)], with: [], action: .create) - XCTAssertEqual(expected.values.first?.sourceSnapshot?.columns.last?.ddlCollation, #"app."Case Insens""#) - XCTAssertNil(StructureChangeGuard.refusal(expected: expected, actual: unchanged)) - XCTAssertNotNil(StructureChangeGuard.refusal(expected: expected, actual: respelled)) + #expect(expected.values.first?.sourceSnapshot?.columns.last?.ddlCollation == #"app."Case Insens""#) + #expect(StructureChangeGuard.refusal(expected: expected, actual: unchanged) == nil) + #expect(StructureChangeGuard.refusal(expected: expected, actual: respelled) != nil) } - func testACreateWhoseSecondReadDescribesAnIndexDifferentlyIsRefused() { + @Test + func aCreateWhoseSecondReadDescribesAnIndexDifferentlyIsRefused() { let keys = "USING btree (lower((country)::text))" let predicate = "(total > (0)::numeric)" let expressions = ["lower(country)"] @@ -408,18 +418,19 @@ final class StructureChangeGuardTests: XCTestCase { ] let index = expected.values.first?.sourceSnapshot?.indexes.last - XCTAssertEqual(index?.expressions, expressions) - XCTAssertEqual(index?.includedColumns, included) - XCTAssertEqual(index?.ddlMethodAndKeys, keys) - XCTAssertEqual(index?.ddlWhereClause, predicate) - XCTAssertNil(StructureChangeGuard.refusal(expected: expected, actual: unchanged)) + #expect(index?.expressions == expressions) + #expect(index?.includedColumns == included) + #expect(index?.ddlMethodAndKeys == keys) + #expect(index?.ddlWhereClause == predicate) + #expect(StructureChangeGuard.refusal(expected: expected, actual: unchanged) == nil) for (label, read) in differingReads { let actual = inputs(comparing: [read], with: [], action: .create) - XCTAssertNotNil(StructureChangeGuard.refusal(expected: expected, actual: actual), label) + #expect(StructureChangeGuard.refusal(expected: expected, actual: actual) != nil, Comment(rawValue: label)) } } - func testASyncWhoseSecondReadReportsAnotherIndexTypeIsRefused() { + @Test + func aSyncWhoseSecondReadReportsAnotherIndexTypeIsRefused() { let typedRead = { (type: String) in self.ordersRead(extraIndexes: [ PluginIndexInfo(name: "orders_country_idx", columns: ["country"], type: type) @@ -428,35 +439,37 @@ final class StructureChangeGuardTests: XCTestCase { let expectedCreate = inputs(comparing: [typedRead("hnsw")], with: [], action: .create) let expectedAlter = inputs(comparing: [typedRead("hnsw")], with: [typedRead("ivfflat")], action: .alter) - XCTAssertEqual(expectedCreate.values.first?.sourceSnapshot?.indexes.last?.type.rawValue, "HNSW") - XCTAssertNil(StructureChangeGuard.refusal( + #expect(expectedCreate.values.first?.sourceSnapshot?.indexes.last?.type.rawValue == "HNSW") + #expect(StructureChangeGuard.refusal( expected: expectedCreate, actual: inputs(comparing: [typedRead("HNSW")], with: [], action: .create) - )) - XCTAssertNotNil(StructureChangeGuard.refusal( + ) == nil) + #expect(StructureChangeGuard.refusal( expected: expectedCreate, actual: inputs(comparing: [typedRead("ivfflat")], with: [], action: .create) - )) - XCTAssertNil(StructureChangeGuard.refusal( + ) != nil) + #expect(StructureChangeGuard.refusal( expected: expectedAlter, actual: inputs(comparing: [typedRead("hnsw")], with: [typedRead("ivfflat")], action: .alter) - )) - XCTAssertNotNil(StructureChangeGuard.refusal( + ) == nil) + #expect(StructureChangeGuard.refusal( expected: expectedAlter, actual: inputs(comparing: [typedRead("hnsw")], with: [typedRead("bloom")], action: .alter) - )) + ) != nil) } - func testAnAlterWhoseTargetColumnMovedBetweenReadsIsRefused() { + @Test + func anAlterWhoseTargetColumnMovedBetweenReadsIsRefused() { let expected = inputs(comparing: [ordersRead()], with: [legacyOrdersRead()], action: .alter) let actual = inputs( comparing: [ordersRead()], with: [legacyOrdersRead(totalType: "decimal(9,2)")], action: .alter ) - XCTAssertNotNil(StructureChangeGuard.refusal(expected: expected, actual: actual)) + #expect(StructureChangeGuard.refusal(expected: expected, actual: actual) != nil) } - func testEveryKindOfChangeIsComparedWithoutTheIdentityItWasReadWith() { + @Test + func everyKindOfChangeIsComparedWithoutTheIdentityItWasReadWith() { let index = { (name: String) in EditableIndexDefinition( id: UUID(), name: name, columns: ["total"], type: .btree, isUnique: false, @@ -495,26 +508,28 @@ final class StructureChangeGuardTests: XCTestCase { for (label, make) in makers { let expected = inputs([table(changes: [make()])], action: .alter) let actual = inputs([table(changes: [make()])], action: .alter) - XCTAssertNil(StructureChangeGuard.refusal(expected: expected, actual: actual), label) + #expect(StructureChangeGuard.refusal(expected: expected, actual: actual) == nil, Comment(rawValue: label)) } } // MARK: - Scope of the check - func testAnObjectTheUserSkippedIsNotChecked() { + @Test + func anObjectTheUserSkippedIsNotChecked() { let expected = StructureChangeGuard.inputs( for: [table(changes: [.addColumn(column("total"))])], actions: { _ in .skip }, sourceSnapshots: [:] ) - XCTAssertTrue(expected.isEmpty) - XCTAssertNil(StructureChangeGuard.refusal(expected: expected, actual: [:])) + #expect(expected.isEmpty) + #expect(StructureChangeGuard.refusal(expected: expected, actual: [:]) == nil) } /// Another table moving in the same schema is somebody else's work. Refusing on it would make a /// busy database impossible to sync. - func testAnObjectOutsideTheSelectionDoesNotRefuse() { + @Test + func anObjectOutsideTheSelectionDoesNotRefuse() { let selected = table(changes: [.addColumn(column("total"))]) let other = CompareObjectResult( identity: CompareObjectIdentity(kind: .table, schema: "shop", name: "customers"), @@ -524,25 +539,23 @@ final class StructureChangeGuardTests: XCTestCase { var actual = inputs([selected], action: .alter) actual.merge(inputs([other], action: .alter)) { first, _ in first } - XCTAssertNil( - StructureChangeGuard.refusal(expected: inputs([selected], action: .alter), actual: actual) - ) + #expect(StructureChangeGuard.refusal(expected: inputs([selected], action: .alter), actual: actual) == nil) } - func testTheRefusalNamesTheObject() { + @Test + func theRefusalNamesTheObject() { let refusal = StructureChangeGuard.refusal( expected: inputs([table(changes: [.addColumn(column("total"))])], action: .alter), actual: [:] ) - let message = try? XCTUnwrap(refusal?.errorDescription) - XCTAssertTrue( - message?.contains("shop.orders") ?? false, - "the message must name what changed, got \(message ?? "nil")" - ) + let message = try? #require(refusal?.errorDescription) + #expect(message?.contains("shop.orders") ?? false, + "the message must name what changed, got \(message ?? "nil")") } - func testAnObjectThatCouldNotBeReadAgainIsRefusedWithItsReason() throws { + @Test + func anObjectThatCouldNotBeReadAgainIsRefusedWithItsReason() throws { let result = view(definition: ["CREATE VIEW recent_orders AS SELECT 1"]) let reason = SourceDefinitionDefect.unreadable("SHOW VIEW command denied").reason(on: .source) @@ -552,22 +565,21 @@ final class StructureChangeGuardTests: XCTestCase { unreadable: [result.id: reason] ) - let message = try XCTUnwrap(refusal?.errorDescription) - XCTAssertTrue(message.contains("shop.recent_orders"), message) - XCTAssertTrue(message.contains(reason), message) - XCTAssertFalse(message.contains("changed after it was compared"), message) + let message = try #require(refusal?.errorDescription) + #expect(message.contains("shop.recent_orders"), Comment(rawValue: message)) + #expect(message.contains(reason), Comment(rawValue: message)) + #expect(!message.contains("changed after it was compared"), Comment(rawValue: message)) } - func testAnUnreadableObjectOutsideTheSelectionDoesNotRefuse() { + @Test + func anUnreadableObjectOutsideTheSelectionDoesNotRefuse() { let selected = view(definition: ["CREATE VIEW recent_orders AS SELECT 1"]) let expected = inputs([selected], action: .create) - XCTAssertNil( - StructureChangeGuard.refusal( + #expect(StructureChangeGuard.refusal( expected: expected, actual: expected, unreadable: ["view|shop|other|": "denied"] - ) - ) + ) == nil) } } diff --git a/TableProTests/Core/Compare/StructureDiffEngineTests.swift b/TableProTests/Core/Compare/StructureDiffEngineTests.swift index edaffc1126..ec9211af9f 100644 --- a/TableProTests/Core/Compare/StructureDiffEngineTests.swift +++ b/TableProTests/Core/Compare/StructureDiffEngineTests.swift @@ -4,11 +4,11 @@ // import Foundation -import XCTest +import Testing @testable import TablePro -final class StructureDiffEngineTests: XCTestCase { +struct StructureDiffEngineTests { private func column( _ name: String, _ dataType: String = "int", @@ -97,57 +97,63 @@ final class StructureDiffEngineTests: XCTestCase { // MARK: - Table presence - func testTableOnlyInSourceIsCreateAndDefaultsToSkip() { + @Test + func tableOnlyInSourceIsCreateAndDefaultsToSkip() { let engine = StructureDiffEngine() let report = engine.compare( source: [table("users", columns: [column("id")])], target: [] ) - XCTAssertEqual(report.results.count, 1) - XCTAssertEqual(report.results[0].status, .onlyInSource) - XCTAssertEqual(report.results[0].suggestedAction, .create) + #expect(report.results.count == 1) + #expect(report.results[0].status == .onlyInSource) + #expect(report.results[0].suggestedAction == .create) } - func testTableOnlyInTargetIsDrop() { + @Test + func tableOnlyInTargetIsDrop() { let engine = StructureDiffEngine() let report = engine.compare( source: [], target: [table("legacy", columns: [column("id")])] ) - XCTAssertEqual(report.results[0].status, .onlyInTarget) - XCTAssertEqual(report.results[0].suggestedAction, .drop) + #expect(report.results[0].status == .onlyInTarget) + #expect(report.results[0].suggestedAction == .drop) } - func testIdenticalTablesReportIdentical() { + @Test + func identicalTablesReportIdentical() { let engine = StructureDiffEngine() let source = table("users", columns: [column("id"), column("name", "varchar(20)")]) let target = table("users", columns: [column("id"), column("name", "varchar(20)")]) let result = engine.compareTable(source: source, target: target) - XCTAssertEqual(result.status, .identical) - XCTAssertTrue(result.changes.isEmpty) + #expect(result.status == .identical) + #expect(result.changes.isEmpty) } // MARK: - Column changes, direction is target becomes source - func testMissingColumnInTargetProducesAddColumn() { + @Test + func missingColumnInTargetProducesAddColumn() { let engine = StructureDiffEngine() let result = engine.compareTable( source: table("users", columns: [column("id"), column("email", "varchar(255)")]), target: table("users", columns: [column("id")]) ) - XCTAssertEqual(result.status, .differs) + #expect(result.status == .differs) guard case .addColumn(let added) = result.changes.first else { - return XCTFail("Expected addColumn, got \(result.changes)") + Issue.record("Expected addColumn, got \(result.changes)") + return } - XCTAssertEqual(added.name, "email") + #expect(added.name == "email") } - func testExtraColumnInTargetProducesDeleteColumn() { + @Test + func extraColumnInTargetProducesDeleteColumn() { let engine = StructureDiffEngine() let result = engine.compareTable( source: table("users", columns: [column("id")]), @@ -155,12 +161,14 @@ final class StructureDiffEngineTests: XCTestCase { ) guard case .deleteColumn(let removed) = result.changes.first else { - return XCTFail("Expected deleteColumn, got \(result.changes)") + Issue.record("Expected deleteColumn, got \(result.changes)") + return } - XCTAssertEqual(removed.name, "obsolete") + #expect(removed.name == "obsolete") } - func testModifyColumnCarriesTargetAsOldAndSourceAsNew() { + @Test + func modifyColumnCarriesTargetAsOldAndSourceAsNew() { let engine = StructureDiffEngine() let result = engine.compareTable( source: table("users", columns: [column("id", "bigint")]), @@ -168,35 +176,39 @@ final class StructureDiffEngineTests: XCTestCase { ) guard case .modifyColumn(let old, let new) = result.changes.first else { - return XCTFail("Expected modifyColumn, got \(result.changes)") + Issue.record("Expected modifyColumn, got \(result.changes)") + return } - XCTAssertEqual(old.dataType, "int", "old must be the target's current state") - XCTAssertEqual(new.dataType, "bigint", "new must be the source's desired state") + #expect(old.dataType == "int", "old must be the target's current state") + #expect(new.dataType == "bigint", "new must be the source's desired state") } - func testNullabilityChangeIsDetected() { + @Test + func nullabilityChangeIsDetected() { let engine = StructureDiffEngine() let result = engine.compareTable( source: table("users", columns: [column("id", nullable: false)]), target: table("users", columns: [column("id", nullable: true)]) ) - XCTAssertEqual(result.status, .differs) - XCTAssertEqual(result.changes.count, 1) + #expect(result.status == .differs) + #expect(result.changes.count == 1) } // MARK: - Ignore options each kill a named false positive - func testIdentifierCaseOnlyDifferenceIsIgnoredByDefault() { + @Test + func identifierCaseOnlyDifferenceIsIgnoredByDefault() { let result = StructureDiffEngine().compareTable( source: table("Users", columns: [column("ID")]), target: table("users", columns: [column("id")]) ) - XCTAssertEqual(result.status, .identical) + #expect(result.status == .identical) } - func testIdentifierCaseDifferenceIsReportedWhenOptionOff() { + @Test + func identifierCaseDifferenceIsReportedWhenOptionOff() { var options = StructureCompareOptions() options.ignoreIdentifierCase = false let result = StructureDiffEngine(options: options).compareTable( @@ -204,109 +216,113 @@ final class StructureDiffEngineTests: XCTestCase { target: table("users", columns: [column("id")]) ) - XCTAssertEqual(result.status, .differs) + #expect(result.status == .differs) } - func testAutoIncrementSeedOnlyDifferenceIsIgnoredByDefault() { + @Test + func autoIncrementSeedOnlyDifferenceIsIgnoredByDefault() { let result = StructureDiffEngine().compareTable( source: table("users", columns: [column("id", extra: "auto_increment=1522")]), target: table("users", columns: [column("id", extra: "auto_increment=184")]) ) - XCTAssertEqual(result.status, .identical, "AUTO_INCREMENT drift must not be a difference") + #expect(result.status == .identical, "AUTO_INCREMENT drift must not be a difference") } - func testWhitespaceOnlyDefaultDifferenceIsIgnoredByDefault() { + @Test + func whitespaceOnlyDefaultDifferenceIsIgnoredByDefault() { let result = StructureDiffEngine().compareTable( source: table("users", columns: [column("flag", defaultValue: "0")]), target: table("users", columns: [column("flag", defaultValue: " 0 ")]) ) - XCTAssertEqual(result.status, .identical) + #expect(result.status == .identical) } - func testNullDefaultOnANullableColumnMatchesNoDefault() { + @Test + func nullDefaultOnANullableColumnMatchesNoDefault() { let result = StructureDiffEngine().compareTable( source: table("users", columns: [column("nickname", defaultValue: "NULL")]), target: table("users", columns: [column("nickname")]) ) - XCTAssertEqual(result.status, .identical) + #expect(result.status == .identical) } - func testNullDefaultOnANotNullColumnIsStillADifference() { + @Test + func nullDefaultOnANotNullColumnIsStillADifference() { let result = StructureDiffEngine().compareTable( source: table("users", columns: [column("nickname", nullable: false, defaultValue: "NULL")]), target: table("users", columns: [column("nickname", nullable: false)]) ) - XCTAssertNotEqual(result.status, .identical) + #expect(result.status != .identical) } - func testCollationOnlyDifferenceIsIgnoredByDefaultAndReportedWhenOptionOff() { + @Test + func collationOnlyDifferenceIsIgnoredByDefaultAndReportedWhenOptionOff() { let source = table("users", columns: [column("name", "varchar(20)", collation: "utf8mb4_general_ci")]) let target = table("users", columns: [column("name", "varchar(20)", collation: "utf8mb4_unicode_ci")]) - XCTAssertEqual(StructureDiffEngine().compareTable(source: source, target: target).status, .identical) + #expect(StructureDiffEngine().compareTable(source: source, target: target).status == .identical) var options = StructureCompareOptions() options.ignoreCollationAndCharset = false - XCTAssertEqual( - StructureDiffEngine(options: options).compareTable(source: source, target: target).status, - .differs - ) + #expect(StructureDiffEngine(options: options).compareTable(source: source, target: target).status == .differs) } - func testCommentOnlyDifferenceIsIgnoredByDefaultAndReportedWhenOptionOff() { + @Test + func commentOnlyDifferenceIsIgnoredByDefaultAndReportedWhenOptionOff() { let source = table("users", columns: [column("id", comment: "primary id")]) let target = table("users", columns: [column("id", comment: nil)]) - XCTAssertEqual(StructureDiffEngine().compareTable(source: source, target: target).status, .identical) + #expect(StructureDiffEngine().compareTable(source: source, target: target).status == .identical) var options = StructureCompareOptions() options.ignoreCommentsAndOwners = false - XCTAssertEqual( - StructureDiffEngine(options: options).compareTable(source: source, target: target).status, - .differs - ) + #expect(StructureDiffEngine(options: options).compareTable(source: source, target: target).status == .differs) } - func testColumnOrderIsIgnoredByDefaultAndNotedWhenOptionOff() { + @Test + func columnOrderIsIgnoredByDefaultAndNotedWhenOptionOff() { let source = table("users", columns: [column("a"), column("b")]) let target = table("users", columns: [column("b"), column("a")]) - XCTAssertEqual(StructureDiffEngine().compareTable(source: source, target: target).status, .identical) + #expect(StructureDiffEngine().compareTable(source: source, target: target).status == .identical) var options = StructureCompareOptions() options.ignoreColumnOrder = false let result = StructureDiffEngine(options: options).compareTable(source: source, target: target) - XCTAssertEqual(result.status, .differs) - XCTAssertTrue(result.changes.isEmpty, "reordering emits no DDL") - XCTAssertEqual(result.notes.count, 1) + #expect(result.status == .differs) + #expect(result.changes.isEmpty, "reordering emits no DDL") + #expect(result.notes.count == 1) } // MARK: - Name-only differences never mark an object changed - func testIndexMatchedOnStructureDespiteGeneratedNameDifference() { + @Test + func indexMatchedOnStructureDespiteGeneratedNameDifference() { let result = StructureDiffEngine().compareTable( source: table("users", columns: [column("email")], indexes: [index("idx_a1b2", columns: ["email"])]), target: table("users", columns: [column("email")], indexes: [index("idx_c3d4", columns: ["email"])]) ) - XCTAssertTrue(result.changes.isEmpty, "a name-only index difference must emit no DDL") - XCTAssertEqual(result.notes.count, 1) + #expect(result.changes.isEmpty, "a name-only index difference must emit no DDL") + #expect(result.notes.count == 1) } - func testIndexColumnDifferenceIsARealChange() { + @Test + func indexColumnDifferenceIsARealChange() { let result = StructureDiffEngine().compareTable( source: table("users", columns: [column("a"), column("b")], indexes: [index("i", columns: ["a", "b"])]), target: table("users", columns: [column("a"), column("b")], indexes: [index("i", columns: ["a"])]) ) - XCTAssertEqual(result.changes.count, 2, "expected one add and one delete") + #expect(result.changes.count == 2, "expected one add and one delete") } - func testForeignKeyMatchedOnStructureDespiteNameDifference() { + @Test + func foreignKeyMatchedOnStructureDespiteNameDifference() { let source = table( "orders", columns: [column("user_id")], @@ -320,13 +336,14 @@ final class StructureDiffEngineTests: XCTestCase { let result = StructureDiffEngine().compareTable(source: source, target: target) - XCTAssertTrue(result.changes.isEmpty) - XCTAssertEqual(result.notes.count, 1) + #expect(result.changes.isEmpty) + #expect(result.notes.count == 1) } // MARK: - Primary key - func testPrimaryKeyDifferenceProducesSingleModifyPrimaryKey() { + @Test + func primaryKeyDifferenceProducesSingleModifyPrimaryKey() { let source = table("users", columns: [column("id", primaryKey: true), column("tenant", primaryKey: true)]) let target = table("users", columns: [column("id", primaryKey: true), column("tenant")]) @@ -336,15 +353,17 @@ final class StructureDiffEngineTests: XCTestCase { if case .modifyPrimaryKey = $0 { return true } return false } - XCTAssertEqual(primaryKeyChanges.count, 1) + #expect(primaryKeyChanges.count == 1) guard case .modifyPrimaryKey(let old, let new) = primaryKeyChanges[0] else { - return XCTFail("Expected modifyPrimaryKey") + Issue.record("Expected modifyPrimaryKey") + return } - XCTAssertEqual(old, ["id"]) - XCTAssertEqual(new, ["id", "tenant"]) + #expect(old == ["id"]) + #expect(new == ["id", "tenant"]) } - func testPrimaryKeyMembershipAloneDoesNotAlsoEmitModifyColumn() { + @Test + func primaryKeyMembershipAloneDoesNotAlsoEmitModifyColumn() { let source = table("users", columns: [column("id", primaryKey: true)]) let target = table("users", columns: [column("id", primaryKey: false)]) @@ -354,12 +373,13 @@ final class StructureDiffEngineTests: XCTestCase { if case .modifyColumn = $0 { return true } return false } - XCTAssertTrue(columnChanges.isEmpty, "primary key membership is reported once, via modifyPrimaryKey") + #expect(columnChanges.isEmpty, "primary key membership is reported once, via modifyPrimaryKey") } // MARK: - Symmetry - func testComparisonIsSymmetricInTheObjectsItReports() { + @Test + func comparisonIsSymmetricInTheObjectsItReports() { let engine = StructureDiffEngine() let left = [table("a", columns: [column("id")]), table("b", columns: [column("id")])] let right = [table("b", columns: [column("id")]), table("c", columns: [column("id")])] @@ -367,8 +387,8 @@ final class StructureDiffEngineTests: XCTestCase { let forward = engine.compare(source: left, target: right) let backward = engine.compare(source: right, target: left) - XCTAssertEqual(forward.count(of: .onlyInSource), backward.count(of: .onlyInTarget)) - XCTAssertEqual(forward.count(of: .onlyInTarget), backward.count(of: .onlyInSource)) - XCTAssertEqual(forward.count(of: .identical), backward.count(of: .identical)) + #expect(forward.count(of: .onlyInSource) == backward.count(of: .onlyInTarget)) + #expect(forward.count(of: .onlyInTarget) == backward.count(of: .onlyInSource)) + #expect(forward.count(of: .identical) == backward.count(of: .identical)) } } diff --git a/TableProTests/Core/Compare/TableDefinitionRendererTests.swift b/TableProTests/Core/Compare/TableDefinitionRendererTests.swift index 66a1f394ad..f38eae3ce5 100644 --- a/TableProTests/Core/Compare/TableDefinitionRendererTests.swift +++ b/TableProTests/Core/Compare/TableDefinitionRendererTests.swift @@ -95,20 +95,17 @@ final class TableDefinitionRendererTests: XCTestCase { @MainActor final class CompareSyncProfileStorageTests: XCTestCase { - private var defaults: UserDefaults! + private var suite: EphemeralDefaults! private var storage: CompareSyncProfileStorage! - private let suiteName = "CompareSyncProfileStorageTests" - override func setUp() { - super.setUp() - UserDefaults().removePersistentDomain(forName: suiteName) - defaults = UserDefaults(suiteName: suiteName) - storage = CompareSyncProfileStorage(defaults: defaults) + override func setUpWithError() throws { + try super.setUpWithError() + suite = try EphemeralDefaults() + storage = CompareSyncProfileStorage(defaults: suite.defaults) } override func tearDown() { - UserDefaults().removePersistentDomain(forName: suiteName) - defaults = nil + suite = nil storage = nil super.tearDown() } diff --git a/TableProTests/Core/Compare/TimestampValueTests.swift b/TableProTests/Core/Compare/TimestampValueTests.swift index 08ddb36fd9..f01b00c1ce 100644 --- a/TableProTests/Core/Compare/TimestampValueTests.swift +++ b/TableProTests/Core/Compare/TimestampValueTests.swift @@ -9,108 +9,122 @@ // exact-integer range and puts the same class of precision loss back into the fixed path. // +import Foundation @testable import TablePro -import XCTest +import Testing -final class TimestampValueTests: XCTestCase { +struct TimestampValueTests { private func value(_ text: String) throws -> TimestampValue { - try XCTUnwrap(TimestampValue.parse(text), "\(text) should parse as a timestamp") + try #require(TimestampValue.parse(text), "\(text) should parse as a timestamp") } // MARK: - Sub-second precision - func testMicrosecondDifferenceIsNotIdenticalAtSixDigits() throws { + @Test + func microsecondDifferenceIsNotIdenticalAtSixDigits() throws { let earlier = try value("2024-01-01 10:00:00.123456") let later = try value("2024-01-01 10:00:00.123457") - XCTAssertFalse(earlier.equals(later, fractionalDigits: 6)) + #expect(!earlier.equals(later, fractionalDigits: 6)) } - func testMicrosecondDifferenceIsIdenticalAtThreeDigits() throws { + @Test + func microsecondDifferenceIsIdenticalAtThreeDigits() throws { let earlier = try value("2024-01-01 10:00:00.123456") let later = try value("2024-01-01 10:00:00.123999") - XCTAssertTrue(earlier.equals(later, fractionalDigits: 3)) + #expect(earlier.equals(later, fractionalDigits: 3)) } - func testNanosecondDifferenceIsNotIdenticalAtNineDigits() throws { + @Test + func nanosecondDifferenceIsNotIdenticalAtNineDigits() throws { let earlier = try value("2024-01-01 10:00:00.123456789") let later = try value("2024-01-01 10:00:00.123456790") - XCTAssertFalse(earlier.equals(later, fractionalDigits: 9)) - XCTAssertTrue(earlier.equals(later, fractionalDigits: 6)) + #expect(!earlier.equals(later, fractionalDigits: 9)) + #expect(earlier.equals(later, fractionalDigits: 6)) } - func testWholeSecondsCompareEqualAtEveryPrecision() throws { + @Test + func wholeSecondsCompareEqualAtEveryPrecision() throws { let first = try value("2024-01-01 10:00:00") let second = try value("2024-01-01T10:00:00") for digits in 0 ... 9 { - XCTAssertTrue(first.equals(second, fractionalDigits: digits)) + #expect(first.equals(second, fractionalDigits: digits)) } } - func testFractionalDigitsAreReadExactlyRatherThanRounded() throws { + @Test + func fractionalDigitsAreReadExactlyRatherThanRounded() throws { let value = try value("2024-01-01 00:00:00.5") - XCTAssertEqual(value.nanosecondsSinceEpoch % 1_000_000_000, 500_000_000) + #expect(value.nanosecondsSinceEpoch % 1_000_000_000 == 500_000_000) } - func testMoreThanNineFractionalDigitsAreTruncatedNotMisread() throws { + @Test + func moreThanNineFractionalDigitsAreTruncatedNotMisread() throws { let value = try value("2024-01-01 00:00:00.1234567891234") - XCTAssertEqual(value.nanosecondsSinceEpoch % 1_000_000_000, 123_456_789) + #expect(value.nanosecondsSinceEpoch % 1_000_000_000 == 123_456_789) } // MARK: - Offsets /// The same instant written at two offsets is one instant, so it is never a difference. - func testEquivalentInstantsAtDifferentOffsetsCompareEqual() throws { + @Test + func equivalentInstantsAtDifferentOffsetsCompareEqual() throws { let utc = try value("2024-01-01 10:00:00+00:00") let offset = try value("2024-01-01 15:30:00+05:30") - XCTAssertTrue(utc.equals(offset, fractionalDigits: 6)) + #expect(utc.equals(offset, fractionalDigits: 6)) } - func testOffsetWithFractionalSecondsParses() throws { + @Test + func offsetWithFractionalSecondsParses() throws { let utc = try value("2024-01-01 10:00:00.250000+00:00") - XCTAssertEqual(utc.nanosecondsSinceEpoch % 1_000_000_000, 250_000_000) + #expect(utc.nanosecondsSinceEpoch % 1_000_000_000 == 250_000_000) } // MARK: - Non-timestamps - func testShortStringsAndPlainNumbersAreNotTimestamps() { + @Test + func shortStringsAndPlainNumbersAreNotTimestamps() { for text in ["", "abc", "12", "2024"] { - XCTAssertNil(TimestampValue.parse(text), "\(text) should not parse as a timestamp") + #expect(TimestampValue.parse(text) == nil, "\(text) should not parse as a timestamp") } } - func testDateOnlyParses() throws { - XCTAssertNoThrow(try value("2024-01-01")) + @Test + func dateOnlyParses() throws { + #expect(throws: Never.self) { try value("2024-01-01") } } } -final class FractionalSecondTests: XCTestCase { - func testFractionIsSplitOffAndScaledToNanoseconds() { +struct FractionalSecondTests { + @Test + func fractionIsSplitOffAndScaledToNanoseconds() { let split = FractionalSecond.split(from: "2024-01-01 10:00:00.123456") - XCTAssertEqual(split.withoutFraction, "2024-01-01 10:00:00") - XCTAssertEqual(split.nanoseconds, 123_456_000) + #expect(split.withoutFraction == "2024-01-01 10:00:00") + #expect(split.nanoseconds == 123_456_000) } /// A `+05:30` offset holds a colon, not a dot, so it must survive untouched. - func testOffsetSurvivesTheSplit() { + @Test + func offsetSurvivesTheSplit() { let split = FractionalSecond.split(from: "2024-01-01 10:00:00.5+05:30") - XCTAssertEqual(split.withoutFraction, "2024-01-01 10:00:00+05:30") - XCTAssertEqual(split.nanoseconds, 500_000_000) + #expect(split.withoutFraction == "2024-01-01 10:00:00+05:30") + #expect(split.nanoseconds == 500_000_000) } - func testTextWithNoFractionIsUnchanged() { + @Test + func textWithNoFractionIsUnchanged() { let split = FractionalSecond.split(from: "2024-01-01 10:00:00") - XCTAssertEqual(split.withoutFraction, "2024-01-01 10:00:00") - XCTAssertEqual(split.nanoseconds, 0) + #expect(split.withoutFraction == "2024-01-01 10:00:00") + #expect(split.nanoseconds == 0) } } diff --git a/TableProTests/Core/Concurrency/OnceTaskTests.swift b/TableProTests/Core/Concurrency/OnceTaskTests.swift index 5ae856290b..b9fa937c8d 100644 --- a/TableProTests/Core/Concurrency/OnceTaskTests.swift +++ b/TableProTests/Core/Concurrency/OnceTaskTests.swift @@ -1,14 +1,9 @@ -// -// OnceTaskTests.swift -// TableProTests -// - import Foundation -import TableProPluginKit @testable import TablePro -import XCTest +import TableProTestSupport +import Testing -final class OnceTaskTests: XCTestCase { +struct OnceTaskTests { actor Counter { private(set) var value: Int = 0 @@ -21,66 +16,49 @@ final class OnceTaskTests: XCTestCase { let tag: String } - /// Work that ignores cancellation, standing in for a driver blocked in a C call. - actor Gate { - private var continuation: CheckedContinuation? - private var isOpen = false - - func wait() async { - guard !isOpen else { return } - await withCheckedContinuation { continuation in - self.continuation = continuation - } - } - - func open() { - isOpen = true - continuation?.resume() - continuation = nil - } - } - - func testRerunDoesNotAdoptAbandonedNonCooperativeWork() async throws { + @Test + func rerunDoesNotAdoptAbandonedNonCooperativeWork() async throws { let dedup = OnceTask() - let gate = Gate() - let started = expectation(description: "abandoned work started") - started.assertForOverFulfill = false + let started = AsyncGate() + // Ignores cancellation, standing in for a driver blocked in a C call. + let release = AsyncGate() let abandoned = Task { try await dedup.execute(key: "k") { - started.fulfill() - await gate.wait() + await started.open() + await release.wait() return 1 } } - await fulfillment(of: [started], timeout: 2.0) + await started.wait() await dedup.cancel(key: "k") let rerunValue = try await dedup.execute(key: "k") { 2 } - XCTAssertEqual(rerunValue, 2, "A rerun must run fresh work, not adopt the abandoned attempt") + #expect(rerunValue == 2, "A rerun must run fresh work, not adopt the abandoned attempt") - await gate.open() + await release.open() let abandonedValue = try await abandoned.value - XCTAssertEqual(abandonedValue, 1, "The abandoned attempt still completes; its result belongs to no one") + #expect(abandonedValue == 1, "The abandoned attempt still completes; its result belongs to no one") } - func testConcurrentSameKeyRunsWorkOnce() async throws { + @Test + func concurrentSameKeyRunsWorkOnce() async throws { let dedup = OnceTask() let counter = Counter() - let firstRegistered = expectation(description: "first work started") - firstRegistered.assertForOverFulfill = false + let firstRegistered = AsyncGate() let firstTask = Task { try await dedup.execute(key: "k") { await counter.increment() - firstRegistered.fulfill() + await firstRegistered.open() + // Keeps the first call in flight while the second arrives; nothing marks that arrival. try await Task.sleep(for: .milliseconds(200)) return 42 } } - await fulfillment(of: [firstRegistered], timeout: 2.0) + await firstRegistered.wait() let second = try await dedup.execute(key: "k") { await counter.increment() @@ -89,23 +67,29 @@ final class OnceTaskTests: XCTestCase { let first = try await firstTask.value let invocations = await counter.value - XCTAssertEqual(invocations, 1, "Work block must run exactly once for concurrent same-key callers") - XCTAssertEqual(first, second, "Concurrent callers must observe the same value") - XCTAssertEqual(first, 42, "Both callers must receive the value produced by the first work block") + #expect(invocations == 1, "Work block must run exactly once for concurrent same-key callers") + #expect(first == second, "Concurrent callers must observe the same value") + #expect(first == 42, "Both callers must receive the value produced by the first work block") } - func testConcurrentDifferentKeysRunWorkSeparately() async throws { + /// Each block finishes only once the other has started, so both are in flight together. + @Test(.timeLimit(.minutes(1))) + func concurrentDifferentKeysRunWorkSeparately() async throws { let dedup = OnceTask() let counter = Counter() + let alphaStarted = AsyncGate() + let betaStarted = AsyncGate() async let alpha = dedup.execute(key: "alpha") { await counter.increment() - try await Task.sleep(for: .milliseconds(20)) + await alphaStarted.open() + await betaStarted.wait() return "alpha-value" } async let beta = dedup.execute(key: "beta") { await counter.increment() - try await Task.sleep(for: .milliseconds(20)) + await betaStarted.open() + await alphaStarted.wait() return "beta-value" } @@ -113,12 +97,13 @@ final class OnceTaskTests: XCTestCase { let betaValue = try await beta let invocations = await counter.value - XCTAssertEqual(invocations, 2, "Distinct keys must each run their own work block") - XCTAssertEqual(alphaValue, "alpha-value") - XCTAssertEqual(betaValue, "beta-value") + #expect(invocations == 2, "Distinct keys must each run their own work block") + #expect(alphaValue == "alpha-value") + #expect(betaValue == "beta-value") } - func testThrowingWorkPropagatesAndClearsInFlight() async throws { + @Test + func throwingWorkPropagatesAndClearsInFlight() async throws { let dedup = OnceTask() let counter = Counter() @@ -127,9 +112,9 @@ final class OnceTaskTests: XCTestCase { await counter.increment() throw TestError(tag: "first") } - XCTFail("Expected throw from first execute") + Issue.record("Expected throw from first execute") } catch let error as TestError { - XCTAssertEqual(error.tag, "first") + #expect(error.tag == "first") } let secondValue = try await dedup.execute(key: "k") { @@ -137,36 +122,32 @@ final class OnceTaskTests: XCTestCase { return 7 } - XCTAssertEqual(secondValue, 7, "After a throw, the next execute must rerun the work") + #expect(secondValue == 7, "After a throw, the next execute must rerun the work") let invocations = await counter.value - XCTAssertEqual(invocations, 2, "Both work blocks must have run (throw cleared the in-flight slot)") + #expect(invocations == 2, "Both work blocks must have run (throw cleared the in-flight slot)") } - func testCancelKeyClearsInFlightAndAllowsRerun() async throws { + @Test + func cancelKeyClearsInFlightAndAllowsRerun() async throws { let dedup = OnceTask() let counter = Counter() - let started = expectation(description: "work started") - started.assertForOverFulfill = false + let started = AsyncGate() let inFlight = Task { try await dedup.execute(key: "k") { await counter.increment() - started.fulfill() + await started.open() + // Parks until the cancel ends the sleep. try await Task.sleep(for: .seconds(5)) return 1 } } - await fulfillment(of: [started], timeout: 2.0) + await started.wait() await dedup.cancel(key: "k") - do { - _ = try await inFlight.value - XCTFail("Expected CancellationError from cancelled in-flight call") - } catch is CancellationError { - // expected - } catch { - XCTFail("Expected CancellationError, got \(error)") + await #expect(throws: CancellationError.self, "Expected CancellationError from cancelled in-flight call") { + try await inFlight.value } let rerunValue = try await dedup.execute(key: "k") { @@ -174,12 +155,13 @@ final class OnceTaskTests: XCTestCase { return 11 } - XCTAssertEqual(rerunValue, 11, "After cancel, a fresh execute must run the work again") + #expect(rerunValue == 11, "After cancel, a fresh execute must run the work again") let invocations = await counter.value - XCTAssertEqual(invocations, 2) + #expect(invocations == 2) } - func testSequentialSameKeyRunsWorkAgain() async throws { + @Test + func sequentialSameKeyRunsWorkAgain() async throws { let dedup = OnceTask() let counter = Counter() @@ -192,45 +174,40 @@ final class OnceTaskTests: XCTestCase { return 2 } - XCTAssertEqual(first, 1) - XCTAssertEqual(second, 2) + #expect(first == 1) + #expect(second == 2) let invocations = await counter.value - XCTAssertEqual(invocations, 2, "Sequential calls (after first completes) must each run the work") + #expect(invocations == 2, "Sequential calls (after first completes) must each run the work") } - func testCancelAllCancelsEveryInFlight() async throws { + @Test + func cancelAllCancelsEveryInFlight() async throws { let dedup = OnceTask() - let firstStarted = expectation(description: "first started") - let secondStarted = expectation(description: "second started") - firstStarted.assertForOverFulfill = false - secondStarted.assertForOverFulfill = false + let firstStarted = AsyncGate() + let secondStarted = AsyncGate() let firstTask = Task { try await dedup.execute(key: "a") { - firstStarted.fulfill() + await firstStarted.open() try await Task.sleep(for: .seconds(5)) return 1 } } let secondTask = Task { try await dedup.execute(key: "b") { - secondStarted.fulfill() + await secondStarted.open() try await Task.sleep(for: .seconds(5)) return 2 } } - await fulfillment(of: [firstStarted, secondStarted], timeout: 2.0) + await firstStarted.wait() + await secondStarted.wait() await dedup.cancelAll() for task in [firstTask, secondTask] { - do { - _ = try await task.value - XCTFail("Expected CancellationError from cancelAll") - } catch is CancellationError { - // expected - } catch { - XCTFail("Expected CancellationError, got \(error)") + await #expect(throws: CancellationError.self, "Expected CancellationError from cancelAll") { + try await task.value } } } diff --git a/TableProTests/Core/Concurrency/SessionDriverGateTests.swift b/TableProTests/Core/Concurrency/SessionDriverGateTests.swift index b65eb7a3fc..a624622796 100644 --- a/TableProTests/Core/Concurrency/SessionDriverGateTests.swift +++ b/TableProTests/Core/Concurrency/SessionDriverGateTests.swift @@ -12,6 +12,7 @@ import Foundation @testable import TablePro import TableProPluginKit +import TableProTestSupport import Testing /// One-shot rendezvous so a test can wait for work to reach a known point instead @@ -55,21 +56,16 @@ private final class BoolBox { private struct GateBodyError: Error, Equatable {} -/// Lets queued work reach its next suspension point without sleeping. +/// The main queue runs its jobs in order, so a job queued now runs after every caller the case +/// started has reached its next suspension point. @MainActor -private func drainMainActor(_ times: Int = 8) async { - for _ in 0..= count } } @MainActor @@ -97,7 +93,7 @@ struct SessionDriverGateTests { log.record("second-start") } } - await drainMainActor() + await runQueuedMainActorWork() #expect(log.events == ["first-start"]) @@ -175,7 +171,7 @@ struct SessionDriverGateTests { log.record("cancelled-waiter-ran") } } - await drainMainActor() + await runQueuedMainActorWork() waiter.cancel() await #expect(throws: CancellationError.self) { @@ -214,7 +210,7 @@ struct SessionDriverGateTests { log.record("drained-waiter-ran") } } - await drainMainActor() + await runQueuedMainActorWork() gate.drain(connectionId: connectionId) @@ -263,7 +259,7 @@ struct SessionDriverGateTests { log.record("queued-start") } } - await waitForWaiters(1, on: gate, connectionId) + try await waitForWaiters(1, on: gate, connectionId) #expect(gate.waiterCount(for: connectionId) == 1) releaseDrained.signal() @@ -316,7 +312,7 @@ struct SessionDriverGateTests { log.record("arriving-start") } } - await waitForWaiters(1, on: gate, connectionId) + try await waitForWaiters(1, on: gate, connectionId) #expect(gate.waiterCount(for: connectionId) == 1) #expect(log.events == ["later-start"]) @@ -352,7 +348,7 @@ struct SessionDriverGateTests { log.record("handed-off-ran") } } - await waitForWaiters(1, on: gate, connectionId) + try await waitForWaiters(1, on: gate, connectionId) #expect(gate.waiterCount(for: connectionId) == 1) releaseHolder.signal() diff --git a/TableProTests/Core/Concurrency/TaskCancellationShieldTests.swift b/TableProTests/Core/Concurrency/TaskCancellationShieldTests.swift index 3fa6d3b7d3..cda3edb24c 100644 --- a/TableProTests/Core/Concurrency/TaskCancellationShieldTests.swift +++ b/TableProTests/Core/Concurrency/TaskCancellationShieldTests.swift @@ -1,10 +1,6 @@ -// -// TaskCancellationShieldTests.swift -// TableProTests -// - import Foundation @testable import TablePro +import TableProTestSupport import Testing struct TaskCancellationShieldTests { @@ -14,24 +10,28 @@ struct TaskCancellationShieldTests { @Test("Work inside the shield never sees the cancellation of the task awaiting it") func shieldedWorkNeverSeesCancellation() async { let observed = Observation() + let started = AsyncGate() + let cancelIssued = AsyncGate() let task = Task { try await TaskCancellationShield.run { await withTaskCancellationHandler { - await observed.record(cancelled: Task.isCancelled) - try? await Task.sleep(for: .milliseconds(30)) - await observed.record(cancelledAtEnd: Task.isCancelled) + observed.record(cancelled: Task.isCancelled) + await started.open() + await cancelIssued.wait() + observed.record(cancelledAtEnd: Task.isCancelled) } onCancel: { - Task { await observed.recordHandlerFired() } + observed.recordHandlerFired() } } } - try? await Task.sleep(for: .milliseconds(5)) + await started.wait() task.cancel() + await cancelIssued.open() try? await task.value - #expect(await observed.sawCancellationAtStart == false) - #expect(await observed.sawCancellationAtEnd == false) - #expect(await observed.handlerFired == false) + #expect(observed.sawCancellationAtStart == false) + #expect(observed.sawCancellationAtEnd == false) + #expect(observed.handlerFired == false) } /// The same work without the shield, so the test proves the shield is what makes the @@ -39,32 +39,36 @@ struct TaskCancellationShieldTests { @Test("The same work run structurally does see it") func structuredWorkSeesCancellation() async { let observed = Observation() + let started = AsyncGate() let task = Task { await withTaskCancellationHandler { - try? await Task.sleep(for: .milliseconds(30)) - await observed.record(cancelledAtEnd: Task.isCancelled) + await started.open() + // Parks until the cancel arrives, which ends the sleep at once. + try? await Task.sleep(for: .seconds(60)) + observed.record(cancelledAtEnd: Task.isCancelled) } onCancel: { - Task { await observed.recordHandlerFired() } + observed.recordHandlerFired() } } - try? await Task.sleep(for: .milliseconds(5)) + await started.wait() task.cancel() await task.value - try? await Task.sleep(for: .milliseconds(20)) - #expect(await observed.sawCancellationAtEnd) - #expect(await observed.handlerFired) + #expect(observed.sawCancellationAtEnd) + #expect(observed.handlerFired) } @Test("A cancelled caller still gets the value the shielded work produced") func shieldedWorkStillReturnsItsValue() async throws { + let cancelIssued = AsyncGate() let task = Task { () -> Int in try await TaskCancellationShield.run { - try? await Task.sleep(for: .milliseconds(20)) + await cancelIssued.wait() return 42 } } task.cancel() + await cancelIssued.open() #expect(try await task.value == 42) } @@ -80,20 +84,27 @@ private enum ProbeError: Error { case refused } -private actor Observation { - private(set) var sawCancellationAtStart = false - private(set) var sawCancellationAtEnd = false - private(set) var handlerFired = false +/// A cancellation handler runs synchronously inside `cancel()`, so it records under a lock rather +/// than through a task that could land after the assertions. +private final class Observation: @unchecked Sendable { + private let lock = NSLock() + private var cancelledAtStart = false + private var cancelledAtEnd = false + private var handlerRan = false + + var sawCancellationAtStart: Bool { lock.withLock { cancelledAtStart } } + var sawCancellationAtEnd: Bool { lock.withLock { cancelledAtEnd } } + var handlerFired: Bool { lock.withLock { handlerRan } } func record(cancelled: Bool) { - sawCancellationAtStart = cancelled + lock.withLock { cancelledAtStart = cancelled } } func record(cancelledAtEnd: Bool) { - sawCancellationAtEnd = cancelledAtEnd + lock.withLock { self.cancelledAtEnd = cancelledAtEnd } } func recordHandlerFired() { - handlerFired = true + lock.withLock { handlerRan = true } } } diff --git a/TableProTests/Core/ConnectionLibrary/ConnectionLibraryStorageTests.swift b/TableProTests/Core/ConnectionLibrary/ConnectionLibraryStorageTests.swift index 3a7136fa13..7b8c1cb1a3 100644 --- a/TableProTests/Core/ConnectionLibrary/ConnectionLibraryStorageTests.swift +++ b/TableProTests/Core/ConnectionLibrary/ConnectionLibraryStorageTests.swift @@ -12,7 +12,8 @@ import Testing @MainActor struct ConnectionLibraryStorageTests { - private let defaults: UserDefaults + private let suite: EphemeralDefaults + private var defaults: UserDefaults { suite.defaults } private let fileURL: URL private let appEvents: AppEvents private let storage: ConnectionStorage @@ -23,7 +24,8 @@ struct ConnectionLibraryStorageTests { init() throws { let unique = UUID().uuidString - let suiteDefaults = try #require(UserDefaults(suiteName: "com.TablePro.tests.ConnectionLibraryStorage.\(unique)")) + let ephemeral = try EphemeralDefaults() + let suiteDefaults = ephemeral.defaults let tracker = SyncChangeTracker(metadataStorage: SyncMetadataStorage(userDefaults: suiteDefaults)) let storeURL = FileManager.default.temporaryDirectory .appendingPathComponent("tablepro-tests") @@ -42,7 +44,7 @@ struct ConnectionLibraryStorageTests { integrity: storeIntegrity ) integrity = storeIntegrity - defaults = suiteDefaults + suite = ephemeral fileURL = storeURL appEvents = events storage = connectionStorage diff --git a/TableProTests/Core/ConnectionLibrary/ConnectionListPreferencesTests.swift b/TableProTests/Core/ConnectionLibrary/ConnectionListPreferencesTests.swift index 012e1a2d19..1164afb2c4 100644 --- a/TableProTests/Core/ConnectionLibrary/ConnectionListPreferencesTests.swift +++ b/TableProTests/Core/ConnectionLibrary/ConnectionListPreferencesTests.swift @@ -10,11 +10,11 @@ import Testing @MainActor internal struct ConnectionListPreferencesTests { - private let suiteName = "com.TablePro.tests.ConnectionListPreferences.\(UUID().uuidString)" - private let defaults: UserDefaults + private let suite: EphemeralDefaults + private var defaults: UserDefaults { suite.defaults } internal init() throws { - defaults = try #require(UserDefaults(suiteName: suiteName)) + suite = try EphemeralDefaults() } private func makePreferences() -> ConnectionListPreferences { @@ -23,14 +23,12 @@ internal struct ConnectionListPreferencesTests { @Test("Recent is shown until the user hides it") internal func showsRecentByDefault() { - defer { defaults.removePersistentDomain(forName: suiteName) } #expect(makePreferences().showsRecent) } @Test("Hiding Recent survives a relaunch on this Mac") internal func hidingRecentPersists() { - defer { defaults.removePersistentDomain(forName: suiteName) } makePreferences().setShowsRecent(false) #expect(!makePreferences().showsRecent) @@ -38,7 +36,6 @@ internal struct ConnectionListPreferencesTests { @Test("Resetting shows Recent again and forgets the stored choice") internal func resetRestoresTheDefault() { - defer { defaults.removePersistentDomain(forName: suiteName) } let preferences = makePreferences() preferences.setShowsRecent(false) @@ -51,7 +48,6 @@ internal struct ConnectionListPreferencesTests { @Test("Only a real change is published") internal func publishesOnlyChanges() { - defer { defaults.removePersistentDomain(forName: suiteName) } let preferences = makePreferences() var received: [Bool] = [] let cancellable = preferences.$showsRecent.dropFirst().sink { received.append($0) } diff --git a/TableProTests/Core/ConnectionLibrary/RecentConnectionsRecorderTests.swift b/TableProTests/Core/ConnectionLibrary/RecentConnectionsRecorderTests.swift index 2b856182d8..e6cdecc122 100644 --- a/TableProTests/Core/ConnectionLibrary/RecentConnectionsRecorderTests.swift +++ b/TableProTests/Core/ConnectionLibrary/RecentConnectionsRecorderTests.swift @@ -11,7 +11,8 @@ import Testing @MainActor struct RecentConnectionsRecorderTests { - private let defaults: UserDefaults + private let suite: EphemeralDefaults + private var defaults: UserDefaults { suite.defaults } private let appEvents = AppEvents() private let storage: ConnectionStorage private let store: RecentConnectionsStore @@ -19,7 +20,8 @@ struct RecentConnectionsRecorderTests { init() throws { let unique = UUID().uuidString - defaults = try #require(UserDefaults(suiteName: "com.TablePro.tests.RecentConnections.\(unique)")) + suite = try EphemeralDefaults() + let defaults = suite.defaults let fileURL = FileManager.default.temporaryDirectory .appendingPathComponent("tablepro-tests") .appendingPathComponent("recent-connections_\(unique).json") diff --git a/TableProTests/Core/Coordinators/BrowseSearchExactCountTests.swift b/TableProTests/Core/Coordinators/BrowseSearchExactCountTests.swift index cc8acc3c69..cc44600920 100644 --- a/TableProTests/Core/Coordinators/BrowseSearchExactCountTests.swift +++ b/TableProTests/Core/Coordinators/BrowseSearchExactCountTests.swift @@ -13,7 +13,7 @@ import TableProPluginKit import TableProRedisCore import Testing -private final class BrowseCountStubDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class BrowseCountStubDriver: PluginDriverStub, @unchecked Sendable { private let lock = NSLock() private var received: [[PluginQueryFilter]] = [] private let databaseSize: Int? @@ -43,22 +43,6 @@ private final class BrowseCountStubDriver: PluginDatabaseDriver, @unchecked Send func execute(query: String) async throws -> PluginQueryResult { PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) } - - func connect() async throws {} - func disconnect() {} - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } struct BrowseSearchExactCountTests { diff --git a/TableProTests/Core/Coordinators/ExactRowCounterTests.swift b/TableProTests/Core/Coordinators/ExactRowCounterTests.swift index b062e06bff..a5243fec8f 100644 --- a/TableProTests/Core/Coordinators/ExactRowCounterTests.swift +++ b/TableProTests/Core/Coordinators/ExactRowCounterTests.swift @@ -7,7 +7,7 @@ private enum CountStubError: Error { case refused } -private final class CountStubDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class CountStubDriver: PluginDriverStub, @unchecked Sendable { private let lock = NSLock() private let ownsQueryBuilding: Bool private let driverCount: Result @@ -53,22 +53,6 @@ private final class CountStubDriver: PluginDatabaseDriver, @unchecked Sendable { columns: ["count"], columnTypeNames: ["INT64"], rows: [[.text("7")]], rowsAffected: 0, executionTime: 0 ) } - - func connect() async throws {} - func disconnect() {} - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } struct ExactRowCounterTests { diff --git a/TableProTests/Core/Coordinators/FindMatcherTests.swift b/TableProTests/Core/Coordinators/FindMatcherTests.swift index fa5b6e686d..185f7af4ab 100644 --- a/TableProTests/Core/Coordinators/FindMatcherTests.swift +++ b/TableProTests/Core/Coordinators/FindMatcherTests.swift @@ -163,6 +163,35 @@ struct TabFindStateTests { #expect(value.currentMatchIndex == nil) #expect(value.currentMatch == nil) } + + /// The bar seeds its field from the selected tab's state, so the term has to stay with the tab it + /// was typed in. The other half of #2667, the bar's `.id(tab.id)`, is view identity. + @Test("each tab keeps its own find term across a switch") + @MainActor + func termStaysWithItsTab() { + let tabManager = QueryTabManager() + let coordinator = MainContentCoordinator( + connection: TestFixtures.makeConnection(), + tabManager: tabManager, + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + defer { coordinator.teardown() } + let first = QueryTab(title: "Album", query: "", tabType: .table) + let second = QueryTab(title: "Artist", query: "", tabType: .table) + tabManager.tabs = [first, second] + + tabManager.selectedTabId = first.id + coordinator.findCoordinator.show() + coordinator.findCoordinator.setTerm("alice") + tabManager.selectedTabId = second.id + coordinator.findCoordinator.show() + coordinator.findCoordinator.setTerm("berlin") + tabManager.selectedTabId = first.id + + #expect(coordinator.findCoordinator.selectedTabFindState?.term == "alice") + #expect(tabManager.tabs.first { $0.id == second.id }?.findState.term == "berlin") + } } /// The find bar renders only on table tabs, and a table tab pages through `currentPage`, never diff --git a/TableProTests/Core/Coordinators/RowEditingCoordinatorCopyTests.swift b/TableProTests/Core/Coordinators/RowEditingCoordinatorCopyTests.swift index ad83f322f1..fa9a443225 100644 --- a/TableProTests/Core/Coordinators/RowEditingCoordinatorCopyTests.swift +++ b/TableProTests/Core/Coordinators/RowEditingCoordinatorCopyTests.swift @@ -11,237 +11,197 @@ import Testing @testable import TablePro -@MainActor -private final class RowEditingCopyClipboard: ClipboardProvider { - var text: String? - var hasGridRowsValue = false - - func readText() -> String? { text } - func readGridRows() -> GridRowsClipboardPayload? { nil } - func writeText(_ text: String) { self.text = text; hasGridRowsValue = false } - func writeCsv(_ csv: String) { text = csv; hasGridRowsValue = false } - - var copiedImages: [NSImage] = [] - - func writeImage(_ image: NSImage) { - copiedImages.append(image) - } - func writeRows(tsv: String, html: String?, gridRows: GridRowsClipboardPayload) { text = tsv; hasGridRowsValue = true } - var hasText: Bool { text != nil } - var hasGridRows: Bool { hasGridRowsValue } -} - -@MainActor -private final class RowEditingCopyLayoutPersister: ColumnLayoutPersisting { - func load(for key: ColumnLayoutTableKey) -> ColumnLayoutState? { nil } - func save(_ layout: ColumnLayoutState, for key: ColumnLayoutTableKey) {} - func clear(for key: ColumnLayoutTableKey) {} -} +extension GlobalStateSuites { + @Suite(.recordingClipboard) + @MainActor + struct RowEditingCoordinatorCopyTests { + private let fixture = CoordinatorFixture() + + private func makeCoordinator(tableRows: TableRows? = nil) -> MainContentCoordinator { + var tab = QueryTab(title: "Q1", query: "SELECT id, name FROM users", tabType: .query) + tab.execution.lastExecutedAt = Date() + fixture.addTab( + tab, + rows: tableRows ?? TableRows.from( + queryRows: [ + [.text("1"), .text("Alice")], + [.text("2"), .text("Bob")] + ], + columns: ["id", "name"], + columnTypes: [.text(rawType: nil), .text(rawType: nil)] + ) + ) + return fixture.coordinator + } -@MainActor -struct RowEditingCoordinatorCopyTests { - private func makeCoordinator(tableRows: TableRows? = nil) -> MainContentCoordinator { - let tabManager = QueryTabManager() - let coordinator = MainContentCoordinator( - connection: TestFixtures.makeConnection(), - tabManager: tabManager, - changeManager: DataChangeManager(), - toolbarState: ConnectionToolbarState() - ) - var tab = QueryTab(title: "Q1", query: "SELECT id, name FROM users", tabType: .query) - tab.execution.lastExecutedAt = Date() - tabManager.tabs.append(tab) - tabManager.selectedTabId = tab.id - - coordinator.setActiveTableRows( - tableRows ?? TableRows.from( - queryRows: [ - [.text("1"), .text("Alice")], - [.text("2"), .text("Bob")] - ], - columns: ["id", "name"], - columnTypes: [.text(rawType: nil), .text(rawType: nil)] - ), - for: tab.id - ) - return coordinator - } + /// Narrows the tab to the rows whose `name` is one of `names`, which is what a value filter + /// does and the only thing in the shipping app that makes display order differ from storage + /// order. No grid is attached, so this also covers the readers that run with none. + private func applyNameFilter(_ names: Set, to coordinator: MainContentCoordinator) { + guard let tabId = coordinator.tabManager.selectedTab?.id else { return } + var state = GridValueFilterState() + state.set( + ColumnValueFilter(selectedValues: names, includesNull: false), + columnName: "name", + forColumn: 1 + ) + coordinator.setValueFilter(state, forTab: tabId) + } - /// Narrows the tab to the rows whose `name` is one of `names`, which is what a value filter - /// does and the only thing in the shipping app that makes display order differ from storage - /// order. No grid is attached, so this also covers the readers that run with none. - private func applyNameFilter(_ names: Set, to coordinator: MainContentCoordinator) { - guard let tabId = coordinator.tabManager.selectedTab?.id else { return } - var state = GridValueFilterState() - state.set( - ColumnValueFilter(selectedValues: names, includesNull: false), - columnName: "name", - forColumn: 1 - ) - coordinator.setValueFilter(state, forTab: tabId) - } + @Test("Copy as JSON resolves display positions through the value-filtered order") + func copyAsJsonResolvesDisplayOrder() throws { + let clipboard = try #require(RecordingClipboard.current) - @Test("Copy as JSON resolves display positions through the value-filtered order") - func copyAsJsonResolvesDisplayOrder() { - let clipboard = RowEditingCopyClipboard() - ClipboardService.shared = clipboard - defer { ClipboardService.shared = NSPasteboardClipboardProvider() } + let coordinator = makeCoordinator() + applyNameFilter(["Bob"], to: coordinator) - let coordinator = makeCoordinator() - applyNameFilter(["Bob"], to: coordinator) + coordinator.copySelectedRowsAsJson(indices: [0]) - coordinator.copySelectedRowsAsJson(indices: [0]) + #expect(clipboard.text?.contains("Bob") == true) + #expect(clipboard.text?.contains("Alice") != true) + } - #expect(clipboard.text?.contains("Bob") == true) - #expect(clipboard.text?.contains("Alice") != true) - } + @Test("the display order survives with no grid mounted, which is what JSON mode reads") + func displayOrderResolvesWithNoGridAttached() { + let coordinator = makeCoordinator() + applyNameFilter(["Bob"], to: coordinator) - @Test("the display order survives with no grid mounted, which is what JSON mode reads") - func displayOrderResolvesWithNoGridAttached() { - let coordinator = makeCoordinator() - applyNameFilter(["Bob"], to: coordinator) + #expect(coordinator.dataTabDelegate == nil) + #expect(coordinator.activeGridDisplayIDs == [.existing(1)]) + } - #expect(coordinator.dataTabDelegate == nil) - #expect(coordinator.activeGridDisplayIDs == [.existing(1)]) - } + @Test("replacing the rows wholesale drops the value filter") + func newResultDropsTheValueFilter() { + let coordinator = makeCoordinator() + applyNameFilter(["Bob"], to: coordinator) + #expect(coordinator.activeGridDisplayIDs != nil) + + guard let tabId = coordinator.tabManager.selectedTab?.id else { + Issue.record("no selected tab") + return + } + coordinator.setActiveTableRows( + TableRows.from( + queryRows: [[.text("3"), .text("Carol")]], + columns: ["id", "name"], + columnTypes: [.text(rawType: nil), .text(rawType: nil)] + ), + for: tabId + ) + + #expect(coordinator.activeGridDisplayIDs == nil) + #expect(coordinator.tabManager.selectedTab?.valueFilter.isActive == false) + } - @Test("replacing the rows wholesale drops the value filter") - func newResultDropsTheValueFilter() { - let coordinator = makeCoordinator() - applyNameFilter(["Bob"], to: coordinator) - #expect(coordinator.activeGridDisplayIDs != nil) + /// Fetch All extends the same result rather than replacing it, so the filter stays and the + /// order is re-resolved over the rows that arrived. The memo keys on the registry's row-set + /// revision, so this holds with no grid mounted to recompute it. + @Test("loading more rows into the same result keeps the value filter and re-resolves the order") + func loadingMoreRowsKeepsTheValueFilter() { + let coordinator = makeCoordinator() + applyNameFilter(["Bob"], to: coordinator) + #expect(coordinator.activeGridDisplayIDs == [.existing(1)]) + + guard let tabId = coordinator.tabManager.selectedTab?.id else { + Issue.record("no selected tab") + return + } + coordinator.mutateActiveTableRows(for: tabId) { rows in + rows.replace(rows: [ + [.text("1"), .text("Alice")], + [.text("2"), .text("Bob")], + [.text("3"), .text("Bob")] + ]) + } - guard let tabId = coordinator.tabManager.selectedTab?.id else { - Issue.record("no selected tab") - return + #expect(coordinator.tabManager.selectedTab?.valueFilter.isActive == true) + #expect(coordinator.activeGridDisplayIDs == [.existing(1), .existing(2)]) } - coordinator.setActiveTableRows( - TableRows.from( - queryRows: [[.text("3"), .text("Carol")]], - columns: ["id", "name"], - columnTypes: [.text(rawType: nil), .text(rawType: nil)] - ), - for: tabId - ) - - #expect(coordinator.activeGridDisplayIDs == nil) - #expect(coordinator.tabManager.selectedTab?.valueFilter.isActive == false) - } - /// Fetch All extends the same result rather than replacing it, so the filter stays and the - /// order is re-resolved over the rows that arrived. The memo keys on the registry's row-set - /// revision, so this holds with no grid mounted to recompute it. - @Test("loading more rows into the same result keeps the value filter and re-resolves the order") - func loadingMoreRowsKeepsTheValueFilter() { - let coordinator = makeCoordinator() - applyNameFilter(["Bob"], to: coordinator) - #expect(coordinator.activeGridDisplayIDs == [.existing(1)]) - - guard let tabId = coordinator.tabManager.selectedTab?.id else { - Issue.record("no selected tab") - return - } - coordinator.mutateActiveTableRows(for: tabId) { rows in - rows.replace(rows: [ - [.text("1"), .text("Alice")], - [.text("2"), .text("Bob")], - [.text("3"), .text("Bob")] - ]) + /// The clear runs one line before `setActiveTableRows` drives the grid's full reload, and the + /// grid keeps its own mirror of the filter. Left un-adopted, the reload resolved the new rows + /// through the old filter and pruning it against the new columns wrote it back onto the tab. + @Test("a mounted grid does not resurrect the value filter a new result cleared") + func newResultClearsTheFilterOnTheMountedGridToo() { + let coordinator = makeCoordinator() + let delegate = DataTabGridDelegate() + let grid = TableViewCoordinator( + changeManager: AnyChangeManager(DataChangeManager()), + isEditable: true, + selectedRowIndices: .constant([]), + delegate: delegate, + layoutPersister: InMemoryColumnLayoutStore() + ) + delegate.dataGridAttach(tableViewCoordinator: grid) + coordinator.dataTabDelegate = delegate + + guard let tabId = coordinator.tabManager.selectedTab?.id else { + Issue.record("no selected tab") + return + } + grid.tableRowsProvider = { [weak coordinator] in + coordinator?.tabSessionRegistry.tableRows(for: tabId) ?? TableRows() + } + grid.valueFilterBinding = Binding( + get: { coordinator.tabManager.selectedTab?.valueFilter ?? GridValueFilterState() }, + set: { coordinator.setValueFilter($0, forTab: tabId) } + ) + applyNameFilter(["Bob"], to: coordinator) + grid.adoptValueFilter(coordinator.tabManager.selectedTab?.valueFilter ?? GridValueFilterState()) + #expect(grid.valueFilterState.isActive) + + coordinator.setActiveTableRows( + TableRows.from( + queryRows: [[.text("9"), .text("Zoe")]], + columns: ["id", "nickname"], + columnTypes: [.text(rawType: nil), .text(rawType: nil)] + ), + for: tabId + ) + + #expect(coordinator.tabManager.selectedTab?.valueFilter.isActive == false) + #expect(grid.valueFilterState.isActive == false) + #expect(coordinator.activeGridDisplayIDs == nil) + withExtendedLifetime((delegate, grid)) {} } - #expect(coordinator.tabManager.selectedTab?.valueFilter.isActive == true) - #expect(coordinator.activeGridDisplayIDs == [.existing(1), .existing(2)]) - } + @Test("Copy as JSON keeps storage order when no display mapping exists") + func copyAsJsonWithoutDisplayMapping() throws { + let clipboard = try #require(RecordingClipboard.current) - /// The clear runs one line before `setActiveTableRows` drives the grid's full reload, and the - /// grid keeps its own mirror of the filter. Left un-adopted, the reload resolved the new rows - /// through the old filter and pruning it against the new columns wrote it back onto the tab. - @Test("a mounted grid does not resurrect the value filter a new result cleared") - func newResultClearsTheFilterOnTheMountedGridToo() { - let coordinator = makeCoordinator() - let delegate = DataTabGridDelegate() - let grid = TableViewCoordinator( - changeManager: AnyChangeManager(DataChangeManager()), - isEditable: true, - selectedRowIndices: .constant([]), - delegate: delegate, - layoutPersister: RowEditingCopyLayoutPersister() - ) - delegate.dataGridAttach(tableViewCoordinator: grid) - coordinator.dataTabDelegate = delegate - - guard let tabId = coordinator.tabManager.selectedTab?.id else { - Issue.record("no selected tab") - return - } - grid.tableRowsProvider = { [weak coordinator] in - coordinator?.tabSessionRegistry.tableRows(for: tabId) ?? TableRows() - } - grid.valueFilterBinding = Binding( - get: { coordinator.tabManager.selectedTab?.valueFilter ?? GridValueFilterState() }, - set: { coordinator.setValueFilter($0, forTab: tabId) } - ) - applyNameFilter(["Bob"], to: coordinator) - grid.adoptValueFilter(coordinator.tabManager.selectedTab?.valueFilter ?? GridValueFilterState()) - #expect(grid.valueFilterState.isActive) - - coordinator.setActiveTableRows( - TableRows.from( - queryRows: [[.text("9"), .text("Zoe")]], - columns: ["id", "nickname"], - columnTypes: [.text(rawType: nil), .text(rawType: nil)] - ), - for: tabId - ) - - #expect(coordinator.tabManager.selectedTab?.valueFilter.isActive == false) - #expect(grid.valueFilterState.isActive == false) - #expect(coordinator.activeGridDisplayIDs == nil) - withExtendedLifetime((delegate, grid)) {} - } + let coordinator = makeCoordinator() - @Test("Copy as JSON keeps storage order when no display mapping exists") - func copyAsJsonWithoutDisplayMapping() { - let clipboard = RowEditingCopyClipboard() - ClipboardService.shared = clipboard - defer { ClipboardService.shared = NSPasteboardClipboardProvider() } + coordinator.copySelectedRowsAsJson(indices: [0]) - let coordinator = makeCoordinator() + #expect(clipboard.text?.contains("Alice") == true) + #expect(clipboard.text?.contains("Bob") != true) + } - coordinator.copySelectedRowsAsJson(indices: [0]) + @Test("Copy as JSON skips display positions past the current rows") + func copyAsJsonSkipsOutOfRangeIndices() throws { + let clipboard = try #require(RecordingClipboard.current) - #expect(clipboard.text?.contains("Alice") == true) - #expect(clipboard.text?.contains("Bob") != true) - } + let coordinator = makeCoordinator() - @Test("Copy as JSON skips display positions past the current rows") - func copyAsJsonSkipsOutOfRangeIndices() { - let clipboard = RowEditingCopyClipboard() - ClipboardService.shared = clipboard - defer { ClipboardService.shared = NSPasteboardClipboardProvider() } + coordinator.copySelectedRowsAsJson(indices: [1, 99]) - let coordinator = makeCoordinator() + #expect(clipboard.text?.contains("Bob") == true) + } - coordinator.copySelectedRowsAsJson(indices: [1, 99]) + @Test("Copy as JSON preserves wide integer values") + func copyAsJsonPreservesWideInteger() throws { + let clipboard = try #require(RecordingClipboard.current) + let value = "340282366920938463463374607431768211455" + let tableRows = TableRows( + rows: [Row(id: .existing(0), values: [.text(value)])], + columns: ["value"], + columnTypes: [.integer(rawType: "UINT128")] + ) + let coordinator = makeCoordinator(tableRows: tableRows) - #expect(clipboard.text?.contains("Bob") == true) - } + coordinator.copySelectedRowsAsJson(indices: [0]) - @Test("Copy as JSON preserves wide integer values") - func copyAsJsonPreservesWideInteger() { - let clipboard = RowEditingCopyClipboard() - ClipboardService.shared = clipboard - defer { ClipboardService.shared = NSPasteboardClipboardProvider() } - let value = "340282366920938463463374607431768211455" - let tableRows = TableRows( - rows: [Row(id: .existing(0), values: [.text(value)])], - columns: ["value"], - columnTypes: [.integer(rawType: "UINT128")] - ) - let coordinator = makeCoordinator(tableRows: tableRows) - - coordinator.copySelectedRowsAsJson(indices: [0]) - - #expect(clipboard.text?.contains("\"value\": \(value)") == true) + #expect(clipboard.text?.contains("\"value\": \(value)") == true) + } } } diff --git a/TableProTests/Core/Coordinators/RowEditingCoordinatorJsonModeTests.swift b/TableProTests/Core/Coordinators/RowEditingCoordinatorJsonModeTests.swift index 5d7ca61dce..629e1be274 100644 --- a/TableProTests/Core/Coordinators/RowEditingCoordinatorJsonModeTests.swift +++ b/TableProTests/Core/Coordinators/RowEditingCoordinatorJsonModeTests.swift @@ -16,14 +16,11 @@ import Testing @MainActor struct RowEditingCoordinatorJsonModeTests { + private let fixture = CoordinatorFixture() + private func makeCoordinator(mode: ResultsViewMode) -> MainContentCoordinator { - let tabManager = QueryTabManager() - let coordinator = MainContentCoordinator( - connection: TestFixtures.makeConnection(), - tabManager: tabManager, - changeManager: DataChangeManager(), - toolbarState: ConnectionToolbarState() - ) + let tabManager = fixture.tabManager + let coordinator = fixture.coordinator var tab = QueryTab(title: "users", query: "SELECT * FROM users", tabType: .table, tableName: "users") tab.execution.lastExecutedAt = Date() tab.display.resultsViewMode = mode diff --git a/TableProTests/Core/Coordinators/RowEditingCoordinatorValueFilterTests.swift b/TableProTests/Core/Coordinators/RowEditingCoordinatorValueFilterTests.swift index d79d1a368f..385406b99a 100644 --- a/TableProTests/Core/Coordinators/RowEditingCoordinatorValueFilterTests.swift +++ b/TableProTests/Core/Coordinators/RowEditingCoordinatorValueFilterTests.swift @@ -11,14 +11,11 @@ import Testing @MainActor struct RowEditingCoordinatorValueFilterTests { + private let fixture = CoordinatorFixture() + private func makeCoordinator() -> MainContentCoordinator { - let tabManager = QueryTabManager() - let coordinator = MainContentCoordinator( - connection: TestFixtures.makeConnection(), - tabManager: tabManager, - changeManager: DataChangeManager(), - toolbarState: ConnectionToolbarState() - ) + let tabManager = fixture.tabManager + let coordinator = fixture.coordinator var tab = QueryTab(title: "users", query: "SELECT * FROM users", tabType: .table, tableName: "users") tab.execution.lastExecutedAt = Date() tab.display.resultsViewMode = .data diff --git a/TableProTests/Core/CrossEngine/CrossEngineKeyTranslationTests.swift b/TableProTests/Core/CrossEngine/CrossEngineKeyTranslationTests.swift index b003a75b28..c2495942b1 100644 --- a/TableProTests/Core/CrossEngine/CrossEngineKeyTranslationTests.swift +++ b/TableProTests/Core/CrossEngine/CrossEngineKeyTranslationTests.swift @@ -7,10 +7,11 @@ // 12.3.3; the SQL Server and Oracle ones are those engines' documented limits. // +import Foundation @testable import TablePro -import XCTest +import Testing -final class CrossEngineKeyTranslationTests: XCTestCase { +struct CrossEngineKeyTranslationTests { private func column( _ name: String, _ dataType: String, @@ -90,50 +91,54 @@ final class CrossEngineKeyTranslationTests: XCTestCase { // MARK: - MySQL primary keys - func testAMySQLPrimaryKeyKeepsALengthThatFits() { + @Test + func aMySQLPrimaryKeyKeepsALengthThatFits() { let result = translate([varchar("code", 300, isPrimaryKey: true)]) - XCTAssertEqual(dataType(result, "code"), "VARCHAR(300)") - XCTAssertTrue(result.notes.isEmpty) + #expect(dataType(result, "code") == "VARCHAR(300)") + #expect(result.notes.isEmpty) } /// `VARCHAR(1000)` is 4,000 bytes, past the 3,072 an InnoDB key takes, and `VARCHAR(5000)` would /// have been a `LONGTEXT` key, which MySQL refuses outright. - func testAMySQLPrimaryKeyTooWideIsCutTo255() { + @Test + func aMySQLPrimaryKeyTooWideIsCutTo255() { for length in [1_000, 5_000] { let result = translate([varchar("code", length, isPrimaryKey: true)]) - XCTAssertEqual(dataType(result, "code"), "VARCHAR(255)", "\(length)") - XCTAssertTrue(hasKeyNote(result, "code"), "\(length)") + #expect(dataType(result, "code") == "VARCHAR(255)", "\(length)") + #expect(hasKeyNote(result, "code"), "\(length)") } } /// The widest part is cut first, and only until the key fits. - func testACompositeMySQLKeyCutsItsWidestPartFirst() { + @Test + func aCompositeMySQLKeyCutsItsWidestPartFirst() { let result = translate([varchar("a", 600, isPrimaryKey: true), varchar("b", 200, isPrimaryKey: true)]) - XCTAssertEqual(dataType(result, "a"), "VARCHAR(255)") - XCTAssertEqual(dataType(result, "b"), "VARCHAR(200)") + #expect(dataType(result, "a") == "VARCHAR(255)") + #expect(dataType(result, "b") == "VARCHAR(200)") let fits = translate([varchar("a", 766, isPrimaryKey: true), bigint("b", isPrimaryKey: true)]) - XCTAssertEqual(dataType(fits, "a"), "VARCHAR(766)") + #expect(dataType(fits, "a") == "VARCHAR(766)") let over = translate([varchar("a", 767, isPrimaryKey: true), bigint("b", isPrimaryKey: true)]) - XCTAssertEqual(dataType(over, "a"), "VARCHAR(255)") + #expect(dataType(over, "a") == "VARCHAR(255)") } /// Four `VARCHAR(200)` parts are 3,200 bytes and none is longer than 255. Four `VARCHAR(192)` were /// created and `VARCHAR(193)` beside three of them refused with ERROR 1071; with a `BIGINT` part as /// well, four `VARCHAR(191)` were created. - func testAMySQLKeyWithNoPartLongerThan255SharesTheBytes() { + @Test + func aMySQLKeyWithNoPartLongerThan255SharesTheBytes() { let names = ["a", "b", "c", "d"] let result = translate(names.map { varchar($0, 200, isPrimaryKey: true) }) - XCTAssertEqual(dataTypes(result, names), Array(repeating: "VARCHAR(192)", count: 4)) - XCTAssertTrue(names.allSatisfy { hasKeyNote(result, $0) }) + #expect(dataTypes(result, names) == Array(repeating: "VARCHAR(192)", count: 4)) + #expect(names.allSatisfy { hasKeyNote(result, $0) }) let withInteger = translate(names.map { varchar($0, 200, isPrimaryKey: true) } + [bigint("e", isPrimaryKey: true)]) let bytes = names.compactMap { name in withInteger.targetKinds[name].flatMap { MySQLStorageWidth.keyBytes($0) } }.reduce(8, +) - XCTAssertLessThanOrEqual(bytes, MySQLStorageWidth.maximumKeyBytes) - XCTAssertTrue(dataTypes(withInteger, names).allSatisfy { $0 == "VARCHAR(191)" || $0 == "VARCHAR(192)" }) + #expect(bytes <= MySQLStorageWidth.maximumKeyBytes) + #expect(dataTypes(withInteger, names).allSatisfy { $0 == "VARCHAR(191)" || $0 == "VARCHAR(192)" }) } // MARK: - MySQL foreign keys @@ -141,7 +146,8 @@ final class CrossEngineKeyTranslationTests: XCTestCase { /// A foreign key is indexed like any other key: a `TEXT` column in one was refused with ERROR 1170, /// a `VARCHAR(769)` with ERROR 1071 and two `VARCHAR(500)` with ERROR 1071, where `VARCHAR(768)` /// and two `VARCHAR(384)` were created. - func testAMySQLForeignKeyIsBoundedLikeAKey() { + @Test + func aMySQLForeignKeyIsBoundedLikeAKey() { let result = translate( [ bigint("id", isPrimaryKey: true), @@ -153,154 +159,167 @@ final class CrossEngineKeyTranslationTests: XCTestCase { ], foreignKeys: [foreignKey(["note"]), foreignKey(["code"]), foreignKey(["fits"]), foreignKey(["a", "b"])] ) - XCTAssertEqual(dataType(result, "note"), "VARCHAR(255)") - XCTAssertEqual(dataType(result, "code"), "VARCHAR(255)") - XCTAssertEqual(dataType(result, "fits"), "VARCHAR(768)") - XCTAssertEqual(dataTypes(result, ["a", "b"]), ["VARCHAR(255)", "VARCHAR(500)"]) - XCTAssertTrue(hasKeyNote(result, "note")) - XCTAssertTrue(hasKeyNote(result, "code")) - XCTAssertFalse(result.notes.contains { $0.subject == "fits" }) + #expect(dataType(result, "note") == "VARCHAR(255)") + #expect(dataType(result, "code") == "VARCHAR(255)") + #expect(dataType(result, "fits") == "VARCHAR(768)") + #expect(dataTypes(result, ["a", "b"]) == ["VARCHAR(255)", "VARCHAR(500)"]) + #expect(hasKeyNote(result, "note")) + #expect(hasKeyNote(result, "code")) + #expect(!(result.notes.contains { $0.subject == "fits" })) } // MARK: - MySQL indexes - func testAMySQLIndexCutsOnlyAsMuchAsItNeeds() { + @Test + func aMySQLIndexCutsOnlyAsMuchAsItNeeds() { let result = translate( [varchar("a", 500), varchar("b", 500), varchar("c", 320)], indexes: [index("ab_idx", ["a", "b"]), index("c_idx", ["c"])] ) let prefixes = Dictionary(uniqueKeysWithValues: result.snapshot.indexes.map { ($0.name, $0.columnPrefixes) }) - XCTAssertEqual(prefixes["ab_idx"], ["a": 255]) - XCTAssertEqual(prefixes["c_idx"], [:]) - XCTAssertTrue(result.notes.isEmpty) + #expect(prefixes["ab_idx"] == ["a": 255]) + #expect(prefixes["c_idx"] == [:]) + #expect(result.notes.isEmpty) } - func testAUniqueMySQLIndexCutToAPrefixIsReported() { + @Test + func aUniqueMySQLIndexCutToAPrefixIsReported() { let result = translate([varchar("email", 1_000)], indexes: [index("email_key", ["email"], unique: true)]) - XCTAssertEqual(result.snapshot.indexes.first?.columnPrefixes, ["email": 255]) - XCTAssertTrue(hasKeyNote(result, "email_key")) + #expect(result.snapshot.indexes.first?.columnPrefixes == ["email": 255]) + #expect(hasKeyNote(result, "email_key")) } /// Four unbounded parts at 255 characters each are 4,080 bytes, and none can be cut shorter. - func testAMySQLIndexNoPrefixCanFitIsLeftOut() { + @Test + func aMySQLIndexNoPrefixCanFitIsLeftOut() { let result = translate( [column("a", "text"), column("b", "text"), column("c", "text"), column("d", "text")], indexes: [index("wide_idx", ["a", "b", "c", "d"])] ) - XCTAssertTrue(result.snapshot.indexes.isEmpty) - XCTAssertTrue(hasKeyNote(result, "wide_idx")) + #expect(result.snapshot.indexes.isEmpty) + #expect(hasKeyNote(result, "wide_idx")) } // MARK: - SQL Server - func testASQLServerKeyIsBoundedOnTheTypeItIsWrittenAs() { + @Test + func aSQLServerKeyIsBoundedOnTheTypeItIsWrittenAs() { let result = translate([varchar("code", 5_000, isPrimaryKey: true)], to: .mssql) - XCTAssertEqual(dataType(result, "code"), "NVARCHAR(450)") - XCTAssertTrue(hasKeyNote(result, "code")) + #expect(dataType(result, "code") == "NVARCHAR(450)") + #expect(hasKeyNote(result, "code")) } /// An `NVARCHAR(1000)` key is 2,000 bytes against a clustered key's 900, so a key over 450 /// characters is refused on insert either way, and the cut says so before the copy runs. - func testASQLServerKeyPastItsLimitIsCutTo450() { + @Test + func aSQLServerKeyPastItsLimitIsCutTo450() { let result = translate([varchar("code", 1_000, isPrimaryKey: true)], to: .mssql) - XCTAssertEqual(dataType(result, "code"), "NVARCHAR(450)") - XCTAssertTrue(hasKeyNote(result, "code")) + #expect(dataType(result, "code") == "NVARCHAR(450)") + #expect(hasKeyNote(result, "code")) } /// SQL Server refuses a key whose fixed-length columns pass 900 bytes with Msg 1944, and creates /// one of longer variable columns, refusing only the row whose key is longer. - func testAFixedLengthSQLServerKeyPastItsLimitBecomesVariable() { + @Test + func aFixedLengthSQLServerKeyPastItsLimitBecomesVariable() { let single = translate([character("code", 500, isPrimaryKey: true)], to: .mssql) - XCTAssertEqual(dataType(single, "code"), "NVARCHAR(450)") + #expect(dataType(single, "code") == "NVARCHAR(450)") let pair = translate( [character("a", 300, isPrimaryKey: true), character("b", 300, isPrimaryKey: true)], to: .mssql ) - XCTAssertEqual(dataTypes(pair, ["a", "b"]), ["NCHAR(300)", "NVARCHAR(300)"]) + #expect(dataTypes(pair, ["a", "b"]) == ["NCHAR(300)", "NVARCHAR(300)"]) let variable = translate( [varchar("a", 300, isPrimaryKey: true), varchar("b", 300, isPrimaryKey: true)], to: .mssql ) - XCTAssertEqual(dataTypes(variable, ["a", "b"]), ["NVARCHAR(300)", "NVARCHAR(300)"]) - XCTAssertFalse(hasKeyNote(variable, "a") || hasKeyNote(variable, "b")) + #expect(dataTypes(variable, ["a", "b"]) == ["NVARCHAR(300)", "NVARCHAR(300)"]) + #expect(!(hasKeyNote(variable, "a") || hasKeyNote(variable, "b"))) } /// An index has 1,700 bytes, and only fixed-length columns past them refuse it. - func testASQLServerIndexWhoseFixedColumnsPassItsLimitIsLeftOut() { + @Test + func aSQLServerIndexWhoseFixedColumnsPassItsLimitIsLeftOut() { let result = translate( [character("a", 450), character("b", 450), varchar("body", 1_000)], indexes: [index("ab_idx", ["a", "b"]), index("body_idx", ["body"])], to: .mssql ) - XCTAssertEqual(result.snapshot.indexes.map(\.name), ["body_idx"]) - XCTAssertTrue(hasKeyNote(result, "ab_idx")) + #expect(result.snapshot.indexes.map(\.name) == ["body_idx"]) + #expect(hasKeyNote(result, "ab_idx")) } /// SQL Server builds no index for a foreign key, so only a column no key can hold is bounded. - func testASQLServerForeignKeyIsBoundedOnlyWhereItHasNoLength() { + @Test + func aSQLServerForeignKeyIsBoundedOnlyWhereItHasNoLength() { let result = translate( [bigint("id", isPrimaryKey: true), column("note", "text", catalog: "text"), varchar("code", 1_000)], foreignKeys: [foreignKey(["note"]), foreignKey(["code"])], to: .mssql ) - XCTAssertEqual(dataType(result, "note"), "NVARCHAR(450)") - XCTAssertEqual(dataType(result, "code"), "NVARCHAR(1000)") + #expect(dataType(result, "note") == "NVARCHAR(450)") + #expect(dataType(result, "code") == "NVARCHAR(1000)") } // MARK: - Oracle /// A `varchar(2000)` is past what a `VARCHAR2` holds in characters, so it is written as a `CLOB`, /// which Oracle cannot key. The key is then counted in characters like every other length. - func testAnOracleKeyIsBoundedOnTheTypeItIsWrittenAs() { + @Test + func anOracleKeyIsBoundedOnTheTypeItIsWrittenAs() { let result = translate([varchar("code", 2_000, isPrimaryKey: true), varchar("name", 50)], to: .oracle) - XCTAssertEqual(dataType(result, "code"), "VARCHAR2(1000 CHAR)") - XCTAssertEqual(dataType(result, "name"), "VARCHAR2(50 CHAR)") - XCTAssertTrue(result.notes.contains { $0.subject == "code" && $0.reason.contains("CLOB") }) + #expect(dataType(result, "code") == "VARCHAR2(1000 CHAR)") + #expect(dataType(result, "name") == "VARCHAR2(50 CHAR)") + #expect(result.notes.contains { $0.subject == "code" && $0.reason.contains("CLOB") }) } /// Oracle counts two `VARCHAR2(1000 CHAR)` parts at 4,000 bytes each plus one a part, 8,002 bytes /// against 6,398, and refuses the key with ORA-01450. One of them fits. - func testACompositeOracleKeyPastItsLimitSharesTheBytes() { + @Test + func aCompositeOracleKeyPastItsLimitSharesTheBytes() { let pair = translate( [varchar("a", 1_000, isPrimaryKey: true), varchar("b", 1_000, isPrimaryKey: true)], to: .oracle ) - XCTAssertEqual(dataTypes(pair, ["a", "b"]), ["VARCHAR2(799 CHAR)", "VARCHAR2(800 CHAR)"]) - XCTAssertTrue(hasKeyNote(pair, "a") && hasKeyNote(pair, "b")) + #expect(dataTypes(pair, ["a", "b"]) == ["VARCHAR2(799 CHAR)", "VARCHAR2(800 CHAR)"]) + #expect(hasKeyNote(pair, "a") && hasKeyNote(pair, "b")) let single = translate([varchar("code", 1_000, isPrimaryKey: true)], to: .oracle) - XCTAssertEqual(dataType(single, "code"), "VARCHAR2(1000 CHAR)") - XCTAssertTrue(single.notes.isEmpty) + #expect(dataType(single, "code") == "VARCHAR2(1000 CHAR)") + #expect(single.notes.isEmpty) } /// Oracle has no key prefixes, so an index past 6,398 bytes can only be left out. - func testAnOracleIndexPastItsLimitIsLeftOut() { + @Test + func anOracleIndexPastItsLimitIsLeftOut() { let columns = ["a", "b", "c", "d"].map { varchar($0, 500) } let result = translate( columns, indexes: [index("four_idx", ["a", "b", "c", "d"]), index("three_idx", ["a", "b", "c"])], to: .oracle ) - XCTAssertEqual(result.snapshot.indexes.map(\.name), ["three_idx"]) - XCTAssertTrue(hasKeyNote(result, "four_idx")) + #expect(result.snapshot.indexes.map(\.name) == ["three_idx"]) + #expect(hasKeyNote(result, "four_idx")) } - func testAnOracleForeignKeyIsBoundedOnlyWhereItHasNoLength() { + @Test + func anOracleForeignKeyIsBoundedOnlyWhereItHasNoLength() { let result = translate( [bigint("id", isPrimaryKey: true), column("note", "text", catalog: "text"), varchar("a", 1_000), varchar("b", 1_000)], foreignKeys: [foreignKey(["note"]), foreignKey(["a", "b"])], to: .oracle ) - XCTAssertEqual(dataType(result, "note"), "VARCHAR2(1000 CHAR)") - XCTAssertEqual(dataTypes(result, ["a", "b"]), ["VARCHAR2(1000 CHAR)", "VARCHAR2(1000 CHAR)"]) + #expect(dataType(result, "note") == "VARCHAR2(1000 CHAR)") + #expect(dataTypes(result, ["a", "b"]) == ["VARCHAR2(1000 CHAR)", "VARCHAR2(1000 CHAR)"]) } - func testAnEngineWithoutKeyLimitsKeepsEveryLength() { + @Test + func anEngineWithoutKeyLimitsKeepsEveryLength() { let result = translate( [varchar("a", 5_000, isPrimaryKey: true), varchar("b", 5_000, isPrimaryKey: true)], indexes: [index("ab_idx", ["a", "b"])], to: .duckdb ) - XCTAssertEqual(dataTypes(result, ["a", "b"]), ["VARCHAR(5000)", "VARCHAR(5000)"]) - XCTAssertEqual(result.snapshot.indexes.count, 1) + #expect(dataTypes(result, ["a", "b"]) == ["VARCHAR(5000)", "VARCHAR(5000)"]) + #expect(result.snapshot.indexes.count == 1) } } diff --git a/TableProTests/Core/CrossEngine/CrossEngineStructureTranslatorTests.swift b/TableProTests/Core/CrossEngine/CrossEngineStructureTranslatorTests.swift index 60c4a9589a..eaf059c1c3 100644 --- a/TableProTests/Core/CrossEngine/CrossEngineStructureTranslatorTests.swift +++ b/TableProTests/Core/CrossEngine/CrossEngineStructureTranslatorTests.swift @@ -3,11 +3,12 @@ // TableProTests // +import Foundation @testable import TablePro import TableProPluginKit -import XCTest +import Testing -final class CrossEngineStructureTranslatorTests: XCTestCase { +struct CrossEngineStructureTranslatorTests { private func column( _ name: String, _ type: String, @@ -87,34 +88,36 @@ final class CrossEngineStructureTranslatorTests: XCTestCase { /// The one guarantee that lets a change this wide be trusted: a copy that already worked runs /// the path it always ran, byte for byte. - func testASameEngineCopyIsUntouched() { + @Test + func aSameEngineCopyIsUntouched() { let source = snapshot(columns: [column("id", "INT", autoIncrement: true, isPrimaryKey: true)]) let result = CrossEngineStructureTranslator.translate(source, from: .mysql, to: .mysql) - XCTAssertFalse(result.translated) - XCTAssertTrue(result.notes.isEmpty) - XCTAssertEqual(result.snapshot.columns.map(\.dataType), ["INT"]) - XCTAssertEqual(result.snapshot.engine, "InnoDB") - XCTAssertEqual(result.snapshot.charset, "utf8mb4") + #expect(!result.translated) + #expect(result.notes.isEmpty) + #expect(result.snapshot.columns.map(\.dataType) == ["INT"]) + #expect(result.snapshot.engine == "InnoDB") + #expect(result.snapshot.charset == "utf8mb4") } - func testTwoEnginesOfOneFamilyAreUntouched() { + @Test + func twoEnginesOfOneFamilyAreUntouched() { let source = snapshot(columns: [column("id", "INT")]) - XCTAssertFalse( - CrossEngineStructureTranslator.translate(source, from: .mysql, to: .mariadb).translated - ) + #expect(!(CrossEngineStructureTranslator.translate(source, from: .mysql, to: .mariadb).translated)) } - func testASameFamilyCopyKeepsTheServersTypeSpelling() { + @Test + func aSameFamilyCopyKeepsTheServersTypeSpelling() { let shape = column("shape", "geometry", ddlSpelling: "public.geometry(Point,4326)") let result = CrossEngineStructureTranslator.translate( snapshot(columns: [shape]), from: .postgresql, to: .postgresql ) - XCTAssertEqual(result.snapshot.columns.map(\.ddlSpelling), ["public.geometry(Point,4326)"]) + #expect(result.snapshot.columns.map(\.ddlSpelling) == ["public.geometry(Point,4326)"]) } /// `DATE` renders as `DATE` on MySQL, so a spelling cleared only by a changed `dataType` would /// have carried PostgreSQL's text into a MySQL table. - func testATranslatedColumnCarriesNoSourceSpellings() { + @Test + func aTranslatedColumnCarriesNoSourceSpellings() { let made = column("made", "DATE", defaultValue: "CURRENT_DATE", ddlSpelling: "date", ddlDefault: "CURRENT_DATE") let shape = column( "shape", @@ -126,15 +129,16 @@ final class CrossEngineStructureTranslatorTests: XCTestCase { let result = CrossEngineStructureTranslator.translate( snapshot(columns: [made, shape]), from: .postgresql, to: .mysql ) - XCTAssertTrue(result.translated) - XCTAssertEqual(result.snapshot.columns.map(\.ddlSpelling), [nil, nil]) - XCTAssertEqual(result.snapshot.columns.map(\.ddlDefault), [nil, nil]) - XCTAssertEqual(result.snapshot.columns.map(\.ddlGenerationExpression), [nil, nil]) + #expect(result.translated) + #expect(result.snapshot.columns.map(\.ddlSpelling) == [nil, nil]) + #expect(result.snapshot.columns.map(\.ddlDefault) == [nil, nil]) + #expect(result.snapshot.columns.map(\.ddlGenerationExpression) == [nil, nil]) } // MARK: - Types - func testTypesAreSaidInTheTargetsOwnWords() { + @Test + func typesAreSaidInTheTargetsOwnWords() { let source = snapshot(columns: [ column("id", "INT", autoIncrement: true, isPrimaryKey: true), column("flag", "TINYINT(1)"), @@ -142,41 +146,40 @@ final class CrossEngineStructureTranslatorTests: XCTestCase { column("made", "DATETIME(6)") ]) let result = CrossEngineStructureTranslator.translate(source, from: .mysql, to: .postgresql) - XCTAssertTrue(result.translated) - XCTAssertEqual( - result.snapshot.columns.map(\.dataType), - ["INTEGER", "BOOLEAN", "TEXT", "TIMESTAMP(6)"] - ) + #expect(result.translated) + #expect(result.snapshot.columns.map(\.dataType) == ["INTEGER", "BOOLEAN", "TEXT", "TIMESTAMP(6)"]) } /// All three name something on the source's side and are rejected outright by every other /// engine's `CREATE TABLE`. - func testMySQLOnlyAttributesAreDropped() { + @Test + func mySQLOnlyAttributesAreDropped() { let source = snapshot(columns: [ column("made", "TIMESTAMP", onUpdate: "CURRENT_TIMESTAMP", extra: "on update CURRENT_TIMESTAMP"), column("name", "VARCHAR(20)", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci") ]) let result = CrossEngineStructureTranslator.translate(source, from: .mysql, to: .postgresql) - XCTAssertNil(result.snapshot.columns[0].onUpdate) - XCTAssertNil(result.snapshot.columns[0].extra) - XCTAssertNil(result.snapshot.columns[1].charset) - XCTAssertNil(result.snapshot.columns[1].collation) - XCTAssertNil(result.snapshot.engine) - XCTAssertNil(result.snapshot.charset) - XCTAssertNil(result.snapshot.collation) + #expect(result.snapshot.columns[0].onUpdate == nil) + #expect(result.snapshot.columns[0].extra == nil) + #expect(result.snapshot.columns[1].charset == nil) + #expect(result.snapshot.columns[1].collation == nil) + #expect(result.snapshot.engine == nil) + #expect(result.snapshot.charset == nil) + #expect(result.snapshot.collation == nil) } /// `UNSIGNED` is written by the MySQL driver from the attribute, so it survives into a MySQL /// target and is dropped for every other one, where it would be a syntax error. - func testUnsignedFollowsTheTarget() { + @Test + func unsignedFollowsTheTarget() { let source = snapshot(columns: [column("count", "INT UNSIGNED", unsigned: true)]) let toPostgres = CrossEngineStructureTranslator.translate(source, from: .mysql, to: .postgresql) - XCTAssertFalse(toPostgres.snapshot.columns[0].unsigned) - XCTAssertEqual(toPostgres.snapshot.columns[0].dataType, "BIGINT") + #expect(!(toPostgres.snapshot.columns[0].unsigned)) + #expect(toPostgres.snapshot.columns[0].dataType == "BIGINT") let fromPostgres = snapshot(columns: [column("count", "bigint")]) let toMySQL = CrossEngineStructureTranslator.translate(fromPostgres, from: .postgresql, to: .mysql) - XCTAssertFalse(toMySQL.snapshot.columns[0].unsigned) + #expect(!(toMySQL.snapshot.columns[0].unsigned)) } // MARK: - Defaults @@ -184,200 +187,214 @@ final class CrossEngineStructureTranslatorTests: XCTestCase { /// A `SERIAL` is an integer whose default calls a sequence written in PostgreSQL's own DDL. /// Carried over as text the target either rejects it or stores the literal string; carried as /// the target's own generated key it means what the source meant. - func testASequenceDefaultBecomesAutoIncrement() { + @Test + func aSequenceDefaultBecomesAutoIncrement() { let source = snapshot(columns: [ column("id", "integer", defaultValue: "nextval('orders_id_seq'::regclass)", isPrimaryKey: true) ]) let result = CrossEngineStructureTranslator.translate(source, from: .postgresql, to: .mysql) - XCTAssertTrue(result.snapshot.columns[0].autoIncrement) - XCTAssertNil(result.snapshot.columns[0].defaultValue) + #expect(result.snapshot.columns[0].autoIncrement) + #expect(result.snapshot.columns[0].defaultValue == nil) } - func testTheCurrentTimestampDefaultSurvives() { + @Test + func theCurrentTimestampDefaultSurvives() { let source = snapshot(columns: [column("made", "timestamp", defaultValue: "now()")]) let result = CrossEngineStructureTranslator.translate(source, from: .postgresql, to: .mysql) - XCTAssertEqual(result.snapshot.columns[0].defaultValue, "CURRENT_TIMESTAMP") + #expect(result.snapshot.columns[0].defaultValue == "CURRENT_TIMESTAMP") } /// Every writer emits the default verbatim, so a default this cannot translate reaches the /// server in the source's own dialect and breaks the CREATE TABLE. - func testAnUntranslatableDefaultIsDroppedWithAReason() { + @Test + func anUntranslatableDefaultIsDroppedWithAReason() { let source = snapshot(columns: [column("id", "uuid", defaultValue: "uuid_generate_v4()")]) let result = CrossEngineStructureTranslator.translate(source, from: .postgresql, to: .mysql) - XCTAssertNil(result.snapshot.columns[0].defaultValue) - XCTAssertTrue(result.notes.contains { $0.subject == "id" && $0.isLossy }) + #expect(result.snapshot.columns[0].defaultValue == nil) + #expect(result.notes.contains { $0.subject == "id" && $0.isLossy }) } /// A bare word used to be kept, on the premise that the target's writer would quote it. Every /// writer now emits verbatim, so `session_user` reached MySQL as a column reference and the /// CREATE TABLE failed, taking that table's rows out of the copy with it. - func testABareKeywordDefaultIsDroppedRatherThanKept() { + @Test + func aBareKeywordDefaultIsDroppedRatherThanKept() { for keyword in ["session_user", "CURRENT_USER", "current_role", "current_schema", "USER"] { let source = snapshot(columns: [column("owner", "character varying(50)", defaultValue: keyword)]) let result = CrossEngineStructureTranslator.translate(source, from: .postgresql, to: .mysql) - XCTAssertNil(result.snapshot.columns[0].defaultValue, keyword) - XCTAssertTrue(result.notes.contains { $0.subject == "owner" && $0.isLossy }, keyword) + #expect(result.snapshot.columns[0].defaultValue == nil, Comment(rawValue: keyword)) + #expect(result.notes.contains { $0.subject == "owner" && $0.isLossy }, Comment(rawValue: keyword)) } } /// A MySQL or MariaDB nullable column reads back with a NULL default. Carried across, SQL Server /// turns it into a named default constraint on every such column, and a later DROP COLUMN on the /// target then fails on each one. No default means the same thing on every engine. - func testANullDefaultIsNotCarriedAcross() { + @Test + func aNullDefaultIsNotCarriedAcross() { let source = snapshot(columns: [column("nickname", "VARCHAR(50)", defaultValue: "NULL")]) let result = CrossEngineStructureTranslator.translate(source, from: .mysql, to: .mssql) - XCTAssertNil(result.snapshot.columns[0].defaultValue) - XCTAssertFalse(result.notes.contains { $0.subject == "nickname" }) + #expect(result.snapshot.columns[0].defaultValue == nil) + #expect(!(result.notes.contains { $0.subject == "nickname" })) } - func testAPostgresCastIsStrippedFromALiteralDefault() { + @Test + func aPostgresCastIsStrippedFromALiteralDefault() { let source = snapshot(columns: [column("state", "character varying(10)", defaultValue: "'new'::character varying")]) let result = CrossEngineStructureTranslator.translate(source, from: .postgresql, to: .mysql) - XCTAssertEqual(result.snapshot.columns[0].defaultValue, "'new'") + #expect(result.snapshot.columns[0].defaultValue == "'new'") } - func testABooleanDefaultFollowsTheTargetsSpelling() { + @Test + func aBooleanDefaultFollowsTheTargetsSpelling() { let source = snapshot(columns: [column("live", "boolean", defaultValue: "false")]) - XCTAssertEqual( - CrossEngineStructureTranslator.translate(source, from: .postgresql, to: .mysql) - .snapshot.columns[0].defaultValue, - "0" - ) + #expect(CrossEngineStructureTranslator.translate(source, from: .postgresql, to: .mysql) + .snapshot.columns[0].defaultValue == "0") let mysqlSource = snapshot(columns: [column("live", "TINYINT(1)", defaultValue: "1")]) - XCTAssertEqual( - CrossEngineStructureTranslator.translate(mysqlSource, from: .mysql, to: .postgresql) - .snapshot.columns[0].defaultValue, - "TRUE" - ) + #expect(CrossEngineStructureTranslator.translate(mysqlSource, from: .mysql, to: .postgresql) + .snapshot.columns[0].defaultValue == "TRUE") } // MARK: - Generated columns /// The expression is the source's own SQL and nothing parses it, so the column is created as /// an ordinary one. That is what lets its values be copied instead of arriving empty. - func testAComputedColumnBecomesAnOrdinaryOne() { + @Test + func aComputedColumnBecomesAnOrdinaryOne() { let source = snapshot(columns: [ column("total", "DECIMAL(10,2)", generation: "price * quantity") ]) let result = CrossEngineStructureTranslator.translate(source, from: .mysql, to: .postgresql) - XCTAssertNil(result.snapshot.columns[0].generationExpression) - XCTAssertNil(result.snapshot.columns[0].generationKind) - XCTAssertTrue(result.notes.contains { $0.subject == "total" }) + #expect(result.snapshot.columns[0].generationExpression == nil) + #expect(result.snapshot.columns[0].generationKind == nil) + #expect(result.notes.contains { $0.subject == "total" }) } /// The review keeps a type change to one line, cut in the middle, because a spelling such as an /// `ENUM` value list has no space to wrap at. A note written as a sentence still wraps. - func testATypeChangeIsMarkedApartFromASentence() throws { + @Test + func aTypeChangeIsMarkedApartFromASentence() throws { let source = snapshot(columns: [ column("status", "enum('pending','paid','shipped','delivered','cancelled','refunded')"), column("total", "DECIMAL(10,2)", generation: "price * quantity") ]) let result = CrossEngineStructureTranslator.translate(source, from: .mariadb, to: .postgresql) - let status = try XCTUnwrap(result.notes.first { $0.subject == "status" }) - XCTAssertTrue(status.isTypeChange) - XCTAssertTrue(status.summary.hasSuffix("→ VARCHAR(9)")) - let total = try XCTUnwrap(result.notes.first { $0.subject == "total" }) - XCTAssertFalse(total.isTypeChange) + let status = try #require(result.notes.first { $0.subject == "status" }) + #expect(status.isTypeChange) + #expect(status.summary.hasSuffix("→ VARCHAR(9)")) + let total = try #require(result.notes.first { $0.subject == "total" }) + #expect(!total.isTypeChange) } // MARK: - Keys and indexes /// MySQL refuses a `PRIMARY KEY` on a `LONGTEXT` outright, and the whole `CREATE TABLE` fails /// with it rather than the copy losing anything. - func testAKeyColumnIsBoundedWhereTheEngineNeedsIt() { + @Test + func aKeyColumnIsBoundedWhereTheEngineNeedsIt() { let source = snapshot(columns: [column("code", "text", isPrimaryKey: true)]) let result = CrossEngineStructureTranslator.translate(source, from: .postgresql, to: .mysql) - XCTAssertEqual(result.snapshot.columns[0].dataType, "VARCHAR(255)") - XCTAssertTrue(result.notes.contains { $0.subject == "code" }) + #expect(result.snapshot.columns[0].dataType == "VARCHAR(255)") + #expect(result.notes.contains { $0.subject == "code" }) } - func testAKeyColumnIsLeftUnboundedWhereTheEngineAllowsIt() { + @Test + func aKeyColumnIsLeftUnboundedWhereTheEngineAllowsIt() { let source = snapshot(columns: [column("code", "LONGTEXT", isPrimaryKey: true)]) let result = CrossEngineStructureTranslator.translate(source, from: .mysql, to: .postgresql) - XCTAssertEqual(result.snapshot.columns[0].dataType, "TEXT") + #expect(result.snapshot.columns[0].dataType == "TEXT") } /// A `GIN` index reaches MySQL as a plain `INDEX` over a column that is now `JSON`, and MySQL /// refuses the whole table for it. - func testAnIndexTheTargetCannotBuildIsLeftOut() { + @Test + func anIndexTheTargetCannotBuildIsLeftOut() { let source = snapshot( columns: [column("doc", "jsonb")], indexes: [index("doc_gin", ["doc"], type: .gin)] ) let result = CrossEngineStructureTranslator.translate(source, from: .postgresql, to: .mysql) - XCTAssertTrue(result.snapshot.indexes.isEmpty) - XCTAssertTrue(result.notes.contains { $0.subject == "doc_gin" }) + #expect(result.snapshot.indexes.isEmpty) + #expect(result.notes.contains { $0.subject == "doc_gin" }) } /// MySQL indexes an unbounded text column only with a key length, and refuses it without one. - func testAnIndexOnUnboundedTextGetsAKeyPrefixOnMySQL() { + @Test + func anIndexOnUnboundedTextGetsAKeyPrefixOnMySQL() { let source = snapshot( columns: [column("body", "text")], indexes: [index("body_idx", ["body"])] ) let result = CrossEngineStructureTranslator.translate(source, from: .postgresql, to: .mysql) - XCTAssertEqual(result.snapshot.indexes.first?.columnPrefixes["body"], 255) + #expect(result.snapshot.indexes.first?.columnPrefixes["body"] == 255) } - func testAPartialIndexLosesItsClauseWhereTheEngineHasNone() { + @Test + func aPartialIndexLosesItsClauseWhereTheEngineHasNone() { let source = snapshot( columns: [column("state", "text")], indexes: [index("live_idx", ["state"], whereClause: "state = 'live'")] ) let result = CrossEngineStructureTranslator.translate(source, from: .postgresql, to: .mysql) - XCTAssertNil(result.snapshot.indexes.first?.whereClause) - XCTAssertTrue(result.notes.contains { $0.subject == "live_idx" }) + #expect(result.snapshot.indexes.first?.whereClause == nil) + #expect(result.notes.contains { $0.subject == "live_idx" }) } - func testThePrimaryKeyIndexIsKeptForTheTargetToWrite() { + @Test + func thePrimaryKeyIndexIsKeptForTheTargetToWrite() { let source = snapshot( columns: [column("id", "INT", isPrimaryKey: true)], indexes: [index("PRIMARY", ["id"], unique: true, primary: true)] ) let result = CrossEngineStructureTranslator.translate(source, from: .mysql, to: .postgresql) - XCTAssertEqual(result.snapshot.primaryKeyColumns, ["id"]) + #expect(result.snapshot.primaryKeyColumns == ["id"]) } // MARK: - Value kinds - func testAJSONColumnCopiedToAPostgresServerWithoutJSONArrivesAsTextWithANote() { + @Test + func aJSONColumnCopiedToAPostgresServerWithoutJSONArrivesAsTextWithANote() { let source = snapshot(columns: [column("payload", "JSON")]) let result = CrossEngineStructureTranslator.translate( source, from: .mysql, to: .postgresql, targetServerVersion: "9.1.24" ) - XCTAssertEqual(result.snapshot.columns.map(\.dataType), ["TEXT"]) - XCTAssertTrue(result.notes.contains { $0.subject == "payload" }) + #expect(result.snapshot.columns.map(\.dataType) == ["TEXT"]) + #expect(result.notes.contains { $0.subject == "payload" }) } - func testAJSONColumnCopiedToAPostgresServerWithoutJSONBArrivesAsJSON() { + @Test + func aJSONColumnCopiedToAPostgresServerWithoutJSONBArrivesAsJSON() { let source = snapshot(columns: [column("payload", "JSON")]) let result = CrossEngineStructureTranslator.translate( source, from: .mysql, to: .postgresql, targetServerVersion: "9.3.25" ) - XCTAssertEqual(result.snapshot.columns.map(\.dataType), ["JSON"]) - XCTAssertTrue(result.notes.isEmpty) + #expect(result.snapshot.columns.map(\.dataType) == ["JSON"]) + #expect(result.notes.isEmpty) } - func testAJSONColumnCopiedIntoRedshiftKeepsJSONBWhateverVersionItReports() { + @Test + func aJSONColumnCopiedIntoRedshiftKeepsJSONBWhateverVersionItReports() { let source = snapshot(columns: [column("payload", "JSON")]) let result = CrossEngineStructureTranslator.translate( source, from: .mysql, to: .redshift, targetServerVersion: "8.0.2" ) - XCTAssertEqual(result.snapshot.columns.map(\.dataType), ["JSONB"]) + #expect(result.snapshot.columns.map(\.dataType) == ["JSONB"]) } - func testTheTargetKindsDescribeEveryColumn() { + @Test + func theTargetKindsDescribeEveryColumn() { let source = snapshot(columns: [column("flag", "TINYINT(1)"), column("made", "DATETIME")]) let result = CrossEngineStructureTranslator.translate(source, from: .mysql, to: .postgresql) - XCTAssertEqual(result.targetKinds["flag"], .boolean) - XCTAssertEqual(result.targetKinds["made"], .timestamp(precision: 6, hasTimeZone: false)) + #expect(result.targetKinds["flag"] == .boolean) + #expect(result.targetKinds["made"] == .timestamp(precision: 6, hasTimeZone: false)) } /// The kinds describe what was written, not what was read. Carrying the source's answer over /// told the coercer a MySQL `DATETIME` still had the time zone its PostgreSQL source declared, /// so the offset it exists to strip was left on every value, and an array bound for a `JSON` /// column was not recognised as needing conversion at all. - func testTheTargetKindsFollowWhatTheRendererWroteRatherThanTheSource() { + @Test + func theTargetKindsFollowWhatTheRendererWroteRatherThanTheSource() { let source = snapshot(columns: [ column("made", "timestamptz"), column("tags", "integer[]"), @@ -385,33 +402,35 @@ final class CrossEngineStructureTranslatorTests: XCTestCase { ]) let result = CrossEngineStructureTranslator.translate(source, from: .postgresql, to: .mysql) - XCTAssertEqual(result.snapshot.columns.map(\.dataType), ["DATETIME(6)", "JSON", "TINYINT(1)"]) - XCTAssertEqual(result.targetKinds["made"], .timestamp(precision: 6, hasTimeZone: false)) - XCTAssertEqual(result.targetKinds["tags"], .json) - XCTAssertEqual(result.targetKinds["live"], .boolean) + #expect(result.snapshot.columns.map(\.dataType) == ["DATETIME(6)", "JSON", "TINYINT(1)"]) + #expect(result.targetKinds["made"] == .timestamp(precision: 6, hasTimeZone: false)) + #expect(result.targetKinds["tags"] == .json) + #expect(result.targetKinds["live"] == .boolean) - XCTAssertEqual(result.sourceKinds["made"], .timestamp(precision: 6, hasTimeZone: true)) - XCTAssertEqual(result.sourceKinds["tags"], .array(element: .integer(bytes: 4))) - XCTAssertEqual(result.sourceKinds["live"], .boolean) + #expect(result.sourceKinds["made"] == .timestamp(precision: 6, hasTimeZone: true)) + #expect(result.sourceKinds["tags"] == .array(element: .integer(bytes: 4))) + #expect(result.sourceKinds["live"] == .boolean) } /// A cut precision loses digits, so it is a conversion the review step has to name. - func testAClampedDecimalPrecisionIsReported() { + @Test + func aClampedDecimalPrecisionIsReported() { let source = snapshot(columns: [column("amount", "DECIMAL(65,30)")]) let result = CrossEngineStructureTranslator.translate(source, from: .mysql, to: .mssql) - XCTAssertEqual(result.snapshot.columns[0].dataType, "DECIMAL(38, 30)") - XCTAssertTrue(result.notes.contains { $0.subject == "amount" && $0.isLossy }) + #expect(result.snapshot.columns[0].dataType == "DECIMAL(38, 30)") + #expect(result.notes.contains { $0.subject == "amount" && $0.isLossy }) } /// A prefix on a unique index is a weaker constraint than the source had, and the copy fails on /// the first pair of rows that agree for 255 characters. - func testAUniqueIndexCutToAPrefixIsReported() { + @Test + func aUniqueIndexCutToAPrefixIsReported() { let source = snapshot( columns: [column("body", "text")], indexes: [index("body_unique", ["body"], unique: true)] ) let result = CrossEngineStructureTranslator.translate(source, from: .postgresql, to: .mysql) - XCTAssertEqual(result.snapshot.indexes.first?.columnPrefixes["body"], 255) - XCTAssertTrue(result.notes.contains { $0.subject == "body_unique" && $0.isLossy }) + #expect(result.snapshot.indexes.first?.columnPrefixes["body"] == 255) + #expect(result.notes.contains { $0.subject == "body_unique" && $0.isLossy }) } } diff --git a/TableProTests/Core/CrossEngine/CrossEngineTypeModifierTranslationTests.swift b/TableProTests/Core/CrossEngine/CrossEngineTypeModifierTranslationTests.swift index 0a2f67952d..9189ba45e2 100644 --- a/TableProTests/Core/CrossEngine/CrossEngineTypeModifierTranslationTests.swift +++ b/TableProTests/Core/CrossEngine/CrossEngineTypeModifierTranslationTests.swift @@ -6,10 +6,11 @@ // and a table that keeps them still fits the target's row limits. // +import Foundation @testable import TablePro -import XCTest +import Testing -final class CrossEngineTypeModifierTranslationTests: XCTestCase { +struct CrossEngineTypeModifierTranslationTests { private func column( _ name: String, _ dataType: String, @@ -83,22 +84,24 @@ final class CrossEngineTypeModifierTranslationTests: XCTestCase { // MARK: - Modifiers - func testTheCatalogSpellingsModifiersReachMySQL() { + @Test + func theCatalogSpellingsModifiersReachMySQL() { let result = translate([ column("amount", "NUMERIC", catalog: "numeric(10,2)"), varchar("code", 50), column("made", "TIMESTAMP WITH TIME ZONE", catalog: "timestamp(3) with time zone"), column("flags", "BIT", catalog: "bit(8)") ]) - XCTAssertEqual(result.snapshot.columns.map(\.dataType), ["DECIMAL(10, 2)", "VARCHAR(50)", "DATETIME(3)", "BIT(8)"]) - XCTAssertFalse(result.notes.contains { $0.subject == "amount" || $0.subject == "code" || $0.subject == "flags" }) - XCTAssertTrue(result.notes.contains { $0.summary == "made: timestamp(3) with time zone → DATETIME(3)" }) - XCTAssertEqual(result.sourceKinds["amount"], .decimal(precision: 10, scale: 2)) + #expect(result.snapshot.columns.map(\.dataType) == ["DECIMAL(10, 2)", "VARCHAR(50)", "DATETIME(3)", "BIT(8)"]) + #expect(!(result.notes.contains { $0.subject == "amount" || $0.subject == "code" || $0.subject == "flags" })) + #expect(result.notes.contains { $0.summary == "made: timestamp(3) with time zone → DATETIME(3)" }) + #expect(result.sourceKinds["amount"] == .decimal(precision: 10, scale: 2)) } /// A qualified spelling names nothing any family reads, so each column keeps the reading its /// display spelling gave it. - func testAQualifiedCatalogSpellingKeepsTheColumnsClassification() { + @Test + func aQualifiedCatalogSpellingKeepsTheColumnsClassification() { let result = translate( [ column("shape", "geometry", catalog: "public.geometry(Point,4326)"), @@ -107,44 +110,48 @@ final class CrossEngineTypeModifierTranslationTests: XCTestCase { ], indexes: [index("shape_idx", ["shape"])] ) - XCTAssertEqual(result.snapshot.columns.map(\.dataType), ["LONGTEXT", "VARCHAR(255)", "LONGTEXT"]) - XCTAssertEqual(result.sourceKinds["shape"], .spatial) - XCTAssertEqual(result.snapshot.indexes.first?.columnPrefixes["shape"], 255) + #expect(result.snapshot.columns.map(\.dataType) == ["LONGTEXT", "VARCHAR(255)", "LONGTEXT"]) + #expect(result.sourceKinds["shape"] == .spatial) + #expect(result.snapshot.indexes.first?.columnPrefixes["shape"] == 255) } - func testAnUnconstrainedNumericIsApproximatedAtMySQLsWidest() { + @Test + func anUnconstrainedNumericIsApproximatedAtMySQLsWidest() { let result = translate([column("total", "NUMERIC", catalog: "numeric")]) - XCTAssertEqual(dataType(result, "total"), "DECIMAL(65, 30)") - XCTAssertTrue(result.notes.contains { $0.subject == "total" && $0.isLossy }) + #expect(dataType(result, "total") == "DECIMAL(65, 30)") + #expect(result.notes.contains { $0.subject == "total" && $0.isLossy }) } /// PostgreSQL 15 takes a scale below zero and above the precision, and MySQL takes neither /// spelling. `numeric(5,-2)` holds whole hundreds and `numeric(3,5)` fractions under 0.01. - func testANumericScaleMySQLCannotSpellKeepsItsDigits() { + @Test + func aNumericScaleMySQLCannotSpellKeepsItsDigits() { let result = translate([ column("rounded", "NUMERIC", catalog: "numeric(5,-2)"), column("tiny", "NUMERIC", catalog: "numeric(3,5)"), column("fine", "NUMERIC", catalog: "numeric(40,35)") ]) - XCTAssertEqual(result.snapshot.columns.map(\.dataType), ["DECIMAL(7, 0)", "DECIMAL(5, 5)", "DECIMAL(35, 30)"]) - XCTAssertTrue(result.notes.contains { $0.subject == "rounded" && $0.fidelity == .widened }) - XCTAssertTrue(result.notes.contains { $0.subject == "tiny" && $0.fidelity == .widened }) - XCTAssertTrue(result.notes.contains { $0.subject == "fine" && $0.isLossy }) + #expect(result.snapshot.columns.map(\.dataType) == ["DECIMAL(7, 0)", "DECIMAL(5, 5)", "DECIMAL(35, 30)"]) + #expect(result.notes.contains { $0.subject == "rounded" && $0.fidelity == .widened }) + #expect(result.notes.contains { $0.subject == "tiny" && $0.fidelity == .widened }) + #expect(result.notes.contains { $0.subject == "fine" && $0.isLossy }) } /// A bare Oracle `NUMBER` declares no precision, and keeps the exact rendering it always had. - func testABareOracleNumberCrossesAsItAlwaysDid() { + @Test + func aBareOracleNumberCrossesAsItAlwaysDid() { let source = TableStructureSnapshot(name: "t", columns: [column("id", "number")]) let toMySQL = CrossEngineStructureTranslator.translate(source, from: .oracle, to: .mysql) - XCTAssertEqual(dataType(toMySQL, "id"), "DECIMAL(38)") - XCTAssertTrue(toMySQL.notes.isEmpty) + #expect(dataType(toMySQL, "id") == "DECIMAL(38)") + #expect(toMySQL.notes.isEmpty) let toSQLServer = CrossEngineStructureTranslator.translate(source, from: .oracle, to: .mssql) - XCTAssertEqual(dataType(toSQLServer, "id"), "DECIMAL(38)") + #expect(dataType(toSQLServer, "id") == "DECIMAL(38)") } /// The catalog spells an Oracle key `NUMBER(10)` and an `INTEGER` `NUMBER(*,0)`, and both hold whole numbers. /// Read as decimals, the key reached MySQL as `DECIMAL(10)` and stopped being an integer. - func testOracleWholeNumbersCrossAsIntegers() { + @Test + func oracleWholeNumbersCrossAsIntegers() { let source = TableStructureSnapshot(name: "t", columns: [ column("id", "NUMBER(10)"), column("qty", "NUMBER(*,0)"), @@ -152,17 +159,18 @@ final class CrossEngineTypeModifierTranslationTests: XCTestCase { column("rounded", "NUMBER(5,-2)") ]) let toMySQL = CrossEngineStructureTranslator.translate(source, from: .oracle, to: .mysql) - XCTAssertEqual(dataType(toMySQL, "id"), "BIGINT") - XCTAssertEqual(dataType(toMySQL, "qty"), "DECIMAL(39, 0)") - XCTAssertEqual(dataType(toMySQL, "price"), "DECIMAL(38, 2)") - XCTAssertEqual(dataType(toMySQL, "rounded"), "DECIMAL(7, 0)") + #expect(dataType(toMySQL, "id") == "BIGINT") + #expect(dataType(toMySQL, "qty") == "DECIMAL(39, 0)") + #expect(dataType(toMySQL, "price") == "DECIMAL(38, 2)") + #expect(dataType(toMySQL, "rounded") == "DECIMAL(7, 0)") let toPostgres = CrossEngineStructureTranslator.translate(source, from: .oracle, to: .postgresql) - XCTAssertEqual(dataType(toPostgres, "id"), "BIGINT") + #expect(dataType(toPostgres, "id") == "BIGINT") } /// A zone declared after the precision is still a zone: `TIMESTAMP(6) WITH TIME ZONE` reached PostgreSQL as a plain /// `timestamp` when the catalog wrote a length after the zone. - func testAnOracleZonedTimestampKeepsItsZone() { + @Test + func anOracleZonedTimestampKeepsItsZone() { let source = TableStructureSnapshot(name: "t", columns: [ column("at", "TIMESTAMP(6) WITH TIME ZONE"), column("seen", "TIMESTAMP(3) WITH LOCAL TIME ZONE"), @@ -170,40 +178,43 @@ final class CrossEngineTypeModifierTranslationTests: XCTestCase { column("doc", "JSON") ]) let toPostgres = CrossEngineStructureTranslator.translate(source, from: .oracle, to: .postgresql) - XCTAssertEqual(dataType(toPostgres, "at"), "TIMESTAMPTZ(6)") - XCTAssertEqual(dataType(toPostgres, "seen"), "TIMESTAMPTZ(3)") - XCTAssertEqual(dataType(toPostgres, "flag"), "BOOLEAN") - XCTAssertEqual(dataType(toPostgres, "doc"), "JSONB") + #expect(dataType(toPostgres, "at") == "TIMESTAMPTZ(6)") + #expect(dataType(toPostgres, "seen") == "TIMESTAMPTZ(3)") + #expect(dataType(toPostgres, "flag") == "BOOLEAN") + #expect(dataType(toPostgres, "doc") == "JSONB") } - func testAppendingIntoATableReadsItsCatalogSpelling() { + @Test + func appendingIntoATableReadsItsCatalogSpelling() { let existing = TableStructureSnapshot(name: "t", columns: [ column("amount", "NUMERIC", catalog: "numeric(10,2)"), column("code", "character varying(20)") ]) let kinds = CrossEngineStructureTranslator.kinds(of: existing, family: .postgres) - XCTAssertEqual(kinds["amount"], .decimal(precision: 10, scale: 2)) - XCTAssertEqual(kinds["code"], .text(length: 20, isFixed: false)) + #expect(kinds["amount"] == .decimal(precision: 10, scale: 2)) + #expect(kinds["code"] == .text(length: 20, isFixed: false)) } // MARK: - MySQL rows /// Seventeen `VARCHAR(1000)` columns are 68,034 bytes, over the 65,535 a row may take on both /// servers, and one of them stored as `TEXT` brings the table under it. - func testATableOverMySQLsRowLimitMovesItsWidestColumnOutOfTheRow() { + @Test + func aTableOverMySQLsRowLimitMovesItsWidestColumnOutOfTheRow() { let wide = (0..<17).map { varchar("c\($0)", 1_000) } let result = translate([bigintKey] + wide) let moved = result.snapshot.columns.filter { $0.dataType == "TEXT" }.map(\.name) - XCTAssertEqual(moved, ["c0"]) - XCTAssertTrue(result.notes.contains { $0.subject == "c0" && $0.fidelity == .widened }) - XCTAssertEqual(result.targetKinds["c0"], .text(length: nil, isFixed: false)) + #expect(moved == ["c0"]) + #expect(result.notes.contains { $0.subject == "c0" && $0.fidelity == .widened }) + #expect(result.targetKinds["c0"] == .text(length: nil, isFixed: false)) } /// MariaDB refused a `BIGINT` key with 41 `VARCHAR(50)` columns and created it with 40. The /// column an index covers is left whole while another can move. - func testATableOverMariaDBsRecordLimitMovesAnUnindexedColumn() { + @Test + func aTableOverMariaDBsRecordLimitMovesAnUnindexedColumn() { let fits = translate([bigintKey] + (0..<40).map { varchar("c\($0)", 50) }, to: .mariadb) - XCTAssertFalse(fits.snapshot.columns.contains { $0.dataType == "TEXT" }) + #expect(!(fits.snapshot.columns.contains { $0.dataType == "TEXT" })) let over = translate( [bigintKey] + (0..<41).map { varchar("c\($0)", 50) }, @@ -211,26 +222,24 @@ final class CrossEngineTypeModifierTranslationTests: XCTestCase { to: .mariadb ) let moved = over.snapshot.columns.filter { $0.dataType == "TEXT" }.map(\.name) - XCTAssertEqual(moved, ["c1"]) - XCTAssertEqual(over.snapshot.indexes.first?.columnPrefixes, [:]) + #expect(moved == ["c1"]) + #expect(over.snapshot.indexes.first?.columnPrefixes == [:]) } /// MySQL 8.4 created the same 41 columns as declared, because it counts each as 41 bytes of the /// record rather than 201. A MySQL connection to a MariaDB server is read by its banner. - func testMySQLKeepsAColumnMariaDBWouldMove() { + @Test + func mySQLKeepsAColumnMariaDBWouldMove() { let columns = [bigintKey] + (0..<41).map { varchar("c\($0)", 50) } - XCTAssertFalse(translate(columns).snapshot.columns.contains { $0.dataType == "TEXT" }) - XCTAssertFalse( - translate(columns, serverVersion: "8.4.11").snapshot.columns.contains { $0.dataType == "TEXT" } - ) - XCTAssertTrue( - translate(columns, serverVersion: "12.3.3-MariaDB").snapshot.columns.contains { $0.dataType == "TEXT" } - ) + #expect(!(translate(columns).snapshot.columns.contains { $0.dataType == "TEXT" })) + #expect(!(translate(columns, serverVersion: "8.4.11").snapshot.columns.contains { $0.dataType == "TEXT" })) + #expect(translate(columns, serverVersion: "12.3.3-MariaDB").snapshot.columns.contains { $0.dataType == "TEXT" }) } /// A `BINARY(255)` is counted whole on both servers. Forty of them beside a `BIGINT` key were /// refused by MySQL 8.4; with 31 kept and 9 as `BLOB` MySQL still refused, and MariaDB created it. - func testMySQLMovesTheColumnsItsOwnRecordCountsWhole() { + @Test + func mySQLMovesTheColumnsItsOwnRecordCountsWhole() { let columns = [column("id", "BIGINT", nullable: false, isPrimaryKey: true)] + (0..<40).map { column("b\($0)", "BINARY(255)") } let source = TableStructureSnapshot(name: "blobs", columns: columns) @@ -238,40 +247,43 @@ final class CrossEngineTypeModifierTranslationTests: XCTestCase { CrossEngineStructureTranslator.translate(source, from: .mssql, to: .mysql, targetServerVersion: serverVersion) .snapshot.columns.filter { $0.dataType == "BLOB" }.count } - XCTAssertEqual(moved("8.4.11"), 10) - XCTAssertEqual(moved("12.3.3-MariaDB"), 9) + #expect(moved("8.4.11") == 10) + #expect(moved("12.3.3-MariaDB") == 9) } /// A foreign key column cannot be a `TEXT`: MySQL refuses it with ERROR 1170 and MariaDB with /// errno 150. MariaDB refused this table as declared and created it with three `VARCHAR(50)` /// columns as `TEXT`, while `pcode` would have saved the most bytes on its own. - func testAForeignKeyColumnNeverMovesOutOfTheRow() { + @Test + func aForeignKeyColumnNeverMovesOutOfTheRow() { let result = translate( [bigintKey] + (0..<41).map { varchar("c\($0)", 50) } + [varchar("pcode", 60)], foreignKeys: [foreignKey(["pcode"])], to: .mariadb ) - XCTAssertEqual(dataType(result, "pcode"), "VARCHAR(60)") - XCTAssertEqual(result.snapshot.columns.filter { $0.dataType == "TEXT" }.count, 3) + #expect(dataType(result, "pcode") == "VARCHAR(60)") + #expect(result.snapshot.columns.filter { $0.dataType == "TEXT" }.count == 3) } - func testOnlyMySQLMovesColumnsOutOfTheRow() { + @Test + func onlyMySQLMovesColumnsOutOfTheRow() { let wide = (0..<17).map { varchar("c\($0)", 1_000) } let result = translate(wide, to: .duckdb) - XCTAssertFalse(result.snapshot.columns.contains { $0.dataType == "TEXT" }) + #expect(!(result.snapshot.columns.contains { $0.dataType == "TEXT" })) } // MARK: - SQL Server lengths /// One note for the table naming every column that kept its length, rather than one per column. - func testSQLServerNamesTheColumnsWhoseLengthCountsUTF16Units() { + @Test + func sQLServerNamesTheColumnsWhoseLengthCountsUTF16Units() { let result = translate([varchar("name", 50), varchar("code", 10), column("body", "text")], to: .mssql) - XCTAssertEqual(dataType(result, "name"), "NVARCHAR(50)") + #expect(dataType(result, "name") == "NVARCHAR(50)") let lengthNotes = result.notes.filter { $0.subject.isEmpty } - XCTAssertEqual(lengthNotes.count, 1) - XCTAssertEqual(lengthNotes.first?.summary.contains("name, code"), true) - XCTAssertEqual(lengthNotes.first?.summary.contains("body"), false) + #expect(lengthNotes.count == 1) + #expect(lengthNotes.first?.summary.contains("name, code") == true) + #expect(lengthNotes.first?.summary.contains("body") == false) - XCTAssertFalse(translate([varchar("name", 50)]).notes.contains { $0.subject.isEmpty }) + #expect(!(translate([varchar("name", 50)]).notes.contains { $0.subject.isEmpty })) } } diff --git a/TableProTests/Core/CrossEngine/CrossEngineValueCoercerTests.swift b/TableProTests/Core/CrossEngine/CrossEngineValueCoercerTests.swift index 3a07d5ce8a..a75608ba10 100644 --- a/TableProTests/Core/CrossEngine/CrossEngineValueCoercerTests.swift +++ b/TableProTests/Core/CrossEngine/CrossEngineValueCoercerTests.swift @@ -3,11 +3,12 @@ // TableProTests // -import TableProPluginKit -import XCTest +import Foundation @testable import TablePro +import TableProPluginKit +import Testing -final class CrossEngineValueCoercerTests: XCTestCase { +struct CrossEngineValueCoercerTests { /// The target's kinds, with the source's taken to match unless a test says otherwise. Most /// coercions are decided by the target; the boolean one is the exception and names both. private func coercer( @@ -27,118 +28,118 @@ final class CrossEngineValueCoercerTests: XCTestCase { // MARK: - When it runs at all /// A table of numbers and strings runs the loop it ran before this existed. - func testATableWithNothingToReshapeNeedsNoCoercion() { + @Test + func aTableWithNothingToReshapeNeedsNoCoercion() { let plain = coercer([.integer(bytes: 4), .text(length: nil, isFixed: false)]) - XCTAssertFalse(plain.isNeeded) - XCTAssertTrue(coercer([.boolean]).isNeeded) - XCTAssertTrue(coercer([.timestamp(precision: nil, hasTimeZone: false)]).isNeeded) + #expect(!plain.isNeeded) + #expect(coercer([.boolean]).isNeeded) + #expect(coercer([.timestamp(precision: nil, hasTimeZone: false)]).isNeeded) } // MARK: - Booleans /// PostgreSQL renders a boolean as `t` and `f`. Bound into a MySQL `TINYINT(1)` that is 0 in /// both cases outside strict mode, so every `true` in the table silently becomes `false`. - func testPostgresBooleansBecomeOneAndZero() { + @Test + func postgresBooleansBecomeOneAndZero() { let row = coercer([.boolean, .boolean]).coerce([.text("t"), .text("f")]) - XCTAssertEqual(row, [.text("1"), .text("0")]) + #expect(row == [.text("1"), .text("0")]) } - func testTheOtherBooleanSpellingsAreNormalised() { + @Test + func theOtherBooleanSpellingsAreNormalised() { let subject = coercer([.boolean]) - XCTAssertEqual(subject.coerce([.text("true")]), [.text("1")]) - XCTAssertEqual(subject.coerce([.text("YES")]), [.text("1")]) - XCTAssertEqual(subject.coerce([.text("off")]), [.text("0")]) - XCTAssertEqual(subject.coerce([.text("0")]), [.text("0")]) + #expect(subject.coerce([.text("true")]) == [.text("1")]) + #expect(subject.coerce([.text("YES")]) == [.text("1")]) + #expect(subject.coerce([.text("off")]) == [.text("0")]) + #expect(subject.coerce([.text("0")]) == [.text("0")]) } /// A MySQL `BIT(1)` arrives as one byte rather than as text. - func testASingleByteBooleanIsRead() { + @Test + func aSingleByteBooleanIsRead() { let subject = coercer([.boolean], from: .mysql) - XCTAssertEqual(subject.coerce([.bytes(Data([1]))]), [.text("1")]) - XCTAssertEqual(subject.coerce([.bytes(Data([0]))]), [.text("0")]) + #expect(subject.coerce([.bytes(Data([1]))]) == [.text("1")]) + #expect(subject.coerce([.bytes(Data([0]))]) == [.text("0")]) } /// A value that is not a boolean spelling is left alone rather than guessed at, so a column the /// source did not really use as one fails visibly instead of arriving wrong. - func testAnUnrecognisedBooleanIsLeftAlone() { - XCTAssertEqual(coercer([.boolean]).coerce([.text("maybe")]), [.text("maybe")]) - XCTAssertEqual(coercer([.boolean]).coerce([.null]), [.null]) + @Test + func anUnrecognisedBooleanIsLeftAlone() { + #expect(coercer([.boolean]).coerce([.text("maybe")]) == [.text("maybe")]) + #expect(coercer([.boolean]).coerce([.null]) == [.null]) } /// A `t` that was text on the source is text on the target too. - func testATextColumnIsNotTouched() { - XCTAssertEqual( - coercer([.text(length: nil, isFixed: false)]).coerce([.text("t")]), [.text("t")] - ) + @Test + func aTextColumnIsNotTouched() { + #expect(coercer([.text(length: nil, isFixed: false)]).coerce([.text("t")]) == [.text("t")]) } /// A boolean copied into a text column keeps what the source wrote. `t` is the value there /// rather than a spelling of one, and rewriting it to `1` would change the data. - func testABooleanCopiedIntoTextKeepsItsOwnSpelling() { + @Test + func aBooleanCopiedIntoTextKeepsItsOwnSpelling() { let subject = coercer([.text(length: nil, isFixed: false)], sources: [.boolean]) - XCTAssertEqual(subject.coerce([.text("t")]), [.text("t")]) + #expect(subject.coerce([.text("t")]) == [.text("t")]) } /// Oracle has no boolean and takes `NUMBER(1)`, so the target kind is a decimal while the /// values are still boolean-shaped. Deciding from the target alone left `t` bound into a number. - func testABooleanIntoANumericTargetIsStillNormalised() { + @Test + func aBooleanIntoANumericTargetIsStillNormalised() { let subject = coercer([.decimal(precision: 1, scale: nil)], sources: [.boolean]) - XCTAssertEqual(subject.coerce([.text("t")]), [.text("1")]) + #expect(subject.coerce([.text("t")]) == [.text("1")]) } // MARK: - Time zones - func testAZoneOffsetIsStrippedForATargetWithoutOne() { + @Test + func aZoneOffsetIsStrippedForATargetWithoutOne() { let subject = coercer([.timestamp(precision: nil, hasTimeZone: false)]) - XCTAssertEqual( - subject.coerce([.text("2024-01-01 10:00:00+07")]), [.text("2024-01-01 10:00:00")] - ) - XCTAssertEqual( - subject.coerce([.text("2024-01-01T10:00:00.123456Z")]), [.text("2024-01-01T10:00:00.123456")] - ) - XCTAssertEqual( - subject.coerce([.text("2024-01-01 10:00:00-03:30")]), [.text("2024-01-01 10:00:00")] - ) + #expect(subject.coerce([.text("2024-01-01 10:00:00+07")]) == [.text("2024-01-01 10:00:00")]) + #expect(subject.coerce([.text("2024-01-01T10:00:00.123456Z")]) == [.text("2024-01-01T10:00:00.123456")]) + #expect(subject.coerce([.text("2024-01-01 10:00:00-03:30")]) == [.text("2024-01-01 10:00:00")]) } /// The crossing that motivates the whole coercion: PostgreSQL `timestamptz` to MySQL /// `DATETIME`. The source has a zone and the target does not, so the decision has to come from /// the target's side. - func testAZonedSourceIsStrippedForAnUnzonedTarget() { + @Test + func aZonedSourceIsStrippedForAnUnzonedTarget() { let subject = coercer( [.timestamp(precision: nil, hasTimeZone: false)], sources: [.timestamp(precision: nil, hasTimeZone: true)] ) - XCTAssertEqual( - subject.coerce([.text("2024-01-01 10:00:00+07")]), [.text("2024-01-01 10:00:00")] - ) + #expect(subject.coerce([.text("2024-01-01 10:00:00+07")]) == [.text("2024-01-01 10:00:00")]) } - func testAZoneAwareTargetKeepsTheOffset() { + @Test + func aZoneAwareTargetKeepsTheOffset() { let subject = coercer([.timestamp(precision: nil, hasTimeZone: true)]) - XCTAssertEqual( - subject.coerce([.text("2024-01-01 10:00:00+07")]), [.text("2024-01-01 10:00:00+07")] - ) + #expect(subject.coerce([.text("2024-01-01 10:00:00+07")]) == [.text("2024-01-01 10:00:00+07")]) } - func testAPlainTimestampIsUnchanged() { + @Test + func aPlainTimestampIsUnchanged() { let subject = coercer([.timestamp(precision: nil, hasTimeZone: false)]) - XCTAssertEqual(subject.coerce([.text("2024-01-01 10:00:00")]), [.text("2024-01-01 10:00:00")]) - XCTAssertEqual(subject.coerce([.text("2024-01-01")]), [.text("2024-01-01")]) + #expect(subject.coerce([.text("2024-01-01 10:00:00")]) == [.text("2024-01-01 10:00:00")]) + #expect(subject.coerce([.text("2024-01-01")]) == [.text("2024-01-01")]) } // MARK: - Zero dates /// No engine but MySQL accepts `0000-00-00`, and a copy that sends it fails on that row. - func testMySQLZeroDatesBecomeNull() { + @Test + func mySQLZeroDatesBecomeNull() { let subject = coercer([.date, .timestamp(precision: nil, hasTimeZone: false)], from: .mysql) - XCTAssertEqual( - subject.coerce([.text("0000-00-00"), .text("0000-00-00 00:00:00")]), [.null, .null] - ) + #expect(subject.coerce([.text("0000-00-00"), .text("0000-00-00 00:00:00")]) == [.null, .null]) } - func testAZeroDateFromAnotherEngineIsLeftAlone() { - XCTAssertEqual(coercer([.date], from: .postgres).coerce([.text("0000-00-00")]), [.text("0000-00-00")]) + @Test + func aZeroDateFromAnotherEngineIsLeftAlone() { + #expect(coercer([.date], from: .postgres).coerce([.text("0000-00-00")]) == [.text("0000-00-00")]) } // MARK: - Arrays into JSON @@ -147,41 +148,46 @@ final class CrossEngineValueCoercerTests: XCTestCase { .array(element: .text(length: nil, isFixed: false)) } - func testAPostgresArrayBecomesJson() { + @Test + func aPostgresArrayBecomesJson() { let subject = coercer([.json], sources: [arrayOfText], from: .postgres) - XCTAssertEqual(subject.coerce([.text("{1,2,3}")]), [.text("[1,2,3]")]) - XCTAssertEqual(subject.coerce([.text("{a,b}")]), [.text("[\"a\",\"b\"]")]) - XCTAssertEqual(subject.coerce([.text("{}")]), [.text("[]")]) + #expect(subject.coerce([.text("{1,2,3}")]) == [.text("[1,2,3]")]) + #expect(subject.coerce([.text("{a,b}")]) == [.text("[\"a\",\"b\"]")]) + #expect(subject.coerce([.text("{}")]) == [.text("[]")]) } /// A JSON object arrives with the same brackets an array literal uses, and it is already JSON. - func testAJsonObjectIsNotMistakenForAnArray() { + @Test + func aJsonObjectIsNotMistakenForAnArray() { let subject = coercer([.json], sources: [arrayOfText], from: .postgres) - XCTAssertEqual(subject.coerce([.text("{\"a\": 1}")]), [.text("{\"a\": 1}")]) + #expect(subject.coerce([.text("{\"a\": 1}")]) == [.text("{\"a\": 1}")]) } /// The brackets alone cannot tell an array from an object, so the source column decides. A /// `jsonb` `{}` went across as `[]`, which is valid JSON and a different type: `jsonb_typeof` /// came back `array`, the copy reported success, and nothing in the UI showed the difference. - func testAJsonSourceIsNeverRewrittenAsAnArray() { + @Test + func aJsonSourceIsNeverRewrittenAsAnArray() { let subject = coercer([.json], sources: [.json], from: .postgres) - XCTAssertEqual(subject.coerce([.text("{}")]), [.text("{}")]) - XCTAssertEqual(subject.coerce([.text("{\"a\": 1}")]), [.text("{\"a\": 1}")]) - XCTAssertEqual(subject.coerce([.text("{ }")]), [.text("{ }")]) + #expect(subject.coerce([.text("{}")]) == [.text("{}")]) + #expect(subject.coerce([.text("{\"a\": 1}")]) == [.text("{\"a\": 1}")]) + #expect(subject.coerce([.text("{ }")]) == [.text("{ }")]) } /// A text column holding something array-shaped is text, not an array. - func testATextSourceIsNeverRewrittenAsAnArray() { + @Test + func aTextSourceIsNeverRewrittenAsAnArray() { let subject = coercer([.json], sources: [.text(length: nil, isFixed: false)], from: .postgres) - XCTAssertEqual(subject.coerce([.text("{a,b}")]), [.text("{a,b}")]) + #expect(subject.coerce([.text("{a,b}")]) == [.text("{a,b}")]) } // MARK: - Shape /// The width is checked before the coercion, so a short row can never reach it. Guarded here /// too, because a positional reshape against the wrong column's type is silent. - func testAShortRowIsNotReshapedPastItsEnd() { + @Test + func aShortRowIsNotReshapedPastItsEnd() { let subject = coercer([.boolean, .boolean, .boolean]) - XCTAssertEqual(subject.coerce([.text("t")]), [.text("1")]) + #expect(subject.coerce([.text("t")]) == [.text("1")]) } } diff --git a/TableProTests/Core/CrossEngine/MySQLStorageWidthTests.swift b/TableProTests/Core/CrossEngine/MySQLStorageWidthTests.swift index d10eb1f6a6..02bb7ce1e3 100644 --- a/TableProTests/Core/CrossEngine/MySQLStorageWidthTests.swift +++ b/TableProTests/Core/CrossEngine/MySQLStorageWidthTests.swift @@ -7,10 +7,11 @@ // was refused. // +import Foundation @testable import TablePro -import XCTest +import Testing -final class MySQLStorageWidthTests: XCTestCase { +struct MySQLStorageWidthTests { private typealias Width = MySQLStorageWidth private func varchar(_ length: Int) -> CanonicalTypeKind { @@ -25,135 +26,144 @@ final class MySQLStorageWidthTests: XCTestCase { /// `PRIMARY KEY (VARCHAR(766), BIGINT)` was created and `(VARCHAR(767), BIGINT)` refused with /// ERROR 1071. - func testATextKeyPartCountsFourBytesACharacter() { + @Test + func aTextKeyPartCountsFourBytesACharacter() { let bigint = Width.keyBytes(.integer(bytes: 8)) ?? 0 - XCTAssertEqual((Width.keyBytes(varchar(766)) ?? 0) + bigint, 3_072) - XCTAssertEqual((Width.keyBytes(varchar(767)) ?? 0) + bigint, 3_076) + #expect((Width.keyBytes(varchar(766)) ?? 0) + bigint == 3_072) + #expect((Width.keyBytes(varchar(767)) ?? 0) + bigint == 3_076) } - func testFixedKeyPartsCountTheirStorage() { - XCTAssertEqual(Width.keyBytes(.timestamp(precision: 6, hasTimeZone: false)), 8) - XCTAssertEqual(Width.keyBytes(.decimal(precision: 65, scale: 30)), 30) - XCTAssertEqual(Width.keyBytes(.date), 3) - XCTAssertEqual(Width.keyBytes(.integer(bytes: 4)), 4) - XCTAssertEqual(Width.keyBytes(.binary(length: 3_064, isFixed: false)), 3_064) + @Test + func fixedKeyPartsCountTheirStorage() { + #expect(Width.keyBytes(.timestamp(precision: 6, hasTimeZone: false)) == 8) + #expect(Width.keyBytes(.decimal(precision: 65, scale: 30)) == 30) + #expect(Width.keyBytes(.date) == 3) + #expect(Width.keyBytes(.integer(bytes: 4)) == 4) + #expect(Width.keyBytes(.binary(length: 3_064, isFixed: false)) == 3_064) } - func testAnUnboundedKeyPartHasAWidthOnlyWithAPrefix() { - XCTAssertNil(Width.keyBytes(.text(length: nil, isFixed: false))) - XCTAssertNil(Width.keyBytes(.json)) - XCTAssertEqual(Width.keyBytes(.text(length: nil, isFixed: false), prefix: 255), 1_020) - XCTAssertEqual(Width.keyBytes(.binary(length: nil, isFixed: false), prefix: 255), 255) - XCTAssertEqual(Width.keyBytes(varchar(1_000), prefix: 255), 1_020) + @Test + func anUnboundedKeyPartHasAWidthOnlyWithAPrefix() { + #expect(Width.keyBytes(.text(length: nil, isFixed: false)) == nil) + #expect(Width.keyBytes(.json) == nil) + #expect(Width.keyBytes(.text(length: nil, isFixed: false), prefix: 255) == 1_020) + #expect(Width.keyBytes(.binary(length: nil, isFixed: false), prefix: 255) == 255) + #expect(Width.keyBytes(varchar(1_000), prefix: 255) == 1_020) } - func testDecimalsPackNineDigitsToFourBytes() { - XCTAssertEqual(Width.decimalBytes(precision: 65, scale: 30), 30) - XCTAssertEqual(Width.decimalBytes(precision: 10, scale: 2), 5) - XCTAssertEqual(Width.decimalBytes(precision: 19, scale: 4), 9) + @Test + func decimalsPackNineDigitsToFourBytes() { + #expect(Width.decimalBytes(precision: 65, scale: 30) == 30) + #expect(Width.decimalBytes(precision: 10, scale: 2) == 5) + #expect(Width.decimalBytes(precision: 19, scale: 4) == 9) } // MARK: - Rows /// One `VARCHAR(16383) NULL` was created at 65,535 bytes, and with a `TINYINT NOT NULL` beside it /// was refused at 65,536. - func testTheRowLimitCountsLengthPrefixesAndTheNullBitmap() { - XCTAssertEqual(Width.rowBytes(of: [column(varchar(16_383), nullable: false)]), 65_534) - XCTAssertEqual(Width.rowBytes(of: [column(varchar(16_383))]), 65_535) - XCTAssertNil(Width.exceededLimit(of: [column(varchar(16_383))], hasPrimaryKey: false, flavor: .mysql)) + @Test + func theRowLimitCountsLengthPrefixesAndTheNullBitmap() { + #expect(Width.rowBytes(of: [column(varchar(16_383), nullable: false)]) == 65_534) + #expect(Width.rowBytes(of: [column(varchar(16_383))]) == 65_535) + #expect(Width.exceededLimit(of: [column(varchar(16_383))], hasPrimaryKey: false, flavor: .mysql) == nil) let over = [column(varchar(16_383)), column(.integer(bytes: 1), nullable: false)] - XCTAssertEqual(Width.rowBytes(of: over), 65_536) - XCTAssertEqual(Width.exceededLimit(of: over, hasPrimaryKey: false, flavor: .mysql), .row) - XCTAssertEqual(Width.exceededLimit(of: over, hasPrimaryKey: false, flavor: .mariadb), .row) + #expect(Width.rowBytes(of: over) == 65_536) + #expect(Width.exceededLimit(of: over, hasPrimaryKey: false, flavor: .mysql) == .row) + #expect(Width.exceededLimit(of: over, hasPrimaryKey: false, flavor: .mariadb) == .row) } /// Both servers count the row the same way: `VARCHAR(63)` 253, `VARCHAR(64)` 258, `CHAR(50)` 200 /// and `LONGTEXT` 12. - func testBothServersCountTheRowAlike() { - XCTAssertEqual(Width.rowBytes(varchar(63)), 253) - XCTAssertEqual(Width.rowBytes(varchar(64)), 258) - XCTAssertEqual(Width.rowBytes(.text(length: 50, isFixed: true)), 200) - XCTAssertEqual(Width.rowBytes(varchar(1_000)), 4_002) - XCTAssertEqual(Width.rowBytes(.text(length: nil, isFixed: false)), 12) + @Test + func bothServersCountTheRowAlike() { + #expect(Width.rowBytes(varchar(63)) == 253) + #expect(Width.rowBytes(varchar(64)) == 258) + #expect(Width.rowBytes(.text(length: 50, isFixed: true)) == 200) + #expect(Width.rowBytes(varchar(1_000)) == 4_002) + #expect(Width.rowBytes(.text(length: nil, isFixed: false)) == 12) } // MARK: - Records /// MySQL counts a variable column, a utf8mb4 `CHAR` and a `TEXT` alike once it passes 40 bytes. - func testMySQLCountsAVariableColumnPast40BytesAs41() { - XCTAssertEqual(Width.recordBytes(varchar(5), flavor: .mysql), 21) - XCTAssertEqual(Width.recordBytes(varchar(10), flavor: .mysql), 41) - XCTAssertEqual(Width.recordBytes(varchar(11), flavor: .mysql), 41) - XCTAssertEqual(Width.recordBytes(varchar(1_000), flavor: .mysql), 41) - XCTAssertEqual(Width.recordBytes(.text(length: 50, isFixed: true), flavor: .mysql), 41) - XCTAssertEqual(Width.recordBytes(.binary(length: 255, isFixed: false), flavor: .mysql), 41) - XCTAssertEqual(Width.recordBytes(.text(length: nil, isFixed: false), flavor: .mysql), 41) - XCTAssertEqual(Width.recordBytes(.binary(length: 100, isFixed: true), flavor: .mysql), 100) + @Test + func mySQLCountsAVariableColumnPast40BytesAs41() { + #expect(Width.recordBytes(varchar(5), flavor: .mysql) == 21) + #expect(Width.recordBytes(varchar(10), flavor: .mysql) == 41) + #expect(Width.recordBytes(varchar(11), flavor: .mysql) == 41) + #expect(Width.recordBytes(varchar(1_000), flavor: .mysql) == 41) + #expect(Width.recordBytes(.text(length: 50, isFixed: true), flavor: .mysql) == 41) + #expect(Width.recordBytes(.binary(length: 255, isFixed: false), flavor: .mysql) == 41) + #expect(Width.recordBytes(.text(length: nil, isFixed: false), flavor: .mysql) == 41) + #expect(Width.recordBytes(.binary(length: 100, isFixed: true), flavor: .mysql) == 100) } /// MariaDB counts a variable column of up to 255 bytes whole, and a longer one and every `TEXT` /// as 21. - func testMariaDBCountsAColumnOfUpTo255BytesWhole() { - XCTAssertEqual(Width.recordBytes(varchar(11), flavor: .mariadb), 45) - XCTAssertEqual(Width.recordBytes(varchar(63), flavor: .mariadb), 253) - XCTAssertEqual(Width.recordBytes(varchar(64), flavor: .mariadb), 21) - XCTAssertEqual(Width.recordBytes(.text(length: 50, isFixed: true), flavor: .mariadb), 201) - XCTAssertEqual(Width.recordBytes(.binary(length: 255, isFixed: false), flavor: .mariadb), 256) - XCTAssertEqual(Width.recordBytes(.binary(length: 256, isFixed: false), flavor: .mariadb), 21) - XCTAssertEqual(Width.recordBytes(.text(length: nil, isFixed: false), flavor: .mariadb), 21) - XCTAssertEqual(Width.recordBytes(.binary(length: 100, isFixed: true), flavor: .mariadb), 100) + @Test + func mariaDBCountsAColumnOfUpTo255BytesWhole() { + #expect(Width.recordBytes(varchar(11), flavor: .mariadb) == 45) + #expect(Width.recordBytes(varchar(63), flavor: .mariadb) == 253) + #expect(Width.recordBytes(varchar(64), flavor: .mariadb) == 21) + #expect(Width.recordBytes(.text(length: 50, isFixed: true), flavor: .mariadb) == 201) + #expect(Width.recordBytes(.binary(length: 255, isFixed: false), flavor: .mariadb) == 256) + #expect(Width.recordBytes(.binary(length: 256, isFixed: false), flavor: .mariadb) == 21) + #expect(Width.recordBytes(.text(length: nil, isFixed: false), flavor: .mariadb) == 21) + #expect(Width.recordBytes(.binary(length: 100, isFixed: true), flavor: .mariadb) == 100) } /// An `INT` primary key, ten `VARCHAR(50) NOT NULL` and 7,693 bytes of `BINARY NOT NULL` were /// created on MySQL at 8,125 bytes, and one more byte was refused with ERROR 1118. MariaDB /// refused the same table. - func testTheRecordLimitIsHalfAPage() { + @Test + func theRecordLimitIsHalfAPage() { let key = column(.integer(bytes: 4), nullable: false) let texts = Array(repeating: column(varchar(50), nullable: false), count: 10) let filler = Array(repeating: column(.binary(length: 255, isFixed: true), nullable: false), count: 30) + [column(.binary(length: 43, isFixed: true), nullable: false)] let fits = [key] + texts + filler - XCTAssertEqual(Width.recordBytes(of: fits, hasPrimaryKey: true, flavor: .mysql), 8_125) - XCTAssertNil(Width.exceededLimit(of: fits, hasPrimaryKey: true, flavor: .mysql)) - XCTAssertEqual(Width.exceededLimit(of: fits, hasPrimaryKey: true, flavor: .mariadb), .record) + #expect(Width.recordBytes(of: fits, hasPrimaryKey: true, flavor: .mysql) == 8_125) + #expect(Width.exceededLimit(of: fits, hasPrimaryKey: true, flavor: .mysql) == nil) + #expect(Width.exceededLimit(of: fits, hasPrimaryKey: true, flavor: .mariadb) == .record) let over = fits + [column(.binary(length: 1, isFixed: true), nullable: false)] - XCTAssertEqual(Width.recordBytes(of: over, hasPrimaryKey: true, flavor: .mysql), 8_126) - XCTAssertEqual(Width.exceededLimit(of: over, hasPrimaryKey: true, flavor: .mysql), .record) + #expect(Width.recordBytes(of: over, hasPrimaryKey: true, flavor: .mysql) == 8_126) + #expect(Width.exceededLimit(of: over, hasPrimaryKey: true, flavor: .mysql) == .record) } /// A `BIGINT` primary key and 41 nullable `VARCHAR(50)` columns were created on MySQL and refused /// by MariaDB; 40 were created on both. - func testTheSameTableFitsOneServerAndNotTheOther() { + @Test + func theSameTableFitsOneServerAndNotTheOther() { let key = column(.integer(bytes: 8), nullable: false) let fortyOne = [key] + Array(repeating: column(varchar(50)), count: 41) - XCTAssertNil(Width.exceededLimit(of: fortyOne, hasPrimaryKey: true, flavor: .mysql)) - XCTAssertEqual(Width.exceededLimit(of: fortyOne, hasPrimaryKey: true, flavor: .mariadb), .record) + #expect(Width.exceededLimit(of: fortyOne, hasPrimaryKey: true, flavor: .mysql) == nil) + #expect(Width.exceededLimit(of: fortyOne, hasPrimaryKey: true, flavor: .mariadb) == .record) let forty = [key] + Array(repeating: column(varchar(50)), count: 40) - XCTAssertNil(Width.exceededLimit(of: forty, hasPrimaryKey: true, flavor: .mariadb)) + #expect(Width.exceededLimit(of: forty, hasPrimaryKey: true, flavor: .mariadb) == nil) } /// Without a primary key InnoDB adds a six-byte row id. - func testATableWithoutAPrimaryKeyCountsTheRowId() { + @Test + func aTableWithoutAPrimaryKeyCountsTheRowId() { let columns = [column(.integer(bytes: 4))] - XCTAssertEqual( - Width.recordBytes(of: columns, hasPrimaryKey: false, flavor: .mysql) - - Width.recordBytes(of: columns, hasPrimaryKey: true, flavor: .mysql), - 6 - ) + #expect(Width.recordBytes(of: columns, hasPrimaryKey: false, flavor: .mysql) + - Width.recordBytes(of: columns, hasPrimaryKey: true, flavor: .mysql) == 6) } // MARK: - Flavor /// A MariaDB server is often reached through a connection typed MySQL, so the banner decides. - func testTheServersBannerDecidesTheFlavor() { - XCTAssertEqual(Width.Flavor.of(.mysql, serverVersion: "8.4.11"), .mysql) - XCTAssertEqual(Width.Flavor.of(.mysql, serverVersion: "12.3.3-MariaDB"), .mariadb) - XCTAssertEqual(Width.Flavor.of(.mariadb, serverVersion: "5.5.5-10.11.6-MariaDB-log"), .mariadb) - XCTAssertEqual(Width.Flavor.of(DatabaseType(rawValue: "TiDB"), serverVersion: "8.0.11-TiDB-v7.5.0"), .mysql) - XCTAssertEqual(Width.Flavor.of(.mariadb, serverVersion: nil), .mariadb) - XCTAssertEqual(Width.Flavor.of(.mysql, serverVersion: nil), .mysql) + @Test + func theServersBannerDecidesTheFlavor() { + #expect(Width.Flavor.of(.mysql, serverVersion: "8.4.11") == .mysql) + #expect(Width.Flavor.of(.mysql, serverVersion: "12.3.3-MariaDB") == .mariadb) + #expect(Width.Flavor.of(.mariadb, serverVersion: "5.5.5-10.11.6-MariaDB-log") == .mariadb) + #expect(Width.Flavor.of(DatabaseType(rawValue: "TiDB"), serverVersion: "8.0.11-TiDB-v7.5.0") == .mysql) + #expect(Width.Flavor.of(.mariadb, serverVersion: nil) == .mariadb) + #expect(Width.Flavor.of(.mysql, serverVersion: nil) == .mysql) } } diff --git a/TableProTests/Core/CrossEngine/SQLTypeParserModifierTests.swift b/TableProTests/Core/CrossEngine/SQLTypeParserModifierTests.swift index e0ca863c9b..8bfb2005da 100644 --- a/TableProTests/Core/CrossEngine/SQLTypeParserModifierTests.swift +++ b/TableProTests/Core/CrossEngine/SQLTypeParserModifierTests.swift @@ -3,10 +3,11 @@ // TableProTests // +import Foundation @testable import TablePro -import XCTest +import Testing -final class SQLTypeParserModifierTests: XCTestCase { +struct SQLTypeParserModifierTests { private func kind(_ spelling: String, _ family: SQLTypeFamily) -> CanonicalTypeKind { SQLTypeParser.parse(spelling, family: family).kind } @@ -18,93 +19,92 @@ final class SQLTypeParserModifierTests: XCTestCase { // MARK: - Catalog spelling /// `information_schema` reports each of these without its modifier, and `format_type` with it. - func testTheCatalogSpellingCarriesTheModifier() { - XCTAssertEqual( - parsed("CHARACTER VARYING", catalog: "character varying(50)").kind, .text(length: 50, isFixed: false) - ) - XCTAssertEqual(parsed("NUMERIC", catalog: "numeric(10,2)").kind, .decimal(precision: 10, scale: 2)) - XCTAssertEqual(parsed("CHARACTER", catalog: "character(10)").kind, .text(length: 10, isFixed: true)) - XCTAssertEqual(parsed("BIT", catalog: "bit(8)").kind, .bitString(length: 8)) - XCTAssertEqual( - parsed("TIMESTAMP WITH TIME ZONE", catalog: "timestamp(3) with time zone").kind, - .timestamp(precision: 3, hasTimeZone: true) - ) - XCTAssertEqual(parsed("CHARACTER VARYING", catalog: "character varying(50)").sourceSpelling, "character varying(50)") + @Test + func theCatalogSpellingCarriesTheModifier() { + #expect(parsed("CHARACTER VARYING", catalog: "character varying(50)").kind == .text(length: 50, isFixed: false)) + #expect(parsed("NUMERIC", catalog: "numeric(10,2)").kind == .decimal(precision: 10, scale: 2)) + #expect(parsed("CHARACTER", catalog: "character(10)").kind == .text(length: 10, isFixed: true)) + #expect(parsed("BIT", catalog: "bit(8)").kind == .bitString(length: 8)) + #expect(parsed("TIMESTAMP WITH TIME ZONE", catalog: "timestamp(3) with time zone").kind == .timestamp(precision: 3, hasTimeZone: true)) + #expect(parsed("CHARACTER VARYING", catalog: "character varying(50)").sourceSpelling == "character varying(50)") } /// A type outside `pg_catalog` is named with its schema, which no family reads, while the display /// spelling still classifies it. - func testAQualifiedCatalogSpellingGivesWayToTheDisplaySpelling() { + @Test + func aQualifiedCatalogSpellingGivesWayToTheDisplaySpelling() { let shape = parsed("geometry", catalog: "public.geometry(Point,4326)") - XCTAssertEqual(shape.kind, .spatial) - XCTAssertEqual(shape.sourceSpelling, "geometry") - XCTAssertEqual(parsed("citext", catalog: "public.citext").kind, .text(length: nil, isFixed: false)) - XCTAssertEqual(parsed("NUMERIC", catalog: "public.price").kind, .decimal(precision: nil, scale: nil)) - XCTAssertEqual( - parsed("geometry[]", catalog: "public.geometry(Point,4326)[]").kind, .array(element: .spatial) - ) + #expect(shape.kind == .spatial) + #expect(shape.sourceSpelling == "geometry") + #expect(parsed("citext", catalog: "public.citext").kind == .text(length: nil, isFixed: false)) + #expect(parsed("NUMERIC", catalog: "public.price").kind == .decimal(precision: nil, scale: nil)) + #expect(parsed("geometry[]", catalog: "public.geometry(Point,4326)[]").kind == .array(element: .spatial)) let mood = parsed("ENUM", catalog: "public.mood") - XCTAssertEqual(mood.kind, .unsupported) - XCTAssertEqual(mood.sourceSpelling, "ENUM") + #expect(mood.kind == .unsupported) + #expect(mood.sourceSpelling == "ENUM") } - func testNoCatalogSpellingReadsTheDisplaySpelling() { - XCTAssertEqual(parsed("CHARACTER VARYING", catalog: nil).kind, .text(length: nil, isFixed: false)) - XCTAssertEqual(parsed("CHARACTER VARYING", catalog: "").kind, .text(length: nil, isFixed: false)) + @Test + func noCatalogSpellingReadsTheDisplaySpelling() { + #expect(parsed("CHARACTER VARYING", catalog: nil).kind == .text(length: nil, isFixed: false)) + #expect(parsed("CHARACTER VARYING", catalog: "").kind == .text(length: nil, isFixed: false)) } - func testAnArrayCatalogSpellingKeepsItsElementsModifier() { - XCTAssertEqual( - parsed("varchar[]", catalog: "character varying(50)[]").kind, - .array(element: .text(length: 50, isFixed: false)) - ) + @Test + func anArrayCatalogSpellingKeepsItsElementsModifier() { + #expect(parsed("varchar[]", catalog: "character varying(50)[]").kind == .array(element: .text(length: 50, isFixed: false))) } // MARK: - PostgreSQL's omitted modifiers - func testAnIntervalWithFieldsIsAnInterval() { + @Test + func anIntervalWithFieldsIsAnInterval() { for spelling in [ "interval hour to minute", "interval day to second(3)", "interval second(2)", "interval year", "interval(3)", "interval" ] { - XCTAssertEqual(kind(spelling, .postgres), .interval, spelling) + #expect(kind(spelling, .postgres) == .interval, Comment(rawValue: spelling)) } } /// `information_schema.columns.datetime_precision` is 6 for each of these. - func testATimeOrTimestampWithoutPrecisionKeepsMicroseconds() { - XCTAssertEqual(kind("timestamp without time zone", .postgres), .timestamp(precision: 6, hasTimeZone: false)) - XCTAssertEqual(kind("timestamp with time zone", .postgres), .timestamp(precision: 6, hasTimeZone: true)) - XCTAssertEqual(kind("TIMESTAMPTZ", .postgres), .timestamp(precision: 6, hasTimeZone: true)) - XCTAssertEqual(kind("time without time zone", .postgres), .time(precision: 6, hasTimeZone: false)) - XCTAssertEqual(kind("time with time zone", .postgres), .time(precision: 6, hasTimeZone: true)) - XCTAssertEqual(kind("timestamp(0) with time zone", .postgres), .timestamp(precision: 0, hasTimeZone: true)) + @Test + func aTimeOrTimestampWithoutPrecisionKeepsMicroseconds() { + #expect(kind("timestamp without time zone", .postgres) == .timestamp(precision: 6, hasTimeZone: false)) + #expect(kind("timestamp with time zone", .postgres) == .timestamp(precision: 6, hasTimeZone: true)) + #expect(kind("TIMESTAMPTZ", .postgres) == .timestamp(precision: 6, hasTimeZone: true)) + #expect(kind("time without time zone", .postgres) == .time(precision: 6, hasTimeZone: false)) + #expect(kind("time with time zone", .postgres) == .time(precision: 6, hasTimeZone: true)) + #expect(kind("timestamp(0) with time zone", .postgres) == .timestamp(precision: 0, hasTimeZone: true)) } - func testOnlyPostgreSQLReadsATimestampWithoutPrecisionAsMicroseconds() { - XCTAssertEqual(kind("DATETIME", .mysql), .timestamp(precision: nil, hasTimeZone: false)) - XCTAssertEqual(kind("TIMESTAMP", .generic), .timestamp(precision: nil, hasTimeZone: false)) + @Test + func onlyPostgreSQLReadsATimestampWithoutPrecisionAsMicroseconds() { + #expect(kind("DATETIME", .mysql) == .timestamp(precision: nil, hasTimeZone: false)) + #expect(kind("TIMESTAMP", .generic) == .timestamp(precision: nil, hasTimeZone: false)) } /// PostgreSQL's `numeric` with no modifier has no precision limit. Every other family's bare /// decimal keeps the 38 digits it has always been read with, which is what a bare Oracle `NUMBER` /// holds. - func testOnlyPostgreSQLReadsABareDecimalAsUnconstrained() { - XCTAssertEqual(kind("numeric", .postgres), .decimal(precision: nil, scale: nil)) - XCTAssertEqual(kind("numeric(10,2)", .postgres), .decimal(precision: 10, scale: 2)) + @Test + func onlyPostgreSQLReadsABareDecimalAsUnconstrained() { + #expect(kind("numeric", .postgres) == .decimal(precision: nil, scale: nil)) + #expect(kind("numeric(10,2)", .postgres) == .decimal(precision: 10, scale: 2)) for family in SQLTypeFamily.allCases where family != .postgres { let spelling = family == .oracle ? "NUMBER" : "DECIMAL" - XCTAssertEqual(kind(spelling, family), .decimal(precision: 38, scale: nil), "\(family)") + #expect(kind(spelling, family) == .decimal(precision: 38, scale: nil), "\(family)") } } // MARK: - Oracle length semantics - func testAnOracleLengthNamingItsUnitIsStillALength() { - XCTAssertEqual(kind("VARCHAR2(50 CHAR)", .oracle), .text(length: 50, isFixed: false)) - XCTAssertEqual(kind("VARCHAR2(50 BYTE)", .oracle), .text(length: 50, isFixed: false)) - XCTAssertEqual(kind("CHAR(10 CHAR)", .oracle), .text(length: 10, isFixed: true)) - XCTAssertEqual(kind("VARCHAR2(50)", .oracle), .text(length: 50, isFixed: false)) + @Test + func anOracleLengthNamingItsUnitIsStillALength() { + #expect(kind("VARCHAR2(50 CHAR)", .oracle) == .text(length: 50, isFixed: false)) + #expect(kind("VARCHAR2(50 BYTE)", .oracle) == .text(length: 50, isFixed: false)) + #expect(kind("CHAR(10 CHAR)", .oracle) == .text(length: 10, isFixed: true)) + #expect(kind("VARCHAR2(50)", .oracle) == .text(length: 50, isFixed: false)) } } diff --git a/TableProTests/Core/CrossEngine/SQLTypeParserTests.swift b/TableProTests/Core/CrossEngine/SQLTypeParserTests.swift index e14d552eab..755f785432 100644 --- a/TableProTests/Core/CrossEngine/SQLTypeParserTests.swift +++ b/TableProTests/Core/CrossEngine/SQLTypeParserTests.swift @@ -3,10 +3,11 @@ // TableProTests // +import Foundation @testable import TablePro -import XCTest +import Testing -final class SQLTypeParserTests: XCTestCase { +struct SQLTypeParserTests { private func kind(_ spelling: String, _ family: SQLTypeFamily) -> CanonicalTypeKind { SQLTypeParser.parse(spelling, family: family).kind } @@ -15,72 +16,80 @@ final class SQLTypeParserTests: XCTestCase { /// The reason the parser is keyed by family rather than by word. Each of these is read /// correctly for one engine and wrongly for the other by any shared table. - func testTheSameWordIsReadDifferentlyPerEngine() { - XCTAssertEqual(kind("TINYINT(1)", .mysql), .boolean) - XCTAssertEqual(kind("TINYINT", .mssql), .integer(bytes: 1)) - XCTAssertEqual(kind("DATE", .postgres), .date) - XCTAssertEqual(kind("DATE", .oracle), .timestamp(precision: 0, hasTimeZone: false)) - XCTAssertEqual(kind("REAL", .postgres), .floatingPoint(bits: 32)) - XCTAssertEqual(kind("REAL", .sqlite), .floatingPoint(bits: 64)) + @Test + func theSameWordIsReadDifferentlyPerEngine() { + #expect(kind("TINYINT(1)", .mysql) == .boolean) + #expect(kind("TINYINT", .mssql) == .integer(bytes: 1)) + #expect(kind("DATE", .postgres) == .date) + #expect(kind("DATE", .oracle) == .timestamp(precision: 0, hasTimeZone: false)) + #expect(kind("REAL", .postgres) == .floatingPoint(bits: 32)) + #expect(kind("REAL", .sqlite) == .floatingPoint(bits: 64)) } /// SQL Server's `TINYINT` holds 0 to 255, so it is unsigned however it is spelled. - func testSQLServerTinyIntIsUnsigned() { - XCTAssertTrue(SQLTypeParser.parse("TINYINT", family: .mssql).isUnsigned) - XCTAssertFalse(SQLTypeParser.parse("TINYINT", family: .mysql).isUnsigned) + @Test + func sQLServerTinyIntIsUnsigned() { + #expect(SQLTypeParser.parse("TINYINT", family: .mssql).isUnsigned) + #expect(!(SQLTypeParser.parse("TINYINT", family: .mysql).isUnsigned)) } // MARK: - Modifiers - func testUnsignedIsReadAsAModifierRatherThanAsPartOfTheName() { + @Test + func unsignedIsReadAsAModifierRatherThanAsPartOfTheName() { let parsed = SQLTypeParser.parse("BIGINT UNSIGNED", family: .mysql) - XCTAssertEqual(parsed.kind, .integer(bytes: 8)) - XCTAssertTrue(parsed.isUnsigned) + #expect(parsed.kind == .integer(bytes: 8)) + #expect(parsed.isUnsigned) } - func testZerofillDoesNotHideTheTypeName() { - XCTAssertEqual(kind("INT UNSIGNED ZEROFILL", .mysql), .integer(bytes: 4)) + @Test + func zerofillDoesNotHideTheTypeName() { + #expect(kind("INT UNSIGNED ZEROFILL", .mysql) == .integer(bytes: 4)) } /// Without stripping these every ClickHouse column is one unknown type called `Nullable`. - func testClickHouseWrappersAreStripped() { - XCTAssertEqual(kind("Nullable(Int32)", .clickhouse), .integer(bytes: 4)) - XCTAssertEqual(kind("LowCardinality(Nullable(String))", .clickhouse), .text(length: nil, isFixed: false)) + @Test + func clickHouseWrappersAreStripped() { + #expect(kind("Nullable(Int32)", .clickhouse) == .integer(bytes: 4)) + #expect(kind("LowCardinality(Nullable(String))", .clickhouse) == .text(length: nil, isFixed: false)) } // MARK: - Parameters - func testLengthAndPrecisionAreKept() { - XCTAssertEqual(kind("VARCHAR(255)", .mysql), .text(length: 255, isFixed: false)) - XCTAssertEqual(kind("CHAR(2)", .postgres), .text(length: 2, isFixed: true)) - XCTAssertEqual(kind("NUMERIC(19,4)", .postgres), .decimal(precision: 19, scale: 4)) - XCTAssertEqual(kind("DECIMAL(10)", .mysql), .decimal(precision: 10, scale: nil)) + @Test + func lengthAndPrecisionAreKept() { + #expect(kind("VARCHAR(255)", .mysql) == .text(length: 255, isFixed: false)) + #expect(kind("CHAR(2)", .postgres) == .text(length: 2, isFixed: true)) + #expect(kind("NUMERIC(19,4)", .postgres) == .decimal(precision: 19, scale: 4)) + #expect(kind("DECIMAL(10)", .mysql) == .decimal(precision: 10, scale: nil)) } /// The parenthesised part sits in the middle of these, so a parser that stops at the first /// bracket reads `timestamp(3) with time zone` as an ordinary `timestamp`. - func testASuffixAfterTheParametersIsPartOfTheName() { - XCTAssertEqual(kind("timestamp(3) with time zone", .postgres), .timestamp(precision: 3, hasTimeZone: true)) - XCTAssertEqual(kind("time(6) without time zone", .postgres), .time(precision: 6, hasTimeZone: false)) + @Test + func aSuffixAfterTheParametersIsPartOfTheName() { + #expect(kind("timestamp(3) with time zone", .postgres) == .timestamp(precision: 3, hasTimeZone: true)) + #expect(kind("time(6) without time zone", .postgres) == .time(precision: 6, hasTimeZone: false)) } - func testEnumLabelsAreRead() { - XCTAssertEqual(kind("enum('small','large')", .mysql), .enumeration(values: ["small", "large"])) - XCTAssertEqual( - kind("Enum8('a' = 1, 'b' = 2)", .clickhouse), .enumeration(values: ["a", "b"]) - ) + @Test + func enumLabelsAreRead() { + #expect(kind("enum('small','large')", .mysql) == .enumeration(values: ["small", "large"])) + #expect(kind("Enum8('a' = 1, 'b' = 2)", .clickhouse) == .enumeration(values: ["a", "b"])) } - func testAnEmptyParameterListIsNotALength() { - XCTAssertEqual(kind("VARCHAR", .mysql), .text(length: nil, isFixed: false)) - XCTAssertEqual(kind("NVARCHAR(MAX)", .mssql), .text(length: nil, isFixed: false)) + @Test + func anEmptyParameterListIsNotALength() { + #expect(kind("VARCHAR", .mysql) == .text(length: nil, isFixed: false)) + #expect(kind("NVARCHAR(MAX)", .mssql) == .text(length: nil, isFixed: false)) } // MARK: - Arrays - func testArraysAreReadOnBothSpellings() { - XCTAssertEqual(kind("integer[]", .postgres), .array(element: .integer(bytes: 4))) - XCTAssertEqual(kind("Array(String)", .clickhouse), .array(element: .text(length: nil, isFixed: false))) + @Test + func arraysAreReadOnBothSpellings() { + #expect(kind("integer[]", .postgres) == .array(element: .integer(bytes: 4))) + #expect(kind("Array(String)", .clickhouse) == .array(element: .text(length: nil, isFixed: false))) } // MARK: - Oracle numbers @@ -90,87 +99,91 @@ final class SQLTypeParserTests: XCTestCase { /// The width is the narrowest integer that holds every value of that many digits, so the /// boundaries are where the digit count outgrows the type: 10 digits do not fit in four bytes /// and 19 do not fit in eight. - func testOracleWholeNumbersBecomeIntegers() { - XCTAssertEqual(kind("NUMBER(2,0)", .oracle), .integer(bytes: 1)) - XCTAssertEqual(kind("NUMBER(4,0)", .oracle), .integer(bytes: 2)) - XCTAssertEqual(kind("NUMBER(9,0)", .oracle), .integer(bytes: 4)) - XCTAssertEqual(kind("NUMBER(10,0)", .oracle), .integer(bytes: 8)) - XCTAssertEqual(kind("NUMBER(18,0)", .oracle), .integer(bytes: 8)) - XCTAssertEqual(kind("NUMBER(38,0)", .oracle), .integer(bytes: 16)) - XCTAssertEqual(kind("NUMBER(10,2)", .oracle), .decimal(precision: 10, scale: 2)) - XCTAssertEqual(kind("NUMBER", .oracle), .decimal(precision: 38, scale: nil)) + @Test + func oracleWholeNumbersBecomeIntegers() { + #expect(kind("NUMBER(2,0)", .oracle) == .integer(bytes: 1)) + #expect(kind("NUMBER(4,0)", .oracle) == .integer(bytes: 2)) + #expect(kind("NUMBER(9,0)", .oracle) == .integer(bytes: 4)) + #expect(kind("NUMBER(10,0)", .oracle) == .integer(bytes: 8)) + #expect(kind("NUMBER(18,0)", .oracle) == .integer(bytes: 8)) + #expect(kind("NUMBER(38,0)", .oracle) == .integer(bytes: 16)) + #expect(kind("NUMBER(10,2)", .oracle) == .decimal(precision: 10, scale: 2)) + #expect(kind("NUMBER", .oracle) == .decimal(precision: 38, scale: nil)) } /// The catalog writes a whole-number column as `NUMBER(p)` and an `INTEGER` as `NUMBER(*,0)`. Read as a /// list of numbers, `NUMBER(10)` was a decimal and the `*` dropped out, so the scale became the precision. - func testOracleCatalogSpellingsOfANumber() { - XCTAssertEqual(kind("NUMBER(10)", .oracle), .integer(bytes: 8)) - XCTAssertEqual(kind("NUMBER(4)", .oracle), .integer(bytes: 2)) - XCTAssertEqual(kind("number(9)", .oracle), .integer(bytes: 4)) - XCTAssertEqual(kind("NUMBER(*,0)", .oracle), .integer(bytes: 16)) - XCTAssertEqual(kind("NUMBER(*, 0)", .oracle), .integer(bytes: 16)) - XCTAssertEqual(kind("NUMBER(*,2)", .oracle), .decimal(precision: 38, scale: 2)) - XCTAssertEqual(kind("NUMBER(*)", .oracle), .decimal(precision: 38, scale: nil)) - XCTAssertEqual(kind("NUMBER(10,-2)", .oracle), .decimal(precision: 10, scale: -2)) - XCTAssertEqual(kind("FLOAT(126)", .oracle), .floatingPoint(bits: 64)) + @Test + func oracleCatalogSpellingsOfANumber() { + #expect(kind("NUMBER(10)", .oracle) == .integer(bytes: 8)) + #expect(kind("NUMBER(4)", .oracle) == .integer(bytes: 2)) + #expect(kind("number(9)", .oracle) == .integer(bytes: 4)) + #expect(kind("NUMBER(*,0)", .oracle) == .integer(bytes: 16)) + #expect(kind("NUMBER(*, 0)", .oracle) == .integer(bytes: 16)) + #expect(kind("NUMBER(*,2)", .oracle) == .decimal(precision: 38, scale: 2)) + #expect(kind("NUMBER(*)", .oracle) == .decimal(precision: 38, scale: nil)) + #expect(kind("NUMBER(10,-2)", .oracle) == .decimal(precision: 10, scale: -2)) + #expect(kind("FLOAT(126)", .oracle) == .floatingPoint(bits: 64)) } /// Oracle's reading of a lone parameter is its own: everywhere else it stays a precision with no scale. - func testOtherFamiliesStillReadALoneParameterAsAPrecision() { - XCTAssertEqual(kind("DECIMAL(10)", .mysql), .decimal(precision: 10, scale: nil)) - XCTAssertEqual(kind("numeric(10)", .postgres), .decimal(precision: 10, scale: nil)) - XCTAssertEqual(kind("DECIMAL(10)", .mssql), .decimal(precision: 10, scale: nil)) - XCTAssertEqual(kind("NUMBER(10)", .generic), .decimal(precision: 10, scale: nil)) + @Test + func otherFamiliesStillReadALoneParameterAsAPrecision() { + #expect(kind("DECIMAL(10)", .mysql) == .decimal(precision: 10, scale: nil)) + #expect(kind("numeric(10)", .postgres) == .decimal(precision: 10, scale: nil)) + #expect(kind("DECIMAL(10)", .mssql) == .decimal(precision: 10, scale: nil)) + #expect(kind("NUMBER(10)", .generic) == .decimal(precision: 10, scale: nil)) } - func testOracleTemporalTypesKeepTheirZoneAndPrecision() { - XCTAssertEqual(kind("TIMESTAMP(6) WITH TIME ZONE", .oracle), .timestamp(precision: 6, hasTimeZone: true)) - XCTAssertEqual( - kind("TIMESTAMP(9) WITH LOCAL TIME ZONE", .oracle), .timestamp(precision: 9, hasTimeZone: true) - ) - XCTAssertEqual(kind("TIMESTAMP(6)", .oracle), .timestamp(precision: 6, hasTimeZone: false)) - XCTAssertEqual(kind("TIMESTAMP(0)", .oracle), .timestamp(precision: 0, hasTimeZone: false)) - XCTAssertEqual(kind("INTERVAL YEAR(4) TO MONTH", .oracle), .interval) - XCTAssertEqual(kind("INTERVAL DAY(3) TO SECOND(2)", .oracle), .interval) + @Test + func oracleTemporalTypesKeepTheirZoneAndPrecision() { + #expect(kind("TIMESTAMP(6) WITH TIME ZONE", .oracle) == .timestamp(precision: 6, hasTimeZone: true)) + #expect(kind("TIMESTAMP(9) WITH LOCAL TIME ZONE", .oracle) == .timestamp(precision: 9, hasTimeZone: true)) + #expect(kind("TIMESTAMP(6)", .oracle) == .timestamp(precision: 6, hasTimeZone: false)) + #expect(kind("TIMESTAMP(0)", .oracle) == .timestamp(precision: 0, hasTimeZone: false)) + #expect(kind("INTERVAL YEAR(4) TO MONTH", .oracle) == .interval) + #expect(kind("INTERVAL DAY(3) TO SECOND(2)", .oracle) == .interval) } - func testOracleNationalCharacterTypesCountCharacters() { - XCTAssertEqual(kind("NVARCHAR2(100)", .oracle), .text(length: 100, isFixed: false)) - XCTAssertEqual(kind("NCHAR(10)", .oracle), .text(length: 10, isFixed: true)) + @Test + func oracleNationalCharacterTypesCountCharacters() { + #expect(kind("NVARCHAR2(100)", .oracle) == .text(length: 100, isFixed: false)) + #expect(kind("NCHAR(10)", .oracle) == .text(length: 10, isFixed: true)) } - func testOracle23aiTypes() { - XCTAssertEqual(kind("BOOLEAN", .oracle), .boolean) - XCTAssertEqual(kind("JSON", .oracle), .json) - XCTAssertEqual(kind("VECTOR", .oracle), .unsupported) - XCTAssertEqual(kind("VECTOR(3, FLOAT32)", .oracle), .unsupported) + @Test + func oracle23aiTypes() { + #expect(kind("BOOLEAN", .oracle) == .boolean) + #expect(kind("JSON", .oracle) == .json) + #expect(kind("VECTOR", .oracle) == .unsupported) + #expect(kind("VECTOR(3, FLOAT32)", .oracle) == .unsupported) } /// A type from another schema is spelled with its owner for DDL, and classified by its bare name. - func testAnOwnerQualifiedOracleTypeIsReadByItsBareName() { - XCTAssertEqual(kind("\"MDSYS\".\"SDO_GEOMETRY\"", .oracle), .unsupported) - XCTAssertEqual(kind("REF \"APP\".\"T_ADDR\"", .oracle), .unsupported) - XCTAssertEqual( - SQLTypeParser.parse("SDO_GEOMETRY", catalogSpelling: "\"MDSYS\".\"SDO_GEOMETRY\"", family: .oracle).kind, - .spatial - ) + @Test + func anOwnerQualifiedOracleTypeIsReadByItsBareName() { + #expect(kind("\"MDSYS\".\"SDO_GEOMETRY\"", .oracle) == .unsupported) + #expect(kind("REF \"APP\".\"T_ADDR\"", .oracle) == .unsupported) + #expect(SQLTypeParser.parse("SDO_GEOMETRY", catalogSpelling: "\"MDSYS\".\"SDO_GEOMETRY\"", family: .oracle).kind == .spatial) } // MARK: - Unknowns /// An unknown word carries its own spelling so the renderer can name it, rather than being /// guessed into a shape it does not have. - func testAnUnknownTypeKeepsItsSpelling() { + @Test + func anUnknownTypeKeepsItsSpelling() { let parsed = SQLTypeParser.parse("tsvector", family: .postgres) - XCTAssertEqual(parsed.kind, .unsupported) - XCTAssertEqual(parsed.sourceSpelling, "tsvector") + #expect(parsed.kind == .unsupported) + #expect(parsed.sourceSpelling == "tsvector") } /// SQLite stores whatever spelling the `CREATE TABLE` used, so a file written by another tool /// is full of other engines' words and none of them may fall through to unsupported. - func testSQLiteFallsBackToTheAnsiReading() { - XCTAssertEqual(kind("VARCHAR(80)", .sqlite), .text(length: 80, isFixed: false)) - XCTAssertEqual(kind("BOOLEAN", .sqlite), .boolean) - XCTAssertEqual(kind("DATETIME", .sqlite), .timestamp(precision: nil, hasTimeZone: false)) + @Test + func sQLiteFallsBackToTheAnsiReading() { + #expect(kind("VARCHAR(80)", .sqlite) == .text(length: 80, isFixed: false)) + #expect(kind("BOOLEAN", .sqlite) == .boolean) + #expect(kind("DATETIME", .sqlite) == .timestamp(precision: nil, hasTimeZone: false)) } } diff --git a/TableProTests/Core/CrossEngine/SQLTypeRendererModifierTests.swift b/TableProTests/Core/CrossEngine/SQLTypeRendererModifierTests.swift index 248f83b7ce..0039f0c5b4 100644 --- a/TableProTests/Core/CrossEngine/SQLTypeRendererModifierTests.swift +++ b/TableProTests/Core/CrossEngine/SQLTypeRendererModifierTests.swift @@ -3,10 +3,11 @@ // TableProTests // +import Foundation @testable import TablePro -import XCTest +import Testing -final class SQLTypeRendererModifierTests: XCTestCase { +struct SQLTypeRendererModifierTests { private func rendered( _ spelling: String, from source: SQLTypeFamily, @@ -25,49 +26,49 @@ final class SQLTypeRendererModifierTests: XCTestCase { // MARK: - PostgreSQL catalog spellings - func testPostgresModifiersReachMySQL() { + @Test + func postgresModifiersReachMySQL() { let amount = rendered("numeric(10,2)", from: .postgres, to: .mysql) - XCTAssertEqual(amount.spelling, "DECIMAL(10, 2)") - XCTAssertEqual(amount.fidelity, .exact) - XCTAssertEqual(spelling("character varying(50)", from: .postgres, to: .mysql), "VARCHAR(50)") - XCTAssertEqual(spelling("character(10)", from: .postgres, to: .mysql), "CHAR(10)") - XCTAssertEqual(spelling("time(6) without time zone", from: .postgres, to: .mysql), "TIME(6)") - XCTAssertEqual(spelling("bit(8)", from: .postgres, to: .mysql), "BIT(8)") + #expect(amount.spelling == "DECIMAL(10, 2)") + #expect(amount.fidelity == .exact) + #expect(spelling("character varying(50)", from: .postgres, to: .mysql) == "VARCHAR(50)") + #expect(spelling("character(10)", from: .postgres, to: .mysql) == "CHAR(10)") + #expect(spelling("time(6) without time zone", from: .postgres, to: .mysql) == "TIME(6)") + #expect(spelling("bit(8)", from: .postgres, to: .mysql) == "BIT(8)") let made = rendered("timestamp(3) with time zone", from: .postgres, to: .mysql) - XCTAssertEqual(made.spelling, "DATETIME(3)") - XCTAssertEqual(made.fidelity, .approximated) + #expect(made.spelling == "DATETIME(3)") + #expect(made.fidelity == .approximated) } /// A bare PostgreSQL timestamp stores microseconds, and a MySQL `DATETIME` without a precision /// stores whole seconds. - func testAPostgresTimestampWithoutPrecisionKeepsMicrosecondsEverywhere() { - XCTAssertEqual(spelling("timestamp without time zone", from: .postgres, to: .mysql), "DATETIME(6)") - XCTAssertEqual(rendered("timestamp without time zone", from: .postgres, to: .mysql).fidelity, .exact) - XCTAssertEqual(spelling("timestamp without time zone", from: .postgres, to: .mssql), "DATETIME2(6)") - XCTAssertEqual(spelling("timestamp with time zone", from: .postgres, to: .clickhouse), "DateTime64(6)") - XCTAssertEqual(spelling("time without time zone", from: .postgres, to: .mysql), "TIME(6)") + @Test + func aPostgresTimestampWithoutPrecisionKeepsMicrosecondsEverywhere() { + #expect(spelling("timestamp without time zone", from: .postgres, to: .mysql) == "DATETIME(6)") + #expect(rendered("timestamp without time zone", from: .postgres, to: .mysql).fidelity == .exact) + #expect(spelling("timestamp without time zone", from: .postgres, to: .mssql) == "DATETIME2(6)") + #expect(spelling("timestamp with time zone", from: .postgres, to: .clickhouse) == "DateTime64(6)") + #expect(spelling("time without time zone", from: .postgres, to: .mysql) == "TIME(6)") } - func testPostgresModifiersReachTheRegistryEngines() { - XCTAssertEqual(spelling("character varying(50)", from: .postgres, to: .mssql), "NVARCHAR(50)") - XCTAssertEqual(spelling("character varying(50)", from: .postgres, to: .oracle), "VARCHAR2(50 CHAR)") - XCTAssertEqual(spelling("character varying(50)", from: .postgres, to: .duckdb), "VARCHAR(50)") - XCTAssertEqual( - spelling("timestamp(3) with time zone", from: .postgres, to: .mssql), "DATETIMEOFFSET(3)" - ) - XCTAssertEqual( - spelling("timestamp(3) with time zone", from: .postgres, to: .oracle), "TIMESTAMP(3) WITH TIME ZONE" - ) - XCTAssertEqual(spelling("timestamp(3) with time zone", from: .postgres, to: .clickhouse), "DateTime64(3)") - XCTAssertEqual(spelling("numeric(10,2)", from: .postgres, to: .oracle), "NUMBER(10, 2)") - XCTAssertEqual(spelling("numeric(10,2)", from: .postgres, to: .clickhouse), "Decimal(10, 2)") + @Test + func postgresModifiersReachTheRegistryEngines() { + #expect(spelling("character varying(50)", from: .postgres, to: .mssql) == "NVARCHAR(50)") + #expect(spelling("character varying(50)", from: .postgres, to: .oracle) == "VARCHAR2(50 CHAR)") + #expect(spelling("character varying(50)", from: .postgres, to: .duckdb) == "VARCHAR(50)") + #expect(spelling("timestamp(3) with time zone", from: .postgres, to: .mssql) == "DATETIMEOFFSET(3)") + #expect(spelling("timestamp(3) with time zone", from: .postgres, to: .oracle) == "TIMESTAMP(3) WITH TIME ZONE") + #expect(spelling("timestamp(3) with time zone", from: .postgres, to: .clickhouse) == "DateTime64(3)") + #expect(spelling("numeric(10,2)", from: .postgres, to: .oracle) == "NUMBER(10, 2)") + #expect(spelling("numeric(10,2)", from: .postgres, to: .clickhouse) == "Decimal(10, 2)") } // MARK: - Unconstrained decimals /// Each engine's widest precision, keeping 20 digits before the point and at most 30 after it. - func testAnUnconstrainedDecimalTakesTheEnginesWidestPrecision() { + @Test + func anUnconstrainedDecimalTakesTheEnginesWidestPrecision() { let expected: [SQLTypeFamily: String] = [ .mysql: "DECIMAL(65, 30)", .mssql: "DECIMAL(38, 18)", @@ -77,38 +78,41 @@ final class SQLTypeRendererModifierTests: XCTestCase { ] for (family, spelling) in expected { let result = SQLTypeRenderer.render(unconstrained, family: family) - XCTAssertEqual(result.spelling, spelling, "\(family)") - XCTAssertEqual(result.fidelity, .approximated, "\(family)") - XCTAssertNotNil(result.reason, "\(family)") + #expect(result.spelling == spelling, "\(family)") + #expect(result.fidelity == .approximated, "\(family)") + #expect(result.reason != nil, "\(family)") } } - func testAnUnconstrainedDecimalStaysUnconstrainedWhereTheEngineHasOne() { + @Test + func anUnconstrainedDecimalStaysUnconstrainedWhereTheEngineHasOne() { for family in [SQLTypeFamily.postgres, .sqlite] { let result = SQLTypeRenderer.render(unconstrained, family: family) - XCTAssertEqual(result.spelling, "NUMERIC", "\(family)") - XCTAssertEqual(result.fidelity, .exact, "\(family)") + #expect(result.spelling == "NUMERIC", "\(family)") + #expect(result.fidelity == .exact, "\(family)") } let oracle = SQLTypeRenderer.render(unconstrained, family: .oracle) - XCTAssertEqual(oracle.spelling, "NUMBER") - XCTAssertEqual(oracle.fidelity, .approximated) - XCTAssertNotNil(oracle.reason) + #expect(oracle.spelling == "NUMBER") + #expect(oracle.fidelity == .approximated) + #expect(oracle.reason != nil) } - func testEveryFamilyAnswersAnUnconstrainedDecimal() { + @Test + func everyFamilyAnswersAnUnconstrainedDecimal() { for family in SQLTypeFamily.allCases { - XCTAssertFalse(SQLTypeRenderer.render(unconstrained, family: family).spelling.isEmpty, "\(family)") + #expect(!(SQLTypeRenderer.render(unconstrained, family: family).spelling.isEmpty), "\(family)") } } /// A bare Oracle `NUMBER` keeps 38 significant digits. Read as unconstrained it would reach MySQL as /// `DECIMAL(65, 30)`, which refuses a 36-digit integer a `DECIMAL(38)` holds. - func testABareOracleNumberKeepsItsExactRendering() { + @Test + func aBareOracleNumberKeepsItsExactRendering() { let toMySQL = rendered("NUMBER", from: .oracle, to: .mysql) - XCTAssertEqual(toMySQL.spelling, "DECIMAL(38)") - XCTAssertEqual(toMySQL.fidelity, .exact) - XCTAssertEqual(spelling("NUMBER", from: .oracle, to: .mssql), "DECIMAL(38)") - XCTAssertEqual(spelling("NUMBER", from: .oracle, to: .postgres), "NUMERIC(38)") + #expect(toMySQL.spelling == "DECIMAL(38)") + #expect(toMySQL.fidelity == .exact) + #expect(spelling("NUMBER", from: .oracle, to: .mssql) == "DECIMAL(38)") + #expect(spelling("NUMBER", from: .oracle, to: .postgres) == "NUMERIC(38)") } // MARK: - Scales @@ -116,84 +120,87 @@ final class SQLTypeRendererModifierTests: XCTestCase { /// PostgreSQL 17 stores `numeric(5,-2)` as whole hundreds below 10^7 and `numeric(3,5)` as /// fractions below 10^-2. MySQL 8.4 and MariaDB 12.3 refuse `DECIMAL(5, -2)` with ERROR 1064 /// and `DECIMAL(3, 5)` with ERROR 1427, and create `DECIMAL(7, 0)` and `DECIMAL(5, 5)`. - func testAScaleOutsideThePrecisionKeepsTheDigitsItsValuesHave() { + @Test + func aScaleOutsideThePrecisionKeepsTheDigitsItsValuesHave() { let rounded = rendered("numeric(5,-2)", from: .postgres, to: .mysql) - XCTAssertEqual(rounded.spelling, "DECIMAL(7, 0)") - XCTAssertEqual(rounded.fidelity, .widened) - XCTAssertNotNil(rounded.reason) + #expect(rounded.spelling == "DECIMAL(7, 0)") + #expect(rounded.fidelity == .widened) + #expect(rounded.reason != nil) let tiny = rendered("numeric(3,5)", from: .postgres, to: .mysql) - XCTAssertEqual(tiny.spelling, "DECIMAL(5, 5)") - XCTAssertEqual(tiny.fidelity, .widened) + #expect(tiny.spelling == "DECIMAL(5, 5)") + #expect(tiny.fidelity == .widened) - XCTAssertEqual(spelling("numeric(5,-2)", from: .postgres, to: .mssql), "DECIMAL(7, 0)") - XCTAssertEqual(spelling("numeric(3,5)", from: .postgres, to: .duckdb), "DECIMAL(5, 5)") - XCTAssertEqual(spelling("numeric(5,-2)", from: .postgres, to: .clickhouse), "Decimal(7, 0)") - XCTAssertEqual(spelling("NUMBER(5,-2)", from: .oracle, to: .mysql), "DECIMAL(7, 0)") + #expect(spelling("numeric(5,-2)", from: .postgres, to: .mssql) == "DECIMAL(7, 0)") + #expect(spelling("numeric(3,5)", from: .postgres, to: .duckdb) == "DECIMAL(5, 5)") + #expect(spelling("numeric(5,-2)", from: .postgres, to: .clickhouse) == "Decimal(7, 0)") + #expect(spelling("NUMBER(5,-2)", from: .oracle, to: .mysql) == "DECIMAL(7, 0)") } /// Oracle's `NUMBER` takes a scale from -84 to 127 with the same meaning. - func testOracleKeepsAScaleOutsideThePrecisionAsWritten() { + @Test + func oracleKeepsAScaleOutsideThePrecisionAsWritten() { let rounded = rendered("numeric(5,-2)", from: .postgres, to: .oracle) - XCTAssertEqual(rounded.spelling, "NUMBER(5, -2)") - XCTAssertEqual(rounded.fidelity, .exact) - XCTAssertEqual(spelling("numeric(3,5)", from: .postgres, to: .oracle), "NUMBER(3, 5)") - XCTAssertEqual(spelling("numeric(10,-90)", from: .postgres, to: .oracle), "NUMBER(38, 0)") + #expect(rounded.spelling == "NUMBER(5, -2)") + #expect(rounded.fidelity == .exact) + #expect(spelling("numeric(3,5)", from: .postgres, to: .oracle) == "NUMBER(3, 5)") + #expect(spelling("numeric(10,-90)", from: .postgres, to: .oracle) == "NUMBER(38, 0)") } /// MySQL 8.4 refuses `DECIMAL(40, 35)` and `DECIMAL(65, 31)` with ERROR 1425, a scale over 30, and /// creates `DECIMAL(35, 30)`. - func testMySQLKeepsAtMost30DigitsAfterThePoint() { + @Test + func mySQLKeepsAtMost30DigitsAfterThePoint() { let fine = rendered("numeric(40,35)", from: .postgres, to: .mysql) - XCTAssertEqual(fine.spelling, "DECIMAL(35, 30)") - XCTAssertEqual(fine.fidelity, .approximated) - XCTAssertNotNil(fine.reason) - - XCTAssertEqual(spelling("numeric(70,40)", from: .postgres, to: .mysql), "DECIMAL(65, 30)") - XCTAssertEqual(spelling("numeric(38,-10)", from: .postgres, to: .mysql), "DECIMAL(48, 0)") - XCTAssertEqual(spelling("numeric(65,30)", from: .postgres, to: .mysql), "DECIMAL(65, 30)") - XCTAssertEqual(rendered("numeric(65,30)", from: .postgres, to: .mysql).fidelity, .exact) + #expect(fine.spelling == "DECIMAL(35, 30)") + #expect(fine.fidelity == .approximated) + #expect(fine.reason != nil) + + #expect(spelling("numeric(70,40)", from: .postgres, to: .mysql) == "DECIMAL(65, 30)") + #expect(spelling("numeric(38,-10)", from: .postgres, to: .mysql) == "DECIMAL(48, 0)") + #expect(spelling("numeric(65,30)", from: .postgres, to: .mysql) == "DECIMAL(65, 30)") + #expect(rendered("numeric(65,30)", from: .postgres, to: .mysql).fidelity == .exact) } /// Only MySQL has a scale ceiling below its precision; elsewhere a precision cut keeps the scale. - func testAPrecisionCutKeepsTheScaleWhereTheEngineAllowsIt() { + @Test + func aPrecisionCutKeepsTheScaleWhereTheEngineAllowsIt() { let fine = rendered("numeric(40,35)", from: .postgres, to: .mssql) - XCTAssertEqual(fine.spelling, "DECIMAL(38, 35)") - XCTAssertEqual(fine.fidelity, .approximated) - XCTAssertEqual(spelling("numeric(38,-10)", from: .postgres, to: .mssql), "DECIMAL(38, 0)") - XCTAssertEqual(spelling("DECIMAL(65,30)", from: .mysql, to: .mssql), "DECIMAL(38, 30)") + #expect(fine.spelling == "DECIMAL(38, 35)") + #expect(fine.fidelity == .approximated) + #expect(spelling("numeric(38,-10)", from: .postgres, to: .mssql) == "DECIMAL(38, 0)") + #expect(spelling("DECIMAL(65,30)", from: .mysql, to: .mssql) == "DECIMAL(38, 30)") } // MARK: - ClickHouse text /// `FixedString(n)` holds at most n bytes, and a padded PostgreSQL `char(20)` holding `José` is 21. - func testFixedLengthTextReachesClickHouseAsString() { + @Test + func fixedLengthTextReachesClickHouseAsString() { let padded = rendered("character(20)", from: .postgres, to: .clickhouse) - XCTAssertEqual(padded.spelling, "String") - XCTAssertEqual(padded.fidelity, .exact) - XCTAssertEqual(spelling("CHAR(20)", from: .mysql, to: .clickhouse), "String") - XCTAssertEqual(spelling("NCHAR(10)", from: .mssql, to: .clickhouse), "String") + #expect(padded.spelling == "String") + #expect(padded.fidelity == .exact) + #expect(spelling("CHAR(20)", from: .mysql, to: .clickhouse) == "String") + #expect(spelling("NCHAR(10)", from: .mssql, to: .clickhouse) == "String") } // MARK: - Oracle character semantics /// A byte length refuses multibyte text an engine counting characters accepted, so the length /// says `CHAR` and a column whose four-byte worst case passes Oracle's byte ceiling moves up. - func testOracleTextCountsCharactersWithinItsByteCeiling() { + @Test + func oracleTextCountsCharactersWithinItsByteCeiling() { let text = { (length: Int, isFixed: Bool) in SQLTypeRenderer.render( CanonicalColumnType(kind: .text(length: length, isFixed: isFixed), sourceSpelling: "text"), family: .oracle ) } - XCTAssertEqual(text(1_000, false).spelling, "VARCHAR2(1000 CHAR)") - XCTAssertEqual(text(1_001, false).spelling, "CLOB") - XCTAssertEqual(text(1_001, false).fidelity, .widened) - XCTAssertEqual(text(500, true).spelling, "CHAR(500 CHAR)") - XCTAssertEqual(text(501, true).spelling, "VARCHAR2(501 CHAR)") - XCTAssertEqual( - SQLTypeParser.parse(text(1_000, false).spelling, family: .oracle).kind, - .text(length: 1_000, isFixed: false) - ) + #expect(text(1_000, false).spelling == "VARCHAR2(1000 CHAR)") + #expect(text(1_001, false).spelling == "CLOB") + #expect(text(1_001, false).fidelity == .widened) + #expect(text(500, true).spelling == "CHAR(500 CHAR)") + #expect(text(501, true).spelling == "VARCHAR2(501 CHAR)") + #expect(SQLTypeParser.parse(text(1_000, false).spelling, family: .oracle).kind == .text(length: 1_000, isFixed: false)) } } diff --git a/TableProTests/Core/CrossEngine/SQLTypeRendererTests.swift b/TableProTests/Core/CrossEngine/SQLTypeRendererTests.swift index 3a5921474c..bf8d83e925 100644 --- a/TableProTests/Core/CrossEngine/SQLTypeRendererTests.swift +++ b/TableProTests/Core/CrossEngine/SQLTypeRendererTests.swift @@ -3,10 +3,11 @@ // TableProTests // +import Foundation @testable import TablePro -import XCTest +import Testing -final class SQLTypeRendererTests: XCTestCase { +struct SQLTypeRendererTests { private func rendered( _ spelling: String, from source: SQLTypeFamily, @@ -25,128 +26,136 @@ final class SQLTypeRendererTests: XCTestCase { // MARK: - The pairs users actually copy - func testMySQLToPostgres() { - XCTAssertEqual(spelling("TINYINT(1)", from: .mysql, to: .postgres), "BOOLEAN") - XCTAssertEqual(spelling("INT", from: .mysql, to: .postgres), "INTEGER") - XCTAssertEqual(spelling("MEDIUMINT", from: .mysql, to: .postgres), "INTEGER") - XCTAssertEqual(spelling("VARCHAR(255)", from: .mysql, to: .postgres), "VARCHAR(255)") - XCTAssertEqual(spelling("LONGTEXT", from: .mysql, to: .postgres), "TEXT") - XCTAssertEqual(spelling("DATETIME(6)", from: .mysql, to: .postgres), "TIMESTAMP(6)") - XCTAssertEqual(spelling("LONGBLOB", from: .mysql, to: .postgres), "BYTEA") - XCTAssertEqual(spelling("JSON", from: .mysql, to: .postgres), "JSONB") - XCTAssertEqual(spelling("DOUBLE", from: .mysql, to: .postgres), "DOUBLE PRECISION") - } - - func testJSONForAPostgresTargetFollowsTheServerVersion() { + @Test + func mySQLToPostgres() { + #expect(spelling("TINYINT(1)", from: .mysql, to: .postgres) == "BOOLEAN") + #expect(spelling("INT", from: .mysql, to: .postgres) == "INTEGER") + #expect(spelling("MEDIUMINT", from: .mysql, to: .postgres) == "INTEGER") + #expect(spelling("VARCHAR(255)", from: .mysql, to: .postgres) == "VARCHAR(255)") + #expect(spelling("LONGTEXT", from: .mysql, to: .postgres) == "TEXT") + #expect(spelling("DATETIME(6)", from: .mysql, to: .postgres) == "TIMESTAMP(6)") + #expect(spelling("LONGBLOB", from: .mysql, to: .postgres) == "BYTEA") + #expect(spelling("JSON", from: .mysql, to: .postgres) == "JSONB") + #expect(spelling("DOUBLE", from: .mysql, to: .postgres) == "DOUBLE PRECISION") + } + + @Test + func jSONForAPostgresTargetFollowsTheServerVersion() { let json = SQLTypeParser.parse("JSON", family: .mysql) - XCTAssertEqual(SQLTypeRenderer.render(json, family: .postgres).spelling, "JSONB") - XCTAssertEqual(SQLTypeRenderer.render(json, family: .postgres, jsonColumnType: .jsonb).spelling, "JSONB") - XCTAssertEqual(SQLTypeRenderer.render(json, family: .postgres, jsonColumnType: .json).spelling, "JSON") + #expect(SQLTypeRenderer.render(json, family: .postgres).spelling == "JSONB") + #expect(SQLTypeRenderer.render(json, family: .postgres, jsonColumnType: .jsonb).spelling == "JSONB") + #expect(SQLTypeRenderer.render(json, family: .postgres, jsonColumnType: .json).spelling == "JSON") let preJSON = SQLTypeRenderer.render(json, family: .postgres, jsonColumnType: .text) - XCTAssertEqual(preJSON.spelling, "TEXT") - XCTAssertEqual(preJSON.fidelity, .approximated) - XCTAssertNotNil(preJSON.reason) + #expect(preJSON.spelling == "TEXT") + #expect(preJSON.fidelity == .approximated) + #expect(preJSON.reason != nil) } - func testAJSONArrayForAPostgresTargetFollowsTheServerVersion() { + @Test + func aJSONArrayForAPostgresTargetFollowsTheServerVersion() { let array = CanonicalColumnType(kind: .array(element: .json), isUnsigned: false, sourceSpelling: "jsonb[]") - XCTAssertEqual(SQLTypeRenderer.render(array, family: .postgres, jsonColumnType: .json).spelling, "JSON[]") - XCTAssertEqual(SQLTypeRenderer.render(array, family: .postgres).spelling, "JSONB[]") + #expect(SQLTypeRenderer.render(array, family: .postgres, jsonColumnType: .json).spelling == "JSON[]") + #expect(SQLTypeRenderer.render(array, family: .postgres).spelling == "JSONB[]") } /// Redshift reports 8.0.2 and CockroachDB reports 13.0.0. Reading either as a PostgreSQL /// version turned a `JSONB` column into `TEXT` on a target that holds `JSONB` perfectly well. - func testOnlyPostgreSQLItselfReadsTheReportedVersion() { - XCTAssertEqual( - PostgreSQLServerVersion.jsonColumnType(for: .postgresql, serverVersion: "9.1.24"), .text - ) - XCTAssertEqual( - PostgreSQLServerVersion.jsonColumnType(for: .redshift, serverVersion: "8.0.2"), .jsonb - ) - XCTAssertEqual( - PostgreSQLServerVersion.jsonColumnType(for: .cockroachdb, serverVersion: "13.0.0"), .jsonb - ) - } - - func testPostgresToMySQL() { - XCTAssertEqual(spelling("boolean", from: .postgres, to: .mysql), "TINYINT(1)") - XCTAssertEqual(spelling("int4", from: .postgres, to: .mysql), "INT") - XCTAssertEqual(spelling("text", from: .postgres, to: .mysql), "LONGTEXT") - XCTAssertEqual(spelling("bytea", from: .postgres, to: .mysql), "LONGBLOB") - XCTAssertEqual(spelling("uuid", from: .postgres, to: .mysql), "CHAR(36)") - XCTAssertEqual(spelling("jsonb", from: .postgres, to: .mysql), "JSON") - XCTAssertEqual(spelling("numeric(19,4)", from: .postgres, to: .mysql), "DECIMAL(19, 4)") + @Test + func onlyPostgreSQLItselfReadsTheReportedVersion() { + #expect(PostgreSQLServerVersion.jsonColumnType(for: .postgresql, serverVersion: "9.1.24") == .text) + #expect(PostgreSQLServerVersion.jsonColumnType(for: .redshift, serverVersion: "8.0.2") == .jsonb) + #expect(PostgreSQLServerVersion.jsonColumnType(for: .cockroachdb, serverVersion: "13.0.0") == .jsonb) + } + + @Test + func postgresToMySQL() { + #expect(spelling("boolean", from: .postgres, to: .mysql) == "TINYINT(1)") + #expect(spelling("int4", from: .postgres, to: .mysql) == "INT") + #expect(spelling("text", from: .postgres, to: .mysql) == "LONGTEXT") + #expect(spelling("bytea", from: .postgres, to: .mysql) == "LONGBLOB") + #expect(spelling("uuid", from: .postgres, to: .mysql) == "CHAR(36)") + #expect(spelling("jsonb", from: .postgres, to: .mysql) == "JSON") + #expect(spelling("numeric(19,4)", from: .postgres, to: .mysql) == "DECIMAL(19, 4)") } /// `text` on PostgreSQL holds a gigabyte and MySQL's `TEXT` holds 64 KB, so the unbounded case /// has to reach `LONGTEXT` or a long value is truncated with no error outside strict mode. - func testUnboundedTextReachesTheWidestMySQLType() { - XCTAssertEqual(spelling("text", from: .postgres, to: .mysql), "LONGTEXT") - XCTAssertEqual(spelling("CLOB", from: .oracle, to: .mysql), "LONGTEXT") - XCTAssertEqual(spelling("NVARCHAR(MAX)", from: .mssql, to: .mysql), "LONGTEXT") + @Test + func unboundedTextReachesTheWidestMySQLType() { + #expect(spelling("text", from: .postgres, to: .mysql) == "LONGTEXT") + #expect(spelling("CLOB", from: .oracle, to: .mysql) == "LONGTEXT") + #expect(spelling("NVARCHAR(MAX)", from: .mssql, to: .mysql) == "LONGTEXT") } // MARK: - Unsigned /// PostgreSQL has no unsigned integers. Kept at the same width, half of a `BIGINT UNSIGNED`'s /// range fails on whichever row first exceeds it, minutes into a copy. - func testUnsignedIntegersWidenRatherThanOverflow() { - XCTAssertEqual(spelling("INT UNSIGNED", from: .mysql, to: .postgres), "BIGINT") - XCTAssertEqual(spelling("SMALLINT UNSIGNED", from: .mysql, to: .postgres), "INTEGER") - XCTAssertEqual(spelling("BIGINT UNSIGNED", from: .mysql, to: .postgres), "NUMERIC(20, 0)") - XCTAssertEqual(spelling("BIGINT UNSIGNED", from: .mysql, to: .mssql), "DECIMAL(20, 0)") + @Test + func unsignedIntegersWidenRatherThanOverflow() { + #expect(spelling("INT UNSIGNED", from: .mysql, to: .postgres) == "BIGINT") + #expect(spelling("SMALLINT UNSIGNED", from: .mysql, to: .postgres) == "INTEGER") + #expect(spelling("BIGINT UNSIGNED", from: .mysql, to: .postgres) == "NUMERIC(20, 0)") + #expect(spelling("BIGINT UNSIGNED", from: .mysql, to: .mssql) == "DECIMAL(20, 0)") } /// `UNSIGNED` is a column attribute on the MySQL side, and the driver writes it from that /// attribute. Spelling it into the type as well produced `INT UNSIGNED UNSIGNED`. - func testTheMySQLRendererNeverSpellsUnsigned() { + @Test + func theMySQLRendererNeverSpellsUnsigned() { let type = CanonicalColumnType(kind: .integer(bytes: 4), isUnsigned: true, sourceSpelling: "int") - XCTAssertEqual(SQLTypeRenderer.render(type, family: .mysql).spelling, "INT") + #expect(SQLTypeRenderer.render(type, family: .mysql).spelling == "INT") } /// A signed byte does not fit in SQL Server's `TINYINT`, which holds 0 to 255. - func testSQLServerWidensASignedByte() { - XCTAssertEqual(spelling("TINYINT", from: .mysql, to: .mssql), "SMALLINT") + @Test + func sQLServerWidensASignedByte() { + #expect(spelling("TINYINT", from: .mysql, to: .mssql) == "SMALLINT") let unsigned = CanonicalColumnType(kind: .integer(bytes: 1), isUnsigned: true, sourceSpelling: "tinyint") - XCTAssertEqual(SQLTypeRenderer.render(unsigned, family: .mssql).spelling, "TINYINT") + #expect(SQLTypeRenderer.render(unsigned, family: .mssql).spelling == "TINYINT") } // MARK: - Fidelity - func testAnExactMappingReportsNothing() { + @Test + func anExactMappingReportsNothing() { let result = rendered("INT", from: .mysql, to: .postgres) - XCTAssertEqual(result.fidelity, .exact) - XCTAssertNil(result.reason) + #expect(result.fidelity == .exact) + #expect(result.reason == nil) } - func testALossyMappingCarriesItsReason() { + @Test + func aLossyMappingCarriesItsReason() { let result = rendered("timestamptz", from: .postgres, to: .mysql) - XCTAssertEqual(result.fidelity, .approximated) - XCTAssertNotNil(result.reason) - XCTAssertEqual(result.spelling, "DATETIME(6)") + #expect(result.fidelity == .approximated) + #expect(result.reason != nil) + #expect(result.spelling == "DATETIME(6)") } - func testAWideningCarriesItsReasonAndKeepsEveryValue() { + @Test + func aWideningCarriesItsReasonAndKeepsEveryValue() { let result = rendered("uuid", from: .postgres, to: .mysql) - XCTAssertEqual(result.fidelity, .widened) - XCTAssertNotNil(result.reason) + #expect(result.fidelity == .widened) + #expect(result.reason != nil) } /// Nothing is ever dropped. A type no family can express becomes the target's widest text and /// says so, because a copy that writes the value as text is better than one missing a column. - func testAnUnknownTypeBecomesTextRatherThanDisappearing() { + @Test + func anUnknownTypeBecomesTextRatherThanDisappearing() { for family in SQLTypeFamily.allCases where family != .postgres { let result = rendered("tsvector", from: .postgres, to: family) - XCTAssertFalse(result.spelling.isEmpty, "\(family) produced no spelling") - XCTAssertEqual(result.fidelity, .approximated, "\(family)") - XCTAssertNotNil(result.reason, "\(family)") + #expect(!result.spelling.isEmpty, "\(family) produced no spelling") + #expect(result.fidelity == .approximated, "\(family)") + #expect(result.reason != nil, "\(family)") } } /// Every family answers every kind, so no copy can be refused for a type the source happened /// to use. - func testEveryFamilyAnswersEveryKind() { + @Test + func everyFamilyAnswersEveryKind() { let kinds: [CanonicalTypeKind] = [ .boolean, .integer(bytes: 4), .decimal(precision: 10, scale: 2), .floatingPoint(bits: 64), .text(length: nil, isFixed: false), .binary(length: nil, isFixed: false), .date, @@ -158,43 +167,45 @@ final class SQLTypeRendererTests: XCTestCase { for kind in kinds { let type = CanonicalColumnType(kind: kind, sourceSpelling: "source_type") let spelling = SQLTypeRenderer.render(type, family: family).spelling - XCTAssertFalse(spelling.isEmpty, "\(family) had no spelling for \(kind)") + #expect(!spelling.isEmpty, "\(family) had no spelling for \(kind)") } } } // MARK: - Enums - func testAMySQLTargetKeepsAnEnum() { - XCTAssertEqual( - SQLTypeRenderer.render( + @Test + func aMySQLTargetKeepsAnEnum() { + #expect(SQLTypeRenderer.render( SQLTypeParser.parse("enum('a','b')", family: .mysql), family: .mysql - ).spelling, - "ENUM('a', 'b')" - ) + ).spelling == "ENUM('a', 'b')") } /// A label with a quote in it has to survive being written back out. - func testAnEnumLabelIsEscaped() { + @Test + func anEnumLabelIsEscaped() { let type = CanonicalColumnType(kind: .enumeration(values: ["it's"]), sourceSpelling: "enum") - XCTAssertEqual(SQLTypeRenderer.render(type, family: .mysql).spelling, "ENUM('it''s')") + #expect(SQLTypeRenderer.render(type, family: .mysql).spelling == "ENUM('it''s')") } /// PostgreSQL enums are a separate `CREATE TYPE` with a dependency of its own, so the column /// becomes text wide enough for the longest label and the review says so. - func testAPostgresTargetSizesTextToTheLongestLabel() { + @Test + func aPostgresTargetSizesTextToTheLongestLabel() { let type = CanonicalColumnType(kind: .enumeration(values: ["a", "medium"]), sourceSpelling: "enum") - XCTAssertEqual(SQLTypeRenderer.render(type, family: .postgres).spelling, "VARCHAR(6)") + #expect(SQLTypeRenderer.render(type, family: .postgres).spelling == "VARCHAR(6)") } // MARK: - Arrays - func testAPostgresTargetKeepsAnArray() { - XCTAssertEqual(spelling("integer[]", from: .postgres, to: .postgres), "INTEGER[]") - XCTAssertEqual(spelling("Array(Int32)", from: .clickhouse, to: .postgres), "INTEGER[]") + @Test + func aPostgresTargetKeepsAnArray() { + #expect(spelling("integer[]", from: .postgres, to: .postgres) == "INTEGER[]") + #expect(spelling("Array(Int32)", from: .clickhouse, to: .postgres) == "INTEGER[]") } - func testAnArrayBecomesJsonOnMySQL() { - XCTAssertEqual(spelling("integer[]", from: .postgres, to: .mysql), "JSON") + @Test + func anArrayBecomesJsonOnMySQL() { + #expect(spelling("integer[]", from: .postgres, to: .mysql) == "JSON") } } diff --git a/TableProTests/Core/DataFiles/DataFileControllerTests.swift b/TableProTests/Core/DataFiles/DataFileControllerTests.swift index a89e8ff46e..ff966159a9 100644 --- a/TableProTests/Core/DataFiles/DataFileControllerTests.swift +++ b/TableProTests/Core/DataFiles/DataFileControllerTests.swift @@ -74,6 +74,32 @@ struct DataFileControllerTests { #expect(column(controller, 1) == ["Rome"]) } + /// Compared as text, "9" sorts after "50" and would be kept too. + @Test("A cell filter on a number column compares numbers, not text") + func cellFilterComparesNumbersAsNumbers() async throws { + let (controller, _) = try await loaded("amount,name\n9,nine\n50,fifty\n100,hundred\n") + + controller.addFilter(TableFilter(columnName: "amount", filterOperator: .greaterThan, value: "50")) + await controller.waitForPendingWork() + + #expect(column(controller, 0) == ["100"]) + } + + @Test("Picking a value from the column statistics filters the file to its rows") + func statisticsValueFiltersTheFile() async throws { + let (controller, _) = try await loaded("city\nParis\nLondon\nParis\nRome\n") + let city = try #require(controller.columnNames.ids.first) + let request = try #require(controller.statisticsRequest(for: city)) + let summary = try await request.summarize { _ in } + let paris = try #require(summary.topValues.first { $0.value == "Paris" }) + #expect(paris.count == 2) + + controller.addEqualsFilter(column: city, value: paris) + await controller.waitForPendingWork() + + #expect(column(controller, 0) == ["Paris", "Paris"]) + } + @Test("Deleting a row under a sort removes that row and keeps the order") func deleteUnderSortTargetsTheVisibleRow() async throws { let (controller, _) = try await loaded("n\n3\n1\n2\n") diff --git a/TableProTests/Core/DataFiles/DataFileSelectionSummaryTests.swift b/TableProTests/Core/DataFiles/DataFileSelectionSummaryTests.swift index 7807012c20..cc53e2305e 100644 --- a/TableProTests/Core/DataFiles/DataFileSelectionSummaryTests.swift +++ b/TableProTests/Core/DataFiles/DataFileSelectionSummaryTests.swift @@ -12,13 +12,6 @@ import TableProPluginKit import TableProTabular import Testing -@MainActor -private final class DataFileSummaryLayoutPersister: ColumnLayoutPersisting { - func load(for key: ColumnLayoutTableKey) -> ColumnLayoutState? { nil } - func save(_ layout: ColumnLayoutState, for key: ColumnLayoutTableKey) {} - func clear(for key: ColumnLayoutTableKey) {} -} - @MainActor struct DataFileSelectionSummaryTests { /// The coordinator holds its delegate and its table view weakly, so the test holds them. @@ -54,7 +47,7 @@ struct DataFileSelectionSummaryTests { changeManager: controller.anyChangeManager, isEditable: controller.isEditable, delegate: delegate, - layoutPersister: DataFileSummaryLayoutPersister(), + layoutPersister: InMemoryColumnLayoutStore(), selectedRowIndices: .constant([]), sortState: .constant(SortState()), columnLayout: .constant(ColumnLayoutState()), diff --git a/TableProTests/Core/DataWrite/DataWriteExecutorTests.swift b/TableProTests/Core/DataWrite/DataWriteExecutorTests.swift index 08b7237ec0..a94e9b0e98 100644 --- a/TableProTests/Core/DataWrite/DataWriteExecutorTests.swift +++ b/TableProTests/Core/DataWrite/DataWriteExecutorTests.swift @@ -11,7 +11,7 @@ import Testing /// One test double for every case in this file: it records the order of everything it was asked to /// do, reports whatever affected-row count the test wants, and can be told to fail on the Nth /// statement. -private final class CountingDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class CountingDriver: PluginDriverStub, @unchecked Sendable { struct Failure: Error {} let affectedRows: Int @@ -49,8 +49,6 @@ private final class CountingDriver: PluginDatabaseDriver, @unchecked Sendable { var currentSchema: String? { nil } var serverVersion: String? { nil } - func connect() async throws {} - func disconnect() {} func ping() async throws {} func execute(query: String) async throws -> PluginQueryResult { @@ -70,21 +68,6 @@ private final class CountingDriver: PluginDatabaseDriver, @unchecked Sendable { trace.append("ROLLBACK") if rollbackFails { throw Failure() } } - - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } struct DataWriteExecutorTests { diff --git a/TableProTests/Core/DataWrite/RewindCipherTests.swift b/TableProTests/Core/DataWrite/RewindCipherTests.swift index 7ba50bff0a..f2422d4075 100644 --- a/TableProTests/Core/DataWrite/RewindCipherTests.swift +++ b/TableProTests/Core/DataWrite/RewindCipherTests.swift @@ -8,23 +8,6 @@ import TableProPluginKit @testable import TablePro import Testing -private final class FakeKeychain: KeychainStoring, @unchecked Sendable { - private var values: [String: String] = [:] - - func writeString(_ value: String, forKey key: String) -> Bool { - values[key] = value - return true - } - - func readStringResult(forKey key: String) -> KeychainStringResult { - values[key].map { .found($0) } ?? .notFound - } - - func delete(forKey key: String) { - values.removeValue(forKey: key) - } -} - private final class LockedKeychain: KeychainStoring, @unchecked Sendable { func writeString(_ value: String, forKey key: String) -> Bool { false } func readStringResult(forKey key: String) -> KeychainStringResult { .locked } @@ -59,7 +42,7 @@ struct RewindCipherTests { @Test("A record survives a round trip through the cipher, binary values included") func roundTrip() throws { - let cipher = RewindCipher(keychain: FakeKeychain()) + let cipher = RewindCipher(keychain: InMemoryKeychain()) let original = record() let sealed = try cipher.seal(original) @@ -68,7 +51,7 @@ struct RewindCipherTests { @Test("The sealed bytes do not carry the row values in the clear") func sealedPayloadIsNotReadable() throws { - let cipher = RewindCipher(keychain: FakeKeychain()) + let cipher = RewindCipher(keychain: InMemoryKeychain()) let sealed = try cipher.seal(record()) #expect(String(data: sealed, encoding: .utf8)?.contains("users") != true) @@ -83,7 +66,7 @@ struct RewindCipherTests { @Test("A record sealed under one key cannot be read under another") func keyIsRequiredToRead() throws { - let sealed = try RewindCipher(keychain: FakeKeychain()).seal(record()) - #expect(throws: (any Error).self) { try RewindCipher(keychain: FakeKeychain()).open(sealed) } + let sealed = try RewindCipher(keychain: InMemoryKeychain()).seal(record()) + #expect(throws: (any Error).self) { try RewindCipher(keychain: InMemoryKeychain()).open(sealed) } } } diff --git a/TableProTests/Core/DataWrite/RowChangeStatementFactoryServerOwnedTests.swift b/TableProTests/Core/DataWrite/RowChangeStatementFactoryServerOwnedTests.swift index 5cb0de7dcf..6a93d5068f 100644 --- a/TableProTests/Core/DataWrite/RowChangeStatementFactoryServerOwnedTests.swift +++ b/TableProTests/Core/DataWrite/RowChangeStatementFactoryServerOwnedTests.swift @@ -8,7 +8,7 @@ import Foundation import TableProPluginKit import Testing -private final class ContextRecordingDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class ContextRecordingDriver: PluginDriverStub, @unchecked Sendable { private(set) var context: PluginRowWriteContext? private(set) var insertedRowData: [Int: [PluginCellValue]] = [:] private(set) var restoredColumns: [String] = [] @@ -46,24 +46,9 @@ private final class ContextRecordingDriver: PluginDatabaseDriver, @unchecked Sen } func quoteIdentifier(_ name: String) -> String { "[\(name)]" } - func connect() async throws {} - func disconnect() {} func execute(query: String) async throws -> PluginQueryResult { PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) } - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } @MainActor diff --git a/TableProTests/Core/Database/BoundedQueryTests.swift b/TableProTests/Core/Database/BoundedQueryTests.swift index 0e394dafe5..1222c0febc 100644 --- a/TableProTests/Core/Database/BoundedQueryTests.swift +++ b/TableProTests/Core/Database/BoundedQueryTests.swift @@ -139,7 +139,7 @@ enum StreamingStubError: Error { case producerFailed } -private final class StreamingStubDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class StreamingStubDriver: PluginDriverStub, @unchecked Sendable { private let totalRows: Int private let batchSize: Int private let failAfterRows: Int? @@ -184,23 +184,6 @@ private final class StreamingStubDriver: PluginDatabaseDriver, @unchecked Sendab continuation.onTermination = { @Sendable _ in task.cancel() } } } - - func connect() async throws {} - func disconnect() {} - func execute(query: String) async throws -> PluginQueryResult { .empty } - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } private final class ProducedRowCounter: @unchecked Sendable { @@ -220,16 +203,13 @@ private final class ProducedRowCounter: @unchecked Sendable { } } -private final class NonBoundedStubDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class NonBoundedStubDriver: PluginDriverStub, @unchecked Sendable { private let rowCount: Int init(rowCount: Int = 0) { self.rowCount = rowCount } - func connect() async throws {} - func disconnect() {} - func execute(query: String) async throws -> PluginQueryResult { PluginQueryResult( columns: ["col1"], @@ -239,32 +219,15 @@ private final class NonBoundedStubDriver: PluginDatabaseDriver, @unchecked Senda executionTime: 0 ) } - - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } -private final class SourceBoundedStubDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class SourceBoundedStubDriver: PluginDriverStub, @unchecked Sendable { private let inner: NonBoundedStubDriver init(rowCount: Int) { inner = NonBoundedStubDriver(rowCount: rowCount) } - func connect() async throws {} - func disconnect() {} - func execute(query: String) async throws -> PluginQueryResult { try await inner.execute(query: query) } @@ -272,18 +235,4 @@ private final class SourceBoundedStubDriver: PluginDatabaseDriver, @unchecked Se func executeBoundedQuery(query: String, rowCap: Int) async throws -> PluginQueryResult? { try await executeUserQuery(query: query, rowCap: rowCap, parameters: nil) } - - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } diff --git a/TableProTests/Core/Database/CancelledConnectionCleanupTests.swift b/TableProTests/Core/Database/CancelledConnectionCleanupTests.swift index a2726911e3..053ba2ee33 100644 --- a/TableProTests/Core/Database/CancelledConnectionCleanupTests.swift +++ b/TableProTests/Core/Database/CancelledConnectionCleanupTests.swift @@ -11,114 +11,116 @@ import Foundation import TableProPluginKit import Testing -@Suite("Cancelled connection cleanup", .serialized) -@MainActor -struct CancelledConnectionCleanupTests { - @Test("Cancelled attempt leaves the session entry intact") - func cancelledLeavesSessionIntact() { - let id = UUID() - DatabaseManager.shared.injectSession( - ConnectionSession(connection: TestFixtures.makeConnection(id: id, name: "Retry")), - for: id - ) - defer { DatabaseManager.shared.removeSession(for: id) } - - DatabaseManager.shared.finalizeConnectionFailure(for: id, cancelled: true) - - #expect(DatabaseManager.shared.activeSessions[id] != nil) - } - - @Test("Genuine failure removes the session entry") - func genuineFailureRemovesSession() { - let id = UUID() - DatabaseManager.shared.injectSession( - ConnectionSession(connection: TestFixtures.makeConnection(id: id, name: "Failed")), - for: id - ) - defer { DatabaseManager.shared.removeSession(for: id) } - - DatabaseManager.shared.finalizeConnectionFailure(for: id, cancelled: false) - - #expect(DatabaseManager.shared.activeSessions[id] == nil) - } - - @Test("Cancelled finalize keeps lastActiveSessionId untouched") - func cancelledKeepsLastActiveSessionId() { - let id = UUID() - DatabaseManager.shared.injectSession( - ConnectionSession(connection: TestFixtures.makeConnection(id: id, name: "Retry")), - for: id - ) - DatabaseManager.shared.lastActiveSessionId = id - defer { - DatabaseManager.shared.removeSession(for: id) - DatabaseManager.shared.lastActiveSessionId = nil +extension GlobalStateSuites { + @Suite("Cancelled connection cleanup") + @MainActor + struct CancelledConnectionCleanupTests { + @Test("Cancelled attempt leaves the session entry intact") + func cancelledLeavesSessionIntact() { + let id = UUID() + DatabaseManager.shared.injectSession( + ConnectionSession(connection: TestFixtures.makeConnection(id: id, name: "Retry")), + for: id + ) + defer { DatabaseManager.shared.removeSession(for: id) } + + DatabaseManager.shared.finalizeConnectionFailure(for: id, cancelled: true) + + #expect(DatabaseManager.shared.activeSessions[id] != nil) } - DatabaseManager.shared.finalizeConnectionFailure(for: id, cancelled: true) + @Test("Genuine failure removes the session entry") + func genuineFailureRemovesSession() { + let id = UUID() + DatabaseManager.shared.injectSession( + ConnectionSession(connection: TestFixtures.makeConnection(id: id, name: "Failed")), + for: id + ) + defer { DatabaseManager.shared.removeSession(for: id) } - #expect(DatabaseManager.shared.lastActiveSessionId == id) - } + DatabaseManager.shared.finalizeConnectionFailure(for: id, cancelled: false) - @Test("Genuine failure clears lastActiveSessionId when no other session remains") - func genuineFailureClearsLastActiveSessionId() { - let id = UUID() - DatabaseManager.shared.injectSession( - ConnectionSession(connection: TestFixtures.makeConnection(id: id, name: "Failed")), - for: id - ) - DatabaseManager.shared.lastActiveSessionId = id - defer { - DatabaseManager.shared.removeSession(for: id) - DatabaseManager.shared.lastActiveSessionId = nil + #expect(DatabaseManager.shared.activeSessions[id] == nil) } - DatabaseManager.shared.finalizeConnectionFailure(for: id, cancelled: false) + @Test("Cancelled finalize keeps lastActiveSessionId untouched") + func cancelledKeepsLastActiveSessionId() { + let id = UUID() + DatabaseManager.shared.injectSession( + ConnectionSession(connection: TestFixtures.makeConnection(id: id, name: "Retry")), + for: id + ) + DatabaseManager.shared.lastActiveSessionId = id + defer { + DatabaseManager.shared.removeSession(for: id) + DatabaseManager.shared.lastActiveSessionId = nil + } + + DatabaseManager.shared.finalizeConnectionFailure(for: id, cancelled: true) + + #expect(DatabaseManager.shared.lastActiveSessionId == id) + } - #expect(DatabaseManager.shared.lastActiveSessionId != id) - } + @Test("Genuine failure clears lastActiveSessionId when no other session remains") + func genuineFailureClearsLastActiveSessionId() { + let id = UUID() + DatabaseManager.shared.injectSession( + ConnectionSession(connection: TestFixtures.makeConnection(id: id, name: "Failed")), + for: id + ) + DatabaseManager.shared.lastActiveSessionId = id + defer { + DatabaseManager.shared.removeSession(for: id) + DatabaseManager.shared.lastActiveSessionId = nil + } + + DatabaseManager.shared.finalizeConnectionFailure(for: id, cancelled: false) + + #expect(DatabaseManager.shared.lastActiveSessionId != id) + } - @Test("Cancelling a pending connection invalidates the attempt still in flight") - func cancelInvalidatesInFlightAttempt() async { - let id = UUID() - let attempt = DatabaseManager.shared.connectionAttempts.begin(for: id) - DatabaseManager.shared.injectSession( - ConnectionSession(connection: TestFixtures.makeConnection(id: id, name: "Pending")), - for: id - ) - defer { DatabaseManager.shared.removeSession(for: id) } + @Test("Cancelling a pending connection invalidates the attempt still in flight") + func cancelInvalidatesInFlightAttempt() async { + let id = UUID() + let attempt = DatabaseManager.shared.connectionAttempts.begin(for: id) + DatabaseManager.shared.injectSession( + ConnectionSession(connection: TestFixtures.makeConnection(id: id, name: "Pending")), + for: id + ) + defer { DatabaseManager.shared.removeSession(for: id) } - await DatabaseManager.shared.cancelEnsureConnected(id) + await DatabaseManager.shared.cancelEnsureConnected(id) - #expect(!DatabaseManager.shared.connectionAttempts.isCurrent(attempt, for: id)) - #expect(DatabaseManager.shared.activeSessions[id] == nil) - } - - @Test("Genuine failure moves lastActiveSessionId to a remaining session") - func genuineFailureSwitchesToRemainingSession() { - let failedId = UUID() - let otherId = UUID() - DatabaseManager.shared.injectSession( - ConnectionSession(connection: TestFixtures.makeConnection(id: failedId, name: "Failed")), - for: failedId - ) - DatabaseManager.shared.injectSession( - ConnectionSession(connection: TestFixtures.makeConnection(id: otherId, name: "Other")), - for: otherId - ) - DatabaseManager.shared.lastActiveSessionId = failedId - defer { - DatabaseManager.shared.removeSession(for: failedId) - DatabaseManager.shared.removeSession(for: otherId) - DatabaseManager.shared.lastActiveSessionId = nil + #expect(!DatabaseManager.shared.connectionAttempts.isCurrent(attempt, for: id)) + #expect(DatabaseManager.shared.activeSessions[id] == nil) } - DatabaseManager.shared.finalizeConnectionFailure(for: failedId, cancelled: false) - - let moved = DatabaseManager.shared.lastActiveSessionId - #expect(moved != failedId) - #expect(moved != nil) - #expect(moved.flatMap { DatabaseManager.shared.activeSessions[$0] } != nil) - #expect(DatabaseManager.shared.activeSessions[otherId] != nil) + @Test("Genuine failure moves lastActiveSessionId to a remaining session") + func genuineFailureSwitchesToRemainingSession() { + let failedId = UUID() + let otherId = UUID() + DatabaseManager.shared.injectSession( + ConnectionSession(connection: TestFixtures.makeConnection(id: failedId, name: "Failed")), + for: failedId + ) + DatabaseManager.shared.injectSession( + ConnectionSession(connection: TestFixtures.makeConnection(id: otherId, name: "Other")), + for: otherId + ) + DatabaseManager.shared.lastActiveSessionId = failedId + defer { + DatabaseManager.shared.removeSession(for: failedId) + DatabaseManager.shared.removeSession(for: otherId) + DatabaseManager.shared.lastActiveSessionId = nil + } + + DatabaseManager.shared.finalizeConnectionFailure(for: failedId, cancelled: false) + + let moved = DatabaseManager.shared.lastActiveSessionId + #expect(moved != failedId) + #expect(moved != nil) + #expect(moved.flatMap { DatabaseManager.shared.activeSessions[$0] } != nil) + #expect(DatabaseManager.shared.activeSessions[otherId] != nil) + } } } diff --git a/TableProTests/Core/Database/ConnectionDeadlineTests.swift b/TableProTests/Core/Database/ConnectionDeadlineTests.swift index 5a18422055..49cfb8dfca 100644 --- a/TableProTests/Core/Database/ConnectionDeadlineTests.swift +++ b/TableProTests/Core/Database/ConnectionDeadlineTests.swift @@ -5,18 +5,6 @@ import TableProPluginKit import TableProTestSupport import Testing -private func waitUntil( - timeout: Duration = .seconds(2), - _ condition: @Sendable () -> Bool -) async -> Bool { - let deadline = ContinuousClock.now.advanced(by: timeout) - while ContinuousClock.now < deadline { - if condition() { return true } - try? await Task.sleep(for: .milliseconds(5)) - } - return condition() -} - struct ConnectionDeadlineTests { @Test("Connect timeout policy defaults and rejects out-of-range values") func connectPolicy() { @@ -233,7 +221,7 @@ struct ConnectionDeadlineTests { resolution.cancel() resolver.finish() } - try #require(await waitUntil { resolver.isResolving }) + try await waitUntil { resolver.isResolving } let startedAt = ContinuousClock.now let result = try #require(await BoundedCall.result(within: .seconds(2)) { @@ -274,7 +262,7 @@ struct PluginDriverAdapterConnectDeadlineTests { plugin.finishConnect() } - try #require(await waitUntil { plugin.isConnecting }) + try await waitUntil { plugin.isConnecting } try await Task.sleep(for: .milliseconds(120)) #expect(plugin.isConnecting) #expect(plugin.disconnectCallCount == 0) @@ -316,7 +304,7 @@ struct PluginDriverAdapterConnectDeadlineTests { connect.cancel() plugin.finishConnect() } - try #require(await waitUntil { plugin.isConnecting }) + try await waitUntil { plugin.isConnecting } let adapter = try #require(adapterBox.withLock { $0 }) let startedAt = ContinuousClock.now @@ -349,14 +337,14 @@ struct PluginDriverAdapterConnectDeadlineTests { #expect(repeatedError is DatabaseError) plugin.finishConnect() - try #require(await waitUntil { plugin.disconnectCallCount == 1 }) + try await waitUntil { plugin.disconnectCallCount == 1 } #expect(!plugin.disconnectedWhileConnecting) #expect(stages.withLock { $0 } == [.authenticating]) } } -private final class DeadlineBlockingPluginDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class DeadlineBlockingPluginDriver: PluginDriverStub, @unchecked Sendable { private struct State { var continuation: CheckedContinuation? var isConnecting = false @@ -411,23 +399,6 @@ private final class DeadlineBlockingPluginDriver: PluginDatabaseDriver, @uncheck func execute(query: String) async throws -> PluginQueryResult { PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) } - - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - - func fetchDatabases() async throws -> [String] { [] } - - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } private final class DeadlineBlockingCredentialResolver: @unchecked Sendable { diff --git a/TableProTests/Core/Database/ConnectionEndReasonTests.swift b/TableProTests/Core/Database/ConnectionEndReasonTests.swift index f6a52a02f3..e4bed9c924 100644 --- a/TableProTests/Core/Database/ConnectionEndReasonTests.swift +++ b/TableProTests/Core/Database/ConnectionEndReasonTests.swift @@ -13,7 +13,6 @@ import Foundation import TableProPluginKit import Testing -@Suite("Connection end reason", .serialized) @MainActor struct ConnectionEndReasonTests { private func injectConnectingSession() -> UUID { diff --git a/TableProTests/Core/Database/ConnectionVerificationTests.swift b/TableProTests/Core/Database/ConnectionVerificationTests.swift index 8d931b3e4e..b22550e6e9 100644 --- a/TableProTests/Core/Database/ConnectionVerificationTests.swift +++ b/TableProTests/Core/Database/ConnectionVerificationTests.swift @@ -11,6 +11,7 @@ import Foundation @testable import TablePro import TableProPluginKit +import TableProTestSupport import Testing @Suite("Connection verification before use", .serialized) @@ -277,18 +278,20 @@ struct ConnectionVerificationTests { /// answer to whatever driver the session holds by then is how a losing attempt tears down the /// one that replaced it. @Test("a late answer about a driver that has been replaced is discarded") - func aLateAnswerAboutAReplacedDriverIsDiscarded() async { + func aLateAnswerAboutAReplacedDriverIsDiscarded() async throws { let slow = MockDatabaseDriver() - slow.pingDelaySeconds = 0.3 + let ping = AsyncGate() + slow.pingGate = ping slow.pingError = DatabaseError.notConnected let connection = makeSession(driver: slow) DatabaseManager.shared.markSessionVerified(connection.id, at: .distantPast) let connectionId = connection.id let check = Task { @MainActor in await DatabaseManager.shared.verifyBeforeUse(connectionId) } - try? await Task.sleep(for: .milliseconds(50)) + try await waitUntil { slow.pingCallCount == 1 } let replacement = MockDatabaseDriver() DatabaseManager.shared.updateSession(connectionId) { $0.driver = replacement } + await ping.open() await check.value #expect(slow.pingCallCount == 1) @@ -338,101 +341,103 @@ struct ConnectionVerificationTests { } } -@Suite("Health monitor scheduling", .serialized) -@MainActor -struct HealthMonitorSchedulingTests { - private func makeSession() -> DatabaseConnection { - FakeMSSQLPluginRegistration.registerIfNeeded() - var connection = TestFixtures.makeConnection(name: "Prod") - connection.type = DatabaseType(rawValue: FakeMSSQLPlugin.databaseTypeId) - var session = ConnectionSession(connection: connection) - session.status = .connected - session.driver = MockDatabaseDriver() - DatabaseManager.shared.injectSession(session, for: connection.id) - return connection - } - - private func withHealthCheck(_ setting: ConnectionHealthCheck, _ body: () async -> Void) async { - let previous = AppSettingsManager.shared.general.connectionHealthCheck - AppSettingsManager.shared.general.connectionHealthCheck = setting - await body() - AppSettingsManager.shared.general.connectionHealthCheck = previous - } - - /// The whole point of the on-demand setting: not a very long interval, no scheduled work at - /// all. A task that wakes up to decide it has nothing to do is still something waking up. - @Test("on demand starts no monitor at all") - func onDemandStartsNoMonitor() async { - let connection = makeSession() - defer { DatabaseManager.shared.removeSession(for: connection.id) } +extension GlobalStateSuites { + @Suite("Health monitor scheduling") + @MainActor + struct HealthMonitorSchedulingTests { + private func makeSession() -> DatabaseConnection { + FakeMSSQLPluginRegistration.registerIfNeeded() + var connection = TestFixtures.makeConnection(name: "Prod") + connection.type = DatabaseType(rawValue: FakeMSSQLPlugin.databaseTypeId) + var session = ConnectionSession(connection: connection) + session.status = .connected + session.driver = MockDatabaseDriver() + DatabaseManager.shared.injectSession(session, for: connection.id) + return connection + } - await withHealthCheck(.onDemand) { - await DatabaseManager.shared.startHealthMonitor(for: connection.id) + private func withHealthCheck(_ setting: ConnectionHealthCheck, _ body: () async -> Void) async { + let previous = AppSettingsManager.shared.general.connectionHealthCheck + AppSettingsManager.shared.general.connectionHealthCheck = setting + await body() + AppSettingsManager.shared.general.connectionHealthCheck = previous } - #expect(DatabaseManager.shared.healthMonitors[connection.id] == nil) - await DatabaseManager.shared.stopHealthMonitor(for: connection.id) - } + /// The whole point of the on-demand setting: not a very long interval, no scheduled work at + /// all. A task that wakes up to decide it has nothing to do is still something waking up. + @Test("on demand starts no monitor at all") + func onDemandStartsNoMonitor() async { + let connection = makeSession() + defer { DatabaseManager.shared.removeSession(for: connection.id) } - @Test("a polling setting starts a monitor") - func aPollingSettingStartsAMonitor() async { - let connection = makeSession() - defer { DatabaseManager.shared.removeSession(for: connection.id) } + await withHealthCheck(.onDemand) { + await DatabaseManager.shared.startHealthMonitor(for: connection.id) + } - await withHealthCheck(.every15Minutes) { - await DatabaseManager.shared.startHealthMonitor(for: connection.id) + #expect(DatabaseManager.shared.healthMonitors[connection.id] == nil) + await DatabaseManager.shared.stopHealthMonitor(for: connection.id) } - #expect(DatabaseManager.shared.healthMonitors[connection.id] != nil) - await DatabaseManager.shared.stopHealthMonitor(for: connection.id) - } + @Test("a polling setting starts a monitor") + func aPollingSettingStartsAMonitor() async { + let connection = makeSession() + defer { DatabaseManager.shared.removeSession(for: connection.id) } - @Test("health checks never ping while a tracked operation is running") - func trackedOperationSuppressesPingRegardlessOfAge() async { - FakeMSSQLPluginRegistration.registerIfNeeded() - var connection = TestFixtures.makeConnection(name: "Prod") - connection.type = DatabaseType(rawValue: FakeMSSQLPlugin.databaseTypeId) - let driver = MockDatabaseDriver(connection: connection) - var session = ConnectionSession(connection: connection, driver: driver) - session.status = .connected - DatabaseManager.shared.injectSession(session, for: connection.id) + await withHealthCheck(.every15Minutes) { + await DatabaseManager.shared.startHealthMonitor(for: connection.id) + } - await withHealthCheck(.every15Minutes) { - await DatabaseManager.shared.startHealthMonitor(for: connection.id) + #expect(DatabaseManager.shared.healthMonitors[connection.id] != nil) + await DatabaseManager.shared.stopHealthMonitor(for: connection.id) } - let monitor = DatabaseManager.shared.healthMonitors[connection.id] - DatabaseManager.shared.queriesInFlight[connection.id] = 1 - DatabaseManager.shared.queryStartTimes[connection.id] = .distantPast - - await monitor?.performHealthCheck() - #expect(driver.pingCallCount == 0) - DatabaseManager.shared.queriesInFlight.removeValue(forKey: connection.id) - DatabaseManager.shared.queryStartTimes.removeValue(forKey: connection.id) - await monitor?.performHealthCheck() - #expect(driver.pingCallCount == 1) + @Test("health checks never ping while a tracked operation is running") + func trackedOperationSuppressesPingRegardlessOfAge() async { + FakeMSSQLPluginRegistration.registerIfNeeded() + var connection = TestFixtures.makeConnection(name: "Prod") + connection.type = DatabaseType(rawValue: FakeMSSQLPlugin.databaseTypeId) + let driver = MockDatabaseDriver(connection: connection) + var session = ConnectionSession(connection: connection, driver: driver) + session.status = .connected + DatabaseManager.shared.injectSession(session, for: connection.id) + + await withHealthCheck(.every15Minutes) { + await DatabaseManager.shared.startHealthMonitor(for: connection.id) + } + let monitor = DatabaseManager.shared.healthMonitors[connection.id] + DatabaseManager.shared.queriesInFlight[connection.id] = 1 + DatabaseManager.shared.queryStartTimes[connection.id] = .distantPast + + await monitor?.performHealthCheck() + #expect(driver.pingCallCount == 0) + + DatabaseManager.shared.queriesInFlight.removeValue(forKey: connection.id) + DatabaseManager.shared.queryStartTimes.removeValue(forKey: connection.id) + await monitor?.performHealthCheck() + #expect(driver.pingCallCount == 1) + + await DatabaseManager.shared.stopHealthMonitor(for: connection.id) + DatabaseManager.shared.removeSession(for: connection.id) + } - await DatabaseManager.shared.stopHealthMonitor(for: connection.id) - DatabaseManager.shared.removeSession(for: connection.id) - } + /// The monitor is built once, when the connection opens, so switching the setting has to reach + /// what is already open or it only applies to the next connection someone makes. + @Test("turning the check off stops the monitor an open connection already has") + func switchingToOnDemandStopsALiveMonitor() async { + let connection = makeSession() + defer { DatabaseManager.shared.removeSession(for: connection.id) } - /// The monitor is built once, when the connection opens, so switching the setting has to reach - /// what is already open or it only applies to the next connection someone makes. - @Test("turning the check off stops the monitor an open connection already has") - func switchingToOnDemandStopsALiveMonitor() async { - let connection = makeSession() - defer { DatabaseManager.shared.removeSession(for: connection.id) } + await withHealthCheck(.every30Seconds) { + await DatabaseManager.shared.startHealthMonitor(for: connection.id) + } + #expect(DatabaseManager.shared.healthMonitors[connection.id] != nil) - await withHealthCheck(.every30Seconds) { - await DatabaseManager.shared.startHealthMonitor(for: connection.id) - } - #expect(DatabaseManager.shared.healthMonitors[connection.id] != nil) + await withHealthCheck(.onDemand) { + await DatabaseManager.shared.startHealthMonitor(for: connection.id) + } - await withHealthCheck(.onDemand) { - await DatabaseManager.shared.startHealthMonitor(for: connection.id) + #expect(DatabaseManager.shared.healthMonitors[connection.id] == nil) + await DatabaseManager.shared.stopHealthMonitor(for: connection.id) } - - #expect(DatabaseManager.shared.healthMonitors[connection.id] == nil) - await DatabaseManager.shared.stopHealthMonitor(for: connection.id) } } diff --git a/TableProTests/Core/Database/DatabaseEndpointPickerModelTests.swift b/TableProTests/Core/Database/DatabaseEndpointPickerModelTests.swift index 7557000a85..ea8af9c72a 100644 --- a/TableProTests/Core/Database/DatabaseEndpointPickerModelTests.swift +++ b/TableProTests/Core/Database/DatabaseEndpointPickerModelTests.swift @@ -7,11 +7,12 @@ // every submenu open refetched. // +import Foundation @testable import TablePro -import XCTest +import Testing @MainActor -final class DatabaseEndpointPickerModelTests: XCTestCase { +final class DatabaseEndpointPickerModelTests { private struct Unreachable: LocalizedError { var errorDescription: String? { "Connection refused" } } @@ -27,11 +28,13 @@ final class DatabaseEndpointPickerModelTests: XCTestCase { DatabaseEndpointPickerModel(databaseLoader: databases, schemaLoader: schemas) } - func testAnUnopenedConnectionReportsLoadingRatherThanAnEmptyList() { - XCTAssertEqual(model().databases(for: UUID()), .loading) + @Test + func anUnopenedConnectionReportsLoadingRatherThanAnEmptyList() { + #expect(model().databases(for: UUID()) == .loading) } - func testDatabasesAreLoadedOnce() async { + @Test + func databasesAreLoadedOnce() async { let calls = Counter() let subject = model(databases: { _ in calls.increment() @@ -42,22 +45,24 @@ final class DatabaseEndpointPickerModelTests: XCTestCase { await subject.loadDatabases(for: connection) await subject.loadDatabases(for: connection) - XCTAssertEqual(calls.value, 1, "reopening the chooser must not refetch") - XCTAssertEqual(subject.databases(for: connection.id), .loaded(["orders"])) + #expect(calls.value == 1, "reopening the chooser must not refetch") + #expect(subject.databases(for: connection.id) == .loaded(["orders"])) } /// A server that refused the connection is not a server with no databases. Caching the failure /// as `[]` is what told users their database list was empty, with no way to retry. - func testAFailedLoadIsRememberedAsAFailure() async { + @Test + func aFailedLoadIsRememberedAsAFailure() async { let subject = model(databases: { _ in throw Unreachable() }) let connection = connection() await subject.loadDatabases(for: connection) - XCTAssertEqual(subject.databases(for: connection.id), .failed("Connection refused")) + #expect(subject.databases(for: connection.id) == .failed("Connection refused")) } - func testAFailedLoadCanBeRetriedWithoutAskingForAReload() async { + @Test + func aFailedLoadCanBeRetriedWithoutAskingForAReload() async { let calls = Counter() let subject = model(databases: { _ in calls.increment() @@ -69,12 +74,13 @@ final class DatabaseEndpointPickerModelTests: XCTestCase { await subject.loadDatabases(for: connection) await subject.loadDatabases(for: connection) - XCTAssertEqual(subject.databases(for: connection.id), .loaded(["orders"])) + #expect(subject.databases(for: connection.id) == .loaded(["orders"])) } /// `CLAUDE.md`: a refresh never clears the cache it is refreshing. The list stays on screen /// while the reload runs, so Try Again cannot blank a pane that already had an answer. - func testAReloadKeepsTheLoadedListUntilTheNewOneArrives() async { + @Test + func aReloadKeepsTheLoadedListUntilTheNewOneArrives() async { let gate = AsyncGate() let subject = model(databases: { _ in await gate.wait() @@ -87,15 +93,17 @@ final class DatabaseEndpointPickerModelTests: XCTestCase { await gate.close() let reload = Task { await subject.loadDatabases(for: connection, reload: true) } - await Task.yield() - XCTAssertEqual(subject.databases(for: connection.id), .loaded(["orders", "audit"])) + // The main queue runs jobs in order, so this lets the reload reach its fetch first. + await Task { @MainActor in }.value + #expect(subject.databases(for: connection.id) == .loaded(["orders", "audit"])) await gate.open() await reload.value - XCTAssertEqual(subject.databases(for: connection.id), .loaded(["orders", "audit"])) + #expect(subject.databases(for: connection.id) == .loaded(["orders", "audit"])) } - func testSchemasAreKeyedPerDatabaseRatherThanPerConnection() async { + @Test + func schemasAreKeyedPerDatabaseRatherThanPerConnection() async { let subject = model(schemas: { endpoint, _ in [endpoint.database + "_schema"] }) let connection = connection() let orders = DatabaseEndpoint.from(connection: connection, database: "orders") @@ -104,8 +112,8 @@ final class DatabaseEndpointPickerModelTests: XCTestCase { await subject.loadSchemas(for: orders, connection: connection) await subject.loadSchemas(for: audit, connection: connection) - XCTAssertEqual(subject.schemas(for: orders), .loaded(["orders_schema"])) - XCTAssertEqual(subject.schemas(for: audit), .loaded(["audit_schema"])) + #expect(subject.schemas(for: orders) == .loaded(["orders_schema"])) + #expect(subject.schemas(for: audit) == .loaded(["audit_schema"])) } } diff --git a/TableProTests/Core/Database/DatabaseEndpointTests.swift b/TableProTests/Core/Database/DatabaseEndpointTests.swift index ed0c40ec85..1f118fc144 100644 --- a/TableProTests/Core/Database/DatabaseEndpointTests.swift +++ b/TableProTests/Core/Database/DatabaseEndpointTests.swift @@ -6,11 +6,12 @@ // not address two databases on one server. // +import Foundation @testable import TablePro import TableProPluginKit -import XCTest +import Testing -final class DatabaseEndpointTests: XCTestCase { +struct DatabaseEndpointTests { private let connectionId = UUID() private func endpoint(database: String, schema: String? = nil) -> DatabaseEndpoint { @@ -25,35 +26,38 @@ final class DatabaseEndpointTests: XCTestCase { /// `canCompare` used to key on the connection id, which made this pair identical and refused /// the comparison the issue asks for by name: two versions of a schema on one server. - func testTwoDatabasesOnOneConnectionAreDistinctEndpoints() { - XCTAssertNotEqual(endpoint(database: "app_prod").id, endpoint(database: "app_staging").id) + @Test + func twoDatabasesOnOneConnectionAreDistinctEndpoints() { + #expect(endpoint(database: "app_prod").id != endpoint(database: "app_staging").id) } - func testTwoSchemasInOneDatabaseAreDistinctEndpoints() { - XCTAssertNotEqual( - endpoint(database: "app", schema: "public").id, - endpoint(database: "app", schema: "sales").id - ) + @Test + func twoSchemasInOneDatabaseAreDistinctEndpoints() { + #expect(endpoint(database: "app", schema: "public").id != endpoint(database: "app", schema: "sales").id) } - func testTheSameScopeIsTheSameEndpoint() { - XCTAssertEqual(endpoint(database: "app", schema: "public").id, endpoint(database: "app", schema: "public").id) + @Test + func theSameScopeIsTheSameEndpoint() { + #expect(endpoint(database: "app", schema: "public").id == endpoint(database: "app", schema: "public").id) } - func testQualifiedDescriptionNamesEveryLevelThatIsSet() { - XCTAssertEqual(endpoint(database: "app", schema: "public").qualifiedDescription, "server / app / public") - XCTAssertEqual(endpoint(database: "app").qualifiedDescription, "server / app") - XCTAssertEqual(endpoint(database: "").qualifiedDescription, "server") + @Test + func qualifiedDescriptionNamesEveryLevelThatIsSet() { + #expect(endpoint(database: "app", schema: "public").qualifiedDescription == "server / app / public") + #expect(endpoint(database: "app").qualifiedDescription == "server / app") + #expect(endpoint(database: "").qualifiedDescription == "server") } - func testChangingDatabaseClearsTheSchema() { + @Test + func changingDatabaseClearsTheSchema() { let moved = endpoint(database: "app", schema: "public").withDatabase("other") - XCTAssertEqual(moved.database, "other") - XCTAssertNil(moved.schema) + #expect(moved.database == "other") + #expect(moved.schema == nil) } - func testReadOnlyEndpointIsRefusedAsATarget() { + @Test + func readOnlyEndpointIsRefusedAsATarget() { let readOnly = DatabaseEndpoint( scope: DatabaseScope(connectionId: connectionId, database: "prod", schema: nil), connectionName: "prod", @@ -62,7 +66,7 @@ final class DatabaseEndpointTests: XCTestCase { color: .red ) - XCTAssertFalse(readOnly.canBeWrittenTo) - XCTAssertNotNil(readOnly.ineligibleAsTargetReason) + #expect(!readOnly.canBeWrittenTo) + #expect(readOnly.ineligibleAsTargetReason != nil) } } diff --git a/TableProTests/Core/Database/DatabaseManagerDisconnectTests.swift b/TableProTests/Core/Database/DatabaseManagerDisconnectTests.swift index 16f283625e..c0090bf306 100644 --- a/TableProTests/Core/Database/DatabaseManagerDisconnectTests.swift +++ b/TableProTests/Core/Database/DatabaseManagerDisconnectTests.swift @@ -11,165 +11,145 @@ import Foundation @testable import TablePro import TableProPluginKit +import TableProTestSupport import Testing -@Suite("Database manager disconnect", .serialized) -@MainActor -struct DatabaseManagerDisconnectTests { - private final class TabStatePersisterSpy: SessionTabStatePersisting { - private(set) var persistedConnectionIds: [UUID] = [] - private(set) var sessionsPresentAtPersist: [Bool] = [] +extension GlobalStateSuites { + @Suite("Database manager disconnect") + @MainActor + struct DatabaseManagerDisconnectTests { + private final class TabStatePersisterSpy: SessionTabStatePersisting { + private(set) var persistedConnectionIds: [UUID] = [] + private(set) var sessionsPresentAtPersist: [Bool] = [] + + func persistTabState(for connectionId: UUID) { + persistedConnectionIds.append(connectionId) + sessionsPresentAtPersist.append(DatabaseManager.shared.activeSessions[connectionId] != nil) + } + } - func persistTabState(for connectionId: UUID) { - persistedConnectionIds.append(connectionId) - sessionsPresentAtPersist.append(DatabaseManager.shared.activeSessions[connectionId] != nil) + private func withInstalledSpy(_ body: (TabStatePersisterSpy) async -> Void) async { + let spy = TabStatePersisterSpy() + let previous = DatabaseManager.shared.tabStatePersister + DatabaseManager.shared.tabStatePersister = spy + await body(spy) + DatabaseManager.shared.tabStatePersister = previous } - } - private func withInstalledSpy(_ body: (TabStatePersisterSpy) async -> Void) async { - let spy = TabStatePersisterSpy() - let previous = DatabaseManager.shared.tabStatePersister - DatabaseManager.shared.tabStatePersister = spy - await body(spy) - DatabaseManager.shared.tabStatePersister = previous - } + @Test("Disconnecting persists the connection's tabs while the session still exists") + func disconnectPersistsTabsBeforeTeardown() async { + await withInstalledSpy { spy in + let id = UUID() + DatabaseManager.shared.injectSession( + ConnectionSession(connection: TestFixtures.makeConnection(id: id, name: "Persisted")), + for: id + ) + + await DatabaseManager.shared.disconnectSession(id) + + #expect(spy.persistedConnectionIds == [id]) + #expect(spy.sessionsPresentAtPersist == [true]) + } + } + + @Test("A disconnect with no session persists nothing") + func disconnectWithoutSessionPersistsNothing() async { + await withInstalledSpy { spy in + await DatabaseManager.shared.disconnectSession(UUID()) + + #expect(spy.persistedConnectionIds.isEmpty) + } + } - @Test("Disconnecting persists the connection's tabs while the session still exists") - func disconnectPersistsTabsBeforeTeardown() async { - await withInstalledSpy { spy in + @Test("A user-requested disconnect is recorded as deliberate") + func userRequestedDisconnectIsRecorded() async { let id = UUID() DatabaseManager.shared.injectSession( - ConnectionSession(connection: TestFixtures.makeConnection(id: id, name: "Persisted")), + ConnectionSession(connection: TestFixtures.makeConnection(id: id, name: "Deliberate")), for: id ) + defer { DatabaseManager.shared.userRequestedDisconnects.remove(id) } - await DatabaseManager.shared.disconnectSession(id) + await DatabaseManager.shared.disconnectSession(id, origin: .userRequested) - #expect(spy.persistedConnectionIds == [id]) - #expect(spy.sessionsPresentAtPersist == [true]) + #expect(DatabaseManager.shared.wasDisconnectedByUser(id)) } - } - @Test("A disconnect with no session persists nothing") - func disconnectWithoutSessionPersistsNothing() async { - await withInstalledSpy { spy in - await DatabaseManager.shared.disconnectSession(UUID()) - - #expect(spy.persistedConnectionIds.isEmpty) - } - } - - @Test("A user-requested disconnect is recorded as deliberate") - func userRequestedDisconnectIsRecorded() async { - let id = UUID() - DatabaseManager.shared.injectSession( - ConnectionSession(connection: TestFixtures.makeConnection(id: id, name: "Deliberate")), - for: id - ) - defer { DatabaseManager.shared.userRequestedDisconnects.remove(id) } - - await DatabaseManager.shared.disconnectSession(id, origin: .userRequested) + /// Quitting and closing a window both run through `disconnectSession`. Recording those as + /// deliberate would drop every connection from "Reopen Last Session", because a deliberate + /// disconnect is the one unavailable state that does not retain restore intent. + @Test("An app-managed disconnect is not recorded as deliberate") + func appManagedDisconnectIsNotRecorded() async { + let id = UUID() + DatabaseManager.shared.injectSession( + ConnectionSession(connection: TestFixtures.makeConnection(id: id, name: "Closed")), + for: id + ) + defer { DatabaseManager.shared.userRequestedDisconnects.remove(id) } - #expect(DatabaseManager.shared.wasDisconnectedByUser(id)) - } + await DatabaseManager.shared.disconnectSession(id) - /// Quitting and closing a window both run through `disconnectSession`. Recording those as - /// deliberate would drop every connection from "Reopen Last Session", because a deliberate - /// disconnect is the one unavailable state that does not retain restore intent. - @Test("An app-managed disconnect is not recorded as deliberate") - func appManagedDisconnectIsNotRecorded() async { - let id = UUID() - DatabaseManager.shared.injectSession( - ConnectionSession(connection: TestFixtures.makeConnection(id: id, name: "Closed")), - for: id - ) - defer { DatabaseManager.shared.userRequestedDisconnects.remove(id) } - - await DatabaseManager.shared.disconnectSession(id) - - #expect(!DatabaseManager.shared.wasDisconnectedByUser(id)) - } + #expect(!DatabaseManager.shared.wasDisconnectedByUser(id)) + } - @Test("Disconnecting removes the session entry") - func disconnectRemovesSessionEntry() async { - let id = UUID() - DatabaseManager.shared.injectSession( - ConnectionSession(connection: TestFixtures.makeConnection(id: id, name: "Gone")), - for: id - ) - defer { DatabaseManager.shared.userRequestedDisconnects.remove(id) } + @Test("Disconnecting removes the session entry") + func disconnectRemovesSessionEntry() async { + let id = UUID() + DatabaseManager.shared.injectSession( + ConnectionSession(connection: TestFixtures.makeConnection(id: id, name: "Gone")), + for: id + ) + defer { DatabaseManager.shared.userRequestedDisconnects.remove(id) } - await DatabaseManager.shared.disconnectSession(id, origin: .userRequested) + await DatabaseManager.shared.disconnectSession(id, origin: .userRequested) - #expect(DatabaseManager.shared.activeSessions[id] == nil) - } + #expect(DatabaseManager.shared.activeSessions[id] == nil) + } - /// A driver stuck in a call keeps its turn across a disconnect. Work queued behind it has to end - /// with the session, or it wakes when that call returns and runs on whatever holds the id then. - @Test("Disconnecting fails the work still queued for the session's driver") - func disconnectFailsQueuedDriverWork() async throws { - let connection = TestFixtures.makeConnection(name: "Queued") - var session = ConnectionSession(connection: connection, driver: MockDatabaseDriver(connection: connection)) - session.status = .connected - DatabaseManager.shared.injectSession(session, for: connection.id) - defer { DatabaseManager.shared.removeSession(for: connection.id) } - let gate = DatabaseManager.shared.sessionDriverGate - - let acquired = Latch() - let release = Latch() - let holder = Task { @MainActor in - try await gate.withExclusiveAccess(connection.id) { - acquired.open() - await release.wait() + /// A driver stuck in a call keeps its turn across a disconnect. Work queued behind it has to end + /// with the session, or it wakes when that call returns and runs on whatever holds the id then. + @Test("Disconnecting fails the work still queued for the session's driver") + func disconnectFailsQueuedDriverWork() async throws { + let connection = TestFixtures.makeConnection(name: "Queued") + var session = ConnectionSession(connection: connection, driver: MockDatabaseDriver(connection: connection)) + session.status = .connected + DatabaseManager.shared.injectSession(session, for: connection.id) + defer { DatabaseManager.shared.removeSession(for: connection.id) } + let gate = DatabaseManager.shared.sessionDriverGate + + let acquired = AsyncGate() + let release = AsyncGate() + let holder = Task { @MainActor in + try await gate.withExclusiveAccess(connection.id) { + await acquired.open() + await release.wait() + } } - } - await acquired.wait() - - let scope = DatabaseScope(connectionId: connection.id, database: connection.database, schema: nil) - let lease = Task { @MainActor in - try await DatabaseManager.shared.withScopedDriver( - scope: scope, - route: .sessionDriver, - cancellation: .cancellableRead(DriverLeaseOwner()) - ) { driver in - driver.connection.database + await acquired.wait() + + let scope = DatabaseScope(connectionId: connection.id, database: connection.database, schema: nil) + let lease = Task { @MainActor in + try await DatabaseManager.shared.withScopedDriver( + scope: scope, + route: .sessionDriver, + cancellation: .cancellableRead(DriverLeaseOwner()) + ) { driver in + driver.connection.database + } } - } - for _ in 0..<10_000 where gate.waiterCount(for: connection.id) < 1 { - await Task.yield() - } - #expect(gate.waiterCount(for: connection.id) == 1) + try await waitUntil { gate.waiterCount(for: connection.id) >= 1 } + #expect(gate.waiterCount(for: connection.id) == 1) - await DatabaseManager.shared.disconnectSession(connection.id) + await DatabaseManager.shared.disconnectSession(connection.id) - #expect(gate.waiterCount(for: connection.id) == 0) + #expect(gate.waiterCount(for: connection.id) == 0) - release.open() - try await holder.value - - await #expect(throws: CancellationError.self) { - try await lease.value - } - } -} + await release.open() + try await holder.value -@MainActor -private final class Latch { - private var waiters: [CheckedContinuation] = [] - private var isOpen = false - - func open() { - guard !isOpen else { return } - isOpen = true - let pending = waiters - waiters = [] - for waiter in pending { - waiter.resume() + await #expect(throws: CancellationError.self) { + try await lease.value + } } } - - func wait() async { - guard !isOpen else { return } - await withCheckedContinuation { waiters.append($0) } - } } diff --git a/TableProTests/Core/Database/DatabaseManagerSchemaChangeRoutingTests.swift b/TableProTests/Core/Database/DatabaseManagerSchemaChangeRoutingTests.swift index f9ab338384..dd260a77d9 100644 --- a/TableProTests/Core/Database/DatabaseManagerSchemaChangeRoutingTests.swift +++ b/TableProTests/Core/Database/DatabaseManagerSchemaChangeRoutingTests.swift @@ -142,674 +142,676 @@ private final class SchemaRoutingDriver: SchemaRoutingBaseDriver, PluginDatabase } } -@Suite("DatabaseManager schema change routing", .serialized) -@MainActor -struct DatabaseManagerSchemaChangeRoutingTests { - /// An engine that changes database on its live connection but cannot pool a second one, - /// so a save has nowhere to run but the session driver and has to pin it. - private static let singleConnectionTypeId = "SchemaRoutingSingleConnection" - - private static var singleConnectionType: DatabaseType { - registerSingleConnectionTypeIfNeeded() - return DatabaseType(rawValue: singleConnectionTypeId) - } - - private static func registerSingleConnectionTypeIfNeeded() { - guard PluginMetadataRegistry.shared.snapshot(forRegisteredTypeId: singleConnectionTypeId) == nil else { - return - } - var capabilities = PluginMetadataSnapshot.CapabilityFlags.defaults - capabilities.supportsConnectionPooling = false - let snapshot = PluginMetadataSnapshot( - displayName: singleConnectionTypeId, iconName: "cylinder", defaultPort: 1_234, - requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: true, - isDownloadable: false, primaryUrlScheme: "schemaroutingsingle", parameterStyle: .questionMark, - navigationModel: .standard, explainVariants: [], pathFieldRole: .database, - supportsHealthMonitor: false, urlSchemes: ["schemaroutingsingle"], postConnectActions: [], - brandColorHex: "#000000", queryLanguageName: "SQL", editorLanguage: .sql, - connectionMode: .network, supportsDatabaseSwitching: true, - capabilities: capabilities, schema: .defaults, editor: .defaults, connection: .defaults - ) - PluginMetadataRegistry.shared.register(snapshot: snapshot, forTypeId: singleConnectionTypeId) - } - - private static func makeAddColumnChange(named name: String = "notes") -> SchemaChange { - var column = EditableColumnDefinition.placeholder() - column.name = name - column.dataType = "TEXT" - return .addColumn(column) - } - - /// `savedDatabase` is the connection's persisted default, `browseDatabase` is where the - /// sidebar currently points. The tab's scope is passed separately at every call site so - /// all three can be distinguished. - private static func makeSession( - type: DatabaseType = .mysql, - savedDatabase: String = "testdb", - browseDatabase: String? = nil, - browseSchema: String? = nil - ) -> (DatabaseConnection, SchemaRoutingDriver) { - let connection = TestFixtures.makeConnection(database: savedDatabase, type: type) - let pluginDriver = SchemaRoutingDriver(currentSchema: browseSchema) - let adapter = PluginDriverAdapter(connection: connection, pluginDriver: pluginDriver) - var session = ConnectionSession(connection: connection, driver: adapter) - session.browseDatabase = browseDatabase ?? savedDatabase - session.browseSchema = browseSchema - DatabaseManager.shared.injectSession(session, for: connection.id) - return (connection, pluginDriver) - } +extension GlobalStateSuites { + @Suite("DatabaseManager schema change routing") + @MainActor + struct DatabaseManagerSchemaChangeRoutingTests { + /// An engine that changes database on its live connection but cannot pool a second one, + /// so a save has nowhere to run but the session driver and has to pin it. + private static let singleConnectionTypeId = "SchemaRoutingSingleConnection" + + private static var singleConnectionType: DatabaseType { + registerSingleConnectionTypeIfNeeded() + return DatabaseType(rawValue: singleConnectionTypeId) + } - /// Stands in for the connection the pool would open on the scope, which the pool puts on - /// the scope's database and schema before handing it out. - private static func seedPooledDriver( - _ connection: DatabaseConnection, - scope: DatabaseScope - ) async throws -> SchemaRoutingDriver { - let pluginDriver = SchemaRoutingDriver(currentSchema: scope.schema) - let adapter = PluginDriverAdapter(connection: connection, pluginDriver: pluginDriver) - try await adapter.connect() - MetadataConnectionPool.shared.injectEntry(adapter, scope: scope) - return pluginDriver - } + private static func registerSingleConnectionTypeIfNeeded() { + guard PluginMetadataRegistry.shared.snapshot(forRegisteredTypeId: singleConnectionTypeId) == nil else { + return + } + var capabilities = PluginMetadataSnapshot.CapabilityFlags.defaults + capabilities.supportsConnectionPooling = false + let snapshot = PluginMetadataSnapshot( + displayName: singleConnectionTypeId, iconName: "cylinder", defaultPort: 1_234, + requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: true, + isDownloadable: false, primaryUrlScheme: "schemaroutingsingle", parameterStyle: .questionMark, + navigationModel: .standard, explainVariants: [], pathFieldRole: .database, + supportsHealthMonitor: false, urlSchemes: ["schemaroutingsingle"], postConnectActions: [], + brandColorHex: "#000000", queryLanguageName: "SQL", editorLanguage: .sql, + connectionMode: .network, supportsDatabaseSwitching: true, + capabilities: capabilities, schema: .defaults, editor: .defaults, connection: .defaults + ) + PluginMetadataRegistry.shared.register(snapshot: snapshot, forTypeId: singleConnectionTypeId) + } - private static func composeAndSave( - changes: [SchemaChange], - databaseType: DatabaseType, - scope: DatabaseScope - ) async throws { - let script = try await DatabaseManager.shared.schemaChangeStatements( - tableName: "orders", - objectKind: .table, - changes: changes, - scope: scope - ) - try await DatabaseManager.shared.executeSchemaChanges(script, databaseType: databaseType, scope: scope) - } + private static func makeAddColumnChange(named name: String = "notes") -> SchemaChange { + var column = EditableColumnDefinition.placeholder() + column.name = name + column.dataType = "TEXT" + return .addColumn(column) + } - private static func tearDown(_ connections: DatabaseConnection...) { - for connection in connections { - MetadataConnectionPool.shared.closeAll(connectionId: connection.id) - DatabaseManager.shared.removeSession(for: connection.id) + /// `savedDatabase` is the connection's persisted default, `browseDatabase` is where the + /// sidebar currently points. The tab's scope is passed separately at every call site so + /// all three can be distinguished. + private static func makeSession( + type: DatabaseType = .mysql, + savedDatabase: String = "testdb", + browseDatabase: String? = nil, + browseSchema: String? = nil + ) -> (DatabaseConnection, SchemaRoutingDriver) { + let connection = TestFixtures.makeConnection(database: savedDatabase, type: type) + let pluginDriver = SchemaRoutingDriver(currentSchema: browseSchema) + let adapter = PluginDriverAdapter(connection: connection, pluginDriver: pluginDriver) + var session = ConnectionSession(connection: connection, driver: adapter) + session.browseDatabase = browseDatabase ?? savedDatabase + session.browseSchema = browseSchema + DatabaseManager.shared.injectSession(session, for: connection.id) + return (connection, pluginDriver) } - } - private static func makeScope( - _ connection: DatabaseConnection, - database: String, - schema: String? = nil - ) -> DatabaseScope? { - DatabaseScope(connectionId: connection.id, database: database, schema: schema) - } + /// Stands in for the connection the pool would open on the scope, which the pool puts on + /// the scope's database and schema before handing it out. + private static func seedPooledDriver( + _ connection: DatabaseConnection, + scope: DatabaseScope + ) async throws -> SchemaRoutingDriver { + let pluginDriver = SchemaRoutingDriver(currentSchema: scope.schema) + let adapter = PluginDriverAdapter(connection: connection, pluginDriver: pluginDriver) + try await adapter.connect() + MetadataConnectionPool.shared.injectEntry(adapter, scope: scope) + return pluginDriver + } - @Test("A save takes a pooled connection wherever the engine can open one") - func schemaChangeRouteIsPooledWhereverTheEngineCanPool() throws { - let (pooling, _) = Self.makeSession(savedDatabase: "orders") - let (single, _) = Self.makeSession(type: Self.singleConnectionType, savedDatabase: "orders") - defer { Self.tearDown(pooling, single) } + private static func composeAndSave( + changes: [SchemaChange], + databaseType: DatabaseType, + scope: DatabaseScope + ) async throws { + let script = try await DatabaseManager.shared.schemaChangeStatements( + tableName: "orders", + objectKind: .table, + changes: changes, + scope: scope + ) + try await DatabaseManager.shared.executeSchemaChanges(script, databaseType: databaseType, scope: scope) + } - let poolingScope = try #require(Self.makeScope(pooling, database: "orders")) - let singleScope = try #require(Self.makeScope(single, database: "orders")) - let serverScope = try #require(Self.makeScope(pooling, database: "")) + private static func tearDown(_ connections: DatabaseConnection...) { + for connection in connections { + MetadataConnectionPool.shared.closeAll(connectionId: connection.id) + DatabaseManager.shared.removeSession(for: connection.id) + } + } - #expect(DatabaseManager.shared.schemaChangeRoute(for: poolingScope) == .pooled) - #expect(DatabaseManager.shared.schemaChangeRoute(for: singleScope) == .sessionDriver) - #expect(DatabaseManager.shared.schemaChangeRoute(for: serverScope) == .sessionDriver) - } + private static func makeScope( + _ connection: DatabaseConnection, + database: String, + schema: String? = nil + ) -> DatabaseScope? { + DatabaseScope(connectionId: connection.id, database: database, schema: schema) + } - @Test("A review's leading statements show in the script, reach the check before writing as composed, and run first") - func reviewLeadingStatementsRunFirst() async throws { - let (connection, _) = Self.makeSession(savedDatabase: "orders") - defer { Self.tearDown(connection) } + @Test("A save takes a pooled connection wherever the engine can open one") + func schemaChangeRouteIsPooledWhereverTheEngineCanPool() throws { + let (pooling, _) = Self.makeSession(savedDatabase: "orders") + let (single, _) = Self.makeSession(type: Self.singleConnectionType, savedDatabase: "orders") + defer { Self.tearDown(pooling, single) } - let scope = try #require(Self.makeScope(connection, database: "orders")) - let pooled = try await Self.seedPooledDriver(connection, scope: scope) - pooled.review = PluginSchemaChangeReview(leadingStatements: ["PREPARE orders"]) + let poolingScope = try #require(Self.makeScope(pooling, database: "orders")) + let singleScope = try #require(Self.makeScope(single, database: "orders")) + let serverScope = try #require(Self.makeScope(pooling, database: "")) - let script = try await DatabaseManager.shared.schemaChangeStatements( - tableName: "orders", objectKind: .table, changes: [Self.makeAddColumnChange()], scope: scope - ) - #expect(script.statements.first?.sql == "PREPARE orders;") - #expect(script.statements.first?.isDestructive == false) - #expect(script.operations.count == 1) - #expect(script.review == PluginSchemaChangeReview(leadingStatements: ["PREPARE orders"])) - #expect(pooled.reviewedOperationCounts == [1]) - - pooled.review = PluginSchemaChangeReview(leadingStatements: ["PREPARE orders AGAIN"]) - try await DatabaseManager.shared.executeSchemaChanges(script, databaseType: .mysql, scope: scope) - #expect(pooled.reviewsCheckedBeforeWriting == [PluginSchemaChangeReview(leadingStatements: ["PREPARE orders"])]) - #expect(pooled.executedQueries.count == 2) - #expect(pooled.executedQueries.first == "PREPARE orders;") - #expect(pooled.executedQueries.last?.contains("ADD COLUMN") == true) - } + #expect(DatabaseManager.shared.schemaChangeRoute(for: poolingScope) == .pooled) + #expect(DatabaseManager.shared.schemaChangeRoute(for: singleScope) == .sessionDriver) + #expect(DatabaseManager.shared.schemaChangeRoute(for: serverScope) == .sessionDriver) + } - @Test("A review refusal stops SQL Preview and Save with the driver's reason before anything runs") - func reviewRefusalStopsTheSave() async throws { - let (connection, _) = Self.makeSession(savedDatabase: "orders") - defer { Self.tearDown(connection) } + @Test("A review's leading statements show in the script, reach the check before writing as composed, and run first") + func reviewLeadingStatementsRunFirst() async throws { + let (connection, _) = Self.makeSession(savedDatabase: "orders") + defer { Self.tearDown(connection) } - let scope = try #require(Self.makeScope(connection, database: "orders")) - let pooled = try await Self.seedPooledDriver(connection, scope: scope) - pooled.review = PluginSchemaChangeReview(refusal: "Index email_1 uses email.") + let scope = try #require(Self.makeScope(connection, database: "orders")) + let pooled = try await Self.seedPooledDriver(connection, scope: scope) + pooled.review = PluginSchemaChangeReview(leadingStatements: ["PREPARE orders"]) - await #expect(throws: SchemaOperationRefusedError(reason: "Index email_1 uses email.")) { - _ = try await DatabaseManager.shared.schemaChangeStatements( + let script = try await DatabaseManager.shared.schemaChangeStatements( tableName: "orders", objectKind: .table, changes: [Self.makeAddColumnChange()], scope: scope ) + #expect(script.statements.first?.sql == "PREPARE orders;") + #expect(script.statements.first?.isDestructive == false) + #expect(script.operations.count == 1) + #expect(script.review == PluginSchemaChangeReview(leadingStatements: ["PREPARE orders"])) + #expect(pooled.reviewedOperationCounts == [1]) + + pooled.review = PluginSchemaChangeReview(leadingStatements: ["PREPARE orders AGAIN"]) + try await DatabaseManager.shared.executeSchemaChanges(script, databaseType: .mysql, scope: scope) + #expect(pooled.reviewsCheckedBeforeWriting == [PluginSchemaChangeReview(leadingStatements: ["PREPARE orders"])]) + #expect(pooled.executedQueries.count == 2) + #expect(pooled.executedQueries.first == "PREPARE orders;") + #expect(pooled.executedQueries.last?.contains("ADD COLUMN") == true) } - #expect(pooled.executedQueries.isEmpty) - #expect(pooled.statementsRunBeforeEachCheck.isEmpty) - } - - /// SQL Preview composes the same script as Save, so a check that reads the data belongs only on - /// the path that goes on to write. - @Test("The check that reads the data runs on Save alone, on the writing connection, before any statement") - func checkBeforeWritingRunsOnSaveOnly() async throws { - let (connection, driver) = Self.makeSession(savedDatabase: "orders") - defer { Self.tearDown(connection) } - - let scope = try #require(Self.makeScope(connection, database: "orders")) - let pooled = try await Self.seedPooledDriver(connection, scope: scope) - - let script = try await DatabaseManager.shared.schemaChangeStatements( - tableName: "orders", objectKind: .table, changes: [Self.makeAddColumnChange()], scope: scope - ) - #expect(pooled.statementsRunBeforeEachCheck.isEmpty) - - try await DatabaseManager.shared.executeSchemaChanges(script, databaseType: .mysql, scope: scope) - #expect(pooled.statementsRunBeforeEachCheck == [0]) - #expect(driver.statementsRunBeforeEachCheck.isEmpty) - } - - @Test("A refusal before writing stops the save before its first statement") - func refusalBeforeWritingStopsTheSave() async throws { - let (connection, _) = Self.makeSession(savedDatabase: "orders") - defer { Self.tearDown(connection) } - - let scope = try #require(Self.makeScope(connection, database: "orders")) - let pooled = try await Self.seedPooledDriver(connection, scope: scope) - pooled.refusalBeforeWriting = "Some documents hold both a and b." - await #expect(throws: SchemaOperationRefusedError(reason: "Some documents hold both a and b.")) { - try await Self.composeAndSave(changes: [Self.makeAddColumnChange()], databaseType: .mysql, scope: scope) + @Test("A review refusal stops SQL Preview and Save with the driver's reason before anything runs") + func reviewRefusalStopsTheSave() async throws { + let (connection, _) = Self.makeSession(savedDatabase: "orders") + defer { Self.tearDown(connection) } + + let scope = try #require(Self.makeScope(connection, database: "orders")) + let pooled = try await Self.seedPooledDriver(connection, scope: scope) + pooled.review = PluginSchemaChangeReview(refusal: "Index email_1 uses email.") + + await #expect(throws: SchemaOperationRefusedError(reason: "Index email_1 uses email.")) { + _ = try await DatabaseManager.shared.schemaChangeStatements( + tableName: "orders", objectKind: .table, changes: [Self.makeAddColumnChange()], scope: scope + ) + } + #expect(pooled.executedQueries.isEmpty) + #expect(pooled.statementsRunBeforeEachCheck.isEmpty) } - #expect(pooled.executedQueries.isEmpty) - } - - @Test("Schema changes run on the requested connection, not the last activated one") - func schemaChangeUsesRequestedConnection() async throws { - let (connectionA, driverA) = Self.makeSession(savedDatabase: "alpha") - let (connectionB, driverB) = Self.makeSession(savedDatabase: "beta") - DatabaseManager.shared.lastActiveSessionId = connectionA.id - defer { - Self.tearDown(connectionA, connectionB) - DatabaseManager.shared.lastActiveSessionId = nil - } - - let scope = try #require(Self.makeScope(connectionB, database: "beta")) - let pooledB = try await Self.seedPooledDriver(connectionB, scope: scope) - try await Self.composeAndSave( - changes: [Self.makeAddColumnChange()], - databaseType: .mysql, - scope: scope - ) - - #expect(pooledB.executedQueries.count == 1) - #expect(pooledB.executedQueries.first?.contains("ADD COLUMN") == true) - #expect(driverA.executedQueries.isEmpty) - #expect(driverB.executedQueries.isEmpty) - } - /// The session driver holds whatever transaction a query tab left open. A save that ran - /// there wrapped its DDL in a BEGIN that joined that transaction and a COMMIT that took the - /// tab's uncommitted work with it, or a ROLLBACK that threw it away. - @Test("A save runs on the tab's database on its own connection, leaving the session driver alone") - func schemaChangeRunsOnItsOwnConnectionWithoutMovingTheBrowseCursor() async throws { - let (connection, driver) = Self.makeSession( - savedDatabase: "analytics", - browseDatabase: "inventory" - ) - defer { Self.tearDown(connection) } - - let scope = try #require(Self.makeScope(connection, database: "orders")) - let pooled = try await Self.seedPooledDriver(connection, scope: scope) - try await Self.composeAndSave( - changes: [Self.makeAddColumnChange()], - databaseType: .mysql, - scope: scope - ) + /// SQL Preview composes the same script as Save, so a check that reads the data belongs only on + /// the path that goes on to write. + @Test("The check that reads the data runs on Save alone, on the writing connection, before any statement") + func checkBeforeWritingRunsOnSaveOnly() async throws { + let (connection, driver) = Self.makeSession(savedDatabase: "orders") + defer { Self.tearDown(connection) } - #expect(pooled.executedQueries.count == 1) - #expect(driver.executedQueries.isEmpty) - #expect(driver.switchedDatabases.isEmpty) + let scope = try #require(Self.makeScope(connection, database: "orders")) + let pooled = try await Self.seedPooledDriver(connection, scope: scope) - let session = DatabaseManager.shared.session(for: connection.id) - #expect(session?.browseDatabase == "inventory") - #expect(session?.connection.database == "analytics") - } + let script = try await DatabaseManager.shared.schemaChangeStatements( + tableName: "orders", objectKind: .table, changes: [Self.makeAddColumnChange()], scope: scope + ) + #expect(pooled.statementsRunBeforeEachCheck.isEmpty) - @Test("An engine with one connection pins the session driver to the target database") - func singleConnectionEnginePinsItsTargetDatabase() async throws { - let (connection, driver) = Self.makeSession(type: Self.singleConnectionType, savedDatabase: "orders") - defer { Self.tearDown(connection) } + try await DatabaseManager.shared.executeSchemaChanges(script, databaseType: .mysql, scope: scope) + #expect(pooled.statementsRunBeforeEachCheck == [0]) + #expect(driver.statementsRunBeforeEachCheck.isEmpty) + } - let scope = try #require(Self.makeScope(connection, database: "orders")) - try await Self.composeAndSave( - changes: [Self.makeAddColumnChange()], - databaseType: Self.singleConnectionType, - scope: scope - ) + @Test("A refusal before writing stops the save before its first statement") + func refusalBeforeWritingStopsTheSave() async throws { + let (connection, _) = Self.makeSession(savedDatabase: "orders") + defer { Self.tearDown(connection) } - #expect(!driver.switchedDatabases.isEmpty) - #expect(driver.switchedDatabases.allSatisfy { $0 == "orders" }) - #expect(driver.executedQueries.count == 1) - } + let scope = try #require(Self.makeScope(connection, database: "orders")) + let pooled = try await Self.seedPooledDriver(connection, scope: scope) + pooled.refusalBeforeWriting = "Some documents hold both a and b." - @Test("A failed database pin aborts the save before any DDL runs") - func failedDatabasePinAbortsSave() async throws { - let (connection, driver) = Self.makeSession( - type: Self.singleConnectionType, - savedDatabase: "orders", - browseDatabase: "inventory" - ) - driver.switchDatabaseError = DatabaseError.queryFailed("unknown database") - defer { Self.tearDown(connection) } + await #expect(throws: SchemaOperationRefusedError(reason: "Some documents hold both a and b.")) { + try await Self.composeAndSave(changes: [Self.makeAddColumnChange()], databaseType: .mysql, scope: scope) + } + #expect(pooled.executedQueries.isEmpty) + } - let scope = try #require(Self.makeScope(connection, database: "orders")) - await #expect(throws: DatabaseError.self) { + @Test("Schema changes run on the requested connection, not the last activated one") + func schemaChangeUsesRequestedConnection() async throws { + let (connectionA, driverA) = Self.makeSession(savedDatabase: "alpha") + let (connectionB, driverB) = Self.makeSession(savedDatabase: "beta") + DatabaseManager.shared.lastActiveSessionId = connectionA.id + defer { + Self.tearDown(connectionA, connectionB) + DatabaseManager.shared.lastActiveSessionId = nil + } + + let scope = try #require(Self.makeScope(connectionB, database: "beta")) + let pooledB = try await Self.seedPooledDriver(connectionB, scope: scope) try await Self.composeAndSave( changes: [Self.makeAddColumnChange()], - databaseType: Self.singleConnectionType, + databaseType: .mysql, scope: scope ) - } - #expect(driver.executedQueries.isEmpty) - } - - @Test("Engines that need a reconnect to switch database are never switched mid-save") - func reconnectRequiredEngineIsNotSwitched() async throws { - let (connection, driver) = Self.makeSession( - type: .postgresql, - savedDatabase: "orders" - ) - defer { Self.tearDown(connection) } - - let scope = try #require(Self.makeScope(connection, database: "orders")) - let pooled = try await Self.seedPooledDriver(connection, scope: scope) - try await Self.composeAndSave( - changes: [Self.makeAddColumnChange()], - databaseType: .postgresql, - scope: scope - ) - - #expect(driver.switchedDatabases.isEmpty) - #expect(driver.executedQueries.isEmpty) - #expect(pooled.executedQueries.count == 1) - } + #expect(pooledB.executedQueries.count == 1) + #expect(pooledB.executedQueries.first?.contains("ADD COLUMN") == true) + #expect(driverA.executedQueries.isEmpty) + #expect(driverB.executedQueries.isEmpty) + } - @Test("A schema-grouped engine qualifies the DDL with the edited table's schema") - func schemaGroupedEngineKeepsTableSchema() async throws { - let (connection, driver) = Self.makeSession( - type: .mssql, - savedDatabase: "orders", - browseDatabase: "inventory", - browseSchema: "dbo" - ) - defer { Self.tearDown(connection) } - - let scope = try #require(Self.makeScope(connection, database: "orders", schema: "sales")) - let pooled = try await Self.seedPooledDriver(connection, scope: scope) - try await Self.composeAndSave( - changes: [Self.makeAddColumnChange()], - databaseType: .mssql, - scope: scope - ) + /// The session driver holds whatever transaction a query tab left open. A save that ran + /// there wrapped its DDL in a BEGIN that joined that transaction and a COMMIT that took the + /// tab's uncommitted work with it, or a ROLLBACK that threw it away. + @Test("A save runs on the tab's database on its own connection, leaving the session driver alone") + func schemaChangeRunsOnItsOwnConnectionWithoutMovingTheBrowseCursor() async throws { + let (connection, driver) = Self.makeSession( + savedDatabase: "analytics", + browseDatabase: "inventory" + ) + defer { Self.tearDown(connection) } - #expect(pooled.executedQueries.first?.contains("`sales`.`orders`") == true) - #expect(driver.currentSchema == "dbo") - #expect(driver.executedQueries.isEmpty) - } + let scope = try #require(Self.makeScope(connection, database: "orders")) + let pooled = try await Self.seedPooledDriver(connection, scope: scope) + try await Self.composeAndSave( + changes: [Self.makeAddColumnChange()], + databaseType: .mysql, + scope: scope + ) - @Test("A save announces a structure change to the edited table in the edited tab's scope, not the browse cursor's") - func schemaChangeAnnouncesTheEditedTable() async throws { - let (connection, _) = Self.makeSession( - savedDatabase: "analytics", - browseDatabase: "inventory" - ) - defer { Self.tearDown(connection) } + #expect(pooled.executedQueries.count == 1) + #expect(driver.executedQueries.isEmpty) + #expect(driver.switchedDatabases.isEmpty) - let scope = try #require(Self.makeScope(connection, database: "orders")) - _ = try await Self.seedPooledDriver(connection, scope: scope) - let outcome = await Self.recordChanges(on: connection.id) { - try await Self.composeAndSave(changes: [Self.makeAddColumnChange()], databaseType: .mysql, scope: scope) + let session = DatabaseManager.shared.session(for: connection.id) + #expect(session?.browseDatabase == "inventory") + #expect(session?.connection.database == "analytics") } - #expect(outcome.error == nil) - #expect(outcome.changes == [Self.structureChange(scope)]) - #expect(outcome.refreshes.isEmpty) - } - - @Test("A save that only sets the comment announces a comment change, and the session driver keeps its columns") - func commentOnlySaveAnnouncesAComment() async throws { - let (connection, driver) = Self.makeSession(savedDatabase: "orders") - defer { Self.tearDown(connection) } + @Test("An engine with one connection pins the session driver to the target database") + func singleConnectionEnginePinsItsTargetDatabase() async throws { + let (connection, driver) = Self.makeSession(type: Self.singleConnectionType, savedDatabase: "orders") + defer { Self.tearDown(connection) } - let scope = try #require(Self.makeScope(connection, database: "orders")) - _ = try await Self.seedPooledDriver(connection, scope: scope) - let outcome = await Self.recordChanges(on: connection.id) { + let scope = try #require(Self.makeScope(connection, database: "orders")) try await Self.composeAndSave( - changes: [.modifyTableComment(old: nil, new: "Customer orders")], - databaseType: .mysql, + changes: [Self.makeAddColumnChange()], + databaseType: Self.singleConnectionType, scope: scope ) + + #expect(!driver.switchedDatabases.isEmpty) + #expect(driver.switchedDatabases.allSatisfy { $0 == "orders" }) + #expect(driver.executedQueries.count == 1) } - #expect(outcome.error == nil) - #expect(outcome.changes == [AnnouncedChange(scope: scope, name: "orders", kind: .comment, originTabId: nil)]) - #expect(driver.changedTableDefinitions.isEmpty) - } + @Test("A failed database pin aborts the save before any DDL runs") + func failedDatabasePinAbortsSave() async throws { + let (connection, driver) = Self.makeSession( + type: Self.singleConnectionType, + savedDatabase: "orders", + browseDatabase: "inventory" + ) + driver.switchDatabaseError = DatabaseError.queryFailed("unknown database") + defer { Self.tearDown(connection) } + + let scope = try #require(Self.makeScope(connection, database: "orders")) + await #expect(throws: DatabaseError.self) { + try await Self.composeAndSave( + changes: [Self.makeAddColumnChange()], + databaseType: Self.singleConnectionType, + scope: scope + ) + } + + #expect(driver.executedQueries.isEmpty) + } - @Test("A save that sets the comment beside a column change announces a structure change") - func commentWithColumnSaveAnnouncesStructure() async throws { - let (connection, _) = Self.makeSession(savedDatabase: "orders") - defer { Self.tearDown(connection) } + @Test("Engines that need a reconnect to switch database are never switched mid-save") + func reconnectRequiredEngineIsNotSwitched() async throws { + let (connection, driver) = Self.makeSession( + type: .postgresql, + savedDatabase: "orders" + ) + defer { Self.tearDown(connection) } - let scope = try #require(Self.makeScope(connection, database: "orders")) - let pooled = try await Self.seedPooledDriver(connection, scope: scope) - let outcome = await Self.recordChanges(on: connection.id) { + let scope = try #require(Self.makeScope(connection, database: "orders")) + let pooled = try await Self.seedPooledDriver(connection, scope: scope) try await Self.composeAndSave( - changes: [.modifyTableComment(old: nil, new: "Customer orders"), Self.makeAddColumnChange()], - databaseType: .mysql, + changes: [Self.makeAddColumnChange()], + databaseType: .postgresql, scope: scope ) + + #expect(driver.switchedDatabases.isEmpty) + #expect(driver.executedQueries.isEmpty) + #expect(pooled.executedQueries.count == 1) } - #expect(outcome.changes == [Self.structureChange(scope)]) - #expect(pooled.executedQueries.last?.contains("COMMENT = 'Customer orders'") == true) - } + @Test("A schema-grouped engine qualifies the DDL with the edited table's schema") + func schemaGroupedEngineKeepsTableSchema() async throws { + let (connection, driver) = Self.makeSession( + type: .mssql, + savedDatabase: "orders", + browseDatabase: "inventory", + browseSchema: "dbo" + ) + defer { Self.tearDown(connection) } - /// The session driver is not the one the save ran on, and it keeps what it learned about the - /// table's columns: a MongoDB driver typed a renamed field's writes and later pages by the old - /// name until a first page read them again. - @Test("The session's own driver is told the table changed after a save that wrote, and never after a refusal") - func sessionDriverForgetsTheTableDefinition() async throws { - let (connection, driver) = Self.makeSession(savedDatabase: "orders") - defer { Self.tearDown(connection) } - - let scope = try #require(Self.makeScope(connection, database: "orders")) - let pooled = try await Self.seedPooledDriver(connection, scope: scope) - try await Self.composeAndSave(changes: [Self.makeAddColumnChange()], databaseType: .mysql, scope: scope) - #expect(driver.changedTableDefinitions == ["orders"]) - #expect(pooled.changedTableDefinitions.isEmpty) - - pooled.failingStatement = "ADD COLUMN" - _ = await Self.recordChanges(on: connection.id) { - try await Self.composeAndSave(changes: [Self.makeAddColumnChange()], databaseType: .mysql, scope: scope) - } - #expect(driver.changedTableDefinitions == ["orders", "orders"]) + let scope = try #require(Self.makeScope(connection, database: "orders", schema: "sales")) + let pooled = try await Self.seedPooledDriver(connection, scope: scope) + try await Self.composeAndSave( + changes: [Self.makeAddColumnChange()], + databaseType: .mssql, + scope: scope + ) - pooled.failingStatement = nil - pooled.refusalBeforeWriting = "Some documents hold both a and b." - _ = await Self.recordChanges(on: connection.id) { - try await Self.composeAndSave(changes: [Self.makeAddColumnChange()], databaseType: .mysql, scope: scope) + #expect(pooled.executedQueries.first?.contains("`sales`.`orders`") == true) + #expect(driver.currentSchema == "dbo") + #expect(driver.executedQueries.isEmpty) } - #expect(driver.changedTableDefinitions == ["orders", "orders"]) - } - @Test("The check after writing is handed the review the save was composed with") - func checkAfterWritingGetsTheComposedReview() async throws { - let (connection, _) = Self.makeSession(savedDatabase: "orders") - defer { Self.tearDown(connection) } - - let scope = try #require(Self.makeScope(connection, database: "orders")) - let pooled = try await Self.seedPooledDriver(connection, scope: scope) - let composed = PluginSchemaChangeReview(leadingStatements: ["PREPARE orders"], basis: "entry 1") - pooled.review = composed - let script = try await DatabaseManager.shared.schemaChangeStatements( - tableName: "orders", objectKind: .table, changes: [Self.makeAddColumnChange()], scope: scope - ) - pooled.review = PluginSchemaChangeReview(basis: "entry 2") - try await DatabaseManager.shared.executeSchemaChanges(script, databaseType: .mysql, scope: scope) + @Test("A save announces a structure change to the edited table in the edited tab's scope, not the browse cursor's") + func schemaChangeAnnouncesTheEditedTable() async throws { + let (connection, _) = Self.makeSession( + savedDatabase: "analytics", + browseDatabase: "inventory" + ) + defer { Self.tearDown(connection) } - #expect(pooled.reviewsCheckedBeforeWriting == [composed]) - #expect(pooled.reviewsCheckedAfterWriting == [composed]) - } + let scope = try #require(Self.makeScope(connection, database: "orders")) + _ = try await Self.seedPooledDriver(connection, scope: scope) + let outcome = await Self.recordChanges(on: connection.id) { + try await Self.composeAndSave(changes: [Self.makeAddColumnChange()], databaseType: .mysql, scope: scope) + } - /// The save's questions went to the driver through a cast to the plugin adapter, so a - /// `DatabaseDriver` of any other kind was never asked and its save ran unchecked. - @Test("A DatabaseDriver that is not a plugin adapter is asked before and after writing") - func databaseDriverAnswersThroughTheProtocol() async throws { - let connection = TestFixtures.makeConnection(database: "orders", type: Self.singleConnectionType) - let mock = MockDatabaseDriver(connection: connection) - var session = ConnectionSession(connection: connection, driver: mock) - session.browseDatabase = "orders" - DatabaseManager.shared.injectSession(session, for: connection.id) - defer { Self.tearDown(connection) } - - let scope = try #require(Self.makeScope(connection, database: "orders")) - let script = SchemaChangeScript( - tableName: "orders", - statements: [SchemaStatement(sql: "ALTER orders", description: "Alter", isDestructive: false)], - operations: [], - review: PluginSchemaChangeReview() - ) - mock.schemaChangeRefusalToReturn = "Refused by the driver." - await #expect(throws: SchemaOperationRefusedError(reason: "Refused by the driver.")) { - try await DatabaseManager.shared.executeSchemaChanges(script, databaseType: Self.singleConnectionType, scope: scope) + #expect(outcome.error == nil) + #expect(outcome.changes == [Self.structureChange(scope)]) + #expect(outcome.refreshes.isEmpty) } - #expect(!mock.executedQueries.contains("ALTER orders")) - mock.schemaChangeRefusalToReturn = nil - mock.schemaChangeShortfallToReturn = "Not finished." - let outcome = await Self.recordChanges(on: connection.id) { - try await DatabaseManager.shared.executeSchemaChanges(script, databaseType: Self.singleConnectionType, scope: scope) - } - #expect(outcome.error?.localizedDescription == "Not finished.") - #expect(mock.executedQueries.contains("ALTER orders")) - #expect(mock.changedTableDefinitions == ["orders"]) - } + @Test("A save that only sets the comment announces a comment change, and the session driver keeps its columns") + func commentOnlySaveAnnouncesAComment() async throws { + let (connection, driver) = Self.makeSession(savedDatabase: "orders") + defer { Self.tearDown(connection) } - private static func recordChanges( - on connectionId: UUID, - during save: () async throws -> Void - ) async -> (changes: [AnnouncedChange], refreshes: [DataRefreshRequest], error: Error?) { - let recorder = BroadcastRecorder() - let cancellables = recorder.observe(connectionId: connectionId) - defer { cancellables.forEach { $0.cancel() } } - do { - try await save() - return (recorder.objectChanges.map(\.announced), recorder.refreshRequests, nil) - } catch { - return (recorder.objectChanges.map(\.announced), recorder.refreshRequests, error) + let scope = try #require(Self.makeScope(connection, database: "orders")) + _ = try await Self.seedPooledDriver(connection, scope: scope) + let outcome = await Self.recordChanges(on: connection.id) { + try await Self.composeAndSave( + changes: [.modifyTableComment(old: nil, new: "Customer orders")], + databaseType: .mysql, + scope: scope + ) + } + + #expect(outcome.error == nil) + #expect(outcome.changes == [AnnouncedChange(scope: scope, name: "orders", kind: .comment, originTabId: nil)]) + #expect(driver.changedTableDefinitions.isEmpty) } - } - private static func structureChange(_ scope: DatabaseScope) -> AnnouncedChange { - AnnouncedChange(scope: scope, name: "orders", kind: .structure, originTabId: nil) - } - - /// MongoDB keeps every document an `updateMany` changed before it stopped, and MySQL commits - /// each DDL statement as it runs, so the rows on screen can describe a table that moved on. - @Test("A statement that fails once the save has started writing reloads the rows") - func failureAfterWritingReloadsRows() async throws { - let (connection, _) = Self.makeSession(savedDatabase: "orders") - defer { Self.tearDown(connection) } + @Test("A save that sets the comment beside a column change announces a structure change") + func commentWithColumnSaveAnnouncesStructure() async throws { + let (connection, _) = Self.makeSession(savedDatabase: "orders") + defer { Self.tearDown(connection) } + + let scope = try #require(Self.makeScope(connection, database: "orders")) + let pooled = try await Self.seedPooledDriver(connection, scope: scope) + let outcome = await Self.recordChanges(on: connection.id) { + try await Self.composeAndSave( + changes: [.modifyTableComment(old: nil, new: "Customer orders"), Self.makeAddColumnChange()], + databaseType: .mysql, + scope: scope + ) + } + + #expect(outcome.changes == [Self.structureChange(scope)]) + #expect(pooled.executedQueries.last?.contains("COMMENT = 'Customer orders'") == true) + } - let scope = try #require(Self.makeScope(connection, database: "orders")) - let pooled = try await Self.seedPooledDriver(connection, scope: scope) - pooled.review = PluginSchemaChangeReview(leadingStatements: ["PREPARE orders"]) - pooled.failingStatement = "ADD COLUMN" + /// The session driver is not the one the save ran on, and it keeps what it learned about the + /// table's columns: a MongoDB driver typed a renamed field's writes and later pages by the old + /// name until a first page read them again. + @Test("The session's own driver is told the table changed after a save that wrote, and never after a refusal") + func sessionDriverForgetsTheTableDefinition() async throws { + let (connection, driver) = Self.makeSession(savedDatabase: "orders") + defer { Self.tearDown(connection) } - let outcome = await Self.recordChanges(on: connection.id) { + let scope = try #require(Self.makeScope(connection, database: "orders")) + let pooled = try await Self.seedPooledDriver(connection, scope: scope) try await Self.composeAndSave(changes: [Self.makeAddColumnChange()], databaseType: .mysql, scope: scope) + #expect(driver.changedTableDefinitions == ["orders"]) + #expect(pooled.changedTableDefinitions.isEmpty) + + pooled.failingStatement = "ADD COLUMN" + _ = await Self.recordChanges(on: connection.id) { + try await Self.composeAndSave(changes: [Self.makeAddColumnChange()], databaseType: .mysql, scope: scope) + } + #expect(driver.changedTableDefinitions == ["orders", "orders"]) + + pooled.failingStatement = nil + pooled.refusalBeforeWriting = "Some documents hold both a and b." + _ = await Self.recordChanges(on: connection.id) { + try await Self.composeAndSave(changes: [Self.makeAddColumnChange()], databaseType: .mysql, scope: scope) + } + #expect(driver.changedTableDefinitions == ["orders", "orders"]) } - let error = try #require(outcome.error) - #expect(error is DatabaseError) - #expect(error.localizedDescription.contains("statement timed out")) - #expect(pooled.executedQueries.count == 2) - #expect(pooled.statementsRunBeforeShortfallCheck.isEmpty) - #expect(outcome.changes == [Self.structureChange(scope)]) - } - @Test("A shortfall found after the last statement fails the save with the driver's reason and reloads the rows") - func shortfallAfterWritingFailsTheSave() async throws { - let (connection, _) = Self.makeSession(savedDatabase: "orders") - defer { Self.tearDown(connection) } + @Test("The check after writing is handed the review the save was composed with") + func checkAfterWritingGetsTheComposedReview() async throws { + let (connection, _) = Self.makeSession(savedDatabase: "orders") + defer { Self.tearDown(connection) } - let scope = try #require(Self.makeScope(connection, database: "orders")) - let pooled = try await Self.seedPooledDriver(connection, scope: scope) - let reason = "The save did not finish: one document still holds old." - pooled.shortfallAfterWriting = reason + let scope = try #require(Self.makeScope(connection, database: "orders")) + let pooled = try await Self.seedPooledDriver(connection, scope: scope) + let composed = PluginSchemaChangeReview(leadingStatements: ["PREPARE orders"], basis: "entry 1") + pooled.review = composed + let script = try await DatabaseManager.shared.schemaChangeStatements( + tableName: "orders", objectKind: .table, changes: [Self.makeAddColumnChange()], scope: scope + ) + pooled.review = PluginSchemaChangeReview(basis: "entry 2") + try await DatabaseManager.shared.executeSchemaChanges(script, databaseType: .mysql, scope: scope) - let outcome = await Self.recordChanges(on: connection.id) { - try await Self.composeAndSave(changes: [Self.makeAddColumnChange()], databaseType: .mysql, scope: scope) + #expect(pooled.reviewsCheckedBeforeWriting == [composed]) + #expect(pooled.reviewsCheckedAfterWriting == [composed]) } - let error = try #require(outcome.error) - #expect(error is DatabaseError) - #expect(error.localizedDescription == reason) - #expect(pooled.statementsRunBeforeShortfallCheck == [1]) - #expect(outcome.changes == [Self.structureChange(scope)]) - } - @Test("The check after writing runs once every statement has run, and a finished save reloads the rows once") - func finishedSaveChecksAfterItsLastStatement() async throws { - let (connection, _) = Self.makeSession(savedDatabase: "orders") - defer { Self.tearDown(connection) } + /// The save's questions went to the driver through a cast to the plugin adapter, so a + /// `DatabaseDriver` of any other kind was never asked and its save ran unchecked. + @Test("A DatabaseDriver that is not a plugin adapter is asked before and after writing") + func databaseDriverAnswersThroughTheProtocol() async throws { + let connection = TestFixtures.makeConnection(database: "orders", type: Self.singleConnectionType) + let mock = MockDatabaseDriver(connection: connection) + var session = ConnectionSession(connection: connection, driver: mock) + session.browseDatabase = "orders" + DatabaseManager.shared.injectSession(session, for: connection.id) + defer { Self.tearDown(connection) } + + let scope = try #require(Self.makeScope(connection, database: "orders")) + let script = SchemaChangeScript( + tableName: "orders", + statements: [SchemaStatement(sql: "ALTER orders", description: "Alter", isDestructive: false)], + operations: [], + review: PluginSchemaChangeReview() + ) + mock.schemaChangeRefusalToReturn = "Refused by the driver." + await #expect(throws: SchemaOperationRefusedError(reason: "Refused by the driver.")) { + try await DatabaseManager.shared.executeSchemaChanges(script, databaseType: Self.singleConnectionType, scope: scope) + } + #expect(!mock.executedQueries.contains("ALTER orders")) + + mock.schemaChangeRefusalToReturn = nil + mock.schemaChangeShortfallToReturn = "Not finished." + let outcome = await Self.recordChanges(on: connection.id) { + try await DatabaseManager.shared.executeSchemaChanges(script, databaseType: Self.singleConnectionType, scope: scope) + } + #expect(outcome.error?.localizedDescription == "Not finished.") + #expect(mock.executedQueries.contains("ALTER orders")) + #expect(mock.changedTableDefinitions == ["orders"]) + } - let scope = try #require(Self.makeScope(connection, database: "orders")) - let pooled = try await Self.seedPooledDriver(connection, scope: scope) - pooled.review = PluginSchemaChangeReview(leadingStatements: ["PREPARE orders"]) + private static func recordChanges( + on connectionId: UUID, + during save: () async throws -> Void + ) async -> (changes: [AnnouncedChange], refreshes: [DataRefreshRequest], error: Error?) { + let recorder = BroadcastRecorder() + let cancellables = recorder.observe(connectionId: connectionId) + defer { cancellables.forEach { $0.cancel() } } + do { + try await save() + return (recorder.objectChanges.map(\.announced), recorder.refreshRequests, nil) + } catch { + return (recorder.objectChanges.map(\.announced), recorder.refreshRequests, error) + } + } - let outcome = await Self.recordChanges(on: connection.id) { - try await Self.composeAndSave(changes: [Self.makeAddColumnChange()], databaseType: .mysql, scope: scope) + private static func structureChange(_ scope: DatabaseScope) -> AnnouncedChange { + AnnouncedChange(scope: scope, name: "orders", kind: .structure, originTabId: nil) } - #expect(outcome.error == nil) - #expect(pooled.statementsRunBeforeShortfallCheck == [2]) - #expect(outcome.changes.count == 1) - } - @Test("A save refused before writing reloads nothing") - func refusalBeforeWritingReloadsNothing() async throws { - let (connection, _) = Self.makeSession(savedDatabase: "orders") - defer { Self.tearDown(connection) } + /// MongoDB keeps every document an `updateMany` changed before it stopped, and MySQL commits + /// each DDL statement as it runs, so the rows on screen can describe a table that moved on. + @Test("A statement that fails once the save has started writing reloads the rows") + func failureAfterWritingReloadsRows() async throws { + let (connection, _) = Self.makeSession(savedDatabase: "orders") + defer { Self.tearDown(connection) } + + let scope = try #require(Self.makeScope(connection, database: "orders")) + let pooled = try await Self.seedPooledDriver(connection, scope: scope) + pooled.review = PluginSchemaChangeReview(leadingStatements: ["PREPARE orders"]) + pooled.failingStatement = "ADD COLUMN" + + let outcome = await Self.recordChanges(on: connection.id) { + try await Self.composeAndSave(changes: [Self.makeAddColumnChange()], databaseType: .mysql, scope: scope) + } + let error = try #require(outcome.error) + #expect(error is DatabaseError) + #expect(error.localizedDescription.contains("statement timed out")) + #expect(pooled.executedQueries.count == 2) + #expect(pooled.statementsRunBeforeShortfallCheck.isEmpty) + #expect(outcome.changes == [Self.structureChange(scope)]) + } - let scope = try #require(Self.makeScope(connection, database: "orders")) - let pooled = try await Self.seedPooledDriver(connection, scope: scope) - pooled.refusalBeforeWriting = "Some documents hold both a and b." + @Test("A shortfall found after the last statement fails the save with the driver's reason and reloads the rows") + func shortfallAfterWritingFailsTheSave() async throws { + let (connection, _) = Self.makeSession(savedDatabase: "orders") + defer { Self.tearDown(connection) } + + let scope = try #require(Self.makeScope(connection, database: "orders")) + let pooled = try await Self.seedPooledDriver(connection, scope: scope) + let reason = "The save did not finish: one document still holds old." + pooled.shortfallAfterWriting = reason + + let outcome = await Self.recordChanges(on: connection.id) { + try await Self.composeAndSave(changes: [Self.makeAddColumnChange()], databaseType: .mysql, scope: scope) + } + let error = try #require(outcome.error) + #expect(error is DatabaseError) + #expect(error.localizedDescription == reason) + #expect(pooled.statementsRunBeforeShortfallCheck == [1]) + #expect(outcome.changes == [Self.structureChange(scope)]) + } - let outcome = await Self.recordChanges(on: connection.id) { - try await Self.composeAndSave(changes: [Self.makeAddColumnChange()], databaseType: .mysql, scope: scope) + @Test("The check after writing runs once every statement has run, and a finished save reloads the rows once") + func finishedSaveChecksAfterItsLastStatement() async throws { + let (connection, _) = Self.makeSession(savedDatabase: "orders") + defer { Self.tearDown(connection) } + + let scope = try #require(Self.makeScope(connection, database: "orders")) + let pooled = try await Self.seedPooledDriver(connection, scope: scope) + pooled.review = PluginSchemaChangeReview(leadingStatements: ["PREPARE orders"]) + + let outcome = await Self.recordChanges(on: connection.id) { + try await Self.composeAndSave(changes: [Self.makeAddColumnChange()], databaseType: .mysql, scope: scope) + } + #expect(outcome.error == nil) + #expect(pooled.statementsRunBeforeShortfallCheck == [2]) + #expect(outcome.changes.count == 1) } - #expect(outcome.error is SchemaOperationRefusedError) - #expect(pooled.statementsRunBeforeShortfallCheck.isEmpty) - #expect(outcome.changes.isEmpty) - } - @Test("An inserted document announces its collection as a rows change and nothing broader") - func documentWriteAnnouncesItsCollection() async throws { - let (connection, _) = Self.makeSession(savedDatabase: "probe") - defer { Self.tearDown(connection) } - - let recorder = BroadcastRecorder() - let cancellables = recorder.observe(connectionId: connection.id) - defer { cancellables.forEach { $0.cancel() } } - - let scope = try #require(Self.makeScope(connection, database: "probe")) - try await DatabaseManager.shared.executeDocumentWrite( - PluginDocumentWrite(table: "people", schema: nil, operation: .insert(document: "{}")), - statement: "db.people.insertOne({})", - databaseType: .mysql, - scope: scope, - operationDescription: "Insert Document", - gate: AlwaysAllowGate() - ) + @Test("A save refused before writing reloads nothing") + func refusalBeforeWritingReloadsNothing() async throws { + let (connection, _) = Self.makeSession(savedDatabase: "orders") + defer { Self.tearDown(connection) } + + let scope = try #require(Self.makeScope(connection, database: "orders")) + let pooled = try await Self.seedPooledDriver(connection, scope: scope) + pooled.refusalBeforeWriting = "Some documents hold both a and b." + + let outcome = await Self.recordChanges(on: connection.id) { + try await Self.composeAndSave(changes: [Self.makeAddColumnChange()], databaseType: .mysql, scope: scope) + } + #expect(outcome.error is SchemaOperationRefusedError) + #expect(pooled.statementsRunBeforeShortfallCheck.isEmpty) + #expect(outcome.changes.isEmpty) + } - #expect(recorder.objectChanges.map(\.announced) == [ - .init(scope: scope, name: "people", kind: .rows, originTabId: nil) - ]) - #expect(recorder.refreshRequests.isEmpty) - } + @Test("An inserted document announces its collection as a rows change and nothing broader") + func documentWriteAnnouncesItsCollection() async throws { + let (connection, _) = Self.makeSession(savedDatabase: "probe") + defer { Self.tearDown(connection) } - private static func invoicesDefinition() -> PluginCreateTableDefinition { - PluginCreateTableDefinition( - tableName: "invoices", - columns: [PluginColumnDefinition(name: "id", dataType: "INT")], - primaryKeyColumns: [] - ) - } + let recorder = BroadcastRecorder() + let cancellables = recorder.observe(connectionId: connection.id) + defer { cancellables.forEach { $0.cancel() } } - @Test( - "The composer hands over a driver on the tab's schema while the session driver sits on another", - arguments: [true, false] - ) - func composerDriverSitsOnTheScopeSchema(pooled: Bool) async throws { - let type = pooled ? DatabaseType.mssql : Self.singleConnectionType - let (connection, driver) = Self.makeSession(type: type, savedDatabase: "orders", browseSchema: "dbo") - defer { Self.tearDown(connection) } - - let scope = try #require(Self.makeScope(connection, database: "orders", schema: "sales")) - if pooled { - _ = try await Self.seedPooledDriver(connection, scope: scope) - } - let composedOn = try await DatabaseManager.shared.withSchemaComposer( - scope: scope, - route: DatabaseManager.shared.schemaChangeRoute(for: scope) - ) { _, pluginDriver in - pluginDriver.currentSchema + let scope = try #require(Self.makeScope(connection, database: "probe")) + try await DatabaseManager.shared.executeDocumentWrite( + PluginDocumentWrite(table: "people", schema: nil, operation: .insert(document: "{}")), + statement: "db.people.insertOne({})", + databaseType: .mysql, + scope: scope, + operationDescription: "Insert Document", + gate: AlwaysAllowGate() + ) + + #expect(recorder.objectChanges.map(\.announced) == [ + .init(scope: scope, name: "people", kind: .rows, originTabId: nil) + ]) + #expect(recorder.refreshRequests.isEmpty) } - #expect(composedOn == "sales") - #expect(driver.currentSchema == (pooled ? "dbo" : "sales")) - } + private static func invoicesDefinition() -> PluginCreateTableDefinition { + PluginCreateTableDefinition( + tableName: "invoices", + columns: [PluginColumnDefinition(name: "id", dataType: "INT")], + primaryKeyColumns: [] + ) + } - @Test("A Create Table draft is composed on the tab's schema and runs unchanged on its own connection") - func createTableDraftComposesOnTheScopeSchema() async throws { - let (connection, driver) = Self.makeSession(type: .mssql, savedDatabase: "orders", browseSchema: "dbo") - defer { Self.tearDown(connection) } - - let scope = try #require(Self.makeScope(connection, database: "orders", schema: "sales")) - let pooled = try await Self.seedPooledDriver(connection, scope: scope) - var column = EditableColumnDefinition.placeholder() - column.name = "id" - column.dataType = "INT" - let plan = CreateTableDraftBuilder.plan( - tableName: "invoices", - options: CreateTableOptions(), - columns: [column], - indexes: [], - foreignKeys: [], - dialect: ForeignKeyDialect.forType(.mssql), - includesEngineOptions: false + @Test( + "The composer hands over a driver on the tab's schema while the session driver sits on another", + arguments: [true, false] ) + func composerDriverSitsOnTheScopeSchema(pooled: Bool) async throws { + let type = pooled ? DatabaseType.mssql : Self.singleConnectionType + let (connection, driver) = Self.makeSession(type: type, savedDatabase: "orders", browseSchema: "dbo") + defer { Self.tearDown(connection) } + + let scope = try #require(Self.makeScope(connection, database: "orders", schema: "sales")) + if pooled { + _ = try await Self.seedPooledDriver(connection, scope: scope) + } + let composedOn = try await DatabaseManager.shared.withSchemaComposer( + scope: scope, + route: DatabaseManager.shared.schemaChangeRoute(for: scope) + ) { _, pluginDriver in + pluginDriver.currentSchema + } + + #expect(composedOn == "sales") + #expect(driver.currentSchema == (pooled ? "dbo" : "sales")) + } - let composed = try await DatabaseManager.shared.createTableStatements(plan: plan, scope: scope) - try await DatabaseManager.shared.executeCreateTable( - statements: composed.statements, - databaseType: .mssql, - scope: scope - ) + @Test("A Create Table draft is composed on the tab's schema and runs unchanged on its own connection") + func createTableDraftComposesOnTheScopeSchema() async throws { + let (connection, driver) = Self.makeSession(type: .mssql, savedDatabase: "orders", browseSchema: "dbo") + defer { Self.tearDown(connection) } + + let scope = try #require(Self.makeScope(connection, database: "orders", schema: "sales")) + let pooled = try await Self.seedPooledDriver(connection, scope: scope) + var column = EditableColumnDefinition.placeholder() + column.name = "id" + column.dataType = "INT" + let plan = CreateTableDraftBuilder.plan( + tableName: "invoices", + options: CreateTableOptions(), + columns: [column], + indexes: [], + foreignKeys: [], + dialect: ForeignKeyDialect.forType(.mssql), + includesEngineOptions: false + ) - #expect(composed.statements == ["CREATE TABLE `sales`.`invoices` (`id` INT)"]) - #expect(pooled.executedQueries == composed.statements) - #expect(driver.executedQueries.isEmpty) - } + let composed = try await DatabaseManager.shared.createTableStatements(plan: plan, scope: scope) + try await DatabaseManager.shared.executeCreateTable( + statements: composed.statements, + databaseType: .mssql, + scope: scope + ) - @Test("An import's CREATE TABLE is composed on the route that runs it, pinned to the import's schema") - func importCreateTableComposesOnTheExecutionRoute() async throws { - let (connection, driver) = Self.makeSession(type: .postgresql, savedDatabase: "orders", browseSchema: "public") - defer { Self.tearDown(connection) } - - let scope = try #require(Self.makeScope(connection, database: "orders", schema: "sales")) - let route = DatabaseManager.shared.executionRoute(for: scope) - let statements = try await DatabaseManager.shared.createTableStatements( - definition: Self.invoicesDefinition(), - scope: scope, - route: route - ) + #expect(composed.statements == ["CREATE TABLE `sales`.`invoices` (`id` INT)"]) + #expect(pooled.executedQueries == composed.statements) + #expect(driver.executedQueries.isEmpty) + } + + @Test("An import's CREATE TABLE is composed on the route that runs it, pinned to the import's schema") + func importCreateTableComposesOnTheExecutionRoute() async throws { + let (connection, driver) = Self.makeSession(type: .postgresql, savedDatabase: "orders", browseSchema: "public") + defer { Self.tearDown(connection) } + + let scope = try #require(Self.makeScope(connection, database: "orders", schema: "sales")) + let route = DatabaseManager.shared.executionRoute(for: scope) + let statements = try await DatabaseManager.shared.createTableStatements( + definition: Self.invoicesDefinition(), + scope: scope, + route: route + ) - #expect(route == .sessionDriver) - #expect(statements == ["CREATE TABLE `sales`.`invoices` (`id` INT)"]) - #expect(driver.currentSchema == "sales") + #expect(route == .sessionDriver) + #expect(statements == ["CREATE TABLE `sales`.`invoices` (`id` INT)"]) + #expect(driver.currentSchema == "sales") + } } } diff --git a/TableProTests/Core/Database/DatabaseManagerTests.swift b/TableProTests/Core/Database/DatabaseManagerTests.swift index ddf60fa138..a34d9957ed 100644 --- a/TableProTests/Core/Database/DatabaseManagerTests.swift +++ b/TableProTests/Core/Database/DatabaseManagerTests.swift @@ -168,18 +168,14 @@ struct DatabaseManagerQueryTimeoutHandoffTests { let unique = UUID().uuidString let fileURL = FileManager.default.temporaryDirectory .appendingPathComponent("tablepro-session-adoption-\(unique).json") - let suiteName = "com.TablePro.tests.SessionAdoption.\(unique)" - let defaults = try #require(UserDefaults(suiteName: suiteName)) + let suite = try EphemeralDefaults() let storage = ConnectionStorage( fileURL: fileURL, - userDefaults: defaults, + userDefaults: suite.defaults, keychain: InMemoryKeychain() ) let manager = DatabaseManager(connectionStorage: storage) - defer { - defaults.removePersistentDomain(forName: suiteName) - try? FileManager.default.removeItem(at: fileURL) - } + defer { try? FileManager.default.removeItem(at: fileURL) } let originalTag = UUID() let reconciledTag = UUID() diff --git a/TableProTests/Core/Database/DatabaseManagerVersionTests.swift b/TableProTests/Core/Database/DatabaseManagerVersionTests.swift index 873c3d3333..ca053d0c0a 100644 --- a/TableProTests/Core/Database/DatabaseManagerVersionTests.swift +++ b/TableProTests/Core/Database/DatabaseManagerVersionTests.swift @@ -10,7 +10,6 @@ import TableProPluginKit import Testing @testable import TablePro -@Suite("DatabaseManager Version Counters", .serialized) @MainActor struct DatabaseManagerVersionTests { private func makeSession(id: UUID = UUID()) -> (UUID, ConnectionSession) { diff --git a/TableProTests/Core/Database/DatabaseSwitchLeaseOrderingTests.swift b/TableProTests/Core/Database/DatabaseSwitchLeaseOrderingTests.swift index 41e272e764..6aa5b2faf2 100644 --- a/TableProTests/Core/Database/DatabaseSwitchLeaseOrderingTests.swift +++ b/TableProTests/Core/Database/DatabaseSwitchLeaseOrderingTests.swift @@ -10,42 +10,20 @@ import Foundation @testable import TablePro import TableProPluginKit +import TableProTestSupport import Testing -@MainActor -private final class Latch { - private var waiters: [CheckedContinuation] = [] - private var isOpen = false - - func open() { - guard !isOpen else { return } - isOpen = true - let pending = waiters - waiters = [] - for waiter in pending { - waiter.resume() - } - } - - func wait() async { - guard !isOpen else { return } - await withCheckedContinuation { waiters.append($0) } - } -} - @Suite("Database switch lease ordering", .serialized) @MainActor struct DatabaseSwitchLeaseOrderingTests { - private static let typeId = "LeaseOrderingReconnectFake" - private static let unpooledTypeId = "LeaseOrderingUnpooledReconnectFake" - /// A type that reopens its connection to change database, and whose driver plugin is not /// registered, so every switch reaches the reconnect and fails there. `pools` says whether it can /// open a second connection for a database the session has left; one that can switches by /// keeping a connection per database instead (`SessionLanesTests`), so the reconnect needs one - /// that cannot. - private func registerTypeIfNeeded(_ typeId: String = Self.typeId, pools: Bool = false) { - guard PluginMetadataRegistry.shared.snapshot(forRegisteredTypeId: typeId) == nil else { return } + /// that cannot. Each case registers a type of its own, so one case's cleanup never unregisters + /// another's. + private func registerType(pools: Bool = false) -> String { + let typeId = "LeaseOrderingReconnectFake-\(UUID().uuidString)" let defaults = PluginMetadataSnapshot.CapabilityFlags.defaults var capabilities = PluginMetadataSnapshot.CapabilityFlags( supportsSchemaSwitching: true, @@ -72,12 +50,12 @@ struct DatabaseSwitchLeaseOrderingTests { capabilities: capabilities, schema: .defaults, editor: .defaults, connection: .defaults ) PluginMetadataRegistry.shared.register(snapshot: snapshot, forTypeId: typeId) + return typeId } private func makeSession() -> DatabaseConnection { - registerTypeIfNeeded() var connection = TestFixtures.makeConnection(database: "app") - connection.type = DatabaseType(rawValue: Self.typeId) + connection.type = DatabaseType(rawValue: registerType()) var session = ConnectionSession(connection: connection, driver: MockDatabaseDriver(connection: connection)) session.status = .connected session.browseDatabase = "app" @@ -85,23 +63,23 @@ struct DatabaseSwitchLeaseOrderingTests { return connection } - private func cleanUp(_ connectionId: UUID) { - DatabaseManager.shared.removeSession(for: connectionId) - AppSettingsStorage.shared.saveLastDatabase(nil, for: connectionId) - AppSettingsStorage.shared.saveLastSchema(nil, for: connectionId) - PluginMetadataRegistry.shared.unregister(typeId: Self.typeId) + private func cleanUp(_ connection: DatabaseConnection) { + DatabaseManager.shared.removeSession(for: connection.id) + AppSettingsStorage.shared.saveLastDatabase(nil, for: connection.id) + AppSettingsStorage.shared.saveLastSchema(nil, for: connection.id) + PluginMetadataRegistry.shared.unregister(typeId: connection.type.rawValue) } /// Holds the connection's driver until `release` opens, and returns only once it holds it. A /// connection that keeps one driver per database takes its turns per database, so `database` /// names which one to hold. - private func holdDriver(_ connectionId: UUID, database: String? = nil, until release: Latch) async -> Task { - let acquired = Latch() + private func holdDriver(_ connectionId: UUID, database: String? = nil, until release: AsyncGate) async -> Task { + let acquired = AsyncGate() let holder = Task { @MainActor in try await DatabaseManager.shared.sessionDriverGate.withExclusiveAccess( SessionDriverGate.Key(connectionId: connectionId, database: database) ) { - acquired.open() + await acquired.open() await release.wait() } } @@ -109,21 +87,8 @@ struct DatabaseSwitchLeaseOrderingTests { return holder } - /// Waits for callers to queue behind the holder. The bound is there so a caller that never - /// queues, which is the regression these tests guard, fails the assertions after it rather than - /// hanging the suite. - private func waitForQueuedCallers(_ count: Int, on connectionId: UUID) async { - for _ in 0..<10_000 where DatabaseManager.shared.sessionDriverGate.waiterCount(for: connectionId) < count { - await Task.yield() - } - } - - /// Waits for work running off the main actor to reach a point it reports. Bounded for the same - /// reason as `waitForQueuedCallers`. - private func waitUntil(_ condition: () -> Bool) async { - for _ in 0..<2_000 where !condition() { - try? await Task.sleep(for: .milliseconds(5)) - } + private func waitForQueuedCallers(_ count: Int, on connectionId: UUID) async throws { + try await waitUntil { DatabaseManager.shared.sessionDriverGate.waiterCount(for: connectionId) >= count } } /// A session browsing `app`, with the connection's pooled connection to `app` seeded, so a read @@ -156,15 +121,15 @@ struct DatabaseSwitchLeaseOrderingTests { @Test("A lease routed onto a switch's target never runs on the driver the switch is replacing") func leaseDuringASwitchNeverRunsOnTheReplacedDriver() async throws { let connection = makeSession() - defer { cleanUp(connection.id) } + defer { cleanUp(connection) } let original = try #require(DatabaseManager.shared.driver(for: connection.id) as? MockDatabaseDriver) - let release = Latch() + let release = AsyncGate() let holder = await holdDriver(connection.id, until: release) let switchTask = Task { @MainActor in try await DatabaseManager.shared.switchDatabase(to: "orders", for: connection.id, persist: false) } - await waitForQueuedCallers(1, on: connection.id) + try await waitForQueuedCallers(1, on: connection.id) #expect(DatabaseManager.shared.session(for: connection.id)?.browseDatabase == "app") let orders = DatabaseScope(connectionId: connection.id, database: "orders", schema: nil) @@ -177,11 +142,11 @@ struct DatabaseSwitchLeaseOrderingTests { driver.connection.database } } - await waitForQueuedCallers(2, on: connection.id) + try await waitForQueuedCallers(2, on: connection.id) #expect(DatabaseManager.shared.sessionDriverGate.waiterCount(for: connection.id) == 2) #expect(original.disconnectCallCount == 0) - release.open() + await release.open() try await holder.value _ = try? await switchTask.value @@ -196,22 +161,22 @@ struct DatabaseSwitchLeaseOrderingTests { @Test("A database switch names nothing on the session while another operation holds the driver") func switchWaitsForTheDriver() async throws { let connection = makeSession() - defer { cleanUp(connection.id) } + defer { cleanUp(connection) } let original = try #require(DatabaseManager.shared.driver(for: connection.id) as? MockDatabaseDriver) - let release = Latch() + let release = AsyncGate() let holder = await holdDriver(connection.id, until: release) let switchTask = Task { @MainActor in try await DatabaseManager.shared.switchDatabase(to: "orders", for: connection.id, persist: false) } - await waitForQueuedCallers(1, on: connection.id) + try await waitForQueuedCallers(1, on: connection.id) #expect(DatabaseManager.shared.sessionDriverGate.waiterCount(for: connection.id) == 1) #expect(DatabaseManager.shared.session(for: connection.id)?.browseDatabase == "app") #expect(DatabaseManager.shared.session(for: connection.id)?.connection.database == "app") #expect(original.disconnectCallCount == 0) - release.open() + await release.open() try await holder.value _ = try? await switchTask.value } @@ -219,8 +184,8 @@ struct DatabaseSwitchLeaseOrderingTests { @Test("A lease that waited behind a switch which left the driver dead is refused") func leaseQueuedBehindADeadDriverIsRefused() async throws { let connection = makeSession() - defer { cleanUp(connection.id) } - let release = Latch() + defer { cleanUp(connection) } + let release = AsyncGate() let holder = await holdDriver(connection.id, until: release) let app = DatabaseScope(connectionId: connection.id, database: "app", schema: nil) @@ -233,11 +198,11 @@ struct DatabaseSwitchLeaseOrderingTests { driver.connection.database } } - await waitForQueuedCallers(1, on: connection.id) + try await waitForQueuedCallers(1, on: connection.id) #expect(DatabaseManager.shared.sessionDriverGate.waiterCount(for: connection.id) == 1) DatabaseManager.shared.updateSession(connection.id) { $0.liveness = .unreachable(nil) } - release.open() + await release.open() try await holder.value await #expect { @@ -251,20 +216,20 @@ struct DatabaseSwitchLeaseOrderingTests { @Test("A schema switch queued behind the driver runs on the driver installed when its turn comes") func schemaSwitchUsesTheDriverInstalledWhenItRuns() async throws { let connection = makeSession() - defer { cleanUp(connection.id) } + defer { cleanUp(connection) } let original = try #require(DatabaseManager.shared.driver(for: connection.id) as? MockDatabaseDriver) - let release = Latch() + let release = AsyncGate() let holder = await holdDriver(connection.id, until: release) let schemaSwitch = Task { @MainActor in try await DatabaseManager.shared.switchSchema(to: "reporting", for: connection.id) } - await waitForQueuedCallers(1, on: connection.id) + try await waitForQueuedCallers(1, on: connection.id) #expect(DatabaseManager.shared.sessionDriverGate.waiterCount(for: connection.id) == 1) let replacement = MockDatabaseDriver(connection: connection) DatabaseManager.shared.updateSession(connection.id) { $0.driver = replacement } - release.open() + await release.open() try await holder.value try await schemaSwitch.value @@ -277,8 +242,8 @@ struct DatabaseSwitchLeaseOrderingTests { @Test("A switch queued behind the driver is dropped when the session it was asked on has gone") func queuedSwitchIsDroppedForAReplacedSession() async throws { let connection = makeSession() - defer { cleanUp(connection.id) } - let release = Latch() + defer { cleanUp(connection) } + let release = AsyncGate() let holder = await holdDriver(connection.id, until: release) let switchTask = Task { @MainActor in @@ -287,7 +252,7 @@ struct DatabaseSwitchLeaseOrderingTests { let schemaTask = Task { @MainActor in try await DatabaseManager.shared.switchSchema(to: "reporting", for: connection.id) } - await waitForQueuedCallers(2, on: connection.id) + try await waitForQueuedCallers(2, on: connection.id) #expect(DatabaseManager.shared.sessionDriverGate.waiterCount(for: connection.id) == 2) DatabaseManager.shared.removeSession(for: connection.id) @@ -297,7 +262,7 @@ struct DatabaseSwitchLeaseOrderingTests { session.browseDatabase = "app" DatabaseManager.shared.injectSession(session, for: connection.id) - release.open() + await release.open() try await holder.value await #expect(throws: CancellationError.self) { @@ -317,8 +282,8 @@ struct DatabaseSwitchLeaseOrderingTests { @Test("A lease queued when its session ends never runs on the session opened after it") func queuedLeaseNeverRunsOnAReopenedSession() async throws { let connection = makeSession() - defer { cleanUp(connection.id) } - let release = Latch() + defer { cleanUp(connection) } + let release = AsyncGate() let holder = await holdDriver(connection.id, until: release) let ran = LeaseRecord() @@ -332,7 +297,7 @@ struct DatabaseSwitchLeaseOrderingTests { await MainActor.run { ran.didRun = true } } } - await waitForQueuedCallers(1, on: connection.id) + try await waitForQueuedCallers(1, on: connection.id) #expect(DatabaseManager.shared.sessionDriverGate.waiterCount(for: connection.id) == 1) DatabaseManager.shared.finalizeConnectionFailure(for: connection.id, cancelled: false) @@ -341,7 +306,7 @@ struct DatabaseSwitchLeaseOrderingTests { session.browseDatabase = "app" DatabaseManager.shared.injectSession(session, for: connection.id) - release.open() + await release.open() try await holder.value await #expect(throws: CancellationError.self) { @@ -360,7 +325,7 @@ struct DatabaseSwitchLeaseOrderingTests { let (connection, pooled) = makeTableReadSession(type: .postgresql) defer { cleanUpTableRead(connection.id) } let app = appScope(connection) - let release = Latch() + let release = AsyncGate() let holder = await holdDriver(connection.id, database: "app", until: release) let read = Task { @MainActor in @@ -368,11 +333,11 @@ struct DatabaseSwitchLeaseOrderingTests { driver === pooled } } - await waitForQueuedCallers(1, on: connection.id) + try await waitForQueuedCallers(1, on: connection.id) #expect(DatabaseManager.shared.sessionDriverGate.waiterCount(for: connection.id) == 1) DatabaseManager.shared.updateSession(connection.id) { $0.browseDatabase = "orders" } - release.open() + await release.open() try await holder.value #expect(try await read.value) @@ -385,7 +350,7 @@ struct DatabaseSwitchLeaseOrderingTests { let (connection, _) = makeTableReadSession(type: .postgresql) defer { cleanUpTableRead(connection.id) } let app = appScope(connection) - let release = Latch() + let release = AsyncGate() let holder = await holdDriver(connection.id, database: "app", until: release) let ran = LeaseRecord() @@ -398,11 +363,11 @@ struct DatabaseSwitchLeaseOrderingTests { await MainActor.run { ran.didRun = true } } } - await waitForQueuedCallers(1, on: connection.id) + try await waitForQueuedCallers(1, on: connection.id) #expect(DatabaseManager.shared.sessionDriverGate.waiterCount(for: connection.id) == 1) DatabaseManager.shared.updateSession(connection.id) { $0.browseDatabase = "orders" } - release.open() + await release.open() try await holder.value await #expect { @@ -419,11 +384,11 @@ struct DatabaseSwitchLeaseOrderingTests { let (connection, pooled) = makeTableReadSession(type: .postgresql) defer { cleanUpTableRead(connection.id) } let app = appScope(connection) - let release = Latch() + let release = AsyncGate() let holder = await holdDriver(connection.id, database: "app", until: release) let running = LeaseRecord() - let finish = Latch() + let finish = AsyncGate() let read = Task { @MainActor in try await DatabaseManager.shared.withTableReadDriver(scope: app, cancellation: .cancellableRead(DriverLeaseOwner())) { driver in await MainActor.run { running.didRun = true } @@ -431,11 +396,11 @@ struct DatabaseSwitchLeaseOrderingTests { return driver === pooled } } - await waitForQueuedCallers(1, on: connection.id) + try await waitForQueuedCallers(1, on: connection.id) DatabaseManager.shared.updateSession(connection.id) { $0.browseDatabase = "orders" } - release.open() + await release.open() try await holder.value - await waitUntil { running.didRun } + try await waitUntil { running.didRun } #expect(running.didRun) let probe = LeaseRecord() @@ -446,11 +411,11 @@ struct DatabaseSwitchLeaseOrderingTests { probe.didRun = true } } - await waitUntil { probe.didRun } + try await waitUntil { probe.didRun } #expect(probe.didRun) #expect(DatabaseManager.shared.sessionDriverGate.waiterCount(for: connection.id) == 0) - finish.open() + await finish.open() try await prober.value #expect(try await read.value) } @@ -462,7 +427,7 @@ struct DatabaseSwitchLeaseOrderingTests { let (connection, _) = makeTableReadSession(type: .postgresql) defer { cleanUpTableRead(connection.id) } let app = appScope(connection) - let release = Latch() + let release = AsyncGate() let holder = await holdDriver(connection.id, database: "app", until: release) let ran = LeaseRecord() @@ -471,14 +436,14 @@ struct DatabaseSwitchLeaseOrderingTests { await MainActor.run { ran.didRun = true } } } - await waitForQueuedCallers(1, on: connection.id) + try await waitForQueuedCallers(1, on: connection.id) #expect(DatabaseManager.shared.sessionDriverGate.waiterCount(for: connection.id) == 1) DatabaseManager.shared.updateSession(connection.id) { session in session.browseDatabase = "orders" session.liveness = .unreachable(nil) } - release.open() + await release.open() try await holder.value await #expect { @@ -492,14 +457,14 @@ struct DatabaseSwitchLeaseOrderingTests { @Test("A table read left behind by a switch on an engine that cannot pool names its database") func tableReadWithNowhereToGoNamesItsDatabase() async throws { - registerTypeIfNeeded(Self.unpooledTypeId, pools: false) - let (connection, _) = makeTableReadSession(type: DatabaseType(rawValue: Self.unpooledTypeId)) + let unpooledTypeId = registerType(pools: false) + let (connection, _) = makeTableReadSession(type: DatabaseType(rawValue: unpooledTypeId)) defer { cleanUpTableRead(connection.id) - PluginMetadataRegistry.shared.unregister(typeId: Self.unpooledTypeId) + PluginMetadataRegistry.shared.unregister(typeId: unpooledTypeId) } let app = appScope(connection) - let release = Latch() + let release = AsyncGate() let holder = await holdDriver(connection.id, until: release) let ran = LeaseRecord() @@ -508,11 +473,11 @@ struct DatabaseSwitchLeaseOrderingTests { await MainActor.run { ran.didRun = true } } } - await waitForQueuedCallers(1, on: connection.id) + try await waitForQueuedCallers(1, on: connection.id) #expect(DatabaseManager.shared.sessionDriverGate.waiterCount(for: connection.id) == 1) DatabaseManager.shared.updateSession(connection.id) { $0.browseDatabase = "orders" } - release.open() + await release.open() try await holder.value await #expect { diff --git a/TableProTests/Core/Database/ExecuteUserQueryTests.swift b/TableProTests/Core/Database/ExecuteUserQueryTests.swift index b7045bfc0a..810084857e 100644 --- a/TableProTests/Core/Database/ExecuteUserQueryTests.swift +++ b/TableProTests/Core/Database/ExecuteUserQueryTests.swift @@ -116,7 +116,7 @@ struct ExecuteUserQueryTests { } } -private final class StubPluginDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class StubPluginDriver: PluginDriverStub, @unchecked Sendable { private(set) var lastExecutedQuery: String? private(set) var lastParameters: [PluginCellValue]? private let rowsToReturn: [[PluginCellValue]] @@ -127,9 +127,6 @@ private final class StubPluginDriver: PluginDatabaseDriver, @unchecked Sendable self.statusMessage = statusMessage } - func connect() async throws {} - func disconnect() {} - func execute(query: String) async throws -> PluginQueryResult { lastExecutedQuery = query return PluginQueryResult( @@ -154,18 +151,4 @@ private final class StubPluginDriver: PluginDatabaseDriver, @unchecked Sendable statusMessage: statusMessage ) } - - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } diff --git a/TableProTests/Core/Database/HealthMonitorReconnectTests.swift b/TableProTests/Core/Database/HealthMonitorReconnectTests.swift index 6c81b4e7a5..99c4841698 100644 --- a/TableProTests/Core/Database/HealthMonitorReconnectTests.swift +++ b/TableProTests/Core/Database/HealthMonitorReconnectTests.swift @@ -27,13 +27,12 @@ struct HealthMonitorReconnectTests { } private func makeSessionFailingToReconnect( - with error: any Error & Sendable, - after delay: Duration = .zero + with error: any Error & Sendable ) -> (connection: DatabaseConnection, driver: MockDatabaseDriver) { let connection = makeConnectedSession() let driver = MockDatabaseDriver() DatabaseManager.shared.updateSession(connection.id) { $0.driver = driver } - FakeMSSQLPlugin.failConnect(for: connection.id, with: error, after: delay) + FakeMSSQLPlugin.failConnect(for: connection.id, with: error) return (connection, driver) } @@ -114,15 +113,18 @@ struct HealthMonitorReconnectTests { @Test("a late TLS failure from a reconnect that lost its race leaves the winner alone") func lateConfigurationFailureLeavesTheWinnerAlone() async { let error = SSLHandshakeError.clientCertRequired(serverMessage: "certificate required") - let (connection, _) = makeSessionFailingToReconnect(with: error, after: .milliseconds(300)) + let (connection, _) = makeSessionFailingToReconnect(with: error) let connectionId = connection.id + let hold = FakeMSSQLPlugin.holdConnect(for: connectionId) + defer { FakeMSSQLPlugin.clearConnectHold(for: connectionId) } let attempt = Task { @MainActor in await DatabaseManager.shared.performHealthMonitorReconnect(connectionId: connectionId) } - try? await Task.sleep(for: .milliseconds(50)) + await hold.waitUntilReached() let replacement = MockDatabaseDriver() DatabaseManager.shared.updateSession(connectionId) { $0.driver = replacement } + await hold.release() let outcome = await attempt.value let session = DatabaseManager.shared.activeSessions[connectionId] diff --git a/TableProTests/Core/Database/LoadableExtensionGateTests.swift b/TableProTests/Core/Database/LoadableExtensionGateTests.swift index 6569dae3b1..de7c3e8735 100644 --- a/TableProTests/Core/Database/LoadableExtensionGateTests.swift +++ b/TableProTests/Core/Database/LoadableExtensionGateTests.swift @@ -10,6 +10,7 @@ import Testing @MainActor struct LoadableExtensionGateTests { + private let suite: EphemeralDefaults private let approvals: LoadableExtensionApprovalStore private let vec = LoadableExtension(path: "/opt/homebrew/lib/vec0.dylib") private let sqliteFields: [ConnectionField] = [.loadableExtensions()] @@ -28,10 +29,8 @@ struct LoadableExtensionGateTests { ] init() throws { - let suite = "LoadableExtensionGateTests-\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) - approvals = LoadableExtensionApprovalStore(defaults: defaults) + suite = try EphemeralDefaults() + approvals = LoadableExtensionApprovalStore(defaults: suite.defaults) } private func connection(_ fields: [String: String], type: DatabaseType = .sqlite) -> DatabaseConnection { diff --git a/TableProTests/Core/Database/MultiConnectionTests.swift b/TableProTests/Core/Database/MultiConnectionTests.swift index 4449b6a4f9..5b8c3ae98a 100644 --- a/TableProTests/Core/Database/MultiConnectionTests.swift +++ b/TableProTests/Core/Database/MultiConnectionTests.swift @@ -10,7 +10,6 @@ import Testing // MARK: - DatabaseManager Multi-Session Isolation -@Suite("DatabaseManager Multi-Session Isolation", .serialized) @MainActor struct DatabaseManagerMultiSessionTests { @Test("Multiple sessions coexist independently") diff --git a/TableProTests/Core/Database/PluginStreamAbortTests.swift b/TableProTests/Core/Database/PluginStreamAbortTests.swift index ec98a1218d..f4dc45dd5d 100644 --- a/TableProTests/Core/Database/PluginStreamAbortTests.swift +++ b/TableProTests/Core/Database/PluginStreamAbortTests.swift @@ -1,8 +1,3 @@ -// -// PluginStreamAbortTests.swift -// TableProTests -// - import Foundation @testable import TablePro import TableProPluginKit @@ -12,6 +7,11 @@ struct PluginStreamAbortTests { @Test("Terminating the stream sets the flag, and a serial-queue producer stops early") func serialQueueProducerStopsEarly() async throws { let queue = DispatchQueue(label: "test.stream.abort.serial") + // Released by signals rather than created with a count: a semaphore freed below its + // starting value traps. + let step = DispatchSemaphore(value: 0) + step.signal() + step.signal() let counter = ProducedRowCounter() let totalBatches = 40 let batchSize = 500 @@ -22,11 +22,11 @@ struct PluginStreamAbortTests { queue.async { continuation.yield(.header(PluginStreamHeader(columns: ["c"], columnTypeNames: ["TEXT"]))) for index in 0..= 2 { break } } - /// Waited on the producer rather than on the clock. It paces itself at 20ms a row, so a - /// fixed sleep asserts the runner's load as much as the stream's behaviour: CI saw 19 of 20. - /// An abort would leave the loop early, and the signal still arrives, so the count is what - /// says whether the stream stayed alive. The time limit is what fails a producer that hangs. + // The producer goes on only once the consumer has stopped, so an abort would end it early. + for _ in 2..<20 { step.signal() } await producerFinished.wait() #expect(counter.value == 20) _ = stream } + /// A serial queue runs blocks in order, so one queued now runs after the producer has returned. + private static func drain(_ queue: DispatchQueue) async { + await withCheckedContinuation { continuation in + queue.async { continuation.resume() } + } + } + private func consumeFirstBatches( count: Int, from stream: AsyncThrowingStream diff --git a/TableProTests/Core/Database/PreConnectScriptConsentTests.swift b/TableProTests/Core/Database/PreConnectScriptConsentTests.swift index f21fa241c3..2d6b771d76 100644 --- a/TableProTests/Core/Database/PreConnectScriptConsentTests.swift +++ b/TableProTests/Core/Database/PreConnectScriptConsentTests.swift @@ -147,7 +147,6 @@ struct PreConnectScriptConsentTests { /// Every route that can start a connect, driven against a real `DatabaseManager` and a fake driver, /// with a script that leaves a file behind if it ever runs. -@Suite("Pre-connect script consent at every entry point", .serialized) @MainActor struct PreConnectScriptEntryPointTests { private struct Fixture { @@ -167,9 +166,8 @@ struct PreConnectScriptEntryPointTests { .appendingPathComponent("tablepro-preconnect-consent-\(unique).json") let marker = FileManager.default.temporaryDirectory .appendingPathComponent("tablepro-preconnect-ran-\(unique)") - let suiteName = "com.TablePro.tests.PreConnectConsent.\(unique)" - let defaults = try #require(UserDefaults(suiteName: suiteName)) - let storage = ConnectionStorage(fileURL: fileURL, userDefaults: defaults, keychain: InMemoryKeychain()) + let suite = try EphemeralDefaults() + let storage = ConnectionStorage(fileURL: fileURL, userDefaults: suite.defaults, keychain: InMemoryKeychain()) let spy = ScriptQuestionSpy(answer: answer) let manager = DatabaseManager( connectionStorage: storage, @@ -191,7 +189,7 @@ struct PreConnectScriptEntryPointTests { manager.driver(for: connectionId)?.disconnect() manager.removeSession(for: connectionId) FakeMSSQLPlugin.clearConfigurations(for: connectionId) - defaults.removePersistentDomain(forName: suiteName) + suite.defaults.removePersistentDomain(forName: suite.suiteName) try? FileManager.default.removeItem(at: fileURL) try? FileManager.default.removeItem(at: marker) } diff --git a/TableProTests/Core/Database/ProtectedWritePingSuppressionTests.swift b/TableProTests/Core/Database/ProtectedWritePingSuppressionTests.swift index 2143911316..b796af9198 100644 --- a/TableProTests/Core/Database/ProtectedWritePingSuppressionTests.swift +++ b/TableProTests/Core/Database/ProtectedWritePingSuppressionTests.swift @@ -12,7 +12,6 @@ import Foundation import TableProPluginKit import Testing -@Suite("Protected write ping suppression", .serialized) @MainActor struct ProtectedWritePingSuppressionTests { private func seed(_ policy: DriverCancellationPolicy, for connectionId: UUID) { diff --git a/TableProTests/Core/Database/ReconnectCredentialRecoveryTests.swift b/TableProTests/Core/Database/ReconnectCredentialRecoveryTests.swift index 61d8970141..a6604f2766 100644 --- a/TableProTests/Core/Database/ReconnectCredentialRecoveryTests.swift +++ b/TableProTests/Core/Database/ReconnectCredentialRecoveryTests.swift @@ -3,7 +3,6 @@ import Foundation import TableProPluginKit import Testing -@Suite("Reconnect credential recovery", .serialized) @MainActor struct ReconnectCredentialRecoveryTests { @Test("Authentication failures are detected from SQLSTATE") diff --git a/TableProTests/Core/Database/ResultSetBatchAdapterTests.swift b/TableProTests/Core/Database/ResultSetBatchAdapterTests.swift index 03440e9bc7..b6604f8639 100644 --- a/TableProTests/Core/Database/ResultSetBatchAdapterTests.swift +++ b/TableProTests/Core/Database/ResultSetBatchAdapterTests.swift @@ -8,7 +8,7 @@ import Foundation import TableProPluginKit import Testing -private final class BatchAnsweringDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class BatchAnsweringDriver: PluginDriverStub, @unchecked Sendable { let declaresBatches: Bool private(set) var sentBatches: [(query: String, rowCap: Int?, parameters: [PluginCellValue]?)] = [] @@ -20,9 +20,6 @@ private final class BatchAnsweringDriver: PluginDatabaseDriver, @unchecked Senda declaresBatches ? [.resultSetBatches] : [] } - func connect() async throws {} - func disconnect() {} - func execute(query: String) async throws -> PluginQueryResult { PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) } @@ -61,20 +58,6 @@ private final class BatchAnsweringDriver: PluginDatabaseDriver, @unchecked Senda executionTime: 0.05 ) } - - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } struct ResultSetBatchAdapterTests { diff --git a/TableProTests/Core/Database/ScopedDriverCancellationTests.swift b/TableProTests/Core/Database/ScopedDriverCancellationTests.swift index ab47b6a120..5be155f442 100644 --- a/TableProTests/Core/Database/ScopedDriverCancellationTests.swift +++ b/TableProTests/Core/Database/ScopedDriverCancellationTests.swift @@ -25,8 +25,8 @@ struct ScopedDriverCancellationTests { defer { DatabaseManager.shared.runningDrivers.removeValue(forKey: connection.id) } try DatabaseManager.shared.cancelRunningQuery(owner: owner, on: connection.id, delivery: .background) + await Self.settleCancels(on: connection.id) - #expect(await Self.awaitCancel(driver)) #expect(driver.cancelledOnMainThread == false) #expect(driver.cancelCount == 1) } @@ -64,8 +64,9 @@ struct ScopedDriverCancellationTests { try DatabaseManager.shared.cancelRunningQuery(owner: myOwner, on: connection.id, delivery: .immediate) + await Self.settleCancels(on: connection.id) + #expect(mine.cancelCount == 1) - #expect(await Self.awaitCancel(theirs) == false) #expect(theirs.cancelCount == 0) } @@ -81,8 +82,8 @@ struct ScopedDriverCancellationTests { try DatabaseManager.shared.cancelRunningQuery(owner: owner, on: connection.id, delivery: .background) try DatabaseManager.shared.cancelRunningQuery(owner: owner, on: connection.id, delivery: .immediate) + await Self.settleCancels(on: connection.id) - #expect(await Self.awaitCancel(driver) == false) #expect(driver.cancelCount == 0) } @@ -102,8 +103,8 @@ struct ScopedDriverCancellationTests { try DatabaseManager.shared.cancelRunningQuery(owner: owner, on: connection.id, delivery: .background) try DatabaseManager.shared.cancelRunningQuery(owner: owner, on: connection.id, delivery: .immediate) + await Self.settleCancels(on: connection.id) - #expect(await Self.awaitCancel(driver) == false) #expect(driver.cancelCount == 0) } @@ -121,8 +122,8 @@ struct ScopedDriverCancellationTests { try DatabaseManager.shared.cancelRunningQuery(owner: owner, on: connection.id, delivery: .immediate) try DatabaseManager.shared.cancelRunningQuery(owner: owner, on: connection.id, delivery: .background) + await Self.settleCancels(on: connection.id) - #expect(await Self.awaitCancel(driver) == false) #expect(driver.cancelCount == 0) DatabaseManager.shared.endProtectedWrite(token, for: connection.id) @@ -187,8 +188,8 @@ struct ScopedDriverCancellationTests { _ = DatabaseManager.shared.releaseRunningDriver(token, for: connection.id) try DatabaseManager.shared.cancelRunningQuery(owner: owner, on: connection.id, delivery: .immediate) + await Self.settleCancels(on: connection.id) - #expect(await Self.awaitCancel(driver) == false) #expect(driver.cancelCount == 0) } @@ -231,19 +232,12 @@ struct ScopedDriverCancellationTests { ] } - /// The cancel now lands on a global queue, so the assertion has to wait for it. Polling keeps - /// the main actor free for anything the cancel path might still hop back to, and the deadline - /// means a regression fails the test instead of hanging the suite. - private static func awaitCancel( - _ driver: CancelRecordingDriver, - within timeout: Duration = .seconds(2) - ) async -> Bool { - let deadline = ContinuousClock.now + timeout - while ContinuousClock.now < deadline { - if driver.cancelCount > 0 { return true } - try? await Task.sleep(for: .milliseconds(5)) + /// Every background cancel the manager sends is handed to the lease that owns the handle, so + /// awaiting those is the barrier: after it, a cancel that was going to land has landed. + private static func settleCancels(on connectionId: UUID) async { + for entry in (DatabaseManager.shared.runningDrivers[connectionId] ?? [:]).values { + await entry.pendingCancel?.value } - return false } } diff --git a/TableProTests/Core/Database/ScopedDriverPinningTests.swift b/TableProTests/Core/Database/ScopedDriverPinningTests.swift index 74fd93fe51..164699d0b0 100644 --- a/TableProTests/Core/Database/ScopedDriverPinningTests.swift +++ b/TableProTests/Core/Database/ScopedDriverPinningTests.swift @@ -99,7 +99,7 @@ struct ScopedDriverPinningTests { } /// Records the order of the two calls that decide where a statement lands. -private final class CallRecordingPluginDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class CallRecordingPluginDriver: PluginDriverStub, @unchecked Sendable { private let lock = NSLock() private var recorded: [String] = [] private var database: String? @@ -137,20 +137,4 @@ private final class CallRecordingPluginDriver: PluginDatabaseDriver, @unchecked record("execute:\(query)") return PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) } - - func connect() async throws {} - func disconnect() {} - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } diff --git a/TableProTests/Core/Database/ScopedDriverRoutingTests.swift b/TableProTests/Core/Database/ScopedDriverRoutingTests.swift index 11e7dc4277..454c2d4551 100644 --- a/TableProTests/Core/Database/ScopedDriverRoutingTests.swift +++ b/TableProTests/Core/Database/ScopedDriverRoutingTests.swift @@ -11,7 +11,6 @@ import Foundation import TableProPluginKit import Testing -@Suite("Scoped driver routing", .serialized) @MainActor struct ScopedDriverRoutingTests { private static func makeSession( diff --git a/TableProTests/Core/Database/SessionLanesTests.swift b/TableProTests/Core/Database/SessionLanesTests.swift index ab34c758d9..09da6a754e 100644 --- a/TableProTests/Core/Database/SessionLanesTests.swift +++ b/TableProTests/Core/Database/SessionLanesTests.swift @@ -10,13 +10,12 @@ import Foundation @testable import TablePro import TableProPluginKit +import TableProTestSupport import Testing @Suite("Session lanes", .serialized) @MainActor struct SessionLanesTests { - /// Reopens its connection to change database and can open a second one, like PostgreSQL. - private static let typeId = "SessionLaneFake" private final class Opener { var opened: [String: MockDatabaseDriver] = [:] @@ -38,10 +37,13 @@ struct SessionLanesTests { let connection: DatabaseConnection let home: MockDatabaseDriver let opener: Opener + let typeId: String } - private func registerTypeIfNeeded() { - guard PluginMetadataRegistry.shared.snapshot(forRegisteredTypeId: Self.typeId) == nil else { return } + /// Reopens its connection to change database and can open a second one, like PostgreSQL. Each + /// case registers a type of its own, so one case's cleanup never unregisters another's. + private func registerType() -> String { + let typeId = "SessionLaneFake-\(UUID().uuidString)" let defaults = PluginMetadataSnapshot.CapabilityFlags.defaults let capabilities = PluginMetadataSnapshot.CapabilityFlags( supportsSchemaSwitching: true, @@ -57,7 +59,7 @@ struct SessionLanesTests { supportsDropDatabase: defaults.supportsDropDatabase ) let snapshot = PluginMetadataSnapshot( - displayName: Self.typeId, iconName: "cylinder", defaultPort: 1_234, + displayName: typeId, iconName: "cylinder", defaultPort: 1_234, requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: true, isDownloadable: false, primaryUrlScheme: "sessionlanefake", parameterStyle: .questionMark, navigationModel: .standard, explainVariants: [], pathFieldRole: .database, @@ -66,14 +68,15 @@ struct SessionLanesTests { connectionMode: .network, supportsDatabaseSwitching: true, capabilities: capabilities, schema: .defaults, editor: .defaults, connection: .defaults ) - PluginMetadataRegistry.shared.register(snapshot: snapshot, forTypeId: Self.typeId) + PluginMetadataRegistry.shared.register(snapshot: snapshot, forTypeId: typeId) + return typeId } /// A connected session browsing `app` on `home`, with lane opens answered by `opener`. private func makeHarness() -> Harness { - registerTypeIfNeeded() + let typeId = registerType() var connection = TestFixtures.makeConnection(database: "app") - connection.type = DatabaseType(rawValue: Self.typeId) + connection.type = DatabaseType(rawValue: typeId) let home = MockDatabaseDriver(connection: connection) var session = ConnectionSession(connection: connection, driver: home) session.status = .connected @@ -89,7 +92,7 @@ struct SessionLanesTests { } return try await opener.open(scope) } - return Harness(connection: connection, home: home, opener: opener) + return Harness(connection: connection, home: home, opener: opener, typeId: typeId) } private func cleanUp(_ harness: Harness) { @@ -99,7 +102,7 @@ struct SessionLanesTests { } AppSettingsStorage.shared.saveLastDatabase(nil, for: harness.connection.id) AppSettingsStorage.shared.saveLastSchema(nil, for: harness.connection.id) - PluginMetadataRegistry.shared.unregister(typeId: Self.typeId) + PluginMetadataRegistry.shared.unregister(typeId: harness.typeId) } private func session(_ harness: Harness) -> ConnectionSession? { @@ -317,21 +320,27 @@ struct SessionLanesTests { try await DatabaseManager.shared.switchDatabase(to: "logs", for: harness.connection.id, persist: false) harness.home.pingError = DatabaseError.connectionFailed("server closed the connection") harness.home.sessionTransactionStateToReturn = .idle - harness.home.pingDelaySeconds = 1 + let ping = AsyncGate() + harness.home.pingGate = ping let lanes = DatabaseManager.shared.sessionLanes let scope = DatabaseScope(connectionId: harness.connection.id, database: "app", schema: nil) + let connectionId = harness.connection.id let work = Task { @MainActor in try await DatabaseManager.shared.withScopedDriver( scope: scope, route: .sessionDriver, cancellation: .untracked ) { driver in ObjectIdentifier(driver) } } - let deadline = Date().addingTimeInterval(5) - while !lanes.isVerifying(harness.home), Date() < deadline { - try await Task.sleep(for: .milliseconds(10)) - } + try await waitUntil { lanes.isVerifying(harness.home) } #expect(lanes.isVerifying(harness.home)) - try await DatabaseManager.shared.switchDatabase(to: "app", for: harness.connection.id, persist: false) + let generation = lanes.generation(for: connectionId) + let switchBack = Task { @MainActor in + try await DatabaseManager.shared.switchDatabase(to: "app", for: connectionId, persist: false) + } + // The switch takes its generation and then waits for the check, with no suspension between. + try await waitUntil { lanes.generation(for: connectionId) != generation } + await ping.open() + try await switchBack.value let replacement = try #require(harness.opener.opened["app"]) #expect(session(harness)?.driver === replacement) @@ -364,13 +373,13 @@ struct SessionLanesTests { func schemaSwitchSharesTheDatabaseTurn() async throws { let harness = makeHarness() defer { cleanUp(harness) } - let release = AsyncLatch() - let acquired = AsyncLatch() + let release = AsyncGate() + let acquired = AsyncGate() let holder = Task { @MainActor in try await DatabaseManager.shared.sessionDriverGate.withExclusiveAccess( SessionDriverGate.Key(connectionId: harness.connection.id, database: "app") ) { - acquired.open() + await acquired.open() await release.wait() } } @@ -379,13 +388,13 @@ struct SessionLanesTests { let schemaSwitch = Task { @MainActor in try await DatabaseManager.shared.switchSchema(to: "reporting", for: harness.connection.id) } - for _ in 0..<10_000 where DatabaseManager.shared.sessionDriverGate.waiterCount(for: harness.connection.id) < 1 { - await Task.yield() + try await waitUntil { + DatabaseManager.shared.sessionDriverGate.waiterCount(for: harness.connection.id) >= 1 } #expect(DatabaseManager.shared.sessionDriverGate.waiterCount(for: harness.connection.id) == 1) #expect(harness.home.switchSchemaCallCount == 0) - release.open() + await release.open() try await holder.value try await schemaSwitch.value #expect(harness.home.switchSchemaCallCount == 1) @@ -491,13 +500,13 @@ struct SessionLanesTests { let harness = makeHarness() defer { cleanUp(harness) } harness.home.sessionTransactionStateToReturn = .inTransaction - let release = AsyncLatch() - let acquired = AsyncLatch() + let release = AsyncGate() + let acquired = AsyncGate() let holder = Task { @MainActor in try await DatabaseManager.shared.sessionDriverGate.withExclusiveAccess( SessionDriverGate.Key(connectionId: harness.connection.id, database: "app") ) { - acquired.open() + await acquired.open() await release.wait() } } @@ -505,7 +514,7 @@ struct SessionLanesTests { let holding = await DatabaseManager.shared.databasesHoldingTransaction(for: harness.connection.id) - release.open() + await release.open() try await holder.value #expect(holding.isEmpty) } @@ -515,14 +524,21 @@ struct SessionLanesTests { let harness = makeHarness() defer { cleanUp(harness) } harness.home.sessionTransactionStateToReturn = .inTransaction - harness.home.sessionTransactionStateDelaySeconds = 30 - let clock = ContinuousClock() - let started = clock.now + let reply = AsyncGate() + harness.home.sessionTransactionStateGate = reply + let clock = ManualClock() + let connectionId = harness.connection.id + let probe = Task { @MainActor in + await DatabaseManager.shared.databasesHoldingTransaction(for: connectionId, clock: clock) + } - let holding = await DatabaseManager.shared.databasesHoldingTransaction(for: harness.connection.id) + await clock.waitUntilSleeping() + clock.advance(by: .seconds(1)) + let holding = await probe.value #expect(holding.isEmpty) - #expect(clock.now - started < .seconds(5)) + #expect(harness.home.finishedSessionTransactionStateReads == 0) + await reply.open() } /// A driver's cancel can reach past the read: FreeTDS stops every call queued on the connection. @@ -531,33 +547,21 @@ struct SessionLanesTests { let harness = makeHarness() defer { cleanUp(harness) } harness.home.sessionTransactionStateToReturn = .inTransaction - harness.home.sessionTransactionStateDelaySeconds = 1.5 + let reply = AsyncGate() + harness.home.sessionTransactionStateGate = reply + let clock = ManualClock() + let connectionId = harness.connection.id + let probe = Task { @MainActor in + await DatabaseManager.shared.databasesHoldingTransaction(for: connectionId, clock: clock) + } + await clock.waitUntilSleeping() + clock.advance(by: .seconds(1)) + let holding = await probe.value - let holding = await DatabaseManager.shared.databasesHoldingTransaction(for: harness.connection.id) - try await Task.sleep(for: .seconds(1)) + await reply.open() + try await waitUntil { harness.home.finishedSessionTransactionStateReads == 1 } #expect(holding.isEmpty) #expect(!harness.home.sessionTransactionStateReadWasCancelled) } } - -@MainActor -private final class AsyncLatch { - private var waiters: [CheckedContinuation] = [] - private var isOpen = false - - func open() { - guard !isOpen else { return } - isOpen = true - let pending = waiters - waiters = [] - for waiter in pending { - waiter.resume() - } - } - - func wait() async { - guard !isOpen else { return } - await withCheckedContinuation { waiters.append($0) } - } -} diff --git a/TableProTests/Core/Database/StatementTextValidatorTests.swift b/TableProTests/Core/Database/StatementTextValidatorTests.swift index ea91e9ba9d..a2951d3a1d 100644 --- a/TableProTests/Core/Database/StatementTextValidatorTests.swift +++ b/TableProTests/Core/Database/StatementTextValidatorTests.swift @@ -8,7 +8,7 @@ import Foundation import TableProPluginKit import Testing -private final class ExecutionRecordingDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class ExecutionRecordingDriver: PluginDriverStub, @unchecked Sendable { private let lock = NSLock() private var recorded: [String] = [] @@ -21,29 +21,12 @@ private final class ExecutionRecordingDriver: PluginDatabaseDriver, @unchecked S var currentSchema: String? { nil } var serverVersion: String? { nil } - func connect() async throws {} - func disconnect() {} func ping() async throws {} func execute(query: String) async throws -> PluginQueryResult { lock.withLock { recorded.append(query) } return PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) } - - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } struct StatementTextValidatorTests { diff --git a/TableProTests/Core/Database/SwitchDatabasePooledConnectionTests.swift b/TableProTests/Core/Database/SwitchDatabasePooledConnectionTests.swift index cea2061dbf..19020b8be4 100644 --- a/TableProTests/Core/Database/SwitchDatabasePooledConnectionTests.swift +++ b/TableProTests/Core/Database/SwitchDatabasePooledConnectionTests.swift @@ -17,10 +17,9 @@ import Testing @MainActor struct SwitchDatabasePooledConnectionTests { // No driver plugin and no plugin load in the test host, so every switch's reconnect fails at once, with no network. - private static let typeId = "PooledSwitchReconnectFake" - - private func registerTypeIfNeeded() { - guard PluginMetadataRegistry.shared.snapshot(forRegisteredTypeId: Self.typeId) == nil else { return } + /// A type of its own per case, so one case's cleanup never unregisters another's. + private func registerType() -> String { + let typeId = "PooledSwitchReconnectFake-\(UUID().uuidString)" let defaults = PluginMetadataSnapshot.CapabilityFlags.defaults var capabilities = PluginMetadataSnapshot.CapabilityFlags( supportsSchemaSwitching: true, @@ -39,7 +38,7 @@ struct SwitchDatabasePooledConnectionTests { /// than opening a connection of its own for the database. capabilities.supportsConnectionPooling = false let snapshot = PluginMetadataSnapshot( - displayName: Self.typeId, iconName: "cylinder", defaultPort: 1_234, + displayName: typeId, iconName: "cylinder", defaultPort: 1_234, requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: true, isDownloadable: false, primaryUrlScheme: "pooledswitchfake", parameterStyle: .questionMark, navigationModel: .standard, explainVariants: [], pathFieldRole: .database, @@ -48,14 +47,14 @@ struct SwitchDatabasePooledConnectionTests { connectionMode: .network, supportsDatabaseSwitching: true, capabilities: capabilities, schema: .defaults, editor: .defaults, connection: .defaults ) - PluginMetadataRegistry.shared.register(snapshot: snapshot, forTypeId: Self.typeId) + PluginMetadataRegistry.shared.register(snapshot: snapshot, forTypeId: typeId) + return typeId } /// A direct connection, with no tunnel of any kind, browsing `app`. private func makeSession(stoppedAnswering: Bool = false) -> DatabaseConnection { - registerTypeIfNeeded() var connection = TestFixtures.makeConnection(database: "app") - connection.type = DatabaseType(rawValue: Self.typeId) + connection.type = DatabaseType(rawValue: registerType()) var session = ConnectionSession(connection: connection, driver: MockDatabaseDriver(connection: connection)) session.status = .connected session.browseDatabase = "app" @@ -76,18 +75,19 @@ struct SwitchDatabasePooledConnectionTests { return pooled } - private func cleanUp(_ connectionId: UUID) { + private func cleanUp(_ connection: DatabaseConnection) { + let connectionId = connection.id MetadataConnectionPool.shared.closeAll(connectionId: connectionId) DatabaseManager.shared.removeSession(for: connectionId) AppSettingsStorage.shared.saveLastDatabase(nil, for: connectionId) AppSettingsStorage.shared.saveLastSchema(nil, for: connectionId) - PluginMetadataRegistry.shared.unregister(typeId: Self.typeId) + PluginMetadataRegistry.shared.unregister(typeId: connection.type.rawValue) } @Test("A switch over a direct connection leaves the pooled connection to another database open") func switchLeavesOtherDatabasesPooled() async { let connection = makeSession() - defer { cleanUp(connection.id) } + defer { cleanUp(connection) } let pooled = seedPooledConnection(for: connection.id) try? await DatabaseManager.shared.switchDatabase(to: "unreachable", for: connection.id, persist: false) @@ -105,7 +105,7 @@ struct SwitchDatabasePooledConnectionTests { @Test("A switch that reconnects a connection that stopped answering closes its pooled connections") func recoveringSwitchClosesThePool() async { let connection = makeSession(stoppedAnswering: true) - defer { cleanUp(connection.id) } + defer { cleanUp(connection) } let pooled = seedPooledConnection(for: connection.id) try? await DatabaseManager.shared.switchDatabase(to: "unreachable", for: connection.id, persist: false) diff --git a/TableProTests/Core/Database/SwitchDatabaseReconnectFailureTests.swift b/TableProTests/Core/Database/SwitchDatabaseReconnectFailureTests.swift index 380c647fe8..42908cb14f 100644 --- a/TableProTests/Core/Database/SwitchDatabaseReconnectFailureTests.swift +++ b/TableProTests/Core/Database/SwitchDatabaseReconnectFailureTests.swift @@ -17,10 +17,9 @@ import Testing @MainActor struct SwitchDatabaseReconnectFailureTests { // No driver plugin and no plugin load in the test host, so the reconnect fails on its first step, with no network. - private static let typeId = "ReconnectSwitchFake" - - private func registerTypeIfNeeded() { - guard PluginMetadataRegistry.shared.snapshot(forRegisteredTypeId: Self.typeId) == nil else { return } + /// A type of its own per case, so one case's cleanup never unregisters another's. + private func registerType() -> String { + let typeId = "ReconnectSwitchFake-\(UUID().uuidString)" var capabilities = PluginMetadataSnapshot.CapabilityFlags.defaults capabilities = PluginMetadataSnapshot.CapabilityFlags( supportsSchemaSwitching: true, @@ -39,7 +38,7 @@ struct SwitchDatabaseReconnectFailureTests { /// than opening a connection of its own for the database. capabilities.supportsConnectionPooling = false let snapshot = PluginMetadataSnapshot( - displayName: Self.typeId, iconName: "cylinder", defaultPort: 1_234, + displayName: typeId, iconName: "cylinder", defaultPort: 1_234, requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: true, isDownloadable: false, primaryUrlScheme: "reconnectswitchfake", parameterStyle: .questionMark, navigationModel: .standard, explainVariants: [], pathFieldRole: .database, @@ -48,16 +47,16 @@ struct SwitchDatabaseReconnectFailureTests { connectionMode: .network, supportsDatabaseSwitching: true, capabilities: capabilities, schema: .defaults, editor: .defaults, connection: .defaults ) - PluginMetadataRegistry.shared.register(snapshot: snapshot, forTypeId: Self.typeId) + PluginMetadataRegistry.shared.register(snapshot: snapshot, forTypeId: typeId) + return typeId } /// The session carries a driver on purpose. Without one `switchDatabase` refuses at its /// `notConnected` guard and never reaches the reconnect branch, so every assertion below /// would pass against the unfixed code while proving nothing. private func makeSession() -> DatabaseConnection { - registerTypeIfNeeded() var connection = TestFixtures.makeConnection(database: "app") - connection.type = DatabaseType(rawValue: Self.typeId) + connection.type = DatabaseType(rawValue: registerType()) var session = ConnectionSession(connection: connection, driver: MockDatabaseDriver(connection: connection)) session.status = .connected session.browseDatabase = "app" @@ -69,11 +68,12 @@ struct SwitchDatabaseReconnectFailureTests { /// The fake type lives in the process-global registry, so it is withdrawn here rather than /// left for whatever runs next in the same process. - private func cleanUp(_ connectionId: UUID) { + private func cleanUp(_ connection: DatabaseConnection) { + let connectionId = connection.id DatabaseManager.shared.removeSession(for: connectionId) AppSettingsStorage.shared.saveLastDatabase(nil, for: connectionId) AppSettingsStorage.shared.saveLastSchema(nil, for: connectionId) - PluginMetadataRegistry.shared.unregister(typeId: Self.typeId) + PluginMetadataRegistry.shared.unregister(typeId: connection.type.rawValue) } /// Proves the reconnect branch is the one under test: the guard the previous version of this @@ -81,7 +81,7 @@ struct SwitchDatabaseReconnectFailureTests { @Test("The failing switch really reaches the reconnect branch") func reachesTheReconnectBranch() async { let connection = makeSession() - defer { cleanUp(connection.id) } + defer { cleanUp(connection) } try? await DatabaseManager.shared.switchDatabase( to: "unreachable", for: connection.id, persist: false @@ -97,7 +97,7 @@ struct SwitchDatabaseReconnectFailureTests { @Test("A database switch whose reconnect fails throws instead of reporting success") func failedReconnectThrows() async { let connection = makeSession() - defer { cleanUp(connection.id) } + defer { cleanUp(connection) } await #expect(throws: (any Error).self) { try await DatabaseManager.shared.switchDatabase( @@ -109,7 +109,7 @@ struct SwitchDatabaseReconnectFailureTests { @Test("A failed switch never announces a container change") func failedSwitchAnnouncesNothing() async { let connection = makeSession() - defer { cleanUp(connection.id) } + defer { cleanUp(connection) } /// Counted for this connection only. Suites run side by side in one process, and a switch /// another suite completes on its own connection announces on the same subject. @@ -133,7 +133,7 @@ struct SwitchDatabaseReconnectFailureTests { @Test("A failed switch leaves the connection on the database it came from") func failedSwitchRestoresTheConnectionDatabase() async { let connection = makeSession() - defer { cleanUp(connection.id) } + defer { cleanUp(connection) } try? await DatabaseManager.shared.switchDatabase( to: "unreachable", for: connection.id, persist: false @@ -149,7 +149,7 @@ struct SwitchDatabaseReconnectFailureTests { @Test("A failed switch does not save the unreachable database as the default") func failedSwitchDoesNotPersist() async { let connection = makeSession() - defer { cleanUp(connection.id) } + defer { cleanUp(connection) } try? await DatabaseManager.shared.switchDatabase( to: "unreachable", for: connection.id, persist: true diff --git a/TableProTests/Core/Database/TableOperationSQLBuilderTests.swift b/TableProTests/Core/Database/TableOperationSQLBuilderTests.swift index 5797cb4351..8e90842aac 100644 --- a/TableProTests/Core/Database/TableOperationSQLBuilderTests.swift +++ b/TableProTests/Core/Database/TableOperationSQLBuilderTests.swift @@ -8,66 +8,32 @@ import Foundation import TableProPluginKit import Testing -private final class StubDropDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class StubDropDriver: PluginDriverStub, @unchecked Sendable { var supportsSchemas: Bool { false } var supportsTransactions: Bool { false } var currentSchema: String? { nil } var serverVersion: String? { nil } - func connect() async throws {} - func disconnect() {} func ping() async throws {} func execute(query: String) async throws -> PluginQueryResult { PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) } - - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } /// A driver that answers the foreign-key questions, which `StubDropDriver` leaves at the protocol /// default of nil. The two together are what the builder's contract is: whatever the connected /// driver says, and nothing at all when it says nothing. -private final class StubForeignKeyDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class StubForeignKeyDriver: PluginDriverStub, @unchecked Sendable { var supportsSchemas: Bool { false } var supportsTransactions: Bool { false } var currentSchema: String? { nil } var serverVersion: String? { nil } - func connect() async throws {} - func disconnect() {} func ping() async throws {} func execute(query: String) async throws -> PluginQueryResult { PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) } - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } - func foreignKeyDisableStatements() -> [String]? { ["SET FOREIGN_KEY_CHECKS=0"] } func foreignKeyEnableStatements() -> [String]? { ["SET FOREIGN_KEY_CHECKS=1"] } } diff --git a/TableProTests/Core/Database/TriggerInfoMappingTests.swift b/TableProTests/Core/Database/TriggerInfoMappingTests.swift index 28d8c7e5cc..7aeb16248d 100644 --- a/TableProTests/Core/Database/TriggerInfoMappingTests.swift +++ b/TableProTests/Core/Database/TriggerInfoMappingTests.swift @@ -10,7 +10,7 @@ import Foundation import TableProPluginKit import Testing -private final class StubTriggerDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class StubTriggerDriver: PluginDriverStub, @unchecked Sendable { var supportsSchemas: Bool { false } var supportsTransactions: Bool { false } var currentSchema: String? { nil } @@ -31,8 +31,6 @@ private final class StubTriggerDriver: PluginDatabaseDriver, @unchecked Sendable var triggerEditUsesReplace: Bool { editUsesReplace } var supportsTransactionalDDL: Bool { transactionalDDL } - func connect() async throws {} - func disconnect() {} func ping() async throws {} func execute(query: String) async throws -> PluginQueryResult { executedQueries.append(query) @@ -42,21 +40,7 @@ private final class StubTriggerDriver: PluginDatabaseDriver, @unchecked Sendable return PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) } - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } func fetchTriggers(table: String, schema: String?) async throws -> [PluginTriggerInfo] { triggersToReturn } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } struct TriggerInfoMappingTests { diff --git a/TableProTests/Core/Diagnostics/LaunchTracerTests.swift b/TableProTests/Core/Diagnostics/LaunchTracerTests.swift index 3b2dc85ac3..ad6456c492 100644 --- a/TableProTests/Core/Diagnostics/LaunchTracerTests.swift +++ b/TableProTests/Core/Diagnostics/LaunchTracerTests.swift @@ -3,56 +3,62 @@ // TableProTests // +import Foundation @testable import TablePro -import XCTest +import Testing @MainActor -final class LaunchTracerTests: XCTestCase { - func testMarksAreRecordedInOrderWithMonotonicOffsets() { +final class LaunchTracerTests { + @Test + func marksAreRecordedInOrderWithMonotonicOffsets() { let tracer = LaunchTracer(processStart: Date()) tracer.mark(.main) tracer.mark(.applicationCreated) tracer.mark(.didFinishLaunchingBegan) - XCTAssertEqual(tracer.recordedMarks.map(\.stage), [.main, .applicationCreated, .didFinishLaunchingBegan]) - XCTAssertEqual(tracer.recordedMarks.map(\.offset), tracer.recordedMarks.map(\.offset).sorted()) + #expect(tracer.recordedMarks.map(\.stage) == [.main, .applicationCreated, .didFinishLaunchingBegan]) + #expect(tracer.recordedMarks.map(\.offset) == tracer.recordedMarks.map(\.offset).sorted()) } /// Every offset is measured from process exec, so a tracer built with a start in the past /// reports that gap rather than starting its clock at first touch. - func testOffsetsAreMeasuredFromProcessStart() { + @Test + func offsetsAreMeasuredFromProcessStart() { let tracer = LaunchTracer(processStart: Date().addingTimeInterval(-2)) tracer.mark(.main) - let offset = try? XCTUnwrap(tracer.recordedMarks.first?.offset) - XCTAssertGreaterThan(offset ?? 0, 1.9) + let offset = try? #require(tracer.recordedMarks.first?.offset) + #expect((offset ?? 0) > 1.9) } - func testTheFirstFrameEndsTheTraceSoLaterMarksAreIgnored() { + @Test + func theFirstFrameEndsTheTraceSoLaterMarksAreIgnored() { let tracer = LaunchTracer(processStart: Date()) tracer.mark(.main) tracer.mark(.firstFramePresented) tracer.mark(.intentsRouted) - XCTAssertEqual(tracer.recordedMarks.map(\.stage), [.main, .firstFramePresented]) + #expect(tracer.recordedMarks.map(\.stage) == [.main, .firstFramePresented]) } - func testTheReportNamesEveryStageItRecorded() { + @Test + func theReportNamesEveryStageItRecorded() { let tracer = LaunchTracer(processStart: Date()) tracer.mark(.main) tracer.mark(.menuInstalled) let report = tracer.report() - XCTAssertTrue(report.contains("main")) - XCTAssertTrue(report.contains("menuInstalled")) - XCTAssertFalse(report.contains("intentsRouted")) + #expect(report.contains("main")) + #expect(report.contains("menuInstalled")) + #expect(!report.contains("intentsRouted")) } - func testProcessStartIsInThePast() { - XCTAssertLessThan(LaunchTracer.processStartDate(), Date()) + @Test + func processStartIsInThePast() { + #expect(LaunchTracer.processStartDate() < Date()) } } diff --git a/TableProTests/Core/Events/KeyWindowCommandSubscriptionTests.swift b/TableProTests/Core/Events/KeyWindowCommandSubscriptionTests.swift index 835e441d44..f58a12089a 100644 --- a/TableProTests/Core/Events/KeyWindowCommandSubscriptionTests.swift +++ b/TableProTests/Core/Events/KeyWindowCommandSubscriptionTests.swift @@ -1,14 +1,9 @@ -// -// KeyWindowCommandSubscriptionTests.swift -// TableProTests -// - import AppKit import Combine import Foundation -import Testing - @testable import TablePro +import TableProTestSupport +import Testing @MainActor struct KeyWindowCommandSubscriptionTests { @@ -16,12 +11,6 @@ struct KeyWindowCommandSubscriptionTests { var payloads: [Int] = [] } - private func waitUntil(_ condition: () -> Bool) async throws { - for _ in 0 ..< 200 where !condition() { - try await Task.sleep(for: .milliseconds(10)) - } - } - @Test("Only the subscriber whose window is frontmost runs the command") func onlyTheFrontmostSubscriberRuns() async throws { let subject = PassthroughSubject() @@ -35,8 +24,9 @@ struct KeyWindowCommandSubscriptionTests { } subject.send(7) + // Deliveries run on the main run loop in subscription order, so the background + // subscriber's has run by the time the frontmost one's lands. try await waitUntil { !frontmost.payloads.isEmpty } - try await Task.sleep(for: .milliseconds(50)) #expect(frontmost.payloads == [7]) #expect(background.payloads.isEmpty) @@ -58,12 +48,18 @@ struct KeyWindowCommandSubscriptionTests { let subscription = KeyWindowCommandSubscription.sink(subject, whileKey: { window }) { _ in received.payloads.append(1) } + // Subscribed after, so its delivery on the main run loop comes after the window's. + let control = Received() + let controlSubscription = KeyWindowCommandSubscription.sink(subject, when: { true }) { _ in + control.payloads.append(1) + } subject.send(()) - try await Task.sleep(for: .milliseconds(100)) + try await waitUntil { !control.payloads.isEmpty } #expect(!window.isKeyWindow) #expect(received.payloads.isEmpty) subscription.cancel() + controlSubscription.cancel() } } diff --git a/TableProTests/Core/Export/TableTransferServiceTests.swift b/TableProTests/Core/Export/TableTransferServiceTests.swift index 15bd693899..f7401bd25b 100644 --- a/TableProTests/Core/Export/TableTransferServiceTests.swift +++ b/TableProTests/Core/Export/TableTransferServiceTests.swift @@ -10,7 +10,7 @@ import Testing @testable import TablePro /// Serves one table's rows as a source, and records what reaches it as a destination. -private final class TransferStubDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class TransferStubDriver: PluginDriverStub, @unchecked Sendable { let header: [String] let rows: [PluginRow] let columns: [String] @@ -42,9 +42,6 @@ private final class TransferStubDriver: PluginDatabaseDriver, @unchecked Sendabl } } - func connect() async throws {} - func disconnect() {} - func execute(query: String) async throws -> PluginQueryResult { executedQueries.append(query) return PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) @@ -56,26 +53,9 @@ private final class TransferStubDriver: PluginDatabaseDriver, @unchecked Sendabl return PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) } - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { columns.map { PluginColumnInfo(name: $0, dataType: "TEXT") } } - - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - - func fetchDatabases() async throws -> [String] { [] } - - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } struct TableTransferServiceTests { diff --git a/TableProTests/Core/Git/GitIntegrationTests.swift b/TableProTests/Core/Git/GitIntegrationTests.swift index 18a3d28b3a..7a013446a0 100644 --- a/TableProTests/Core/Git/GitIntegrationTests.swift +++ b/TableProTests/Core/Git/GitIntegrationTests.swift @@ -51,6 +51,14 @@ private struct ScratchRepository { return result } + /// An index older than the files it lists is one plain `git status` rewrites, so a write the + /// client failed to suppress changes the index's identity. + func backdateIndex() throws { + let index = url(".git/index").path + let written = try FileManager.default.attributesOfItem(atPath: index)[.modificationDate] as? Date ?? Date() + try FileManager.default.setAttributes([.modificationDate: written.addingTimeInterval(-2)], ofItemAtPath: index) + } + func indexIdentity() throws -> [FileAttributeKey: Any] { let attributes = try FileManager.default.attributesOfItem(atPath: url(".git/index").path) return [ @@ -60,7 +68,7 @@ private struct ScratchRepository { } } -@Suite("Git against a real repository", .enabled(if: GitClient.make() != nil), .serialized) +@Suite("Git against a real repository", .enabled(if: GitClient.make() != nil)) struct GitIntegrationTests { private let client: GitClient @@ -80,7 +88,7 @@ struct GitIntegrationTests { try repo.write("SELECT 10;\n", to: "sql queries/a b.sql") try repo.write("new\n", to: "sql queries/日本.sql") try repo.write("elsewhere\n", to: "other/x.sql") - try await Task.sleep(for: .milliseconds(1_100)) + try repo.backdateIndex() let before = try repo.indexIdentity() let folder = repo.url("sql queries") diff --git a/TableProTests/Core/MCP/MCPConnectionApprovalTests.swift b/TableProTests/Core/MCP/MCPConnectionApprovalTests.swift index 3e9a1496b8..5b0c9eb070 100644 --- a/TableProTests/Core/MCP/MCPConnectionApprovalTests.swift +++ b/TableProTests/Core/MCP/MCPConnectionApprovalTests.swift @@ -161,7 +161,8 @@ struct MCPConnectionApprovalTests { @Test("Ask Once remembers the answer, and the next call does not ask") func askOnceRemembers() async throws { let presenter = RecordingApprovalPresenter(answer: true) - let store = MCPApprovalStore(defaults: Self.scratchDefaults()) + let suite = try EphemeralDefaults() + let store = MCPApprovalStore(defaults: suite.defaults) let policy = makePolicy(approval: .oncePerConnection, presenter: presenter, store: store) let principal = makePrincipal() @@ -178,7 +179,8 @@ struct MCPConnectionApprovalTests { @Test("Ask Every Time asks again on the next call") func everyTimeAsksAgain() async throws { let presenter = RecordingApprovalPresenter(answer: true) - let store = MCPApprovalStore(defaults: Self.scratchDefaults()) + let suite = try EphemeralDefaults() + let store = MCPApprovalStore(defaults: suite.defaults) let policy = makePolicy(approval: .everyTime, presenter: presenter, store: store) let principal = makePrincipal() @@ -208,7 +210,8 @@ struct MCPConnectionApprovalTests { @Test("A refusal stands for a cool-off instead of raising a fresh prompt on the retry") func denialIsRememberedBriefly() async throws { let presenter = RecordingApprovalPresenter(answer: false) - let store = MCPApprovalStore(defaults: Self.scratchDefaults()) + let suite = try EphemeralDefaults() + let store = MCPApprovalStore(defaults: suite.defaults) let policy = makePolicy(approval: .oncePerConnection, presenter: presenter, store: store) let principal = makePrincipal() @@ -225,7 +228,8 @@ struct MCPConnectionApprovalTests { @Test("The bundled bridge keeps its grant when its credential rotates") func bridgeGrantSurvivesCredentialRotation() async throws { - let store = MCPApprovalStore(defaults: Self.scratchDefaults()) + let suite = try EphemeralDefaults() + let store = MCPApprovalStore(defaults: suite.defaults) let presenter = RecordingApprovalPresenter(answer: true) let policy = makePolicy(approval: .oncePerConnection, presenter: presenter, store: store) @@ -256,7 +260,8 @@ struct MCPConnectionApprovalTests { @Test("Revoking a token the user issued drops the grant it was carrying") func revokingAnIssuedTokenDropsItsGrant() async throws { - let store = MCPApprovalStore(defaults: Self.scratchDefaults()) + let suite = try EphemeralDefaults() + let store = MCPApprovalStore(defaults: suite.defaults) let presenter = RecordingApprovalPresenter(answer: true) let policy = makePolicy(approval: .oncePerConnection, presenter: presenter, store: store) let principal = makePrincipal(label: "Laptop token") @@ -274,7 +279,8 @@ struct MCPConnectionApprovalTests { @Test("A remembered grant survives the teardown a settings change triggers") func grantSurvivesServerTeardown() async throws { - let store = MCPApprovalStore(defaults: Self.scratchDefaults()) + let suite = try EphemeralDefaults() + let store = MCPApprovalStore(defaults: suite.defaults) let presenter = RecordingApprovalPresenter(answer: true) let policy = makePolicy(approval: .oncePerConnection, presenter: presenter, store: store) let principal = makePrincipal() @@ -292,7 +298,8 @@ struct MCPConnectionApprovalTests { @Test("Forgetting a grant makes the next call ask again") func forgettingAGrantAsksAgain() async throws { - let store = MCPApprovalStore(defaults: Self.scratchDefaults()) + let suite = try EphemeralDefaults() + let store = MCPApprovalStore(defaults: suite.defaults) let presenter = RecordingApprovalPresenter(answer: true) let policy = makePolicy(approval: .oncePerConnection, presenter: presenter, store: store) let principal = makePrincipal() @@ -313,7 +320,8 @@ struct MCPConnectionApprovalTests { @Test("A token that merely calls itself the bridge inherits none of the bridge's grants") func aTokenNamedLikeTheBridgeInheritsNothing() async throws { - let store = MCPApprovalStore(defaults: Self.scratchDefaults()) + let suite = try EphemeralDefaults() + let store = MCPApprovalStore(defaults: suite.defaults) let presenter = RecordingApprovalPresenter(answer: true) let policy = makePolicy(approval: .oncePerConnection, presenter: presenter, store: store) @@ -336,7 +344,8 @@ struct MCPConnectionApprovalTests { @Test("Ask Every Time asks again even for a connection that already carries a grant") func everyTimeIgnoresAnExistingGrant() async throws { - let store = MCPApprovalStore(defaults: Self.scratchDefaults()) + let suite = try EphemeralDefaults() + let store = MCPApprovalStore(defaults: suite.defaults) let principal = makePrincipal() let remembering = RecordingApprovalPresenter(answer: true) @@ -357,7 +366,8 @@ struct MCPConnectionApprovalTests { @Test("A denial withdraws a grant the user had already given") func denialWithdrawsAnExistingGrant() async throws { - let store = MCPApprovalStore(defaults: Self.scratchDefaults()) + let suite = try EphemeralDefaults() + let store = MCPApprovalStore(defaults: suite.defaults) let principal = makePrincipal() let allowing = makePolicy( @@ -383,11 +393,4 @@ struct MCPConnectionApprovalTests { #expect(await denying.grants().isEmpty) } - - private static func scratchDefaults() -> UserDefaults { - let suite = "com.TablePro.tests.mcpApprovals." + UUID().uuidString - let defaults = UserDefaults(suiteName: suite) ?? .standard - defaults.removePersistentDomain(forName: suite) - return defaults - } } diff --git a/TableProTests/Core/MCP/MCPTokenStoreTests.swift b/TableProTests/Core/MCP/MCPTokenStoreTests.swift index 9f76263c6e..395c80f377 100644 --- a/TableProTests/Core/MCP/MCPTokenStoreTests.swift +++ b/TableProTests/Core/MCP/MCPTokenStoreTests.swift @@ -13,6 +13,7 @@ import Foundation import TableProMCP import TableProMCPTestSupport import TableProPluginKit +import TableProTestSupport import Testing @testable import TablePro @@ -358,9 +359,10 @@ struct MCPTokenStoreTests { } await store.revoke(tokenId: result.token.id) - try? await Task.sleep(for: .milliseconds(100)) + let key = result.token.id.uuidString + try await waitUntil { await recorder.keys().contains(key) } - #expect(await recorder.keys().contains(result.token.id.uuidString)) + #expect(await recorder.keys().contains(key)) } @Test("Deleting drops the token from the list entirely") diff --git a/TableProTests/Core/MCP/Outside/MCPRemoteToolScopeTests.swift b/TableProTests/Core/MCP/Outside/MCPRemoteToolScopeTests.swift index 54b02ac04c..b41864f1ff 100644 --- a/TableProTests/Core/MCP/Outside/MCPRemoteToolScopeTests.swift +++ b/TableProTests/Core/MCP/Outside/MCPRemoteToolScopeTests.swift @@ -8,23 +8,6 @@ import Foundation import TableProMCP import Testing -private final class FakeKeychain: KeychainStoring, @unchecked Sendable { - private var values: [String: String] = [:] - - func writeString(_ value: String, forKey key: String) -> Bool { - values[key] = value - return true - } - - func readStringResult(forKey key: String) -> KeychainStringResult { - values[key].map { .found($0) } ?? .notFound - } - - func delete(forKey key: String) { - values.removeValue(forKey: key) - } -} - private struct StubChatTool: ChatTool { let name: String let description = "stub" @@ -38,11 +21,14 @@ private struct StubChatTool: ChatTool { @MainActor struct MCPRemoteToolScopeTests { + private let suite: EphemeralDefaults + + init() throws { + suite = try EphemeralDefaults() + } + private func makeStore() -> MCPServerStore { - let suite = "MCPRemoteToolScopeTests-\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suite) ?? .standard - defaults.removePersistentDomain(forName: suite) - return MCPServerStore(defaults: defaults, keychain: FakeKeychain()) + MCPServerStore(defaults: suite.defaults, keychain: InMemoryKeychain()) } private func endpoint() -> URL { diff --git a/TableProTests/Core/MCP/Outside/MCPServerStoreTests.swift b/TableProTests/Core/MCP/Outside/MCPServerStoreTests.swift index 152503100b..4fbae967e0 100644 --- a/TableProTests/Core/MCP/Outside/MCPServerStoreTests.swift +++ b/TableProTests/Core/MCP/Outside/MCPServerStoreTests.swift @@ -35,11 +35,14 @@ private final class LockedKeychain: KeychainStoring, @unchecked Sendable { @MainActor struct MCPServerStoreTests { + private let suite: EphemeralDefaults + + init() throws { + suite = try EphemeralDefaults() + } + private func makeDefaults() -> UserDefaults { - let suite = "MCPServerStoreTests-\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suite) else { return .standard } - defaults.removePersistentDomain(forName: suite) - return defaults + suite.defaults } private func endpoint(_ string: String = "https://runbooks.example/mcp") -> URL { diff --git a/TableProTests/Core/MCP/Protocol/Handlers/PromptsListHandlerTests.swift b/TableProTests/Core/MCP/Protocol/Handlers/PromptsListHandlerTests.swift index beab14a647..7d7e9b5bf8 100644 --- a/TableProTests/Core/MCP/Protocol/Handlers/PromptsListHandlerTests.swift +++ b/TableProTests/Core/MCP/Protocol/Handlers/PromptsListHandlerTests.swift @@ -1,110 +1,125 @@ import Foundation +@testable import TablePro import TableProMCP import TableProPluginKit -@testable import TablePro -import XCTest +import Testing -final class PromptsListHandlerTests: XCTestCase { - func testMethodIsPromptsList() { - XCTAssertEqual(PromptsListHandler.method, "prompts/list") +struct PromptsListHandlerTests { + @Test + func methodIsPromptsList() { + #expect(PromptsListHandler.method == "prompts/list") } - func testRequiresResourcesReadScope() { - XCTAssertEqual(PromptsListHandler.requiredScopes, [.resourcesRead]) + @Test + func requiresResourcesReadScope() { + #expect(PromptsListHandler.requiredScopes == [.resourcesRead]) } - func testIsAvailableToLegacyClients() { - XCTAssertTrue(PromptsListHandler.isAvailableToLegacyClients) + @Test + func isAvailableToLegacyClients() { + #expect(PromptsListHandler.isAvailableToLegacyClients) } - func testReturnsTheWholeCatalogRatherThanAnEmptyList() async throws { + @Test + func returnsTheWholeCatalogRatherThanAnEmptyList() async throws { let result = try await list(params: nil) - let prompts = try XCTUnwrap(result.payload["prompts"]?.arrayValue) + let prompts = try #require(result.payload["prompts"]?.arrayValue) - XCTAssertFalse(prompts.isEmpty, "prompts/list is advertised, so it must return the catalog") - XCTAssertEqual(prompts.count, MCPPromptCatalog.all.count) + #expect(!prompts.isEmpty, "prompts/list is advertised, so it must return the catalog") + #expect(prompts.count == MCPPromptCatalog.all.count) let names = prompts.compactMap { $0["name"]?.stringValue } - XCTAssertEqual(names, MCPPromptCatalog.all.map(\.name)) + #expect(names == MCPPromptCatalog.all.map(\.name)) } - func testEveryListedPromptCarriesItsTitleAndDescription() async throws { + @Test + func everyListedPromptCarriesItsTitleAndDescription() async throws { let result = try await list(params: nil) - let prompts = try XCTUnwrap(result.payload["prompts"]?.arrayValue) + let prompts = try #require(result.payload["prompts"]?.arrayValue) for prompt in prompts { - XCTAssertNotNil(prompt["name"]?.stringValue) - XCTAssertEqual(prompt["title"]?.stringValue?.isEmpty, false) - XCTAssertEqual(prompt["description"]?.stringValue?.isEmpty, false) + #expect(prompt["name"]?.stringValue != nil) + #expect(prompt["title"]?.stringValue?.isEmpty == false) + #expect(prompt["description"]?.stringValue?.isEmpty == false) } } - func testResultIsPubliclyCacheableForAnHour() async throws { + @Test + func resultIsPubliclyCacheableForAnHour() async throws { let result = try await list(params: nil) - let hint = try XCTUnwrap(result.cacheHint) + let hint = try #require(result.cacheHint) - XCTAssertEqual(hint.scope, .publicScope) - XCTAssertEqual(hint.ttlMilliseconds, 3_600_000) + #expect(hint.scope == .publicScope) + #expect(hint.ttlMilliseconds == 3_600_000) } - func testModernSerialisationCarriesTheCacheFields() async throws { + @Test + func modernSerialisationCarriesTheCacheFields() async throws { let result = try await list(params: nil) let json = result.asJsonValue(era: .modern, serverInfo: nil) - XCTAssertEqual(json["resultType"]?.stringValue, "complete") - XCTAssertEqual(json["ttlMs"]?.intValue, 3_600_000) - XCTAssertEqual(json["cacheScope"]?.stringValue, "public") + #expect(json["resultType"]?.stringValue == "complete") + #expect(json["ttlMs"]?.intValue == 3_600_000) + #expect(json["cacheScope"]?.stringValue == "public") } - func testLegacySerialisationOmitsTheCacheFields() async throws { + @Test + func legacySerialisationOmitsTheCacheFields() async throws { let result = try await list(params: nil) let json = result.asJsonValue(era: .legacy, serverInfo: nil) - XCTAssertNil(json["resultType"]) - XCTAssertNil(json["ttlMs"]) - XCTAssertNil(json["cacheScope"]) - XCTAssertNotNil(json["prompts"]) + #expect(json["resultType"] == nil) + #expect(json["ttlMs"] == nil) + #expect(json["cacheScope"] == nil) + #expect(json["prompts"] != nil) } - func testASinglePageNeedsNoCursor() async throws { + @Test + func aSinglePageNeedsNoCursor() async throws { let result = try await list(params: nil) - XCTAssertNil(result.payload["nextCursor"]) + #expect(result.payload["nextCursor"] == nil) } - func testACursorForThisMethodResumesTheCatalog() async throws { + @Test + func aCursorForThisMethodResumesTheCatalog() async throws { let cursor = MCPListPagination.encodeCursor(offset: 1, method: PromptsListHandler.method) let result = try await list(params: .object(["cursor": .string(cursor)])) let names = result.payload["prompts"]?.arrayValue?.compactMap { $0["name"]?.stringValue } ?? [] - XCTAssertEqual(names, Array(MCPPromptCatalog.all.map(\.name).dropFirst())) + #expect(names == Array(MCPPromptCatalog.all.map(\.name).dropFirst())) } - func testACursorMintedForAnotherMethodIsRefused() async throws { + @Test + func aCursorMintedForAnotherMethodIsRefused() async throws { let cursor = MCPListPagination.encodeCursor(offset: 0, method: "tools/list") let error = try await failure(params: .object(["cursor": .string(cursor)])) - XCTAssertEqual(error.code, JsonRpcErrorCode.invalidParams) + #expect(error.code == JsonRpcErrorCode.invalidParams) } - func testAGarbageCursorIsRefused() async throws { + @Test + func aGarbageCursorIsRefused() async throws { let error = try await failure(params: .object(["cursor": .string("not-a-cursor")])) - XCTAssertEqual(error.code, JsonRpcErrorCode.invalidParams) + #expect(error.code == JsonRpcErrorCode.invalidParams) } - func testANonStringCursorIsRefused() async throws { + @Test + func aNonStringCursorIsRefused() async throws { let error = try await failure(params: .object(["cursor": .int(3)])) - XCTAssertEqual(error.code, JsonRpcErrorCode.invalidParams) + #expect(error.code == JsonRpcErrorCode.invalidParams) } - func testACursorPastTheEndIsRefused() async throws { + @Test + func aCursorPastTheEndIsRefused() async throws { let cursor = MCPListPagination.encodeCursor( offset: MCPPromptCatalog.all.count + 1, method: PromptsListHandler.method ) let error = try await failure(params: .object(["cursor": .string(cursor)])) - XCTAssertEqual(error.code, JsonRpcErrorCode.invalidParams) + #expect(error.code == JsonRpcErrorCode.invalidParams) } - func testAShortPageHandsBackACursorThatResumesWhereItStopped() throws { + @Test + func aShortPageHandsBackACursorThatResumesWhereItStopped() throws { let names = MCPPromptCatalog.all.map(\.name) let firstPage = try MCPListPagination.page( names, @@ -112,8 +127,8 @@ final class PromptsListHandlerTests: XCTestCase { method: PromptsListHandler.method, pageSize: 2 ) - XCTAssertEqual(firstPage.items, Array(names.prefix(2))) - let cursor = try XCTUnwrap(firstPage.nextCursor) + #expect(firstPage.items == Array(names.prefix(2))) + let cursor = try #require(firstPage.nextCursor) let secondPage = try MCPListPagination.page( names, @@ -121,7 +136,7 @@ final class PromptsListHandlerTests: XCTestCase { method: PromptsListHandler.method, pageSize: 2 ) - XCTAssertEqual(secondPage.items, Array(names.dropFirst(2).prefix(2))) + #expect(secondPage.items == Array(names.dropFirst(2).prefix(2))) } private func list(params: JsonValue?) async throws -> MCPResult { @@ -136,7 +151,7 @@ final class PromptsListHandlerTests: XCTestCase { private func failure(params: JsonValue?) async throws -> MCPProtocolError { do { let result = try await list(params: params) - XCTFail("Expected an MCPProtocolError, got \(result)") + Issue.record("Expected an MCPProtocolError, got \(result)") throw PromptsListTestError.unexpectedSuccess } catch let error as MCPProtocolError { return error diff --git a/TableProTests/Core/MCP/Protocol/Handlers/ResourcesListHandlerTests.swift b/TableProTests/Core/MCP/Protocol/Handlers/ResourcesListHandlerTests.swift index 735eb1b173..4f1a843386 100644 --- a/TableProTests/Core/MCP/Protocol/Handlers/ResourcesListHandlerTests.swift +++ b/TableProTests/Core/MCP/Protocol/Handlers/ResourcesListHandlerTests.swift @@ -1,75 +1,85 @@ import Foundation +@testable import TablePro import TableProMCP import TableProPluginKit -@testable import TablePro -import XCTest +import Testing -final class ResourcesListHandlerTests: XCTestCase { - func testMethodIsResourcesList() { - XCTAssertEqual(ResourcesListHandler.method, "resources/list") +struct ResourcesListHandlerTests { + @Test + func methodIsResourcesList() { + #expect(ResourcesListHandler.method == "resources/list") } - func testRequiresResourcesReadScope() { - XCTAssertEqual(ResourcesListHandler.requiredScopes, [.resourcesRead]) + @Test + func requiresResourcesReadScope() { + #expect(ResourcesListHandler.requiredScopes == [.resourcesRead]) } - func testIsOfferedToLegacyClients() { - XCTAssertTrue(ResourcesListHandler.isAvailableToLegacyClients) + @Test + func isOfferedToLegacyClients() { + #expect(ResourcesListHandler.isAvailableToLegacyClients) } - func testListsTheSavedConnectionsResource() async throws { + @Test + func listsTheSavedConnectionsResource() async throws { let result = try await runList() - let resources = try XCTUnwrap(result.payload["resources"]?.arrayValue) + let resources = try #require(result.payload["resources"]?.arrayValue) let uris = resources.compactMap { $0["uri"]?.stringValue } - XCTAssertTrue(uris.contains("tablepro://connections")) + #expect(uris.contains("tablepro://connections")) } - func testEveryEntryDescribesItself() async throws { + @Test + func everyEntryDescribesItself() async throws { let result = try await runList() - let resources = try XCTUnwrap(result.payload["resources"]?.arrayValue) - XCTAssertFalse(resources.isEmpty) + let resources = try #require(result.payload["resources"]?.arrayValue) + #expect(!resources.isEmpty) for resource in resources { - XCTAssertFalse(resource["uri"]?.stringValue?.isEmpty ?? true) - XCTAssertFalse(resource["name"]?.stringValue?.isEmpty ?? true) - XCTAssertFalse(resource["title"]?.stringValue?.isEmpty ?? true) - XCTAssertFalse(resource["description"]?.stringValue?.isEmpty ?? true) - XCTAssertEqual(resource["mimeType"]?.stringValue, "application/json") + #expect(!(resource["uri"]?.stringValue?.isEmpty ?? true)) + #expect(!(resource["name"]?.stringValue?.isEmpty ?? true)) + #expect(!(resource["title"]?.stringValue?.isEmpty ?? true)) + #expect(!(resource["description"]?.stringValue?.isEmpty ?? true)) + #expect(resource["mimeType"]?.stringValue == "application/json") } } - func testResultIsCompleteAndCacheableForThisPrincipalOnly() async throws { + @Test + func resultIsCompleteAndCacheableForThisPrincipalOnly() async throws { let result = try await runList() - XCTAssertEqual(result.kind, .complete) + #expect(result.kind == .complete) - let hint = try XCTUnwrap(result.cacheHint) - XCTAssertEqual(hint.scope, .privateScope) - XCTAssertEqual(hint.ttlMilliseconds, 30_000) - XCTAssertTrue(MCPProtocolDispatcher.cacheableMethods.contains(ResourcesListHandler.method)) + let hint = try #require(result.cacheHint) + #expect(hint.scope == .privateScope) + #expect(hint.ttlMilliseconds == 30_000) + #expect(MCPProtocolDispatcher.cacheableMethods.contains(ResourcesListHandler.method)) } - func testShortListingCarriesNoCursor() async throws { + @Test + func shortListingCarriesNoCursor() async throws { let result = try await runList() let count = result.payload["resources"]?.arrayValue?.count ?? 0 - XCTAssertLessThanOrEqual(count, MCPListPagination.defaultPageSize) - XCTAssertNil(result.payload["nextCursor"]) + #expect(count <= MCPListPagination.defaultPageSize) + #expect(result.payload["nextCursor"] == nil) } - func testEmptyCursorIsRefused() async throws { + @Test + func emptyCursorIsRefused() async throws { let error = try await failure(params: .object(["cursor": .string("")])) - XCTAssertEqual(error.code, JsonRpcErrorCode.invalidParams) + #expect(error.code == JsonRpcErrorCode.invalidParams) } - func testCursorFromAnotherMethodIsRefused() async throws { + @Test + func cursorFromAnotherMethodIsRefused() async throws { let cursor = MCPListPagination.encodeCursor(offset: 0, method: "tools/list") let error = try await failure(params: .object(["cursor": .string(cursor)])) - XCTAssertEqual(error.code, JsonRpcErrorCode.invalidParams) + #expect(error.code == JsonRpcErrorCode.invalidParams) } - func testCursorPastTheEndIsRefused() async throws { + @Test + func cursorPastTheEndIsRefused() async throws { let cursor = MCPListPagination.encodeCursor(offset: 9_999, method: ResourcesListHandler.method) let error = try await failure(params: .object(["cursor": .string(cursor)])) - XCTAssertEqual(error.code, JsonRpcErrorCode.invalidParams) + #expect(error.code == JsonRpcErrorCode.invalidParams) } private func runList(params: JsonValue? = nil) async throws -> MCPResult { @@ -88,7 +98,7 @@ final class ResourcesListHandlerTests: XCTestCase { } catch let error as MCPProtocolError { return error } - XCTFail("Expected the handler to refuse the request") + Issue.record("Expected the handler to refuse the request") return .internalError(detail: "unreachable") } } diff --git a/TableProTests/Core/MCP/Protocol/Handlers/ResourcesReadHandlerTests.swift b/TableProTests/Core/MCP/Protocol/Handlers/ResourcesReadHandlerTests.swift index ba210603c5..82ccaad774 100644 --- a/TableProTests/Core/MCP/Protocol/Handlers/ResourcesReadHandlerTests.swift +++ b/TableProTests/Core/MCP/Protocol/Handlers/ResourcesReadHandlerTests.swift @@ -1,84 +1,96 @@ import Foundation +@testable import TablePro import TableProMCP import TableProPluginKit -@testable import TablePro -import XCTest +import Testing -final class ResourcesReadHandlerTests: XCTestCase { - func testMethodIsResourcesRead() { - XCTAssertEqual(ResourcesReadHandler.method, "resources/read") +struct ResourcesReadHandlerTests { + @Test + func methodIsResourcesRead() { + #expect(ResourcesReadHandler.method == "resources/read") } - func testRequiresResourcesReadScope() { - XCTAssertEqual(ResourcesReadHandler.requiredScopes, [.resourcesRead]) + @Test + func requiresResourcesReadScope() { + #expect(ResourcesReadHandler.requiredScopes == [.resourcesRead]) } - func testReadsTheConnectionsResource() async throws { + @Test + func readsTheConnectionsResource() async throws { let result = try await read(uri: "tablepro://connections") - XCTAssertEqual(result.kind, .complete) + #expect(result.kind == .complete) - let contents = try XCTUnwrap(result.payload["contents"]?.arrayValue) - XCTAssertEqual(contents.count, 1) + let contents = try #require(result.payload["contents"]?.arrayValue) + #expect(contents.count == 1) - let entry = try XCTUnwrap(contents.first) - XCTAssertEqual(entry["uri"]?.stringValue, "tablepro://connections") - XCTAssertEqual(entry["mimeType"]?.stringValue, "application/json") + let entry = try #require(contents.first) + #expect(entry["uri"]?.stringValue == "tablepro://connections") + #expect(entry["mimeType"]?.stringValue == "application/json") - let text = try XCTUnwrap(entry["text"]?.stringValue) + let text = try #require(entry["text"]?.stringValue) let decoded = try JSONDecoder().decode(JsonValue.self, from: Data(text.utf8)) - XCTAssertNotNil(decoded["connections"]?.arrayValue) + #expect(decoded["connections"]?.arrayValue != nil) } - func testReadIsCacheableForThisPrincipalOnly() async throws { + @Test + func readIsCacheableForThisPrincipalOnly() async throws { let result = try await read(uri: "tablepro://connections") - let hint = try XCTUnwrap(result.cacheHint) - XCTAssertEqual(hint.scope, .privateScope) - XCTAssertEqual(hint.ttlMilliseconds, 15_000) - XCTAssertTrue(MCPProtocolDispatcher.cacheableMethods.contains(ResourcesReadHandler.method)) + let hint = try #require(result.cacheHint) + #expect(hint.scope == .privateScope) + #expect(hint.ttlMilliseconds == 15_000) + #expect(MCPProtocolDispatcher.cacheableMethods.contains(ResourcesReadHandler.method)) } - func testMissingUriIsInvalidParams() async throws { + @Test + func missingUriIsInvalidParams() async throws { let error = try await failure(params: .object([:])) - XCTAssertEqual(error.code, JsonRpcErrorCode.invalidParams) - XCTAssertEqual(error.httpStatus, .badRequest) + #expect(error.code == JsonRpcErrorCode.invalidParams) + #expect(error.httpStatus == .badRequest) } - func testUriThatIsNotAStringIsInvalidParams() async throws { + @Test + func uriThatIsNotAStringIsInvalidParams() async throws { let error = try await failure(params: .object(["uri": .int(7)])) - XCTAssertEqual(error.code, JsonRpcErrorCode.invalidParams) + #expect(error.code == JsonRpcErrorCode.invalidParams) } - func testNonTableproSchemeIsInvalidParams() async throws { + @Test + func nonTableproSchemeIsInvalidParams() async throws { let error = try await failure(params: .object(["uri": .string("https://example.com/foo")])) - XCTAssertEqual(error.code, JsonRpcErrorCode.invalidParams) + #expect(error.code == JsonRpcErrorCode.invalidParams) } - func testUnknownResourceIsInvalidParamsNotResourceNotFound() async throws { + @Test + func unknownResourceIsInvalidParamsNotResourceNotFound() async throws { let error = try await failure(params: .object(["uri": .string("tablepro://unknown/resource")])) - XCTAssertEqual(error.code, JsonRpcErrorCode.invalidParams) - XCTAssertEqual(error.code, -32_602) - XCTAssertNotEqual(error.code, JsonRpcErrorCode.methodNotFound) - XCTAssertNotEqual(error.code, -32_002) + #expect(error.code == JsonRpcErrorCode.invalidParams) + #expect(error.code == -32_602) + #expect(error.code != JsonRpcErrorCode.methodNotFound) + #expect(error.code != -32_002) } - func testConnectionIdThatIsNotAUuidIsInvalidParams() async throws { + @Test + func connectionIdThatIsNotAUuidIsInvalidParams() async throws { let error = try await failure(params: .object(["uri": .string("tablepro://connections/not-a-uuid/schema")])) - XCTAssertEqual(error.code, JsonRpcErrorCode.invalidParams) + #expect(error.code == JsonRpcErrorCode.invalidParams) } - func testUnknownConnectionSubresourceIsInvalidParams() async throws { + @Test + func unknownConnectionSubresourceIsInvalidParams() async throws { let uri = "tablepro://connections/\(UUID().uuidString)/bogus" let error = try await failure(params: .object(["uri": .string(uri)])) - XCTAssertEqual(error.code, JsonRpcErrorCode.invalidParams) + #expect(error.code == JsonRpcErrorCode.invalidParams) } - func testUnknownTableSubresourceIsInvalidParams() async throws { + @Test + func unknownTableSubresourceIsInvalidParams() async throws { let uri = "tablepro://connections/\(UUID().uuidString)/tables/users/statistics" let error = try await failure(params: .object(["uri": .string(uri)])) - XCTAssertEqual(error.code, JsonRpcErrorCode.invalidParams) + #expect(error.code == JsonRpcErrorCode.invalidParams) } - func testCancelledRequestStopsBeforeReading() async throws { + @Test + func cancelledRequestStopsBeforeReading() async throws { let cancellation = MCPCancellationToken() await cancellation.cancel(reason: .clientDisconnected) let services = MCPProtocolHandlerTestSupport.makeToolServices() @@ -93,7 +105,7 @@ final class ResourcesReadHandlerTests: XCTestCase { params: .object(["uri": .string("tablepro://connections")]), context: context ) - XCTFail("Expected the read to stop") + Issue.record("Expected the read to stop") } catch is CancellationError { return } @@ -119,7 +131,7 @@ final class ResourcesReadHandlerTests: XCTestCase { } catch let error as MCPProtocolError { return error } - XCTFail("Expected the handler to refuse the request") + Issue.record("Expected the handler to refuse the request") return .internalError(detail: "unreachable") } } diff --git a/TableProTests/Core/MCP/Protocol/MCPProtocolDispatcherTests.swift b/TableProTests/Core/MCP/Protocol/MCPProtocolDispatcherTests.swift index d59889dfa9..a719153081 100644 --- a/TableProTests/Core/MCP/Protocol/MCPProtocolDispatcherTests.swift +++ b/TableProTests/Core/MCP/Protocol/MCPProtocolDispatcherTests.swift @@ -1,11 +1,11 @@ import Foundation +@testable import TablePro import TableProMCP import TableProMCPTestSupport import TableProPluginKit -@testable import TablePro -import XCTest +import Testing -final class MCPProtocolDispatcherTests: XCTestCase { +struct MCPProtocolDispatcherTests { private let alice = MCPProtocolTestSupport.makePrincipal( fingerprint: "alice", tokenId: UUID(uuidString: "AAAAAAAA-0000-0000-0000-000000000001"), @@ -17,28 +17,30 @@ final class MCPProtocolDispatcherTests: XCTestCase { scopes: [.toolsRead, .toolsWrite] ) - func testUnknownMethodIsMethodNotFoundWithHttpNotFound() async throws { + @Test + func unknownMethodIsMethodNotFoundWithHttpNotFound() async throws { let sink = try await dispatch( MCPProtocolTestSupport.makeModernRequest(method: "unknown/method"), handlers: [StubTestHandler()] ) let errorValue = try await sink.errorEnvelope() - let error = try XCTUnwrap(errorValue) - XCTAssertEqual(error.code, JsonRpcErrorCode.methodNotFound) - XCTAssertEqual(error.code, -32_601) + let error = try #require(errorValue) + #expect(error.code == JsonRpcErrorCode.methodNotFound) + #expect(error.code == -32_601) let status = await sink.firstJsonStatus() - XCTAssertEqual(status?.code, 404) + #expect(status?.code == 404) guard case .errorResponse(let envelope)? = try await sink.firstJsonMessage() else { - XCTFail("Expected an error response") + Issue.record("Expected an error response") return } - XCTAssertEqual(envelope.id, .number(1)) + #expect(envelope.id == .number(1)) } - func testARequestWithoutAPrincipalIsUnauthenticated() async throws { + @Test + func aRequestWithoutAPrincipalIsUnauthenticated() async throws { let sink = try await dispatch( MCPProtocolTestSupport.makeModernRequest(method: StubTestHandler.method), handlers: [StubTestHandler()], @@ -46,18 +48,19 @@ final class MCPProtocolDispatcherTests: XCTestCase { ) let errorValue = try await sink.errorEnvelope() - let error = try XCTUnwrap(errorValue) - XCTAssertEqual(error.code, JsonRpcErrorCode.unauthenticated) + let error = try #require(errorValue) + #expect(error.code == JsonRpcErrorCode.unauthenticated) let status = await sink.firstJsonStatus() - XCTAssertEqual(status?.code, 401) + #expect(status?.code == 401) let challenge = await sink.firstHeaderValue(named: "WWW-Authenticate") - XCTAssertNotNil(challenge) - XCTAssertTrue(challenge?.hasPrefix("Bearer") ?? false) + #expect(challenge != nil) + #expect(challenge?.hasPrefix("Bearer") ?? false) } - func testAMissingScopeIsRefusedWithAChallengeThatNamesTheScopes() async throws { + @Test + func aMissingScopeIsRefusedWithAChallengeThatNamesTheScopes() async throws { let sink = try await dispatch( MCPProtocolTestSupport.makeModernRequest(method: StubScopedHandler.method), handlers: [StubScopedHandler()], @@ -65,22 +68,23 @@ final class MCPProtocolDispatcherTests: XCTestCase { ) let errorValue = try await sink.errorEnvelope() - let error = try XCTUnwrap(errorValue) - XCTAssertEqual(error.code, JsonRpcErrorCode.forbidden) + let error = try #require(errorValue) + #expect(error.code == JsonRpcErrorCode.forbidden) let status = await sink.firstJsonStatus() - XCTAssertEqual(status?.code, 403) + #expect(status?.code == 403) let challengeValue = await sink.firstHeaderValue(named: "WWW-Authenticate") - let challenge = try XCTUnwrap(challengeValue) - XCTAssertTrue(challenge.contains("error=\"insufficient_scope\"")) - XCTAssertTrue(challenge.contains("scope=\"admin tools:write\"")) + let challenge = try #require(challengeValue) + #expect(challenge.contains("error=\"insufficient_scope\"")) + #expect(challenge.contains("scope=\"admin tools:write\"")) let required = error.data?["requiredScopes"]?.arrayValue?.compactMap(\.stringValue) - XCTAssertEqual(required, ["admin", "tools:write"]) + #expect(required == ["admin", "tools:write"]) } - func testAPartiallyScopedPrincipalStillLearnsWhichScopeIsMissing() async throws { + @Test + func aPartiallyScopedPrincipalStillLearnsWhichScopeIsMissing() async throws { let sink = try await dispatch( MCPProtocolTestSupport.makeModernRequest(method: StubScopedHandler.method), handlers: [StubScopedHandler()], @@ -88,11 +92,12 @@ final class MCPProtocolDispatcherTests: XCTestCase { ) let challengeValue = await sink.firstHeaderValue(named: "WWW-Authenticate") - let challenge = try XCTUnwrap(challengeValue) - XCTAssertTrue(challenge.contains("admin")) + let challenge = try #require(challengeValue) + #expect(challenge.contains("admin")) } - func testAHandlerThatFailsNeverLeaksTheConnectionDetailsItFailedOn() async throws { + @Test + func aHandlerThatFailsNeverLeaksTheConnectionDetailsItFailedOn() async throws { let secret = "postgres://root:hunter2@db.internal:5432/app" let handler = StubTestHandler( behavior: .failDriver(StubDriverError(detail: "FATAL: could not connect to \(secret)")) @@ -103,16 +108,17 @@ final class MCPProtocolDispatcherTests: XCTestCase { ) let errorValue = try await sink.errorEnvelope() - let error = try XCTUnwrap(errorValue) - XCTAssertEqual(error.code, JsonRpcErrorCode.internalError) + let error = try #require(errorValue) + #expect(error.code == JsonRpcErrorCode.internalError) let body = await sink.firstJsonBodyText() for leak in ["db.internal", "5432", "root", "hunter2", "postgres://"] { - XCTAssertFalse(body.contains(leak), "response body leaked \(leak)") + #expect(!body.contains(leak), "response body leaked \(leak)") } } - func testAProtocolErrorFromAHandlerIsPassedThroughUnchanged() async throws { + @Test + func aProtocolErrorFromAHandlerIsPassedThroughUnchanged() async throws { let handler = StubTestHandler(behavior: .failProtocol(.invalidParams(detail: "table is required"))) let sink = try await dispatch( MCPProtocolTestSupport.makeModernRequest(method: StubTestHandler.method), @@ -120,14 +126,15 @@ final class MCPProtocolDispatcherTests: XCTestCase { ) let errorValue = try await sink.errorEnvelope() - let error = try XCTUnwrap(errorValue) - XCTAssertEqual(error.code, JsonRpcErrorCode.invalidParams) + let error = try #require(errorValue) + #expect(error.code == JsonRpcErrorCode.invalidParams) let status = await sink.firstJsonStatus() - XCTAssertEqual(status?.code, 400) + #expect(status?.code == 400) } - func testTwoPrincipalsMayShareTheSameRequestIdWithoutCollapsing() async throws { + @Test + func twoPrincipalsMayShareTheSameRequestIdWithoutCollapsing() async throws { let handler = StubTestHandler(behavior: .waitForCancellation) let dispatcher = MCPProtocolTestSupport.makeDispatcher(handlers: [handler]) let request = MCPProtocolTestSupport.makeModernRequest(id: .number(1), method: StubTestHandler.method) @@ -138,38 +145,39 @@ final class MCPProtocolDispatcherTests: XCTestCase { async let bobRun: Void = dispatcher.dispatch(bobExchange) let inflight = await waitForInflight(dispatcher, count: 2) - XCTAssertEqual(inflight, 2) + #expect(inflight == 2) let cancelledAlice = await dispatcher.cancel( requestId: .number(1), principal: alice, reason: .clientRequested("stop") ) - XCTAssertTrue(cancelledAlice) + #expect(cancelledAlice) await aliceSink.waitForCompletion() let aliceErrorValue = try await aliceSink.errorEnvelope() - let aliceError = try XCTUnwrap(aliceErrorValue) - XCTAssertEqual(aliceError.code, JsonRpcErrorCode.requestCancelled) + let aliceError = try #require(aliceErrorValue) + #expect(aliceError.code == JsonRpcErrorCode.requestCancelled) let bobWrites = await bobSink.jsonWrites.count - XCTAssertEqual(bobWrites, 0) + #expect(bobWrites == 0) let stillInflight = await dispatcher.inflightCount() - XCTAssertEqual(stillInflight, 1) + #expect(stillInflight == 1) let cancelledBob = await dispatcher.cancel( requestId: .number(1), principal: bob, reason: .clientRequested(nil) ) - XCTAssertTrue(cancelledBob) + #expect(cancelledBob) await bobSink.waitForCompletion() _ = await aliceRun _ = await bobRun } - func testAnInFlightEntryIsReleasedWhenTheHandlerReturns() async throws { + @Test + func anInFlightEntryIsReleasedWhenTheHandlerReturns() async throws { let dispatcher = MCPProtocolTestSupport.makeDispatcher(handlers: [StubTestHandler()]) let (exchange, sink) = MCPProtocolTestSupport.makeExchange( message: MCPProtocolTestSupport.makeModernRequest(method: StubTestHandler.method), @@ -180,41 +188,44 @@ final class MCPProtocolDispatcherTests: XCTestCase { await sink.waitForCompletion() let inflight = await dispatcher.inflightCount() - XCTAssertEqual(inflight, 0) + #expect(inflight == 0) } - func testAModernResultDeclaresItsTypeAndTheServerIdentity() async throws { + @Test + func aModernResultDeclaresItsTypeAndTheServerIdentity() async throws { let sink = try await dispatch( MCPProtocolTestSupport.makeModernRequest(method: StubTestHandler.method), handlers: [StubTestHandler()] ) let resultValue = try await sink.successResult() - let result = try XCTUnwrap(resultValue) - XCTAssertEqual(result["resultType"]?.stringValue, "complete") - XCTAssertEqual(result["ok"]?.boolValue, true) - XCTAssertEqual(result["_meta"]?[MCPMetaKeys.serverInfo]?["name"]?.stringValue, "tablepro") - XCTAssertNil(result["serverInfo"]) + let result = try #require(resultValue) + #expect(result["resultType"]?.stringValue == "complete") + #expect(result["ok"]?.boolValue == true) + #expect(result["_meta"]?[MCPMetaKeys.serverInfo]?["name"]?.stringValue == "tablepro") + #expect(result["serverInfo"] == nil) let status = await sink.firstJsonStatus() - XCTAssertEqual(status?.code, 200) + #expect(status?.code == 200) } - func testAModernRequestWithoutMetaIsInvalidParams() async throws { + @Test + func aModernRequestWithoutMetaIsInvalidParams() async throws { let sink = try await dispatch( MCPProtocolTestSupport.makeRequest(method: StubTestHandler.method, params: .object([:])), handlers: [StubTestHandler()] ) let errorValue = try await sink.errorEnvelope() - let error = try XCTUnwrap(errorValue) - XCTAssertEqual(error.code, JsonRpcErrorCode.invalidParams) + let error = try #require(errorValue) + #expect(error.code == JsonRpcErrorCode.invalidParams) let status = await sink.firstJsonStatus() - XCTAssertEqual(status?.code, 400) + #expect(status?.code == 400) } - func testAnUnsupportedProtocolVersionIsRefusedBeforeTheHandlerRuns() async throws { + @Test + func anUnsupportedProtocolVersionIsRefusedBeforeTheHandlerRuns() async throws { let handler = StubTestHandler() let request = MCPProtocolTestSupport.makeModernRequest( method: StubTestHandler.method, @@ -223,15 +234,16 @@ final class MCPProtocolDispatcherTests: XCTestCase { let sink = try await dispatch(request, handlers: [handler]) let errorValue = try await sink.errorEnvelope() - let error = try XCTUnwrap(errorValue) - XCTAssertEqual(error.code, JsonRpcErrorCode.unsupportedProtocolVersion) - XCTAssertEqual(error.data?["requested"]?.stringValue, "2025-03-26") + let error = try #require(errorValue) + #expect(error.code == JsonRpcErrorCode.unsupportedProtocolVersion) + #expect(error.data?["requested"]?.stringValue == "2025-03-26") let started = await handler.started.value() - XCTAssertFalse(started) + #expect(!started) } - func testALegacyClientIsRefusedAModernOnlyMethod() async throws { + @Test + func aLegacyClientIsRefusedAModernOnlyMethod() async throws { let adapter = MCPLegacyEraAdapter() let sessionId = try await establishLegacySession(adapter: adapter, principal: alice) let sink = try await dispatch( @@ -243,25 +255,27 @@ final class MCPProtocolDispatcherTests: XCTestCase { ) let errorValue = try await sink.errorEnvelope() - let error = try XCTUnwrap(errorValue) - XCTAssertEqual(error.code, JsonRpcErrorCode.methodNotFound) + let error = try #require(errorValue) + #expect(error.code == JsonRpcErrorCode.methodNotFound) let status = await sink.firstJsonStatus() - XCTAssertEqual(status?.code, 404) + #expect(status?.code == 404) } - func testAModernClientReachesAModernOnlyMethod() async throws { + @Test + func aModernClientReachesAModernOnlyMethod() async throws { let sink = try await dispatch( MCPProtocolTestSupport.makeModernRequest(method: StubModernOnlyHandler.method), handlers: [StubModernOnlyHandler()] ) let resultValue = try await sink.successResult() - let result = try XCTUnwrap(resultValue) - XCTAssertEqual(result["resultType"]?.stringValue, "complete") + let result = try #require(resultValue) + #expect(result["resultType"]?.stringValue == "complete") } - func testALegacyResultCarriesNoModernEnvelopeFields() async throws { + @Test + func aLegacyResultCarriesNoModernEnvelopeFields() async throws { let adapter = MCPLegacyEraAdapter() let sessionId = try await establishLegacySession(adapter: adapter, principal: alice) let handler = StubToolsListHandler( @@ -276,15 +290,16 @@ final class MCPProtocolDispatcherTests: XCTestCase { ) let resultValue = try await sink.successResult() - let result = try XCTUnwrap(resultValue) - XCTAssertNotNil(result["tools"]) - XCTAssertNil(result["resultType"]) - XCTAssertNil(result["ttlMs"]) - XCTAssertNil(result["cacheScope"]) - XCTAssertNil(result["_meta"]) + let result = try #require(resultValue) + #expect(result["tools"] != nil) + #expect(result["resultType"] == nil) + #expect(result["ttlMs"] == nil) + #expect(result["cacheScope"] == nil) + #expect(result["_meta"] == nil) } - func testALegacyInitializeIsAnsweredWithoutAModernEnvelope() async throws { + @Test + func aLegacyInitializeIsAnsweredWithoutAModernEnvelope() async throws { let sink = try await dispatch( MCPProtocolTestSupport.makeRequest( method: "initialize", @@ -298,13 +313,14 @@ final class MCPProtocolDispatcherTests: XCTestCase { ) let resultValue = try await sink.successResult() - let result = try XCTUnwrap(resultValue) - XCTAssertEqual(result["protocolVersion"]?.stringValue, MCPProtocolVersion.v20251125.rawValue) - XCTAssertNotNil(result["serverInfo"]) - XCTAssertNil(result["resultType"]) + let result = try #require(resultValue) + #expect(result["protocolVersion"]?.stringValue == MCPProtocolVersion.v20251125.rawValue) + #expect(result["serverInfo"] != nil) + #expect(result["resultType"] == nil) } - func testALegacyInitializeForAModernVersionIsRefused() async throws { + @Test + func aLegacyInitializeForAModernVersionIsRefused() async throws { let sink = try await dispatch( MCPProtocolTestSupport.makeRequest( method: "initialize", @@ -314,11 +330,12 @@ final class MCPProtocolDispatcherTests: XCTestCase { ) let errorValue = try await sink.errorEnvelope() - let error = try XCTUnwrap(errorValue) - XCTAssertEqual(error.code, JsonRpcErrorCode.unsupportedProtocolVersion) + let error = try #require(errorValue) + #expect(error.code == JsonRpcErrorCode.unsupportedProtocolVersion) } - func testACacheableMethodWithoutAHintIsReportedAsImmediatelyStale() async throws { + @Test + func aCacheableMethodWithoutAHintIsReportedAsImmediatelyStale() async throws { let handler = StubToolsListHandler(behavior: .returning(.complete(["tools": .array([])]))) let sink = try await dispatch( MCPProtocolTestSupport.makeModernRequest(method: StubToolsListHandler.method), @@ -326,12 +343,13 @@ final class MCPProtocolDispatcherTests: XCTestCase { ) let resultValue = try await sink.successResult() - let result = try XCTUnwrap(resultValue) - XCTAssertEqual(result["ttlMs"]?.intValue, 0) - XCTAssertEqual(result["cacheScope"]?.stringValue, "private") + let result = try #require(resultValue) + #expect(result["ttlMs"]?.intValue == 0) + #expect(result["cacheScope"]?.stringValue == "private") } - func testACacheableMethodKeepsTheHintItReturned() async throws { + @Test + func aCacheableMethodKeepsTheHintItReturned() async throws { let handler = StubToolsListHandler( behavior: .returning(.complete(["tools": .array([])], cacheHint: .publicFor(seconds: 300))) ) @@ -341,12 +359,13 @@ final class MCPProtocolDispatcherTests: XCTestCase { ) let resultValue = try await sink.successResult() - let result = try XCTUnwrap(resultValue) - XCTAssertEqual(result["ttlMs"]?.intValue, 300_000) - XCTAssertEqual(result["cacheScope"]?.stringValue, "public") + let result = try #require(resultValue) + #expect(result["ttlMs"]?.intValue == 300_000) + #expect(result["cacheScope"]?.stringValue == "public") } - func testAnUncacheableMethodNeverCarriesCacheFields() async throws { + @Test + func anUncacheableMethodNeverCarriesCacheFields() async throws { let handler = StubToolsCallHandler( behavior: .returning(.complete(["content": .array([])], cacheHint: .publicFor(seconds: 300))) ) @@ -356,13 +375,14 @@ final class MCPProtocolDispatcherTests: XCTestCase { ) let resultValue = try await sink.successResult() - let result = try XCTUnwrap(resultValue) - XCTAssertNil(result["ttlMs"]) - XCTAssertNil(result["cacheScope"]) - XCTAssertEqual(result["resultType"]?.stringValue, "complete") + let result = try #require(resultValue) + #expect(result["ttlMs"] == nil) + #expect(result["cacheScope"] == nil) + #expect(result["resultType"]?.stringValue == "complete") } - func testARetriedRequestCarryingInputResponsesIsNotCacheable() async throws { + @Test + func aRetriedRequestCarryingInputResponsesIsNotCacheable() async throws { let handler = StubResourcesReadHandler( behavior: .returning(.complete(["contents": .array([])], cacheHint: .privateFor(seconds: 60))) ) @@ -373,12 +393,13 @@ final class MCPProtocolDispatcherTests: XCTestCase { let sink = try await dispatch(request, handlers: [handler]) let resultValue = try await sink.successResult() - let result = try XCTUnwrap(resultValue) - XCTAssertEqual(result["ttlMs"]?.intValue, 0) - XCTAssertEqual(result["cacheScope"]?.stringValue, "private") + let result = try #require(resultValue) + #expect(result["ttlMs"]?.intValue == 0) + #expect(result["cacheScope"]?.stringValue == "private") } - func testAnInterimResultCarriesNoCacheHint() async throws { + @Test + func anInterimResultCarriesNoCacheHint() async throws { let interim = MCPResult(kind: .inputRequired, payload: ["inputRequests": .array([])]) let handler = StubResourcesReadHandler(behavior: .returning(interim)) let sink = try await dispatch( @@ -387,13 +408,14 @@ final class MCPProtocolDispatcherTests: XCTestCase { ) let resultValue = try await sink.successResult() - let result = try XCTUnwrap(resultValue) - XCTAssertEqual(result["resultType"]?.stringValue, "input_required") - XCTAssertNil(result["ttlMs"]) - XCTAssertNil(result["cacheScope"]) + let result = try #require(resultValue) + #expect(result["resultType"]?.stringValue == "input_required") + #expect(result["ttlMs"] == nil) + #expect(result["cacheScope"] == nil) } - func testAMethodThatMayNotAskForInputCannotReturnAnInterimResult() async throws { + @Test + func aMethodThatMayNotAskForInputCannotReturnAnInterimResult() async throws { let interim = MCPResult(kind: .inputRequired, payload: [:]) let sink = try await dispatch( MCPProtocolTestSupport.makeModernRequest(method: StubTestHandler.method), @@ -401,11 +423,12 @@ final class MCPProtocolDispatcherTests: XCTestCase { ) let errorValue = try await sink.errorEnvelope() - let error = try XCTUnwrap(errorValue) - XCTAssertEqual(error.code, JsonRpcErrorCode.internalError) + let error = try #require(errorValue) + #expect(error.code == JsonRpcErrorCode.internalError) } - func testAHandlerThatOverrunsItsDeadlineIsTimedOut() async throws { + @Test + func aHandlerThatOverrunsItsDeadlineIsTimedOut() async throws { let gate = ReleaseGate() let sink = try await dispatch( MCPProtocolTestSupport.makeModernRequest(method: StubTestHandler.method), @@ -416,12 +439,13 @@ final class MCPProtocolDispatcherTests: XCTestCase { await gate.release() let errorValue = try await sink.errorEnvelope() - let error = try XCTUnwrap(errorValue) - XCTAssertEqual(error.code, JsonRpcErrorCode.requestTimeout) - XCTAssertEqual(error.code, -33_003) + let error = try #require(errorValue) + #expect(error.code == JsonRpcErrorCode.requestTimeout) + #expect(error.code == -33_003) } - func testClosingTheStreamCancelsTheRequest() async throws { + @Test + func closingTheStreamCancelsTheRequest() async throws { let gate = ReleaseGate() let sink = try await dispatch( MCPProtocolTestSupport.makeModernRequest(method: StubTestHandler.method), @@ -433,12 +457,13 @@ final class MCPProtocolDispatcherTests: XCTestCase { await gate.release() let errorValue = try await sink.errorEnvelope() - let error = try XCTUnwrap(errorValue) - XCTAssertEqual(error.code, JsonRpcErrorCode.requestCancelled) - XCTAssertEqual(error.code, -33_002) + let error = try #require(errorValue) + #expect(error.code == JsonRpcErrorCode.requestCancelled) + #expect(error.code == -33_002) } - func testAnUnknownNotificationIsAcknowledgedAndDropped() async throws { + @Test + func anUnknownNotificationIsAcknowledgedAndDropped() async throws { let sink = try await dispatch( MCPProtocolTestSupport.makeNotification(method: "notifications/initialized"), handlers: [StubTestHandler()] @@ -446,19 +471,21 @@ final class MCPProtocolDispatcherTests: XCTestCase { let accepted = await sink.acceptedCount let writes = await sink.jsonWrites.count - XCTAssertEqual(accepted, 1) - XCTAssertEqual(writes, 0) + #expect(accepted == 1) + #expect(writes == 0) } - func testAResponseMessageIsIgnoredAndAcknowledged() async throws { + @Test + func aResponseMessageIsIgnoredAndAcknowledged() async throws { let response = JsonRpcMessage.successResponse(JsonRpcSuccessResponse(id: .number(1), result: .object([:]))) let sink = try await dispatch(response, handlers: [StubTestHandler()]) let accepted = await sink.acceptedCount - XCTAssertEqual(accepted, 1) + #expect(accepted == 1) } - func testACancellationNotificationCancelsTheMatchingRequest() async throws { + @Test + func aCancellationNotificationCancelsTheMatchingRequest() async throws { let handler = StubTestHandler(behavior: .waitForCancellation) let dispatcher = MCPProtocolTestSupport.makeDispatcher(handlers: [handler]) let request = MCPProtocolTestSupport.makeModernRequest(id: .number(99), method: StubTestHandler.method) @@ -478,15 +505,16 @@ final class MCPProtocolDispatcherTests: XCTestCase { await sink.waitForCompletion() let errorValue = try await sink.errorEnvelope() - let error = try XCTUnwrap(errorValue) - XCTAssertEqual(error.code, JsonRpcErrorCode.requestCancelled) + let error = try #require(errorValue) + #expect(error.code == JsonRpcErrorCode.requestCancelled) let accepted = await cancelSink.acceptedCount - XCTAssertEqual(accepted, 1) + #expect(accepted == 1) _ = await run } - func testACancellationFromAnotherPrincipalIsIgnored() async throws { + @Test + func aCancellationFromAnotherPrincipalIsIgnored() async throws { let handler = StubTestHandler(behavior: .waitForCancellation) let dispatcher = MCPProtocolTestSupport.makeDispatcher(handlers: [handler]) let request = MCPProtocolTestSupport.makeModernRequest(id: .number(5), method: StubTestHandler.method) @@ -505,17 +533,18 @@ final class MCPProtocolDispatcherTests: XCTestCase { await dispatcher.dispatch(cancelExchange) let stillInflight = await dispatcher.inflightCount() - XCTAssertEqual(stillInflight, 1) + #expect(stillInflight == 1) let writes = await sink.jsonWrites.count - XCTAssertEqual(writes, 0) + #expect(writes == 0) _ = await dispatcher.cancelAllInflight() await sink.waitForCompletion() _ = await run } - func testRevokingATokenCancelsItsInFlightRequests() async throws { + @Test + func revokingATokenCancelsItsInFlightRequests() async throws { let handler = StubTestHandler(behavior: .waitForCancellation) let dispatcher = MCPProtocolTestSupport.makeDispatcher(handlers: [handler]) let request = MCPProtocolTestSupport.makeModernRequest(id: .number(11), method: StubTestHandler.method) @@ -524,18 +553,19 @@ final class MCPProtocolDispatcherTests: XCTestCase { async let run: Void = dispatcher.dispatch(exchange) _ = await waitForInflight(dispatcher, count: 1) - let tokenId = try XCTUnwrap(alice.tokenId) + let tokenId = try #require(alice.tokenId) let cancelled = await dispatcher.cancelInflight(matchingTokenId: tokenId) - XCTAssertEqual(cancelled, 1) + #expect(cancelled == 1) await sink.waitForCompletion() let errorValue = try await sink.errorEnvelope() - let error = try XCTUnwrap(errorValue) - XCTAssertEqual(error.code, JsonRpcErrorCode.requestCancelled) + let error = try #require(errorValue) + #expect(error.code == JsonRpcErrorCode.requestCancelled) _ = await run } - func testAnInputRequiredSignalReachesTheClientAsAnInputRequiredResult() async throws { + @Test + func anInputRequiredSignalReachesTheClientAsAnInputRequiredResult() async throws { let signal = MCPInputRequired( inputRequests: [ MCPElicitationRequest.approval( @@ -552,13 +582,13 @@ final class MCPProtocolDispatcherTests: XCTestCase { ) let envelope = try await sink.errorEnvelope() - XCTAssertNil(envelope, "A consent question is not a failure") + #expect(envelope == nil, "A consent question is not a failure") let resultValue = try await sink.successResult() - let result = try XCTUnwrap(resultValue) - XCTAssertEqual(result["resultType"]?.stringValue, "input_required") - XCTAssertEqual(result["requestState"]?.stringValue, "sealed-state") - XCTAssertNotNil(result["inputRequests"]?["approve_connection"]) + let result = try #require(resultValue) + #expect(result["resultType"]?.stringValue == "input_required") + #expect(result["requestState"]?.stringValue == "sealed-state") + #expect(result["inputRequests"]?["approve_connection"] != nil) } private func dispatch( @@ -615,7 +645,7 @@ final class MCPProtocolDispatcherTests: XCTestCase { } } -final class MCPCancelledNotificationFuzzTests: XCTestCase { +struct MCPCancelledNotificationFuzzTests { private static let longIdentifier = String(repeating: "a", count: 8_192) private let principal = MCPProtocolTestSupport.makePrincipal(fingerprint: "fuzz") @@ -636,17 +666,17 @@ final class MCPCancelledNotificationFuzzTests: XCTestCase { "false" ] - func testAHostileRequestIdIsRejectedInsteadOfTrapping() throws { + @Test + func aHostileRequestIdIsRejectedInsteadOfTrapping() throws { for raw in Self.hostileRequestIds { let params = try MCPProtocolTestSupport.jsonValue(fromJson: "{\"requestId\": \(raw)}") - XCTAssertNil( - MCPProtocolDispatcher.cancellationRequestId(in: params), - "requestId \(raw) should not resolve to an in-flight key" - ) + #expect(MCPProtocolDispatcher.cancellationRequestId(in: params) == nil, + "requestId \(raw) should not resolve to an in-flight key") } } - func testAUsableRequestIdIsResolved() throws { + @Test + func aUsableRequestIdIsResolved() throws { let cases: [(String, JsonRpcId)] = [ ("0", .number(0)), ("99", .number(99)), @@ -658,19 +688,21 @@ final class MCPCancelledNotificationFuzzTests: XCTestCase { for (raw, expected) in cases { let params = try MCPProtocolTestSupport.jsonValue(fromJson: "{\"requestId\": \(raw)}") - XCTAssertEqual(MCPProtocolDispatcher.cancellationRequestId(in: params), expected, "requestId \(raw)") + #expect(MCPProtocolDispatcher.cancellationRequestId(in: params) == expected, "requestId \(raw)") } } - func testAMissingOrMalformedParamsObjectIsRejected() throws { - XCTAssertNil(MCPProtocolDispatcher.cancellationRequestId(in: nil)) - XCTAssertNil(MCPProtocolDispatcher.cancellationRequestId(in: .object([:]))) - XCTAssertNil(MCPProtocolDispatcher.cancellationRequestId(in: .string("nope"))) - XCTAssertNil(MCPProtocolDispatcher.cancellationRequestId(in: .array([.int(1)]))) - XCTAssertNil(MCPProtocolDispatcher.cancellationRequestId(in: .object(["requestId": .null]))) + @Test + func aMissingOrMalformedParamsObjectIsRejected() throws { + #expect(MCPProtocolDispatcher.cancellationRequestId(in: nil) == nil) + #expect(MCPProtocolDispatcher.cancellationRequestId(in: .object([:])) == nil) + #expect(MCPProtocolDispatcher.cancellationRequestId(in: .string("nope")) == nil) + #expect(MCPProtocolDispatcher.cancellationRequestId(in: .array([.int(1)])) == nil) + #expect(MCPProtocolDispatcher.cancellationRequestId(in: .object(["requestId": .null])) == nil) } - func testHostileCancellationsAreAcknowledgedAndLeaveTheRequestRunning() async throws { + @Test + func hostileCancellationsAreAcknowledgedAndLeaveTheRequestRunning() async throws { let handler = StubTestHandler(behavior: .waitForCancellation) let dispatcher = MCPProtocolTestSupport.makeDispatcher(handlers: [handler]) let request = MCPProtocolTestSupport.makeModernRequest( @@ -685,38 +717,39 @@ final class MCPCancelledNotificationFuzzTests: XCTestCase { for raw in Self.hostileRequestIds { let params = try MCPProtocolTestSupport.jsonValue(fromJson: "{\"requestId\": \(raw)}") let acknowledged = await sendCancellation(params: params, to: dispatcher) - XCTAssertEqual(acknowledged, 1, "cancellation with requestId \(raw) was not acknowledged") + #expect(acknowledged == 1, "cancellation with requestId \(raw) was not acknowledged") let inflight = await dispatcher.inflightCount() - XCTAssertEqual(inflight, 1, "cancellation with requestId \(raw) disturbed an unrelated request") + #expect(inflight == 1, "cancellation with requestId \(raw) disturbed an unrelated request") } let unmatched = await sendCancellation( params: .object(["requestId": .string("some-other-request")]), to: dispatcher ) - XCTAssertEqual(unmatched, 1) + #expect(unmatched == 1) let writes = await sink.jsonWrites.count - XCTAssertEqual(writes, 0) + #expect(writes == 0) let acknowledged = await sendCancellation( params: .object(["requestId": .string(Self.longIdentifier), "reason": .string("user stopped")]), to: dispatcher ) - XCTAssertEqual(acknowledged, 1) + #expect(acknowledged == 1) await sink.waitForCompletion() let errorValue = try await sink.errorEnvelope() - let error = try XCTUnwrap(errorValue) - XCTAssertEqual(error.code, JsonRpcErrorCode.requestCancelled) + let error = try #require(errorValue) + #expect(error.code == JsonRpcErrorCode.requestCancelled) let remaining = await dispatcher.inflightCount() - XCTAssertEqual(remaining, 0) + #expect(remaining == 0) _ = await run } - func testACancellationWithoutAPrincipalIsDroppedButStillAcknowledged() async throws { + @Test + func aCancellationWithoutAPrincipalIsDroppedButStillAcknowledged() async throws { let dispatcher = MCPProtocolTestSupport.makeDispatcher(handlers: [StubTestHandler()]) let (exchange, sink) = MCPProtocolTestSupport.makeExchange( message: MCPProtocolTestSupport.makeNotification( @@ -729,7 +762,7 @@ final class MCPCancelledNotificationFuzzTests: XCTestCase { await dispatcher.dispatch(exchange) let accepted = await sink.acceptedCount - XCTAssertEqual(accepted, 1) + #expect(accepted == 1) } private func sendCancellation(params: JsonValue, to dispatcher: MCPProtocolDispatcher) async -> Int { diff --git a/TableProTests/Core/MCP/Transport/MCPHttpKeepAliveTests.swift b/TableProTests/Core/MCP/Transport/MCPHttpKeepAliveTests.swift index 1e38f92a6f..7aeef63dd5 100644 --- a/TableProTests/Core/MCP/Transport/MCPHttpKeepAliveTests.swift +++ b/TableProTests/Core/MCP/Transport/MCPHttpKeepAliveTests.swift @@ -4,7 +4,6 @@ import TableProPluginKit @testable import TablePro import Testing -@Suite("MCP HTTP Keep-Alive", .serialized) struct MCPHttpKeepAliveTests { private func shortIdleLimits(_ timeout: Duration) -> MCPHttpServerLimits { MCPHttpServerLimits( diff --git a/TableProTests/Core/MCP/Transport/MCPHttpServerConfigurationTests.swift b/TableProTests/Core/MCP/Transport/MCPHttpServerConfigurationTests.swift index 1dc0ed33bc..c52c986381 100644 --- a/TableProTests/Core/MCP/Transport/MCPHttpServerConfigurationTests.swift +++ b/TableProTests/Core/MCP/Transport/MCPHttpServerConfigurationTests.swift @@ -4,7 +4,6 @@ import TableProPluginKit @testable import TablePro import Testing -@Suite("MCP HTTP Server Configuration", .serialized) struct MCPHttpServerConfigurationTests { @Test("The loopback factory carries the port and the standard limits") func loopbackFactory() { diff --git a/TableProTests/Core/MCP/Transport/MCPHttpServerTransportTests.swift b/TableProTests/Core/MCP/Transport/MCPHttpServerTransportTests.swift index 1e41615554..ba5e69f9c3 100644 --- a/TableProTests/Core/MCP/Transport/MCPHttpServerTransportTests.swift +++ b/TableProTests/Core/MCP/Transport/MCPHttpServerTransportTests.swift @@ -4,7 +4,6 @@ import TableProPluginKit @testable import TablePro import Testing -@Suite("MCP HTTP Server Transport", .serialized) struct MCPHttpServerTransportTests { private func exchange(port: UInt16, request: Data) async throws -> RawHttpTestResponse { let client = RawHttpTestClient(port: port) diff --git a/TableProTests/Core/Menu/MainMenuBuilderTests.swift b/TableProTests/Core/Menu/MainMenuBuilderTests.swift index d62001686e..7077c273d1 100644 --- a/TableProTests/Core/Menu/MainMenuBuilderTests.swift +++ b/TableProTests/Core/Menu/MainMenuBuilderTests.swift @@ -1146,3 +1146,57 @@ struct DatabaseMenuCommandTests { == #selector(MainSplitViewController.retryConnection)) } } + +@MainActor +struct MainMenuBuilderTests { + private func modeItems(in menu: NSMenu) throws -> [NSMenuItem] { + let view = try #require(menu.items.first { $0.title == String(localized: "View") }?.submenu) + let mode = try #require(view.items.first { $0.title == String(localized: "Mode") }?.submenu) + return mode.items.filter { $0.action == ContentModeMenuDelegate.action } + } + + /// A radio pair, never both ticked. With no connection window nothing validates the pair, so a + /// fresh menu bar ticks neither; a window that validates it ticks the mode its connection is in. + @Test("View > Mode never ticks both Browse and Agent") + func modePairIsNeverBothTicked() throws { + let items = try modeItems(in: buildMenu()) + #expect(items.map(\.title) == ConnectionWorkspaceContentMode.allCases.map(\.localizedTitle)) + #expect(items.allSatisfy { $0.state == .off }) + + let connection = TestFixtures.makeConnection(name: "Mode pair") + for mode in ConnectionWorkspaceContentMode.allCases { + let workspace = ConnectionWorkspace( + connectionId: connection.id, + payload: nil, + autoConnect: false, + payloadConnection: connection, + session: nil, + sessionState: nil, + trailingPaneState: nil, + phase: .connecting + ) + workspace.contentMode = mode + let controller = MainSplitViewController(payload: nil, sessionState: nil, adopting: workspace) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 1_000, height: 600), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.contentViewController = controller + defer { + window.contentViewController = nil + workspace.teardown() + } + + for item in items { + _ = controller.validateMenuItem(item) + } + + let ticked = items.filter { $0.state == .on } + #expect(ticked.count == 1, "\(mode)") + #expect(ticked.first?.representedObject as? String == workspace.resolvedContentMode.rawValue, "\(mode)") + } + } +} diff --git a/TableProTests/Core/ObjectCopy/ObjectCopyConcurrentReadRefusalTests.swift b/TableProTests/Core/ObjectCopy/ObjectCopyConcurrentReadRefusalTests.swift index f84bb3fbea..0fa0ea1c65 100644 --- a/TableProTests/Core/ObjectCopy/ObjectCopyConcurrentReadRefusalTests.swift +++ b/TableProTests/Core/ObjectCopy/ObjectCopyConcurrentReadRefusalTests.swift @@ -3,11 +3,12 @@ // TableProTests // +import Foundation @testable import TablePro -import XCTest +import Testing @MainActor -final class ObjectCopyConcurrentReadRefusalTests: XCTestCase { +final class ObjectCopyConcurrentReadRefusalTests { private let connection = UUID() private func endpoint(database: String, schema: String?) -> DatabaseEndpoint { @@ -24,36 +25,37 @@ final class ObjectCopyConcurrentReadRefusalTests: XCTestCase { ObjectCopyPlanner(manager: .shared).concurrentReadRefusal(source: source, target: target) } - func testACopyBetweenTwoSchemasOfOneConnectionIsRefusedInCopyWording() throws { - let refusal = try XCTUnwrap(copyRefusal( + @Test + func aCopyBetweenTwoSchemasOfOneConnectionIsRefusedInCopyWording() throws { + let refusal = try #require(copyRefusal( source: endpoint(database: "shop", schema: "main"), target: endpoint(database: "shop", schema: "archive") )) - XCTAssertFalse(refusal.localizedCaseInsensitiveContains("compare")) - XCTAssertTrue(refusal.contains("schemas")) + #expect(!refusal.localizedCaseInsensitiveContains("compare")) + #expect(refusal.contains("schemas")) } - func testACopyBetweenTwoDatabasesOfOneConnectionIsRefusedInCopyWording() throws { - let refusal = try XCTUnwrap(copyRefusal( + @Test + func aCopyBetweenTwoDatabasesOfOneConnectionIsRefusedInCopyWording() throws { + let refusal = try #require(copyRefusal( source: endpoint(database: "shop", schema: nil), target: endpoint(database: "shop_archive", schema: nil) )) - XCTAssertFalse(refusal.localizedCaseInsensitiveContains("compare")) - XCTAssertTrue(refusal.contains("databases")) + #expect(!refusal.localizedCaseInsensitiveContains("compare")) + #expect(refusal.contains("databases")) } - func testACopyOntoItsOwnScopeIsRefusedInCopyWording() { + @Test + func aCopyOntoItsOwnScopeIsRefusedInCopyWording() { let scope = endpoint(database: "shop", schema: "main") - XCTAssertEqual( - copyRefusal(source: scope, target: scope), - String(localized: "The source and the target are the same database. Choose a different target.") - ) + #expect(copyRefusal(source: scope, target: scope) == String(localized: "The source and the target are the same database. Choose a different target.")) } - func testTwoConnectionsAreNeverRefused() { + @Test + func twoConnectionsAreNeverRefused() { let refusal = copyRefusal( source: endpoint(database: "shop", schema: "main"), target: DatabaseEndpoint( @@ -65,6 +67,6 @@ final class ObjectCopyConcurrentReadRefusalTests: XCTestCase { ) ) - XCTAssertNil(refusal) + #expect(refusal == nil) } } diff --git a/TableProTests/Core/ObjectCopy/ObjectCopyEligibilityTests.swift b/TableProTests/Core/ObjectCopy/ObjectCopyEligibilityTests.swift index d09c1096b6..377c2c42be 100644 --- a/TableProTests/Core/ObjectCopy/ObjectCopyEligibilityTests.swift +++ b/TableProTests/Core/ObjectCopy/ObjectCopyEligibilityTests.swift @@ -5,11 +5,12 @@ // Every refusal a copy can make before it opens a driver. // +import Foundation @testable import TablePro import TableProPluginKit -import XCTest +import Testing -final class ObjectCopyEligibilityTests: XCTestCase { +struct ObjectCopyEligibilityTests { private func endpoint( _ database: String, type: DatabaseType = .mysql, @@ -26,146 +27,159 @@ final class ObjectCopyEligibilityTests: XCTestCase { ) } - func testAReadOnlyTargetIsRefused() { - XCTAssertNotNil(ObjectCopyEligibility.targetRefusal(endpoint("app", safeMode: .readOnly))) - XCTAssertNil(ObjectCopyEligibility.targetRefusal(endpoint("app"))) + @Test + func aReadOnlyTargetIsRefused() { + #expect(ObjectCopyEligibility.targetRefusal(endpoint("app", safeMode: .readOnly)) != nil) + #expect(ObjectCopyEligibility.targetRefusal(endpoint("app")) == nil) } /// A copy stays inside SQL and may cross engines within it. The types are translated before /// the target driver writes any DDL, so the refusal that is left is the one nothing can /// translate: an engine whose query language is not SQL parses neither `CREATE TABLE` nor /// `INSERT … VALUES`. - func testACopyCrossesEnginesButNotLanguages() { - XCTAssertNil(ObjectCopyEligibility.engineRefusal( + @Test + func aCopyCrossesEnginesButNotLanguages() { + #expect(ObjectCopyEligibility.engineRefusal( from: .mysql, to: .postgresql, sourceLanguage: .sql, targetLanguage: .sql - )) - XCTAssertNil(ObjectCopyEligibility.engineRefusal( + ) == nil) + #expect(ObjectCopyEligibility.engineRefusal( from: .mssql, to: .mysql, sourceLanguage: .sql, targetLanguage: .sql - )) - XCTAssertNil(ObjectCopyEligibility.engineRefusal( + ) == nil) + #expect(ObjectCopyEligibility.engineRefusal( from: .mysql, to: .mariadb, sourceLanguage: .sql, targetLanguage: .sql - )) - XCTAssertNotNil(ObjectCopyEligibility.engineRefusal( + ) == nil) + #expect(ObjectCopyEligibility.engineRefusal( from: .mysql, to: .mongodb, sourceLanguage: .sql, targetLanguage: .javascript - )) - XCTAssertNotNil(ObjectCopyEligibility.engineRefusal( + ) != nil) + #expect(ObjectCopyEligibility.engineRefusal( from: .mongodb, to: .mysql, sourceLanguage: .javascript, targetLanguage: .sql - )) + ) != nil) } /// DynamoDB declares `.sql` for PartiQL and Cassandra for CQL, so the editor language alone /// let a MySQL to DynamoDB copy through to a planner that could only fail. A crossing needs a /// type system the translator knows on both sides. - func testACrossingNeedsATypeSystemOnBothSides() { - XCTAssertNotNil(ObjectCopyEligibility.engineRefusal( + @Test + func aCrossingNeedsATypeSystemOnBothSides() { + #expect(ObjectCopyEligibility.engineRefusal( from: .mysql, to: .dynamodb, sourceLanguage: .sql, targetLanguage: .sql - )) - XCTAssertNotNil(ObjectCopyEligibility.engineRefusal( + ) != nil) + #expect(ObjectCopyEligibility.engineRefusal( from: .cassandra, to: .postgresql, sourceLanguage: .sql, targetLanguage: .sql - )) + ) != nil) } /// Nothing is translated within one engine, so an engine no family names still copies to /// itself. That is the path every registry-only driver already used. - func testAnEngineWithNoNamedFamilyStillCopiesToItself() { - XCTAssertNil(ObjectCopyEligibility.engineRefusal( + @Test + func anEngineWithNoNamedFamilyStillCopiesToItself() { + #expect(ObjectCopyEligibility.engineRefusal( from: .dynamodb, to: .dynamodb, sourceLanguage: .sql, targetLanguage: .sql - )) - XCTAssertNil(ObjectCopyEligibility.engineRefusal( + ) == nil) + #expect(ObjectCopyEligibility.engineRefusal( from: .cassandra, to: .cassandra, sourceLanguage: .sql, targetLanguage: .sql - )) + ) == nil) } /// A view, routine or trigger is its definition, and the definition is the source engine's own /// SQL text. Nothing here parses it, so it cannot cross even where the tables beside it can. - func testADefinitionDoesNotCrossEngines() { - XCTAssertNotNil(ObjectCopyEligibility.definitionEngineRefusal(from: .mysql, to: .postgresql)) - XCTAssertNil(ObjectCopyEligibility.definitionEngineRefusal(from: .mysql, to: .mariadb)) - XCTAssertNil(ObjectCopyEligibility.definitionEngineRefusal(from: .postgresql, to: .postgresql)) + @Test + func aDefinitionDoesNotCrossEngines() { + #expect(ObjectCopyEligibility.definitionEngineRefusal(from: .mysql, to: .postgresql) != nil) + #expect(ObjectCopyEligibility.definitionEngineRefusal(from: .mysql, to: .mariadb) == nil) + #expect(ObjectCopyEligibility.definitionEngineRefusal(from: .postgresql, to: .postgresql) == nil) } /// Copying a database onto itself either drops the rows it is about to read or doubles them. - func testTheSameScopeIsRefusedAsATarget() { + @Test + func theSameScopeIsRefusedAsATarget() { let connectionId = UUID() let source = endpoint("app", connectionId: connectionId) let same = endpoint("app", connectionId: connectionId) let other = endpoint("app_copy", connectionId: connectionId) - XCTAssertNotNil(ObjectCopyEligibility.sameObjectRefusal(source: source, target: same)) - XCTAssertNil(ObjectCopyEligibility.sameObjectRefusal(source: source, target: other)) + #expect(ObjectCopyEligibility.sameObjectRefusal(source: source, target: same) != nil) + #expect(ObjectCopyEligibility.sameObjectRefusal(source: source, target: other) == nil) } /// Two databases on one server are a valid pair, which is the case the endpoint type exists for. - func testTwoDatabasesOnOneConnectionAreAValidPair() { + @Test + func twoDatabasesOnOneConnectionAreAValidPair() { let connectionId = UUID() - XCTAssertNil(ObjectCopyEligibility.sameObjectRefusal( + #expect(ObjectCopyEligibility.sameObjectRefusal( source: endpoint("prod", connectionId: connectionId), target: endpoint("staging", connectionId: connectionId) - )) + ) == nil) } /// Right-clicking a PostgreSQL database gives a source with no schema, and choosing that same /// database's `public` as the target used to pass every refusal because the two endpoint ids /// differ. The planner then resolved both sides to `public` and dropped each table before /// streaming from the table it had just emptied. - func testAScopedTargetInsideAnUnscopedSourceIsRefused() { + @Test + func aScopedTargetInsideAnUnscopedSourceIsRefused() { let connectionId = UUID() let wholeDatabase = endpoint("app", type: .postgresql, connectionId: connectionId) let oneSchema = endpoint("app", type: .postgresql, schema: "public", connectionId: connectionId) - XCTAssertNotNil(ObjectCopyEligibility.sameObjectRefusal(source: wholeDatabase, target: oneSchema)) - XCTAssertNotNil(ObjectCopyEligibility.sameObjectRefusal(source: oneSchema, target: wholeDatabase)) + #expect(ObjectCopyEligibility.sameObjectRefusal(source: wholeDatabase, target: oneSchema) != nil) + #expect(ObjectCopyEligibility.sameObjectRefusal(source: oneSchema, target: wholeDatabase) != nil) } - func testTheSameSchemaIsRefusedWhateverItsCase() { + @Test + func theSameSchemaIsRefusedWhateverItsCase() { let connectionId = UUID() - XCTAssertNotNil(ObjectCopyEligibility.sameObjectRefusal( + #expect(ObjectCopyEligibility.sameObjectRefusal( source: endpoint("app", type: .postgresql, schema: "Sales", connectionId: connectionId), target: endpoint("app", type: .postgresql, schema: "sales", connectionId: connectionId) - )) + ) != nil) } /// The case the refusal must not swallow: two schemas of one database are a valid pair, and /// copying between them is the whole point of a schema-scoped endpoint. - func testTwoSchemasOfOneDatabaseAreAValidPair() { + @Test + func twoSchemasOfOneDatabaseAreAValidPair() { let connectionId = UUID() - XCTAssertNil(ObjectCopyEligibility.sameObjectRefusal( + #expect(ObjectCopyEligibility.sameObjectRefusal( source: endpoint("app", type: .postgresql, schema: "sales", connectionId: connectionId), target: endpoint("app", type: .postgresql, schema: "archive", connectionId: connectionId) - )) - XCTAssertNil(ObjectCopyEligibility.sameObjectRefusal( + ) == nil) + #expect(ObjectCopyEligibility.sameObjectRefusal( source: endpoint("app", type: .postgresql, schema: "public", connectionId: connectionId), target: endpoint("app_copy", type: .postgresql, schema: "public", connectionId: connectionId) - )) + ) == nil) } /// An empty schema is how a scope with none is spelled, so it has to read as absent rather than /// as a schema literally named "". - func testAnEmptySchemaCountsAsNoSchema() { + @Test + func anEmptySchemaCountsAsNoSchema() { let connectionId = UUID() - XCTAssertNotNil(ObjectCopyEligibility.sameObjectRefusal( + #expect(ObjectCopyEligibility.sameObjectRefusal( source: endpoint("app", type: .postgresql, schema: "", connectionId: connectionId), target: endpoint("app", type: .postgresql, schema: "public", connectionId: connectionId) - )) + ) != nil) } - func testOnlySQLEnginesCanCopy() { - XCTAssertTrue(ObjectCopyEligibility.supportsCopying(editorLanguage: .sql)) - XCTAssertFalse(ObjectCopyEligibility.supportsCopying(editorLanguage: .javascript)) - XCTAssertFalse(ObjectCopyEligibility.supportsCopying(editorLanguage: .custom("mql"))) + @Test + func onlySQLEnginesCanCopy() { + #expect(ObjectCopyEligibility.supportsCopying(editorLanguage: .sql)) + #expect(!ObjectCopyEligibility.supportsCopying(editorLanguage: .javascript)) + #expect(!(ObjectCopyEligibility.supportsCopying(editorLanguage: .custom("mql")))) } - func testDuplicateIsOnlyOfferedWhereDatabasesExistAndCanBeWritten() { - XCTAssertTrue(ObjectCopyEligibility.mayOfferDuplicateDatabase( + @Test + func duplicateIsOnlyOfferedWhereDatabasesExistAndCanBeWritten() { + #expect(ObjectCopyEligibility.mayOfferDuplicateDatabase( editorLanguage: .sql, supportsDatabaseSwitching: true, isReadOnly: false )) - XCTAssertFalse(ObjectCopyEligibility.mayOfferDuplicateDatabase( + #expect(!ObjectCopyEligibility.mayOfferDuplicateDatabase( editorLanguage: .sql, supportsDatabaseSwitching: false, isReadOnly: false )) - XCTAssertFalse(ObjectCopyEligibility.mayOfferDuplicateDatabase( + #expect(!ObjectCopyEligibility.mayOfferDuplicateDatabase( editorLanguage: .sql, supportsDatabaseSwitching: true, isReadOnly: true )) - XCTAssertFalse(ObjectCopyEligibility.mayOfferDuplicateDatabase( + #expect(!ObjectCopyEligibility.mayOfferDuplicateDatabase( editorLanguage: .javascript, supportsDatabaseSwitching: true, isReadOnly: false )) } @@ -175,33 +189,30 @@ final class ObjectCopyEligibilityTests: XCTestCase { /// The name an engine qualifies its objects with, which is not always the selected schema. /// MySQL has no schemas and reports the database in the schema column, so its foreign keys and /// routines come back qualified by the database name. - func testTheNamespaceIsTheSchemaWhereSchemasExist() { - XCTAssertEqual( - ObjectCopyNamespace.name( + @Test + func theNamespaceIsTheSchemaWhereSchemasExist() { + #expect(ObjectCopyNamespace.name( for: endpoint("app", type: .postgresql, schema: "sales"), supportsSchemas: true, supportsDatabases: true - ), - "sales" - ) + ) == "sales") } - func testTheNamespaceIsTheDatabaseWhereSchemasDoNot() { - XCTAssertEqual( - ObjectCopyNamespace.name( + @Test + func theNamespaceIsTheDatabaseWhereSchemasDoNot() { + #expect(ObjectCopyNamespace.name( for: endpoint("shop"), supportsSchemas: false, supportsDatabases: true - ), - "shop" - ) + ) == "shop") } /// SQLite has one unnamed container, so nothing is qualified and two files compare equal. - func testAnEngineWithNeitherHasNoNamespace() { - XCTAssertNil(ObjectCopyNamespace.name( + @Test + func anEngineWithNeitherHasNoNamespace() { + #expect(ObjectCopyNamespace.name( for: endpoint("chinook.sqlite", type: .sqlite), supportsSchemas: false, supportsDatabases: false - )) + ) == nil) } // MARK: - Definitions @@ -209,18 +220,19 @@ final class ObjectCopyEligibilityTests: XCTestCase { /// Nothing parses the definition, so every object it names keeps the source's qualification. /// A duplicate keeps the schema name, which is why one is copyable; two MySQL databases are /// two namespaces, which is why one is not. - func testADefinitionOnlyCopiesWithinOneNamespace() { - XCTAssertTrue(ObjectCopyEligibility.canCopyDefinition( + @Test + func aDefinitionOnlyCopiesWithinOneNamespace() { + #expect(ObjectCopyEligibility.canCopyDefinition( sourceNamespace: "public", targetNamespace: "public" )) - XCTAssertTrue(ObjectCopyEligibility.canCopyDefinition( + #expect(ObjectCopyEligibility.canCopyDefinition( sourceNamespace: "Public", targetNamespace: "public" )) - XCTAssertTrue(ObjectCopyEligibility.canCopyDefinition(sourceNamespace: nil, targetNamespace: nil)) - XCTAssertFalse(ObjectCopyEligibility.canCopyDefinition( + #expect(ObjectCopyEligibility.canCopyDefinition(sourceNamespace: nil, targetNamespace: nil)) + #expect(!ObjectCopyEligibility.canCopyDefinition( sourceNamespace: "shop", targetNamespace: "shop_copy" )) - XCTAssertFalse(ObjectCopyEligibility.canCopyDefinition( + #expect(!ObjectCopyEligibility.canCopyDefinition( sourceNamespace: "sales", targetNamespace: nil )) } @@ -228,15 +240,13 @@ final class ObjectCopyEligibilityTests: XCTestCase { /// ClickHouse, Oracle, Dameng and BigQuery answer with the view's SELECT rather than its /// CREATE. Running that is a read the runner would report as the view copied, after Replace /// had already dropped the target's. - func testABareBodyIsNotAnExecutableDefinition() { + @Test + func aBareBodyIsNotAnExecutableDefinition() { let postgres = SQLScriptText(databaseType: .postgresql) - XCTAssertNil(SourceDefinitionDefect.of(definition: "CREATE VIEW v AS SELECT 1", sentAs: postgres)) - XCTAssertNil(SourceDefinitionDefect.of(definition: "\n create or replace view v AS SELECT 1", sentAs: postgres)) - XCTAssertEqual( - SourceDefinitionDefect.of(definition: "SELECT id, name FROM orders", sentAs: postgres), - .notACreateStatement - ) - XCTAssertEqual(SourceDefinitionDefect.of(definition: " ", sentAs: postgres), .empty) + #expect(SourceDefinitionDefect.of(definition: "CREATE VIEW v AS SELECT 1", sentAs: postgres) == nil) + #expect(SourceDefinitionDefect.of(definition: "\n create or replace view v AS SELECT 1", sentAs: postgres) == nil) + #expect(SourceDefinitionDefect.of(definition: "SELECT id, name FROM orders", sentAs: postgres) == .notACreateStatement) + #expect(SourceDefinitionDefect.of(definition: " ", sentAs: postgres) == .empty) } } diff --git a/TableProTests/Core/ObjectCopy/ObjectCopyPlanTests.swift b/TableProTests/Core/ObjectCopy/ObjectCopyPlanTests.swift index 33c37e4656..1e0292577f 100644 --- a/TableProTests/Core/ObjectCopy/ObjectCopyPlanTests.swift +++ b/TableProTests/Core/ObjectCopy/ObjectCopyPlanTests.swift @@ -6,11 +6,12 @@ // emptying a table and refilling it have to agree. // +import Foundation @testable import TablePro import TableProPluginKit -import XCTest +import Testing -final class ObjectCopyPlanTests: XCTestCase { +struct ObjectCopyPlanTests { private func endpoint( _ database: String, schema: String? = nil, @@ -87,76 +88,82 @@ final class ObjectCopyPlanTests: XCTestCase { /// The defect this suite exists for. A table with no writable column in common is not in /// `dataSteps`, so clearing it deleted every row the target had and wrote nothing back, while /// the review said only that the two sides shared no writable column. - func testATableThatCopiesNoRowsIsNeverEmptied() { + @Test + func aTableThatCopiesNoRowsIsNeverEmptied() { let copied = step("orders") let unwritable = step("legacy_audit", copiesData: false) let cleared = plan([copied, unwritable]).clearGroups.map(\.selection.name) - XCTAssertEqual(cleared, ["orders"]) + #expect(cleared == ["orders"]) } /// Children first, so the first parent DELETE does not meet child rows a cascading key would /// take out of a table the user never selected. - func testTablesAreEmptiedChildrenFirst() { + @Test + func tablesAreEmptiedChildrenFirst() { let cleared = plan([step("customers"), step("orders")]).clearGroups.map(\.selection.name) - XCTAssertEqual(cleared, ["orders", "customers"]) + #expect(cleared == ["orders", "customers"]) } /// Emptying a table is reversible only while the transaction that emptied it is still open, so /// a run that promises a rollback cannot put its DELETEs in a phase of their own. - func testAPromisedRollbackKeepsTheClearsInsideTheDataTransaction() { - XCTAssertTrue(plan([step("orders")]).clearsInsideDataTransaction) + @Test + func aPromisedRollbackKeepsTheClearsInsideTheDataTransaction() { + #expect(plan([step("orders")]).clearsInsideDataTransaction) } - func testARunThatPromisesNoRollbackClearsAhead() { - XCTAssertFalse(plan( + @Test + func aRunThatPromisesNoRollbackClearsAhead() { + #expect(!(plan( [step("orders")], request: request(errorHandling: .skipAndContinue) - ).clearsInsideDataTransaction) - XCTAssertFalse(plan( + ).clearsInsideDataTransaction)) + #expect(!(plan( [step("orders")], request: request(wrapEachTableInTransaction: false) - ).clearsInsideDataTransaction) + ).clearsInsideDataTransaction)) } /// Nothing to empty means nothing to promise, and the per-table transactions stay. - func testACopyWithNothingToEmptyKeepsItsPerTableTransactions() { - XCTAssertFalse(plan([step("orders", truncates: false)]).clearsInsideDataTransaction) + @Test + func aCopyWithNothingToEmptyKeepsItsPerTableTransactions() { + #expect(!(plan([step("orders", truncates: false)]).clearsInsideDataTransaction)) } /// The script is what the user approves, so it has to be in the order the run uses. Shown /// against each table instead, it said the parent was emptied after the child had been filled. - func testTheScriptEmptiesEveryTableBeforeItReadsAny() { + @Test + func theScriptEmptiesEveryTableBeforeItReadsAny() { let script = plan([step("customers"), step("orders")]).scriptText let lines = script.components(separatedBy: "\n").filter { !$0.isEmpty } guard let lastDelete = lines.lastIndex(where: { $0.hasPrefix("DELETE FROM") }), let firstSelect = lines.firstIndex(where: { $0.hasPrefix("SELECT") }) else { - return XCTFail("the script named neither the clears nor the reads:\n\(script)") + Issue.record("the script named neither the clears nor the reads:\n\(script)") + return } - XCTAssertLessThan(lastDelete, firstSelect) + #expect(lastDelete < firstSelect) /// Children first there too, or the script and the run disagree about the cascade. - XCTAssertEqual( - lines.filter { $0.hasPrefix("DELETE FROM") }, - ["DELETE FROM orders;", "DELETE FROM customers;"] - ) + #expect(lines.filter { $0.hasPrefix("DELETE FROM") } == ["DELETE FROM orders;", "DELETE FROM customers;"]) } /// A new database carries only whatever schema its engine gives it, so the rest are created /// before the first `CREATE TABLE` names one. - func testSchemaStatementsLeadTheScriptAndTheDDL() { + @Test + func schemaStatementsLeadTheScriptAndTheDDL() { let create = statement("CREATE SCHEMA IF NOT EXISTS \"sales\"", "sales") let built = plan([step("orders", truncates: false)], schemaStatements: [create]) - XCTAssertEqual(built.ddlStatements.first?.sql, create.sql) - XCTAssertTrue(built.scriptText.hasPrefix(create.sql + ";\n")) + #expect(built.ddlStatements.first?.sql == create.sql) + #expect(built.scriptText.hasPrefix(create.sql + ";\n")) } /// What the review shows is a script for the target's own client, so an Oracle unit is followed /// by the `/` line SQL*Plus needs to run it, while the statement the runner sends stays bare. - func testTheScriptEndsEachStatementTheWayTheTargetsClientDoes() { + @Test + func theScriptEndsEachStatementTheWayTheTargetsClientDoes() { let oracle = DatabaseEndpoint( scope: DatabaseScope(connectionId: UUID(), database: "APP", schema: nil), connectionName: "server", @@ -181,14 +188,15 @@ final class ObjectCopyPlanTests: XCTestCase { schemaStatements: [statement("CREATE TABLE t (a NUMBER)", "t"), statement(unit, "p")] ) - XCTAssertEqual(copy.scriptText, "CREATE TABLE t (a NUMBER);\n\(unit)\n/") - XCTAssertEqual(copy.ddlStatements.map(\.sql), ["CREATE TABLE t (a NUMBER)", unit]) + #expect(copy.scriptText == "CREATE TABLE t (a NUMBER);\n\(unit)\n/") + #expect(copy.ddlStatements.map(\.sql) == ["CREATE TABLE t (a NUMBER)", unit]) } /// One object fails once per phase it reaches, so a table whose CREATE failed under Skip and /// Continue fails again in the data phase. Listed by the selection's id a SwiftUI `ForEach` saw /// a duplicate identifier and dropped one of the two messages. - func testEveryFailureKeepsAnIdentityOfItsOwn() { + @Test + func everyFailureKeepsAnIdentityOfItsOwn() { let selection = ObjectCopySelection(kind: .table, name: "orders", schema: "public") let result = ObjectCopyRunResult( outcomes: [ @@ -201,14 +209,15 @@ final class ObjectCopyPlanTests: XCTestCase { ) let failures = result.failures - XCTAssertEqual(failures.count, 2) - XCTAssertEqual(Set(failures.map(\.id)).count, 2) - XCTAssertEqual(failures.map(\.outcome.error), ["syntax error", "does not exist"]) + #expect(failures.count == 2) + #expect(Set(failures.map(\.id)).count == 2) + #expect(failures.map(\.outcome.error) == ["syntax error", "does not exist"]) /// The summary still counts the object once, which is the reason the ids had to collide. - XCTAssertEqual(result.failedCount, 1) + #expect(result.failedCount == 1) } - func testPartlyCopiedListsViewsAsWellAsTables() { + @Test + func partlyCopiedListsViewsAsWellAsTables() { let table = ObjectCopyTableStep( selection: ObjectCopySelection(kind: .table, name: "orders", schema: "public"), dropStatements: [], @@ -244,7 +253,7 @@ final class ObjectCopyPlanTests: XCTestCase { definitionSteps: [view, quiet] ) - XCTAssertEqual(copy.partialNotes.map(\.selection.name), ["orders", "totals"]) - XCTAssertEqual(copy.partialNotes.last?.text, SourceObjectIndexes.notCarriedByTargetNote) + #expect(copy.partialNotes.map(\.selection.name) == ["orders", "totals"]) + #expect(copy.partialNotes.last?.text == SourceObjectIndexes.notCarriedByTargetNote) } } diff --git a/TableProTests/Core/ObjectCopy/ObjectCopyPlannerOrderingTests.swift b/TableProTests/Core/ObjectCopy/ObjectCopyPlannerOrderingTests.swift index 8315ef8a3e..83e1162625 100644 --- a/TableProTests/Core/ObjectCopy/ObjectCopyPlannerOrderingTests.swift +++ b/TableProTests/Core/ObjectCopy/ObjectCopyPlannerOrderingTests.swift @@ -6,11 +6,12 @@ // table that has to exist already, and a view selects from a table. // +import Foundation @testable import TablePro import TableProPluginKit -import XCTest +import Testing -final class ObjectCopyPlannerOrderingTests: XCTestCase { +struct ObjectCopyPlannerOrderingTests { private func selection( _ name: String, kind: CompareObjectKind = .table, @@ -47,7 +48,8 @@ final class ObjectCopyPlannerOrderingTests: XCTestCase { ) } - func testAParentIsCreatedBeforeItsChild() { + @Test + func aParentIsCreatedBeforeItsChild() { let orders = selection("orders") let customers = selection("customers") let reads: [ObjectCopySelection: TableStructureRead] = [ @@ -59,13 +61,14 @@ final class ObjectCopyPlannerOrderingTests: XCTestCase { [orders, customers], reads: reads, effectiveSchema: "public" ) - XCTAssertEqual(ordered.map(\.name), ["customers", "orders"]) + #expect(ordered.map(\.name) == ["customers", "orders"]) } /// The regression this guards: `fetchTables` reports no schema while the foreign key reports /// one, so a node keyed `orders` never matched the dependency `public.customers`. Every edge /// vanished and the sort fell through to alphabetical order, which puts the child first. - func testTheEffectiveSchemaIsUsedWhenTheTableReportsNone() { + @Test + func theEffectiveSchemaIsUsedWhenTheTableReportsNone() { let orders = selection("orders", schema: nil) let customers = selection("customers", schema: nil) let reads: [ObjectCopySelection: TableStructureRead] = [ @@ -77,15 +80,14 @@ final class ObjectCopyPlannerOrderingTests: XCTestCase { [orders, customers], reads: reads, effectiveSchema: "public" ) - XCTAssertEqual( - ordered.map(\.name), ["customers", "orders"], - "the edge has to survive the schema the table itself does not report" - ) + #expect(ordered.map(\.name) == ["customers", "orders"], + "the edge has to survive the schema the table itself does not report") } /// A cycle cannot be ordered, and dropping one of its tables would be worse than letting the /// server refuse the second CREATE, so every input still comes back exactly once. - func testACycleStillReturnsEveryTableOnce() { + @Test + func aCycleStillReturnsEveryTableOnce() { let first = selection("a") let second = selection("b") let reads: [ObjectCopySelection: TableStructureRead] = [ @@ -97,21 +99,20 @@ final class ObjectCopyPlannerOrderingTests: XCTestCase { [first, second], reads: reads, effectiveSchema: "public" ) - XCTAssertEqual(Set(ordered.map(\.name)), ["a", "b"]) - XCTAssertEqual(ordered.count, 2) + #expect(Set(ordered.map(\.name)) == ["a", "b"]) + #expect(ordered.count == 2) } - func testASingleTableIsReturnedUnchanged() { + @Test + func aSingleTableIsReturnedUnchanged() { let only = selection("orders") - XCTAssertEqual( - ObjectCopyPlanner.orderedByDependency( + #expect(ObjectCopyPlanner.orderedByDependency( [only], reads: [only: read("orders")], effectiveSchema: "public" - ).map(\.name), - ["orders"] - ) + ).map(\.name) == ["orders"]) } - func testViewsRunBeforeRoutinesAndTriggersRunLast() { + @Test + func viewsRunBeforeRoutinesAndTriggersRunLast() { let ordered = ObjectCopyPlanner.orderedByKind([ selection("audit_trigger", kind: .trigger), selection("total_sales", kind: .procedure), @@ -119,65 +120,53 @@ final class ObjectCopyPlannerOrderingTests: XCTestCase { selection("tax_rate", kind: .function) ]) - XCTAssertEqual(ordered.map(\.name), ["active_users", "tax_rate", "total_sales", "audit_trigger"]) + #expect(ordered.map(\.name) == ["active_users", "tax_rate", "total_sales", "audit_trigger"]) } - func testTwoObjectsOfOneKindKeepTheirGivenOrder() { + @Test + func twoObjectsOfOneKindKeepTheirGivenOrder() { let ordered = ObjectCopyPlanner.orderedByKind([ selection("second", kind: .view), selection("first", kind: .view) ]) - XCTAssertEqual(ordered.map(\.name), ["second", "first"]) + #expect(ordered.map(\.name) == ["second", "first"]) } // MARK: - Matching what the target already has /// Engines fold identifier case differently, and a target that already has the object has to /// be recognised whichever way it spells it, or Skip does not skip and Replace does not drop. - func testAnExistingObjectMatchesWithoutRegardToCase() { - XCTAssertEqual( - ObjectCopyPlanner.objectKey(for: selection("Active_Users", kind: .view)), - ObjectCopyPlanner.objectKey(for: selection("active_users", kind: .view)) - ) + @Test + func anExistingObjectMatchesWithoutRegardToCase() { + #expect(ObjectCopyPlanner.objectKey(for: selection("Active_Users", kind: .view)) == ObjectCopyPlanner.objectKey(for: selection("active_users", kind: .view))) } /// A table and a trigger may share a name, and only the trigger's own presence decides the /// trigger's step. - func testTwoKindsSharingANameAreDifferentObjects() { - XCTAssertNotEqual( - ObjectCopyPlanner.objectKey(for: selection("audit", kind: .table)), - ObjectCopyPlanner.objectKey(for: selection("audit", kind: .trigger, owner: "orders")) - ) + @Test + func twoKindsSharingANameAreDifferentObjects() { + #expect(ObjectCopyPlanner.objectKey(for: selection("audit", kind: .table)) != ObjectCopyPlanner.objectKey(for: selection("audit", kind: .trigger, owner: "orders"))) } /// A materialized view occupies a view's name, and a function occupies a procedure's on /// several engines, so each pair is one object for the purpose of "is it already there". - func testViewsAndRoutinesFoldIntoOneFamilyEach() { - XCTAssertEqual( - ObjectCopyPlanner.objectKey(for: selection("sales", kind: .view)), - ObjectCopyPlanner.objectKey(for: selection("sales", kind: .materializedView)) - ) - XCTAssertEqual( - ObjectCopyPlanner.objectKey(for: selection("total", kind: .procedure)), - ObjectCopyPlanner.objectKey(for: selection("total", kind: .function)) - ) + @Test + func viewsAndRoutinesFoldIntoOneFamilyEach() { + #expect(ObjectCopyPlanner.objectKey(for: selection("sales", kind: .view)) == ObjectCopyPlanner.objectKey(for: selection("sales", kind: .materializedView))) + #expect(ObjectCopyPlanner.objectKey(for: selection("total", kind: .procedure)) == ObjectCopyPlanner.objectKey(for: selection("total", kind: .function))) } /// `f(integer)` and `f(text)` are two routines, and copying one must not be taken for the /// other already being there. - func testTwoOverloadsAreTwoObjects() { - XCTAssertNotEqual( - ObjectCopyPlanner.objectKey(for: selection("f", kind: .function, signature: "(integer)")), - ObjectCopyPlanner.objectKey(for: selection("f", kind: .function, signature: "(text)")) - ) + @Test + func twoOverloadsAreTwoObjects() { + #expect(ObjectCopyPlanner.objectKey(for: selection("f", kind: .function, signature: "(integer)")) != ObjectCopyPlanner.objectKey(for: selection("f", kind: .function, signature: "(text)"))) } - func testTwoTriggersOnDifferentTablesAreTwoObjects() { - XCTAssertNotEqual( - ObjectCopyPlanner.objectKey(for: selection("audit", kind: .trigger, owner: "orders")), - ObjectCopyPlanner.objectKey(for: selection("audit", kind: .trigger, owner: "customers")) - ) + @Test + func twoTriggersOnDifferentTablesAreTwoObjects() { + #expect(ObjectCopyPlanner.objectKey(for: selection("audit", kind: .trigger, owner: "orders")) != ObjectCopyPlanner.objectKey(for: selection("audit", kind: .trigger, owner: "customers"))) } // MARK: - Namespace scopes @@ -206,38 +195,39 @@ final class ObjectCopyPlannerOrderingTests: XCTestCase { /// A database-level copy on PostgreSQL spans every schema, and each has to be read and written /// in its own scope: one read against a nil schema answers only whatever the connection is on. - func testObjectsAreGroupedByTheSchemaTheyWereFoundIn() { + @Test + func objectsAreGroupedByTheSchemaTheyWereFoundIn() { let scopes = ObjectCopyPlanner.scopes(of: request([ selection("orders", schema: "sales"), selection("audit", schema: "logging"), selection("customers", schema: "sales") ])) - XCTAssertEqual(scopes.map(\.namespace), ["sales", "logging"]) - XCTAssertEqual(scopes.first?.objects.map(\.name), ["orders", "customers"]) + #expect(scopes.map(\.namespace) == ["sales", "logging"]) + #expect(scopes.first?.objects.map(\.name) == ["orders", "customers"]) } /// A duplicate keeps every schema name, so each schema's objects land in a schema of the same /// name in the new database. A copy to a chosen target puts them all in the schema chosen. - func testADuplicateKeepsEachSchemaNameAndACopyDoesNot() { + @Test + func aDuplicateKeepsEachSchemaNameAndACopyDoesNot() { let objects = [selection("orders", schema: "sales")] let duplicate = ObjectCopyPlanner.scopes(of: request(objects, duplicates: true)) let copy = ObjectCopyPlanner.scopes(of: request(objects)) - XCTAssertEqual( - duplicate.first?.targetNamespace(for: request(objects, duplicates: true)), "sales" - ) - XCTAssertEqual(copy.first?.targetNamespace(for: request(objects)), "archive") + #expect(duplicate.first?.targetNamespace(for: request(objects, duplicates: true)) == "sales") + #expect(copy.first?.targetNamespace(for: request(objects)) == "archive") } - func testAnEngineWithoutSchemasIsOneScope() { + @Test + func anEngineWithoutSchemasIsOneScope() { let scopes = ObjectCopyPlanner.scopes(of: request([ selection("orders", schema: nil), selection("customers", schema: nil) ])) - XCTAssertEqual(scopes.count, 1) - XCTAssertNil(scopes.first?.namespace) + #expect(scopes.count == 1) + #expect(scopes.first?.namespace == nil) } // MARK: - Retargeting foreign keys @@ -262,46 +252,51 @@ final class ObjectCopyPlannerOrderingTests: XCTestCase { /// Left as it was, the copied child kept referencing the source's parent, so `prod_copy.orders` /// stayed wired to `prod.customers` and the duplicate was never independent of its original. - func testAForeignKeyIntoTheSourceIsMovedToTheTarget() { + @Test + func aForeignKeyIntoTheSourceIsMovedToTheTarget() { let moved = ObjectCopyPlanner.retargeted( snapshot(referencedSchema: "sales"), from: "sales", to: "archive", schema: "archive" ) - XCTAssertEqual(moved.schema, "archive") - XCTAssertEqual(moved.foreignKeys.first?.referencedSchema, "archive") + #expect(moved.schema == "archive") + #expect(moved.foreignKeys.first?.referencedSchema == "archive") } /// A reference that names neither side's schema points at something the copy never touched. - func testAForeignKeyIntoAThirdSchemaIsLeftAlone() { + @Test + func aForeignKeyIntoAThirdSchemaIsLeftAlone() { let moved = ObjectCopyPlanner.retargeted( snapshot(referencedSchema: "reference"), from: "sales", to: "archive", schema: "archive" ) - XCTAssertEqual(moved.foreignKeys.first?.referencedSchema, "reference") + #expect(moved.foreignKeys.first?.referencedSchema == "reference") } /// An unqualified reference means "my own schema", so it follows the table into the target. - func testAnUnqualifiedForeignKeyFollowsTheTable() { + @Test + func anUnqualifiedForeignKeyFollowsTheTable() { let moved = ObjectCopyPlanner.retargeted( snapshot(referencedSchema: nil), from: "sales", to: "archive", schema: "archive" ) - XCTAssertEqual(moved.foreignKeys.first?.referencedSchema, "archive") + #expect(moved.foreignKeys.first?.referencedSchema == "archive") } - func testASchemaThatDoesNotChangeLeavesTheSnapshotAlone() { + @Test + func aSchemaThatDoesNotChangeLeavesTheSnapshotAlone() { let moved = ObjectCopyPlanner.retargeted( snapshot(referencedSchema: "sales"), from: "sales", to: "sales", schema: "sales" ) - XCTAssertEqual(moved.foreignKeys.first?.referencedSchema, "sales") + #expect(moved.foreignKeys.first?.referencedSchema == "sales") } /// A table the sort could not place is appended in the caller's own order, which is why the /// caller has to hand one in. Seeded from `reads.keys` the tail came out in whatever order /// Swift's per-process hash seed gave the dictionary that launch, so the same copy produced a /// different approved script, progress order and outcome list from one run to the next. - func testTablesTheSortCannotPlaceFollowTheOrderTheyWereGivenIn() { + @Test + func tablesTheSortCannotPlaceFollowTheOrderTheyWereGivenIn() { let placed = selection("customers") let names = ["zulu", "alpha", "mike"] let unread = names.map { selection($0) } @@ -311,12 +306,13 @@ final class ObjectCopyPlannerOrderingTests: XCTestCase { [placed] + unread, reads: reads, effectiveSchema: "public" ) - XCTAssertEqual(ordered.map(\.name), ["customers"] + names) + #expect(ordered.map(\.name) == ["customers"] + names) } /// The same selections in the same order answer the same way every time, whatever order the /// reads were built in. - func testTheOrderDoesNotDependOnHowTheReadsWereBuilt() { + @Test + func theOrderDoesNotDependOnHowTheReadsWereBuilt() { let names = ["zulu", "alpha", "mike", "bravo", "yankee"] let selections = names.map { selection($0) } var forwards: [ObjectCopySelection: TableStructureRead] = [:] @@ -331,13 +327,14 @@ final class ObjectCopyPlannerOrderingTests: XCTestCase { selections, reads: backwards, effectiveSchema: "public" ) - XCTAssertEqual(first.map(\.name), second.map(\.name)) - XCTAssertEqual(Set(first.map(\.name)), Set(names)) - XCTAssertEqual(first.count, names.count) + #expect(first.map(\.name) == second.map(\.name)) + #expect(Set(first.map(\.name)) == Set(names)) + #expect(first.count == names.count) } /// The tie-break holds while a real dependency still moves the tables it names. - func testTheGivenOrderYieldsToAForeignKey() { + @Test + func theGivenOrderYieldsToAForeignKey() { let orders = selection("orders") let customers = selection("customers") let audit = selection("audit") @@ -353,9 +350,10 @@ final class ObjectCopyPlannerOrderingTests: XCTestCase { guard let parent = ordered.firstIndex(of: customers), let child = ordered.firstIndex(of: orders) else { - return XCTFail("the sort dropped a table") + Issue.record("the sort dropped a table") + return } - XCTAssertLessThan(parent, child) - XCTAssertEqual(ordered.count, 3) + #expect(parent < child) + #expect(ordered.count == 3) } } diff --git a/TableProTests/Core/ObjectCopy/ObjectCopyRowCopierTests.swift b/TableProTests/Core/ObjectCopy/ObjectCopyRowCopierTests.swift index 52e26c223e..c0fe77d9b5 100644 --- a/TableProTests/Core/ObjectCopy/ObjectCopyRowCopierTests.swift +++ b/TableProTests/Core/ObjectCopy/ObjectCopyRowCopierTests.swift @@ -7,11 +7,12 @@ // ceiling, and the parameters arrive in the order the placeholders expect. // +import Foundation @testable import TablePro import TableProPluginKit -import XCTest +import Testing -private final class CopyDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class CopyDriver: PluginDriverStub, @unchecked Sendable { var streamed: [[PluginCellValue]] = [] var batchSize = 2 var executedQueries: [String] = [] @@ -22,9 +23,6 @@ private final class CopyDriver: PluginDatabaseDriver, @unchecked Sendable { var parameterStyle: ParameterStyle { declaredParameterStyle } - func connect() async throws {} - func disconnect() {} - func execute(query: String) async throws -> PluginQueryResult { executedQueries.append(query) return PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) @@ -56,23 +54,9 @@ private final class CopyDriver: PluginDatabaseDriver, @unchecked Sendable { func beginTransaction() async throws { transactionEvents.append("begin") } func commitTransaction() async throws { transactionEvents.append("commit") } func rollbackTransaction() async throws { transactionEvents.append("rollback") } - - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } -final class ObjectCopyRowCopierTests: XCTestCase { +struct ObjectCopyRowCopierTests { private func step( columns: [String] = ["id", "name"], schema: String? = "public", @@ -104,7 +88,8 @@ final class ObjectCopyRowCopierTests: XCTestCase { (0.. 1, "one mebibyte holds two of these rows") + #expect(target.executedParameters.flatMap { $0 }.count == 12) for batch in target.executedParameters { - XCTAssertLessThanOrEqual( - SQLWriteBatchBudget(maxRows: 1_000) - .byteCount(of: batch.map { PluginCellValue.text($0.asText ?? "") }), - SQLWriteBatchBudget.maximumBytes + 400_012, - "no batch may exceed the budget by more than the one row that cannot be split" - ) + #expect(SQLWriteBatchBudget(maxRows: 1_000) + .byteCount(of: batch.map { PluginCellValue.text($0.asText ?? "") }) <= SQLWriteBatchBudget.maximumBytes + 400_012, + "no batch may exceed the budget by more than the one row that cannot be split") } } /// A row over the budget on its own cannot be split, so it goes out alone rather than being /// dropped or looping forever. - func testARowOverTheBudgetIsStillWritten() async throws { + @Test + func aRowOverTheBudgetIsStillWritten() async throws { let source = CopyDriver() source.streamed = [[.text("1"), .text(String(repeating: "a", count: 2_000_000))]] source.batchSize = 1 @@ -252,15 +237,16 @@ final class ObjectCopyRowCopierTests: XCTestCase { let outcome = try await ObjectCopyRowCopier(step: step(), targetDatabaseType: .mysql) .copy(from: source, to: target) { _ in } - XCTAssertEqual(outcome.inserted, 1) - XCTAssertEqual(target.executedQueries.count, 1) + #expect(outcome.inserted == 1) + #expect(target.executedQueries.count == 1) } // MARK: - Crossing engines /// A `t` from PostgreSQL bound into a MySQL `TINYINT(1)` is 0 outside strict mode, so every /// `true` in the table would arrive as `false` with nothing reported. - func testValuesAreReshapedForTheTarget() async throws { + @Test + func valuesAreReshapedForTheTarget() async throws { let source = CopyDriver() source.streamed = [[.text("1"), .text("t")]] let target = CopyDriver() @@ -276,13 +262,14 @@ final class ObjectCopyRowCopierTests: XCTestCase { step: step(columns: ["id", "live"], coercer: coercer), targetDatabaseType: .mysql ).copy(from: source, to: target) { _ in } - XCTAssertEqual(target.executedParameters.first?.map(\.sortKey), ["1", "1"]) + #expect(target.executedParameters.first?.map(\.sortKey) == ["1", "1"]) } // MARK: - The server-side path /// One statement instead of a stream, and the source driver is never read from at all. - func testAServerSideCopyRunsOneStatementAndReadsNothing() async throws { + @Test + func aServerSideCopyRunsOneStatementAndReadsNothing() async throws { let source = CopyDriver() source.streamed = rows(5) let target = CopyDriver() @@ -296,16 +283,17 @@ final class ObjectCopyRowCopierTests: XCTestCase { step: step(serverSideInsert: statement, estimatedRows: 5), targetDatabaseType: .mysql ).copy(from: source, to: target) { _ in } - XCTAssertEqual(target.executedQueries, [statement.sql]) - XCTAssertTrue(target.executedParameters.isEmpty) - XCTAssertFalse(outcome.cancelled) + #expect(target.executedQueries == [statement.sql]) + #expect(target.executedParameters.isEmpty) + #expect(!outcome.cancelled) } /// The server's own count and never the plan's estimate. `rowsAffected` cannot tell "the driver /// reported nothing" from "the statement matched nothing", so standing the estimate in for zero /// told the user a copy of an empty table had written however many rows a stale table statistic /// had guessed at. - func testTheServerCountIsReportedRatherThanTheEstimate() async throws { + @Test + func theServerCountIsReportedRatherThanTheEstimate() async throws { let outcome = try await ObjectCopyRowCopier( step: step( serverSideInsert: SyncStatement(sql: "INSERT INTO x SELECT 1;", objectName: "x", summary: ""), @@ -314,7 +302,7 @@ final class ObjectCopyRowCopierTests: XCTestCase { targetDatabaseType: .mysql ).copy(from: CopyDriver(), to: CopyDriver()) { _ in } - XCTAssertEqual(outcome.inserted, 0) + #expect(outcome.inserted == 0) } } diff --git a/TableProTests/Core/ObjectCopy/ObjectCopySelectQueryTests.swift b/TableProTests/Core/ObjectCopy/ObjectCopySelectQueryTests.swift index a98f7b837f..cc7171c8d6 100644 --- a/TableProTests/Core/ObjectCopy/ObjectCopySelectQueryTests.swift +++ b/TableProTests/Core/ObjectCopy/ObjectCopySelectQueryTests.swift @@ -6,19 +6,18 @@ // write them, so the stream and the statement cannot drift apart. // +import Foundation @testable import TablePro import TableProPluginKit -import XCTest +import Testing -private final class QuotingDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class QuotingDriver: PluginDriverStub, @unchecked Sendable { private let injectsRowLimit: Bool init(injectsRowLimit: Bool = true) { self.injectsRowLimit = injectsRowLimit } - func connect() async throws {} - func disconnect() {} func execute(query: String) async throws -> PluginQueryResult { PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) } @@ -26,202 +25,155 @@ private final class QuotingDriver: PluginDatabaseDriver, @unchecked Sendable { func injectRowLimit(_ query: String, limit: Int) -> String? { injectsRowLimit ? "\(query) LIMIT \(limit)" : nil } - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } -final class ObjectCopySelectQueryTests: XCTestCase { +struct ObjectCopySelectQueryTests { private let driver = QuotingDriver() - func testColumnsAreNamedAndQuotedInOrder() { - XCTAssertEqual( - ObjectCopySelectQuery.build( + @Test + func columnsAreNamedAndQuotedInOrder() { + #expect(ObjectCopySelectQuery.build( columns: ["id", "total"], table: "orders", schema: "public", driver: driver, databaseType: .postgresql - ), - "SELECT \"id\", \"total\" FROM \"public\".\"orders\"" - ) + ) == "SELECT \"id\", \"total\" FROM \"public\".\"orders\"") } - func testAnUnqualifiedTableKeepsThePlainName() { - XCTAssertEqual( - ObjectCopySelectQuery.build( + @Test + func anUnqualifiedTableKeepsThePlainName() { + #expect(ObjectCopySelectQuery.build( columns: ["id"], table: "orders", schema: nil, driver: driver, databaseType: .postgresql - ), - "SELECT \"id\" FROM \"orders\"" - ) + ) == "SELECT \"id\" FROM \"orders\"") } - func testAnEmptySchemaIsTreatedAsNoSchema() { - XCTAssertEqual( - ObjectCopySelectQuery.build( + @Test + func anEmptySchemaIsTreatedAsNoSchema() { + #expect(ObjectCopySelectQuery.build( columns: ["id"], table: "orders", schema: "", driver: driver, databaseType: .postgresql - ), - "SELECT \"id\" FROM \"orders\"" - ) + ) == "SELECT \"id\" FROM \"orders\"") } - func testTheEngineImplicitSchemaIsLeftUnqualified() { - XCTAssertEqual( - ObjectCopySelectQuery.build( + @Test + func theEngineImplicitSchemaIsLeftUnqualified() { + #expect(ObjectCopySelectQuery.build( columns: ["id"], table: "orders", schema: "(default)", driver: driver, databaseType: .spanner - ), - "SELECT \"id\" FROM \"orders\"" - ) - XCTAssertEqual( - ObjectCopySelectQuery.build( + ) == "SELECT \"id\" FROM \"orders\"") + #expect(ObjectCopySelectQuery.build( columns: ["id"], table: "orders", schema: "sales", driver: driver, databaseType: .spanner - ), - "SELECT \"id\" FROM \"sales\".\"orders\"" - ) + ) == "SELECT \"id\" FROM \"sales\".\"orders\"") } /// A structure-only step has no columns, and a star select is the honest fallback rather than /// a `SELECT FROM`. - func testNoColumnsFallsBackToStar() { - XCTAssertEqual( - ObjectCopySelectQuery.build( + @Test + func noColumnsFallsBackToStar() { + #expect(ObjectCopySelectQuery.build( columns: [], table: "orders", schema: nil, driver: driver, databaseType: .postgresql - ), - "SELECT * FROM \"orders\"" - ) + ) == "SELECT * FROM \"orders\"") } // MARK: - Row scope - func testAFilterBecomesAWhereClause() { - XCTAssertEqual( - ObjectCopySelectQuery.build( + @Test + func aFilterBecomesAWhereClause() { + #expect(ObjectCopySelectQuery.build( columns: ["id"], table: "orders", schema: nil, driver: driver, databaseType: .postgresql, scope: PluginExportRowScope(filter: "total > 10") - ), - "SELECT \"id\" FROM \"orders\" WHERE total > 10" - ) + ) == "SELECT \"id\" FROM \"orders\" WHERE total > 10") } /// Through the driver's own injection, because `LIMIT` is not the spelling on SQL Server or on /// Oracle before 12c. - func testARowLimitGoesThroughTheDriver() { - XCTAssertEqual( - ObjectCopySelectQuery.build( + @Test + func aRowLimitGoesThroughTheDriver() { + #expect(ObjectCopySelectQuery.build( columns: ["id"], table: "orders", schema: nil, driver: driver, databaseType: .postgresql, scope: PluginExportRowScope(filter: "total > 10", rowLimit: 50) - ), - "SELECT \"id\" FROM \"orders\" WHERE total > 10 LIMIT 50" - ) + ) == "SELECT \"id\" FROM \"orders\" WHERE total > 10 LIMIT 50") } - func testARowLimitTheDriverCannotInjectIsSpelledForSQLServer() { - XCTAssertEqual( - ObjectCopySelectQuery.build( + @Test + func aRowLimitTheDriverCannotInjectIsSpelledForSQLServer() { + #expect(ObjectCopySelectQuery.build( columns: ["id"], table: "orders", schema: "dbo", driver: QuotingDriver(injectsRowLimit: false), databaseType: .mssql, scope: PluginExportRowScope(filter: "total > 10", rowLimit: 10) - ), - "SELECT TOP 10 \"id\" FROM \"dbo\".\"orders\" WHERE total > 10" - ) + ) == "SELECT TOP 10 \"id\" FROM \"dbo\".\"orders\" WHERE total > 10") } - func testARowLimitTheDriverCannotInjectIsSpelledForOracle() { - XCTAssertEqual( - ObjectCopySelectQuery.build( + @Test + func aRowLimitTheDriverCannotInjectIsSpelledForOracle() { + #expect(ObjectCopySelectQuery.build( columns: ["ID"], table: "ORDERS", schema: "HR", driver: QuotingDriver(injectsRowLimit: false), databaseType: .oracle, scope: PluginExportRowScope(filter: "TOTAL > 10", rowLimit: 10) - ), - "SELECT \"ID\" FROM \"HR\".\"ORDERS\" WHERE TOTAL > 10 FETCH FIRST 10 ROWS ONLY" - ) + ) == "SELECT \"ID\" FROM \"HR\".\"ORDERS\" WHERE TOTAL > 10 FETCH FIRST 10 ROWS ONLY") } - func testARowLimitTheDriverCannotInjectIsSpelledForDamengWithoutSorting() { - XCTAssertEqual( - ObjectCopySelectQuery.build( + @Test + func aRowLimitTheDriverCannotInjectIsSpelledForDamengWithoutSorting() { + #expect(ObjectCopySelectQuery.build( columns: ["DOC"], table: "ORDERS", schema: "SYSDBA", driver: QuotingDriver(injectsRowLimit: false), databaseType: .dameng, scope: PluginExportRowScope(rowLimit: 10) - ), - "SELECT \"DOC\" FROM \"SYSDBA\".\"ORDERS\" FETCH FIRST 10 ROWS ONLY" - ) + ) == "SELECT \"DOC\" FROM \"SYSDBA\".\"ORDERS\" FETCH FIRST 10 ROWS ONLY") } - func testARowLimitTheDriverCannotInjectStaysLimitOnTrino() { - XCTAssertEqual( - ObjectCopySelectQuery.build( + @Test + func aRowLimitTheDriverCannotInjectStaysLimitOnTrino() { + #expect(ObjectCopySelectQuery.build( columns: ["id"], table: "orders", schema: "sales", driver: QuotingDriver(injectsRowLimit: false), databaseType: .trino, scope: PluginExportRowScope(rowLimit: 10) - ), - "SELECT \"id\" FROM \"sales\".\"orders\" LIMIT 10" - ) + ) == "SELECT \"id\" FROM \"sales\".\"orders\" LIMIT 10") } - func testARowLimitTheDriverCannotInjectIsSpelledForTeradata() { - XCTAssertEqual( - ObjectCopySelectQuery.build( + @Test + func aRowLimitTheDriverCannotInjectIsSpelledForTeradata() { + #expect(ObjectCopySelectQuery.build( columns: ["id"], table: "orders", schema: "sales", driver: QuotingDriver(injectsRowLimit: false), databaseType: .teradata, scope: PluginExportRowScope(rowLimit: 10) - ), - "SELECT TOP 10 \"id\" FROM \"sales\".\"orders\"" - ) + ) == "SELECT TOP 10 \"id\" FROM \"sales\".\"orders\"") } - func testARowLimitTheDriverCannotInjectStaysLimitOnMySQL() { - XCTAssertEqual( - ObjectCopySelectQuery.build( + @Test + func aRowLimitTheDriverCannotInjectStaysLimitOnMySQL() { + #expect(ObjectCopySelectQuery.build( columns: ["id"], table: "orders", schema: nil, driver: QuotingDriver(injectsRowLimit: false), databaseType: .mysql, scope: PluginExportRowScope(filter: "total > 10", rowLimit: 10) - ), - "SELECT \"id\" FROM \"orders\" WHERE total > 10 LIMIT 10" - ) + ) == "SELECT \"id\" FROM \"orders\" WHERE total > 10 LIMIT 10") } /// The text is spliced into this statement, so the rule that a filter is one expression is what /// stops a second statement riding in with it. - func testAFilterCarryingASecondStatementIsRefused() { - XCTAssertEqual( - ObjectCopySelectQuery.build( + @Test + func aFilterCarryingASecondStatementIsRefused() { + #expect(ObjectCopySelectQuery.build( columns: ["id"], table: "orders", schema: nil, driver: driver, databaseType: .postgresql, scope: PluginExportRowScope(filter: "1=1; DROP TABLE orders") - ), - "SELECT \"id\" FROM \"orders\"" - ) + ) == "SELECT \"id\" FROM \"orders\"") } - func testATrailingSemicolonIsATypingHabitRatherThanARefusal() { - XCTAssertEqual( - ObjectCopySelectQuery.build( + @Test + func aTrailingSemicolonIsATypingHabitRatherThanARefusal() { + #expect(ObjectCopySelectQuery.build( columns: ["id"], table: "orders", schema: nil, driver: driver, databaseType: .postgresql, scope: PluginExportRowScope(filter: "total > 10;") - ), - "SELECT \"id\" FROM \"orders\" WHERE total > 10" - ) + ) == "SELECT \"id\" FROM \"orders\" WHERE total > 10") } // MARK: - Estimates /// The driver counts the whole table, so a filtered step would show a bar running to a total it /// can never reach. - func testAFilteredTableReportsNoEstimate() { - XCTAssertNil(ObjectCopyPlanner.estimate( + @Test + func aFilteredTableReportsNoEstimate() { + #expect(ObjectCopyPlanner.estimate( 5_000, scope: PluginExportRowScope(filter: "total > 10") - )) - XCTAssertEqual( - ObjectCopyPlanner.estimate(5_000, scope: PluginExportRowScope(filter: "total > 10", rowLimit: 20)), - 20 - ) - } - - func testARowLimitIsACeilingOnTheEstimate() { - XCTAssertEqual(ObjectCopyPlanner.estimate(5_000, scope: PluginExportRowScope(rowLimit: 20)), 20) - XCTAssertEqual(ObjectCopyPlanner.estimate(10, scope: PluginExportRowScope(rowLimit: 20)), 10) - XCTAssertEqual(ObjectCopyPlanner.estimate(nil, scope: PluginExportRowScope(rowLimit: 20)), 20) - XCTAssertEqual(ObjectCopyPlanner.estimate(5_000, scope: nil), 5_000) + ) == nil) + #expect(ObjectCopyPlanner.estimate(5_000, scope: PluginExportRowScope(filter: "total > 10", rowLimit: 20)) == 20) + } + + @Test + func aRowLimitIsACeilingOnTheEstimate() { + #expect(ObjectCopyPlanner.estimate(5_000, scope: PluginExportRowScope(rowLimit: 20)) == 20) + #expect(ObjectCopyPlanner.estimate(10, scope: PluginExportRowScope(rowLimit: 20)) == 10) + #expect(ObjectCopyPlanner.estimate(nil, scope: PluginExportRowScope(rowLimit: 20)) == 20) + #expect(ObjectCopyPlanner.estimate(5_000, scope: nil) == 5_000) } } diff --git a/TableProTests/Core/ObjectCopy/ObjectCopySequenceStatementTests.swift b/TableProTests/Core/ObjectCopy/ObjectCopySequenceStatementTests.swift index f62d2df19d..a2fb029fba 100644 --- a/TableProTests/Core/ObjectCopy/ObjectCopySequenceStatementTests.swift +++ b/TableProTests/Core/ObjectCopy/ObjectCopySequenceStatementTests.swift @@ -6,14 +6,16 @@ // before the CREATE TABLE that references it runs. // +import Foundation @testable import TablePro -import XCTest +import Testing -final class ObjectCopySequenceStatementTests: XCTestCase { +struct ObjectCopySequenceStatementTests { /// The driver returns the create and the reposition as one block, and the runner sends one /// statement per `execute`, so the block has to arrive already split, each piece as the driver /// takes it. - func testAMultiStatementSequenceDDLBecomesOneStatementEach() { + @Test + func aMultiStatementSequenceDDLBecomesOneStatementEach() { let statements = ObjectCopyPlanner.sequenceStatements( [ "CREATE SEQUENCE \"orders_id_seq\" INCREMENT BY 1 MINVALUE 1 MAXVALUE 100 START WITH 1", @@ -21,7 +23,7 @@ final class ObjectCopySequenceStatementTests: XCTestCase { ], table: "orders" ) - XCTAssertEqual(statements.map(\.sql), [ + #expect(statements.map(\.sql) == [ "CREATE SEQUENCE \"orders_id_seq\" INCREMENT BY 1 MINVALUE 1 MAXVALUE 100 START WITH 1", "SELECT pg_catalog.setval('\"orders_id_seq\"', 42, true)" ]) @@ -29,30 +31,31 @@ final class ObjectCopySequenceStatementTests: XCTestCase { /// A separator is the script's to write, never the statement's, so none is added to what goes /// to the driver. - func testASequenceStatementGetsNoSeparator() { - XCTAssertEqual( - ObjectCopyPlanner.sequenceStatements(["CREATE SEQUENCE \"s\""], table: "orders") - .map(\.sql), - ["CREATE SEQUENCE \"s\""] - ) + @Test + func aSequenceStatementGetsNoSeparator() { + #expect(ObjectCopyPlanner.sequenceStatements(["CREATE SEQUENCE \"s\""], table: "orders") + .map(\.sql) == ["CREATE SEQUENCE \"s\""]) } /// Named after the table, because the progress list and the outcome report are both grouped by /// the object the user selected. - func testTheStatementIsAttributedToTheTableThatNeedsIt() { + @Test + func theStatementIsAttributedToTheTableThatNeedsIt() { let statement = ObjectCopyPlanner.sequenceStatements( ["CREATE SEQUENCE \"s\""], table: "orders" ).first - XCTAssertEqual(statement?.objectName, "orders") + #expect(statement?.objectName == "orders") } - func testATableWithNoSequencesProducesNothing() { - XCTAssertTrue(ObjectCopyPlanner.sequenceStatements([], table: "orders").isEmpty) + @Test + func aTableWithNoSequencesProducesNothing() { + #expect(ObjectCopyPlanner.sequenceStatements([], table: "orders").isEmpty) } /// Ahead of the CREATE TABLE whose default references them, and behind the DROP that a /// replacement runs first. - func testSequencesRunBeforeTheTableTheyBelongTo() { + @Test + func sequencesRunBeforeTheTableTheyBelongTo() { let step = ObjectCopyTableStep( selection: ObjectCopySelection(kind: .table, name: "orders", schema: "public"), dropStatements: [SyncStatement(sql: "DROP TABLE \"orders\"", objectName: "orders", summary: "")], @@ -71,7 +74,7 @@ final class ObjectCopySequenceStatementTests: XCTestCase { copiesIdentityColumn: false, note: nil ) - XCTAssertEqual(step.ddl.map(\.sql), [ + #expect(step.ddl.map(\.sql) == [ "DROP TABLE \"orders\"", "CREATE SEQUENCE \"orders_id_seq\"", "CREATE TABLE \"orders\"" diff --git a/TableProTests/Core/ObjectCopy/ObjectCopyServerSideInsertTests.swift b/TableProTests/Core/ObjectCopy/ObjectCopyServerSideInsertTests.swift index 57d53fca7c..f27b9ec396 100644 --- a/TableProTests/Core/ObjectCopy/ObjectCopyServerSideInsertTests.swift +++ b/TableProTests/Core/ObjectCopy/ObjectCopyServerSideInsertTests.swift @@ -3,22 +3,20 @@ // TableProTests // +import Foundation @testable import TablePro import TableProPluginKit -import XCTest +import Testing /// Quotes with backticks and takes the MySQL spelling of a row limit, which is enough to read the /// statements back. The rules under test are about which name is used, not about how it is quoted. -private final class ServerSideInsertDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class ServerSideInsertDriver: PluginDriverStub, @unchecked Sendable { private let injectsRowLimit: Bool init(injectsRowLimit: Bool = true) { self.injectsRowLimit = injectsRowLimit } - func connect() async throws {} - func disconnect() {} - func execute(query: String) async throws -> PluginQueryResult { PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) } @@ -30,23 +28,9 @@ private final class ServerSideInsertDriver: PluginDatabaseDriver, @unchecked Sen func injectRowLimit(_ query: String, limit: Int) -> String? { injectsRowLimit ? "\(query) LIMIT \(limit)" : nil } - - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } -final class ObjectCopyServerSideInsertTests: XCTestCase { +struct ObjectCopyServerSideInsertTests { private let connectionId = UUID() private func endpoint( @@ -90,45 +74,47 @@ final class ObjectCopyServerSideInsertTests: XCTestCase { /// The whole point of the fast path is that one session can see both sides. Two connections /// cannot, whatever engine they run. - func testTwoConnectionsAreNeverEligible() { - XCTAssertFalse(ObjectCopyServerSideInsert.isEligible( + @Test + func twoConnectionsAreNeverEligible() { + #expect(!(ObjectCopyServerSideInsert.isEligible( source: endpoint("shop"), target: endpoint("shop_copy", connectionId: UUID()) - )) + ))) } - func testOneConnectionAndOneDatabaseIsAlwaysEligible() { + @Test + func oneConnectionAndOneDatabaseIsAlwaysEligible() { for type in [DatabaseType.mysql, .postgresql, .sqlite, .oracle, .duckdb] { - XCTAssertTrue( - ObjectCopyServerSideInsert.isEligible( + #expect(ObjectCopyServerSideInsert.isEligible( source: endpoint("shop", schema: "a", type: type), target: endpoint("shop", schema: "b", type: type) ), - "\(type.rawValue)" - ) + "\(type.rawValue)") } } /// PostgreSQL cannot name a second database in one statement under any spelling, so a copy /// between two of them streams through the app as it always did. - func testOnlyTheEnginesThatCanNameASecondDatabaseCrossOne() { - XCTAssertTrue(ObjectCopyServerSideInsert.isEligible( + @Test + func onlyTheEnginesThatCanNameASecondDatabaseCrossOne() { + #expect(ObjectCopyServerSideInsert.isEligible( source: endpoint("shop", type: .mysql), target: endpoint("shop_copy", type: .mysql) )) - XCTAssertTrue(ObjectCopyServerSideInsert.isEligible( + #expect(ObjectCopyServerSideInsert.isEligible( source: endpoint("shop", type: .mssql), target: endpoint("shop_copy", type: .mssql) )) - XCTAssertFalse(ObjectCopyServerSideInsert.isEligible( + #expect(!(ObjectCopyServerSideInsert.isEligible( source: endpoint("shop", type: .postgresql), target: endpoint("shop_copy", type: .postgresql) - )) - XCTAssertFalse(ObjectCopyServerSideInsert.isEligible( + ))) + #expect(!(ObjectCopyServerSideInsert.isEligible( source: endpoint("shop", type: .duckdb), target: endpoint("shop_copy", type: .duckdb) - )) + ))) } // MARK: - The statement - func testAWithinDatabaseCopyNamesTheSchema() { + @Test + func aWithinDatabaseCopyNamesTheSchema() { let sql = ObjectCopyServerSideInsert.statement( input( source: endpoint("shop", schema: "old", type: .postgresql), @@ -138,37 +124,34 @@ final class ObjectCopyServerSideInsertTests: XCTestCase { ), driver: ServerSideInsertDriver() ) - XCTAssertEqual( - sql, - "INSERT INTO `new`.`orders` (`id`, `name`) SELECT `id`, `name` FROM `old`.`orders`" - ) + #expect(sql == "INSERT INTO `new`.`orders` (`id`, `name`) SELECT `id`, `name` FROM `old`.`orders`") } /// The statement runs in the target's database, so an unqualified source name would resolve /// there: the copy would read the table it was about to write. - func testACrossDatabaseCopyNamesTheDatabase() { + @Test + func aCrossDatabaseCopyNamesTheDatabase() { let sql = ObjectCopyServerSideInsert.statement( input(source: endpoint("shop"), target: endpoint("shop_copy")), driver: ServerSideInsertDriver() ) - XCTAssertEqual( - sql, - "INSERT INTO `orders` (`id`, `name`) SELECT `id`, `name` FROM `shop`.`orders`" - ) + #expect(sql == "INSERT INTO `orders` (`id`, `name`) SELECT `id`, `name` FROM `shop`.`orders`") } /// `Shop` and `shop` are one database on a case-insensitive server and two on a case-sensitive /// one, so the qualifier is written whenever the engine has one rather than when the strings /// look different. - func testTheDatabaseIsNamedEvenWhenBothSidesSpellItTheSame() { + @Test + func theDatabaseIsNamedEvenWhenBothSidesSpellItTheSame() { let sql = ObjectCopyServerSideInsert.statement( input(source: endpoint("shop"), target: endpoint("shop")), driver: ServerSideInsertDriver() ) - XCTAssertEqual(sql?.contains("FROM `shop`.`orders`"), true) + #expect(sql?.contains("FROM `shop`.`orders`") == true) } - func testSQLServerNamesTheDatabaseAndTheSchema() { + @Test + func sQLServerNamesTheDatabaseAndTheSchema() { let sql = ObjectCopyServerSideInsert.statement( input( source: endpoint("shop", schema: "sales", type: .mssql), @@ -178,10 +161,11 @@ final class ObjectCopyServerSideInsertTests: XCTestCase { ), driver: ServerSideInsertDriver() ) - XCTAssertEqual(sql?.contains("FROM `shop`.`sales`.`orders`"), true) + #expect(sql?.contains("FROM `shop`.`sales`.`orders`") == true) } - func testTheRowScopeReachesTheStatement() { + @Test + func theRowScopeReachesTheStatement() { let sql = ObjectCopyServerSideInsert.statement( input( source: endpoint("shop"), @@ -190,11 +174,12 @@ final class ObjectCopyServerSideInsertTests: XCTestCase { ), driver: ServerSideInsertDriver() ) - XCTAssertEqual(sql?.contains("WHERE total > 10"), true) - XCTAssertEqual(sql?.hasSuffix("LIMIT 100"), true) + #expect(sql?.contains("WHERE total > 10") == true) + #expect(sql?.hasSuffix("LIMIT 100") == true) } - func testARowLimitTheDriverCannotInjectIsSpelledForSQLServer() { + @Test + func aRowLimitTheDriverCannotInjectIsSpelledForSQLServer() { let sql = ObjectCopyServerSideInsert.statement( input( source: endpoint("shop", schema: "sales", type: .mssql), @@ -205,14 +190,12 @@ final class ObjectCopyServerSideInsertTests: XCTestCase { ), driver: ServerSideInsertDriver(injectsRowLimit: false) ) - XCTAssertEqual( - sql, - "INSERT INTO `dbo`.`orders` (`id`, `name`) SELECT TOP 100 `id`, `name` FROM `shop`.`sales`.`orders`" - + " WHERE total > 10" - ) + #expect(sql == "INSERT INTO `dbo`.`orders` (`id`, `name`) SELECT TOP 100 `id`, `name` FROM `shop`.`sales`.`orders`" + + " WHERE total > 10") } - func testARowLimitTheDriverCannotInjectIsSpelledForOracle() { + @Test + func aRowLimitTheDriverCannotInjectIsSpelledForOracle() { let sql = ObjectCopyServerSideInsert.statement( input( source: endpoint("ORCL", schema: "HR", type: .oracle), @@ -223,16 +206,14 @@ final class ObjectCopyServerSideInsertTests: XCTestCase { ), driver: ServerSideInsertDriver(injectsRowLimit: false) ) - XCTAssertEqual( - sql, - "INSERT INTO `ARCHIVE`.`orders` (`id`, `name`) SELECT `id`, `name` FROM `HR`.`orders`" - + " FETCH FIRST 100 ROWS ONLY" - ) + #expect(sql == "INSERT INTO `ARCHIVE`.`orders` (`id`, `name`) SELECT `id`, `name` FROM `HR`.`orders`" + + " FETCH FIRST 100 ROWS ONLY") } /// The same rule the streamed path follows: a filter is one expression, and text carrying a /// second statement is refused rather than spliced in. - func testAFilterCarryingASecondStatementIsNotSpliced() { + @Test + func aFilterCarryingASecondStatementIsNotSpliced() { let sql = ObjectCopyServerSideInsert.statement( input( source: endpoint("shop"), @@ -241,13 +222,14 @@ final class ObjectCopyServerSideInsertTests: XCTestCase { ), driver: ServerSideInsertDriver() ) - XCTAssertEqual(sql?.contains("DROP TABLE"), false) - XCTAssertEqual(sql?.contains("WHERE"), false) + #expect(sql?.contains("DROP TABLE") == false) + #expect(sql?.contains("WHERE") == false) } /// A mismatch means the plan and the statement disagree about which columns are written, which /// would put values in the wrong columns. - func testMismatchedColumnListsProduceNoStatement() { + @Test + func mismatchedColumnListsProduceNoStatement() { let mismatched = ObjectCopyServerSideInsert.Input( source: endpoint("shop"), target: endpoint("shop_copy"), @@ -259,6 +241,6 @@ final class ObjectCopyServerSideInsertTests: XCTestCase { targetColumns: ["id", "name"], scope: nil ) - XCTAssertNil(ObjectCopyServerSideInsert.statement(mismatched, driver: ServerSideInsertDriver())) + #expect(ObjectCopyServerSideInsert.statement(mismatched, driver: ServerSideInsertDriver()) == nil) } } diff --git a/TableProTests/Core/ObjectCopy/ObjectCopyTransactionTests.swift b/TableProTests/Core/ObjectCopy/ObjectCopyTransactionTests.swift index ee3cff4293..80bf80c55e 100644 --- a/TableProTests/Core/ObjectCopy/ObjectCopyTransactionTests.swift +++ b/TableProTests/Core/ObjectCopy/ObjectCopyTransactionTests.swift @@ -33,7 +33,7 @@ private final class StubMetadataProvider: ScopedMetadataProviding { func browseScope(for connectionId: UUID) -> DatabaseScope? { nil } } -private final class RecordingCopyDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class RecordingCopyDriver: PluginDriverStub, @unchecked Sendable { private let lock = NSLock() private var statements: [String] = [] private var transactions: [String] = [] @@ -52,9 +52,6 @@ private final class RecordingCopyDriver: PluginDatabaseDriver, @unchecked Sendab func sessionTransactionState() async -> PluginSessionTransactionState { lock.withLock { state } } - func connect() async throws {} - func disconnect() {} - func execute(query: String) async throws -> PluginQueryResult { lock.withLock { statements.append(query) } return PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) @@ -63,21 +60,6 @@ private final class RecordingCopyDriver: PluginDatabaseDriver, @unchecked Sendab func beginTransaction() async throws { lock.withLock { transactions.append("begin") } } func commitTransaction() async throws { lock.withLock { transactions.append("commit") } } func rollbackTransaction() async throws { lock.withLock { transactions.append("rollback") } } - - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } @MainActor diff --git a/TableProTests/Core/Operations/OperationCompletionPolicyTests.swift b/TableProTests/Core/Operations/OperationCompletionPolicyTests.swift index f2444ed79c..00002c8a9f 100644 --- a/TableProTests/Core/Operations/OperationCompletionPolicyTests.swift +++ b/TableProTests/Core/Operations/OperationCompletionPolicyTests.swift @@ -183,9 +183,8 @@ struct OperationCompletionPolicyTests { @Test("Stored settings with per-kind choices load with the switch and the threshold intact") func storedKindChoicesAreIgnored() throws { - let suiteName = "notifications-\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suiteName)) - defer { defaults.removePersistentDomain(forName: suiteName) } + let suite = try EphemeralDefaults() + let defaults = suite.defaults defaults.set( Data(#"{"isEnabled":false,"thresholdSeconds":60,"disabledKindIds":["query","backup"]}"#.utf8), forKey: "com.TablePro.settings.notifications" diff --git a/TableProTests/Core/Operations/OperationCompletionReporterTests.swift b/TableProTests/Core/Operations/OperationCompletionReporterTests.swift index 974125b724..a0985c8efa 100644 --- a/TableProTests/Core/Operations/OperationCompletionReporterTests.swift +++ b/TableProTests/Core/Operations/OperationCompletionReporterTests.swift @@ -1,8 +1,3 @@ -// -// OperationCompletionReporterTests.swift -// TableProTests -// - import Foundation @testable import TablePro import Testing @@ -72,8 +67,7 @@ struct OperationCompletionReporterTests { let presenter = FakeNotificationPresenter() let reporter = makeReporter(presenter: presenter) - reporter.report(completion()) - try await Task.sleep(for: .milliseconds(120)) + await reporter.report(completion())?.value #expect(presenter.posted.count == 1) let content = try #require(presenter.posted.first?.content) @@ -87,8 +81,7 @@ struct OperationCompletionReporterTests { let presenter = FakeNotificationPresenter() let reporter = makeReporter(presenter: presenter) - reporter.report(completion(outcome: .cancelled)) - try await Task.sleep(for: .milliseconds(120)) + await reporter.report(completion(outcome: .cancelled))?.value #expect(presenter.posted.isEmpty) } @@ -99,8 +92,7 @@ struct OperationCompletionReporterTests { var marked: [OperationOwner] = [] let reporter = makeReporter(presenter: presenter, visibility: .onScreen) { marked.append($0) } - reporter.report(completion()) - try await Task.sleep(for: .milliseconds(120)) + await reporter.report(completion())?.value #expect(presenter.posted.isEmpty) #expect(marked.isEmpty) @@ -114,8 +106,7 @@ struct OperationCompletionReporterTests { var marked: [OperationOwner] = [] let reporter = makeReporter(presenter: presenter) { marked.append($0) } - reporter.report(completion()) - try await Task.sleep(for: .milliseconds(120)) + await reporter.report(completion())?.value #expect(presenter.posted.isEmpty) #expect(marked.count == 1) @@ -131,8 +122,7 @@ struct OperationCompletionReporterTests { #expect(presenter.requestedOptions.isEmpty) - reporter.report(completion()) - try await Task.sleep(for: .milliseconds(120)) + await reporter.report(completion())?.value #expect(presenter.requestedOptions == [[.alert, .sound]]) #expect(presenter.posted.count == 1) @@ -163,9 +153,10 @@ struct OperationCompletionReporterTests { let reporter = makeReporter(presenter: presenter) let mine = completion() - reporter.report(mine) - reporter.report(completion()) - try await Task.sleep(for: .milliseconds(150)) + let first = reporter.report(mine) + let second = reporter.report(completion()) + await first?.value + await second?.value #expect(presenter.posted.count == 2) reporter.clearDelivered(for: mine.owner) diff --git a/TableProTests/Core/Plugins/DriverUnavailabilityTests.swift b/TableProTests/Core/Plugins/DriverUnavailabilityTests.swift index c4e6a0fa11..ae3ac98117 100644 --- a/TableProTests/Core/Plugins/DriverUnavailabilityTests.swift +++ b/TableProTests/Core/Plugins/DriverUnavailabilityTests.swift @@ -46,13 +46,20 @@ private final class UnreachableRegistryProtocol: URLProtocol, @unchecked Sendabl @Suite("Driver unavailability", .serialized) @MainActor struct DriverUnavailabilityTests { + private let managerSuite: EphemeralDefaults + private let registrySuite: EphemeralDefaults + + init() throws { + managerSuite = try EphemeralDefaults() + registrySuite = try EphemeralDefaults() + } + private func makeManager() throws -> (manager: PluginManager, root: URL) { let root = FileManager.default.temporaryDirectory .appendingPathComponent("DriverUnavailability-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - let defaults = try #require(UserDefaults(suiteName: "DriverUnavailability.\(UUID().uuidString)")) let manager = PluginManager( - userDefaults: defaults, + userDefaults: managerSuite.defaults, builtInPluginsURL: nil, userPluginsDir: root.appendingPathComponent("Plugins", isDirectory: true) ) @@ -95,9 +102,8 @@ struct DriverUnavailabilityTests { UnreachableRegistryProtocol.reset() let config = URLSessionConfiguration.ephemeral config.protocolClasses = [UnreachableRegistryProtocol.self] - let defaults = try #require(UserDefaults(suiteName: "DriverUnavailabilityRegistry.\(UUID().uuidString)")) return RegistryClient( - userDefaults: defaults, + userDefaults: registrySuite.defaults, session: URLSession(configuration: config), manifestCacheURL: root.appendingPathComponent("registry-manifest.json") ) diff --git a/TableProTests/Core/Plugins/ExportDataSourceAdapterImplicitSchemaTests.swift b/TableProTests/Core/Plugins/ExportDataSourceAdapterImplicitSchemaTests.swift index 5e59db4296..ef1f6e9cab 100644 --- a/TableProTests/Core/Plugins/ExportDataSourceAdapterImplicitSchemaTests.swift +++ b/TableProTests/Core/Plugins/ExportDataSourceAdapterImplicitSchemaTests.swift @@ -3,48 +3,14 @@ import Foundation import TableProPluginKit import Testing -private final class SchemaAwareStubDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class SchemaAwareStubDriver: PluginDriverStub, @unchecked Sendable { var supportsSchemas: Bool { true } var currentSchema: String? { "sales" } - - func connect() async throws {} - func disconnect() {} - func execute(query: String) async throws -> PluginQueryResult { .empty } - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } -private final class SchemaLessStubDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class SchemaLessStubDriver: PluginDriverStub, @unchecked Sendable { var supportsSchemas: Bool { false } var currentSchema: String? { nil } - - func connect() async throws {} - func disconnect() {} - func execute(query: String) async throws -> PluginQueryResult { .empty } - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } struct ExportDataSourceAdapterImplicitSchemaTests { diff --git a/TableProTests/Core/Plugins/ExportDataSourceAdapterRowLimitTests.swift b/TableProTests/Core/Plugins/ExportDataSourceAdapterRowLimitTests.swift index 7394f7d2d9..0e380bac45 100644 --- a/TableProTests/Core/Plugins/ExportDataSourceAdapterRowLimitTests.swift +++ b/TableProTests/Core/Plugins/ExportDataSourceAdapterRowLimitTests.swift @@ -3,7 +3,7 @@ import Foundation import TableProPluginKit import Testing -private final class StreamRecordingDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class StreamRecordingDriver: PluginDriverStub, @unchecked Sendable { private let lock = NSLock() private var queries: [String] = [] @@ -17,22 +17,6 @@ private final class StreamRecordingDriver: PluginDatabaseDriver, @unchecked Send } func quoteIdentifier(_ name: String) -> String { "\"\(name)\"" } - func connect() async throws {} - func disconnect() {} - func execute(query: String) async throws -> PluginQueryResult { .empty } - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } struct ExportDataSourceAdapterRowLimitTests { diff --git a/TableProTests/Core/Plugins/ExportDataSourceAdapterRowScopeTests.swift b/TableProTests/Core/Plugins/ExportDataSourceAdapterRowScopeTests.swift index cf51f693d1..94ba65dbbe 100644 --- a/TableProTests/Core/Plugins/ExportDataSourceAdapterRowScopeTests.swift +++ b/TableProTests/Core/Plugins/ExportDataSourceAdapterRowScopeTests.swift @@ -3,7 +3,7 @@ import Foundation import TableProPluginKit import Testing -private final class RowScopeRecordingDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class RowScopeRecordingDriver: PluginDriverStub, @unchecked Sendable { private let lock = NSLock() private var statements: [String] = [] @@ -20,22 +20,6 @@ private final class RowScopeRecordingDriver: PluginDatabaseDriver, @unchecked Se lock.withLock { statements.append(query) } return .empty } - - func connect() async throws {} - func disconnect() {} - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } struct ExportDataSourceAdapterRowScopeTests { diff --git a/TableProTests/Core/Plugins/ExportDataSourceAdapterScriptTextTests.swift b/TableProTests/Core/Plugins/ExportDataSourceAdapterScriptTextTests.swift index 779f093277..d6275cc03c 100644 --- a/TableProTests/Core/Plugins/ExportDataSourceAdapterScriptTextTests.swift +++ b/TableProTests/Core/Plugins/ExportDataSourceAdapterScriptTextTests.swift @@ -8,24 +8,7 @@ import Foundation import TableProPluginKit import Testing -private final class ScriptTextStubDriver: PluginDatabaseDriver, @unchecked Sendable { - func connect() async throws {} - func disconnect() {} - func execute(query: String) async throws -> PluginQueryResult { .empty } - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } -} +private final class ScriptTextStubDriver: PluginDriverStub, @unchecked Sendable {} struct ExportDataSourceAdapterScriptTextTests { private func adapter(for type: DatabaseType) -> ExportDataSourceAdapter { diff --git a/TableProTests/Core/Plugins/ImportFieldDetectionTests.swift b/TableProTests/Core/Plugins/ImportFieldDetectionTests.swift index 202cd509c9..02d6629e1b 100644 --- a/TableProTests/Core/Plugins/ImportFieldDetectionTests.swift +++ b/TableProTests/Core/Plugins/ImportFieldDetectionTests.swift @@ -6,6 +6,7 @@ import Foundation @testable import TablePro import TableProPluginKit +import TableProTestSupport import Testing private final class BlockingDetectionPlugin: ImportFormatPlugin, @unchecked Sendable { @@ -59,9 +60,7 @@ struct ImportFieldDetectionTests { targetTable: nil ) } - for _ in 0..<500 where !plugin.hasStarted { - try await Task.sleep(nanoseconds: 10_000_000) - } + try await waitUntil { plugin.hasStarted } try #require(plugin.hasStarted) caller.cancel() diff --git a/TableProTests/Core/Plugins/MySQLVariantSupportTests.swift b/TableProTests/Core/Plugins/MySQLVariantSupportTests.swift index 71fa7fd2a6..144af01c39 100644 --- a/TableProTests/Core/Plugins/MySQLVariantSupportTests.swift +++ b/TableProTests/Core/Plugins/MySQLVariantSupportTests.swift @@ -8,7 +8,6 @@ import Foundation import TableProPluginKit import Testing -@Suite("TiDB and Databend support decisions", .serialized) @MainActor struct MySQLVariantSupportTests { private static func mysqlPluginSnapshot() throws -> PluginMetadataSnapshot { diff --git a/TableProTests/Core/Plugins/PluginBundleVersionTests.swift b/TableProTests/Core/Plugins/PluginBundleVersionTests.swift index cc1a2d1daf..2f9d7bf27c 100644 --- a/TableProTests/Core/Plugins/PluginBundleVersionTests.swift +++ b/TableProTests/Core/Plugins/PluginBundleVersionTests.swift @@ -7,7 +7,6 @@ import Foundation @testable import TablePro import Testing -@Suite("Plugin bundle version reading", .serialized) struct PluginBundleVersionTests { private func makeTempDir() throws -> URL { let dir = FileManager.default.temporaryDirectory diff --git a/TableProTests/Core/Plugins/PluginCreateTableStatementsTests.swift b/TableProTests/Core/Plugins/PluginCreateTableStatementsTests.swift index 337143a3bd..aa323b44a5 100644 --- a/TableProTests/Core/Plugins/PluginCreateTableStatementsTests.swift +++ b/TableProTests/Core/Plugins/PluginCreateTableStatementsTests.swift @@ -8,30 +8,13 @@ import Foundation import TableProPluginKit import Testing -private final class SingleStringDDLDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class SingleStringDDLDriver: PluginDriverStub, @unchecked Sendable { let createTableSQL: String? init(createTableSQL: String?) { self.createTableSQL = createTableSQL } - func connect() async throws {} - func disconnect() {} - func execute(query: String) async throws -> PluginQueryResult { .empty } - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } - func generateCreateTableSQL(definition: PluginCreateTableDefinition) -> String? { createTableSQL } diff --git a/TableProTests/Core/Plugins/PluginDriverAdapterConcurrencyTests.swift b/TableProTests/Core/Plugins/PluginDriverAdapterConcurrencyTests.swift index fa43172611..377ae12cb5 100644 --- a/TableProTests/Core/Plugins/PluginDriverAdapterConcurrencyTests.swift +++ b/TableProTests/Core/Plugins/PluginDriverAdapterConcurrencyTests.swift @@ -99,7 +99,7 @@ struct PluginDriverAdapterConcurrencyTests { } } -private final class ConcurrentStubPluginDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class ConcurrentStubPluginDriver: PluginDriverStub, @unchecked Sendable { private let columnTypeNames: [String] init(columnTypeNames: [String]) { @@ -116,9 +116,6 @@ private final class ConcurrentStubPluginDriver: PluginDatabaseDriver, @unchecked ) } - func connect() async throws {} - func disconnect() {} - func execute(query: String) async throws -> PluginQueryResult { await Task.yield() return makeResult() @@ -137,21 +134,4 @@ private final class ConcurrentStubPluginDriver: PluginDatabaseDriver, @unchecked await Task.yield() return makeResult() } - - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - - func fetchDatabases() async throws -> [String] { [] } - - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } diff --git a/TableProTests/Core/Plugins/PluginDriverAdapterPartitionDefaultTests.swift b/TableProTests/Core/Plugins/PluginDriverAdapterPartitionDefaultTests.swift index 907a29ffb5..637a2a6bb5 100644 --- a/TableProTests/Core/Plugins/PluginDriverAdapterPartitionDefaultTests.swift +++ b/TableProTests/Core/Plugins/PluginDriverAdapterPartitionDefaultTests.swift @@ -8,45 +8,26 @@ import Foundation import TableProPluginKit import Testing -private final class PartitionUnawareDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class PartitionUnawareDriver: PluginDriverStub, @unchecked Sendable { var supportsSchemas: Bool { false } var supportsTransactions: Bool { false } var currentSchema: String? { nil } var serverVersion: String? { nil } - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - - func connect() async throws {} - func disconnect() {} func ping() async throws {} func execute(query: String) async throws -> PluginQueryResult { PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) } - - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } /// Answers the older requirement alone, the way every plugin built before `fetchPartitionDetails` /// existed does. The protocol default is what has to bridge it. -private final class LegacyPartitionDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class LegacyPartitionDriver: PluginDriverStub, @unchecked Sendable { var supportsSchemas: Bool { false } var supportsTransactions: Bool { false } var currentSchema: String? { nil } var serverVersion: String? { nil } - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchPartitions(table: String, schema: String?) async throws -> [PluginTableInfo] { [ PluginTableInfo(name: "orders_2024", type: "TABLE", rowCount: 12, schema: "archive", comment: nil), @@ -56,25 +37,10 @@ private final class LegacyPartitionDriver: PluginDatabaseDriver, @unchecked Send ] } - func connect() async throws {} - func disconnect() {} func ping() async throws {} func execute(query: String) async throws -> PluginQueryResult { PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) } - - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } struct PluginDriverAdapterPartitionDefaultTests { diff --git a/TableProTests/Core/Plugins/PluginDriverAdapterSchemaSetTests.swift b/TableProTests/Core/Plugins/PluginDriverAdapterSchemaSetTests.swift index a21a9cd741..0a5e056509 100644 --- a/TableProTests/Core/Plugins/PluginDriverAdapterSchemaSetTests.swift +++ b/TableProTests/Core/Plugins/PluginDriverAdapterSchemaSetTests.swift @@ -8,7 +8,7 @@ import Foundation import TableProPluginKit import Testing -private final class SchemaSetStubDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class SchemaSetStubDriver: PluginDriverStub, @unchecked Sendable { private let lock = NSLock() private var requested: [[String]] = [] @@ -39,56 +39,22 @@ private final class SchemaSetStubDriver: PluginDatabaseDriver, @unchecked Sendab return types } - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - - func connect() async throws {} - func disconnect() {} func ping() async throws {} func execute(query: String) async throws -> PluginQueryResult { PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) } - - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } -private final class SchemaSetUnawareDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class SchemaSetUnawareDriver: PluginDriverStub, @unchecked Sendable { var supportsSchemas: Bool { true } var supportsTransactions: Bool { false } var currentSchema: String? { "public" } var serverVersion: String? { nil } - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - - func connect() async throws {} - func disconnect() {} func ping() async throws {} func execute(query: String) async throws -> PluginQueryResult { PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) } - - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } @MainActor diff --git a/TableProTests/Core/Plugins/PluginDriverAdapterStatementContextTests.swift b/TableProTests/Core/Plugins/PluginDriverAdapterStatementContextTests.swift index da2e08f185..75288ff3a9 100644 --- a/TableProTests/Core/Plugins/PluginDriverAdapterStatementContextTests.swift +++ b/TableProTests/Core/Plugins/PluginDriverAdapterStatementContextTests.swift @@ -8,7 +8,7 @@ import Foundation import TableProPluginKit import Testing -private final class ContextRecordingDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class ContextRecordingDriver: PluginDriverStub, @unchecked Sendable { var supportsSchemas: Bool { false } var supportsTransactions: Bool { false } var currentSchema: String? { nil } @@ -29,9 +29,6 @@ private final class ContextRecordingDriver: PluginDatabaseDriver, @unchecked Sen columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0 ) - func connect() async throws {} - func disconnect() {} - func execute(query: String) async throws -> PluginQueryResult { Self.empty } func execute(query: String, context: PluginStatementContext) async throws -> PluginQueryResult { @@ -62,21 +59,6 @@ private final class ContextRecordingDriver: PluginDatabaseDriver, @unchecked Sen record(context) return AsyncThrowingStream { $0.finish() } } - - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } struct PluginDriverAdapterStatementContextTests { diff --git a/TableProTests/Core/Plugins/PluginDriverAdapterStructureMappingTests.swift b/TableProTests/Core/Plugins/PluginDriverAdapterStructureMappingTests.swift index 2a9b9b6e9e..fa67e84517 100644 --- a/TableProTests/Core/Plugins/PluginDriverAdapterStructureMappingTests.swift +++ b/TableProTests/Core/Plugins/PluginDriverAdapterStructureMappingTests.swift @@ -10,7 +10,7 @@ import Foundation import TableProPluginKit import Testing -private final class StubStructureDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class StubStructureDriver: PluginDriverStub, @unchecked Sendable { var supportsSchemas: Bool { true } var supportsTransactions: Bool { false } var currentSchema: String? { "public" } @@ -45,19 +45,10 @@ private final class StubStructureDriver: PluginDatabaseDriver, @unchecked Sendab PluginStructureFixtures.tableMetadata.first { $0.tableName == table } ?? PluginTableMetadata(tableName: table) } - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func connect() async throws {} - func disconnect() {} func ping() async throws {} func execute(query: String) async throws -> PluginQueryResult { PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } struct PluginDriverAdapterStructureMappingTests { diff --git a/TableProTests/Core/Plugins/PluginDriverAdapterSystemDatabaseTests.swift b/TableProTests/Core/Plugins/PluginDriverAdapterSystemDatabaseTests.swift index 0004159228..db05ac19f2 100644 --- a/TableProTests/Core/Plugins/PluginDriverAdapterSystemDatabaseTests.swift +++ b/TableProTests/Core/Plugins/PluginDriverAdapterSystemDatabaseTests.swift @@ -8,7 +8,7 @@ import Foundation import TableProPluginKit import Testing -private final class StubDatabaseMetadataDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class StubDatabaseMetadataDriver: PluginDriverStub, @unchecked Sendable { var supportsSchemas: Bool { false } var supportsTransactions: Bool { false } var currentSchema: String? { nil } @@ -20,23 +20,11 @@ private final class StubDatabaseMetadataDriver: PluginDatabaseDriver, @unchecked self.metadata = metadata } - func connect() async throws {} - func disconnect() {} func ping() async throws {} func execute(query: String) async throws -> PluginQueryResult { PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) } - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - func fetchDatabases() async throws -> [String] { metadata.map(\.name) } func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { diff --git a/TableProTests/Core/Plugins/PluginDriverAdapterTableOpsTests.swift b/TableProTests/Core/Plugins/PluginDriverAdapterTableOpsTests.swift index 619ec9a2f8..e7ad4563a0 100644 --- a/TableProTests/Core/Plugins/PluginDriverAdapterTableOpsTests.swift +++ b/TableProTests/Core/Plugins/PluginDriverAdapterTableOpsTests.swift @@ -8,7 +8,7 @@ import Foundation import TableProPluginKit import Testing -private final class StubTableOpsDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class StubTableOpsDriver: PluginDriverStub, @unchecked Sendable { var supportsSchemas: Bool { false } var supportsTransactions: Bool { false } var currentSchema: String? { nil } @@ -25,27 +25,10 @@ private final class StubTableOpsDriver: PluginDatabaseDriver, @unchecked Sendabl dropOverride?(name, objectType, schema, cascade) } - func connect() async throws {} - func disconnect() {} func ping() async throws {} func execute(query: String) async throws -> PluginQueryResult { PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) } - - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } struct PluginDriverAdapterTableOpsTests { diff --git a/TableProTests/Core/Plugins/PluginDriverAdapterTableTypeMappingTests.swift b/TableProTests/Core/Plugins/PluginDriverAdapterTableTypeMappingTests.swift index d9bc3a2762..ba050da76b 100644 --- a/TableProTests/Core/Plugins/PluginDriverAdapterTableTypeMappingTests.swift +++ b/TableProTests/Core/Plugins/PluginDriverAdapterTableTypeMappingTests.swift @@ -8,7 +8,7 @@ import Foundation import TableProPluginKit import Testing -private final class StubTableTypeDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class StubTableTypeDriver: PluginDriverStub, @unchecked Sendable { var stubbedSupportsSchemas = false var stubbedCurrentSchema: String? @@ -32,25 +32,10 @@ private final class StubTableTypeDriver: PluginDatabaseDriver, @unchecked Sendab return stubbedPartitions } - func connect() async throws {} - func disconnect() {} func ping() async throws {} func execute(query: String) async throws -> PluginQueryResult { PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) } - - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } struct PluginDriverAdapterTableTypeMappingTests { diff --git a/TableProTests/Core/Plugins/PluginInstallTrackerStagedTests.swift b/TableProTests/Core/Plugins/PluginInstallTrackerStagedTests.swift index 94c56e3361..2cfae67948 100644 --- a/TableProTests/Core/Plugins/PluginInstallTrackerStagedTests.swift +++ b/TableProTests/Core/Plugins/PluginInstallTrackerStagedTests.swift @@ -7,7 +7,6 @@ import Foundation import Testing @testable import TablePro -@Suite("PluginInstallTracker staged phase", .serialized) @MainActor struct PluginInstallTrackerStagedTests { diff --git a/TableProTests/Core/Plugins/PluginInstallerHelpersTests.swift b/TableProTests/Core/Plugins/PluginInstallerHelpersTests.swift index 95d7ffd567..f9703a9c18 100644 --- a/TableProTests/Core/Plugins/PluginInstallerHelpersTests.swift +++ b/TableProTests/Core/Plugins/PluginInstallerHelpersTests.swift @@ -8,7 +8,6 @@ import Foundation import Testing @testable import TablePro -@Suite("PluginInstaller helpers", .serialized) struct PluginInstallerHelpersTests { private func makeTempDir() throws -> URL { diff --git a/TableProTests/Core/Plugins/PluginLazyActivationVerificationTests.swift b/TableProTests/Core/Plugins/PluginLazyActivationVerificationTests.swift index 5b9f166c5c..ddca0cf5be 100644 --- a/TableProTests/Core/Plugins/PluginLazyActivationVerificationTests.swift +++ b/TableProTests/Core/Plugins/PluginLazyActivationVerificationTests.swift @@ -8,7 +8,6 @@ import Foundation import TableProPluginKit import Testing -@Suite("Plugin lazy activation re-verification", .serialized) @MainActor struct PluginLazyActivationVerificationTests { @Test("user-installed lazy bundle that fails the signature re-check is rejected, never loaded") @@ -33,9 +32,8 @@ struct PluginLazyActivationVerificationTests { let infoData = try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0) try infoData.write(to: contentsURL.appendingPathComponent("Info.plist")) - let suiteName = "LazyActivationTest.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suiteName)) - defer { defaults.removePersistentDomain(forName: suiteName) } + let suite = try EphemeralDefaults() + let defaults = suite.defaults let manager = PluginManager(userDefaults: defaults, builtInPluginsURL: nil, userPluginsDir: userPluginsDir) let bundle = try #require(Bundle(url: bundleURL)) @@ -93,9 +91,8 @@ struct PluginLazyActivationVerificationTests { try infoData.write(to: contentsURL.appendingPathComponent("Info.plist")) try Data("not a mach-o binary".utf8).write(to: macosURL.appendingPathComponent("Broken")) - let suiteName = "LazyLoadFailTest.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suiteName)) - defer { defaults.removePersistentDomain(forName: suiteName) } + let suite = try EphemeralDefaults() + let defaults = suite.defaults let manager = PluginManager(userDefaults: defaults, builtInPluginsURL: nil, userPluginsDir: userPluginsDir) let bundle = try #require(Bundle(url: bundleURL)) diff --git a/TableProTests/Core/Plugins/PluginManagerInitialLoadTests.swift b/TableProTests/Core/Plugins/PluginManagerInitialLoadTests.swift new file mode 100644 index 0000000000..cf5569dd4a --- /dev/null +++ b/TableProTests/Core/Plugins/PluginManagerInitialLoadTests.swift @@ -0,0 +1,75 @@ +// +// PluginManagerInitialLoadTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProTestSupport +import Testing + +@MainActor +struct PluginManagerInitialLoadTests { + @MainActor + private final class Flag { + var isSet = false + } + + private struct Fixture { + let manager: PluginManager + let clock: ManualClock + let root: URL + let defaults: EphemeralDefaults + } + + private func makeFixture() throws -> Fixture { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("PluginManagerInitialLoadTests-\(UUID().uuidString)", isDirectory: true) + let defaults = try EphemeralDefaults() + let clock = ManualClock() + let manager = PluginManager( + userDefaults: defaults.defaults, + builtInPluginsURL: nil, + userPluginsDir: root.appendingPathComponent("Plugins", isDirectory: true), + initialLoadClock: clock + ) + return Fixture(manager: manager, clock: clock, root: root, defaults: defaults) + } + + @Test("A wait before any load has started returns at once instead of sitting out its timeout") + func returnsAtOnceWithoutAStartedLoad() async throws { + let fixture = try makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let manager = fixture.manager + let clock = fixture.clock + let returned = Flag() + + let waiter = Task { + await manager.waitForInitialLoad() + returned.isSet = true + } + // A wait that arms its timeout sleeps on the clock, which nothing here advances. + try await waitUntil { returned.isSet || clock.pendingSleeperCount > 0 } + + #expect(returned.isSet) + #expect(clock.pendingSleeperCount == 0) + #expect(!manager.hasFinishedInitialLoad) + clock.advance(by: .seconds(10)) + await waiter.value + } + + @Test("A wait after loadPlugins() has started holds until that load finishes") + func waitsForAStartedLoad() async throws { + let fixture = try makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let manager = fixture.manager + + manager.loadPlugins() + #expect(!manager.hasFinishedInitialLoad) + + // The timeout's clock never advances, so only the load finishing can end this wait. + await manager.waitForInitialLoad() + + #expect(manager.hasFinishedInitialLoad) + } +} diff --git a/TableProTests/Core/Plugins/PluginManagerStagingCleanupTests.swift b/TableProTests/Core/Plugins/PluginManagerStagingCleanupTests.swift index e6cfeffcec..ffeb8a5797 100644 --- a/TableProTests/Core/Plugins/PluginManagerStagingCleanupTests.swift +++ b/TableProTests/Core/Plugins/PluginManagerStagingCleanupTests.swift @@ -7,7 +7,6 @@ import Foundation import Testing @testable import TablePro -@Suite("PluginManager staging directory cleanup", .serialized) struct PluginManagerStagingCleanupTests { private func makeTempPluginsDir() throws -> URL { diff --git a/TableProTests/Core/Plugins/PluginResultColumnHintsTests.swift b/TableProTests/Core/Plugins/PluginResultColumnHintsTests.swift index fd51435f1c..80bac66c37 100644 --- a/TableProTests/Core/Plugins/PluginResultColumnHintsTests.swift +++ b/TableProTests/Core/Plugins/PluginResultColumnHintsTests.swift @@ -8,7 +8,7 @@ import Foundation import TableProPluginKit import Testing -private final class ResultStubDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class ResultStubDriver: PluginDriverStub, @unchecked Sendable { private let result: PluginQueryResult init(result: PluginQueryResult) { @@ -18,22 +18,6 @@ private final class ResultStubDriver: PluginDatabaseDriver, @unchecked Sendable func execute(query: String) async throws -> PluginQueryResult { result } - - func connect() async throws {} - func disconnect() {} - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } struct PluginResultColumnHintsTests { diff --git a/TableProTests/Core/Plugins/PluginRowLocatorTests.swift b/TableProTests/Core/Plugins/PluginRowLocatorTests.swift index 440bc84dbf..263a7b5091 100644 --- a/TableProTests/Core/Plugins/PluginRowLocatorTests.swift +++ b/TableProTests/Core/Plugins/PluginRowLocatorTests.swift @@ -8,15 +8,12 @@ import Foundation import TableProPluginKit import Testing -private final class LocatingDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class LocatingDriver: PluginDriverStub, @unchecked Sendable { var supportsSchemas: Bool { false } var supportsTransactions: Bool { false } var currentSchema: String? { nil } var serverVersion: String? { nil } - func connect() async throws {} - func disconnect() {} - func execute(query: String) async throws -> PluginQueryResult { var result = PluginQueryResult( columns: ["_id"], @@ -28,21 +25,6 @@ private final class LocatingDriver: PluginDatabaseDriver, @unchecked Sendable { result.rowLocators = [#"{"$numberInt":"1"}"#, nil] return result } - - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } struct PluginRowLocatorTests { diff --git a/TableProTests/Core/Plugins/PluginSettingsSnapshotTests.swift b/TableProTests/Core/Plugins/PluginSettingsSnapshotTests.swift index 3b97c6c47b..e48b7e4ecf 100644 --- a/TableProTests/Core/Plugins/PluginSettingsSnapshotTests.swift +++ b/TableProTests/Core/Plugins/PluginSettingsSnapshotTests.swift @@ -9,7 +9,6 @@ import SwiftUI import TableProPluginKit import Testing -@Suite("PluginSettingsSnapshot", .serialized) struct PluginSettingsSnapshotTests { private struct TestOptions: Codable, Equatable { var flag = false diff --git a/TableProTests/Core/Plugins/PluginSettingsTests.swift b/TableProTests/Core/Plugins/PluginSettingsTests.swift index ef7bb5c827..8e3ca78c43 100644 --- a/TableProTests/Core/Plugins/PluginSettingsTests.swift +++ b/TableProTests/Core/Plugins/PluginSettingsTests.swift @@ -1,8 +1,3 @@ -// -// PluginSettingsTests.swift -// TableProTests -// - import Foundation import TableProPluginKit import Testing @@ -129,7 +124,8 @@ struct PluginSettingsStorageTests { } } -@Suite("SettablePlugin snapshot and restore", .serialized) +// Every case writes and clears the one key `settingsStorageId` names. +@Suite(.serialized) struct SettablePluginSnapshotTests { private struct TestOptions: Codable, Equatable { var flag = false diff --git a/TableProTests/Core/Plugins/PluginSignatureSweepTests.swift b/TableProTests/Core/Plugins/PluginSignatureSweepTests.swift index a5b71c87b6..96e538de9c 100644 --- a/TableProTests/Core/Plugins/PluginSignatureSweepTests.swift +++ b/TableProTests/Core/Plugins/PluginSignatureSweepTests.swift @@ -10,6 +10,7 @@ import XCTest @MainActor final class PluginSignatureSweepTests: XCTestCase { private var root: URL! + private var suite: EphemeralDefaults! private var manager: PluginManager! override func setUp() async throws { @@ -17,8 +18,9 @@ final class PluginSignatureSweepTests: XCTestCase { root = FileManager.default.temporaryDirectory .appendingPathComponent("PluginSignatureSweepTests-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + suite = try EphemeralDefaults() manager = PluginManager( - userDefaults: UserDefaults(suiteName: "PluginSignatureSweepTests-\(UUID().uuidString)") ?? .standard, + userDefaults: suite.defaults, builtInPluginsURL: nil, userPluginsDir: root.appendingPathComponent("Plugins", isDirectory: true) ) @@ -26,6 +28,7 @@ final class PluginSignatureSweepTests: XCTestCase { override func tearDown() async throws { manager = nil + suite = nil if let root { try? FileManager.default.removeItem(at: root) } root = nil try await super.tearDown() diff --git a/TableProTests/Core/Plugins/RegistryClientFreshnessTests.swift b/TableProTests/Core/Plugins/RegistryClientFreshnessTests.swift index 48d33bc437..f3fc158c9f 100644 --- a/TableProTests/Core/Plugins/RegistryClientFreshnessTests.swift +++ b/TableProTests/Core/Plugins/RegistryClientFreshnessTests.swift @@ -50,6 +50,12 @@ private final class MockRegistryProtocol: URLProtocol, @unchecked Sendable { @Suite("RegistryClient freshness", .serialized) @MainActor struct RegistryClientFreshnessTests { + private let suite: EphemeralDefaults + + init() throws { + suite = try EphemeralDefaults() + } + private static let testTypeId = "TestFreshnessDB" private func manifestData(includingTestPlugin: Bool) -> Data { @@ -87,10 +93,7 @@ struct RegistryClientFreshnessTests { try cachedManifest.write(to: cacheURL) } - let suiteName = "registry-freshness-tests-\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - throw CocoaError(.fileNoSuchFile) - } + let defaults = suite.defaults let config = URLSessionConfiguration.ephemeral config.protocolClasses = [MockRegistryProtocol.self] diff --git a/TableProTests/Core/Plugins/RegistryClientURLTests.swift b/TableProTests/Core/Plugins/RegistryClientURLTests.swift index b40f48bec8..824b9785ab 100644 --- a/TableProTests/Core/Plugins/RegistryClientURLTests.swift +++ b/TableProTests/Core/Plugins/RegistryClientURLTests.swift @@ -8,19 +8,22 @@ import TableProPluginKit import Testing @testable import TablePro -@Suite("RegistryClient Configurable URL", .serialized) @MainActor struct RegistryClientURLTests { - private let suiteName = "com.TablePro.tests.RegistryClientURLTests.\(UUID().uuidString)" + private let suite: EphemeralDefaults private let customURLKey = RegistryClient.customRegistryURLKey - private func makeClient(customURL: String) throws -> (RegistryClient, UserDefaults) { - let defaults = try #require(UserDefaults(suiteName: suiteName)) + init() throws { + suite = try EphemeralDefaults() + } + + private func makeClient(customURL: String) -> RegistryClient { + let defaults = suite.defaults defaults.set(customURL, forKey: customURLKey) let cacheURL = FileManager.default.temporaryDirectory .appendingPathComponent("registry-url-tests-\(UUID().uuidString)", isDirectory: true) .appendingPathComponent("registry-manifest.json") - return (RegistryClient(userDefaults: defaults, manifestCacheURL: cacheURL), defaults) + return RegistryClient(userDefaults: defaults, manifestCacheURL: cacheURL) } @Test("customRegistryURLKey has expected value") @@ -31,8 +34,7 @@ struct RegistryClientURLTests { @Test("setting a custom URL via UserDefaults is supported") func settingCustomURL() throws { let testURL = "https://custom.example.com/registry/manifest.json" - let (client, defaults) = try makeClient(customURL: testURL) - defer { defaults.removePersistentDomain(forName: suiteName) } + let client = makeClient(customURL: testURL) #expect(client.isUsingCustomRegistry) let requested = client.makeManifestRequest(forceRefresh: false).url @@ -49,8 +51,7 @@ struct RegistryClientURLTests { @Test("invalid custom URL string does not crash") func invalidCustomURLFallsBackSafely() throws { - let (client, defaults) = try makeClient(customURL: "not a valid url %%%") - defer { defaults.removePersistentDomain(forName: suiteName) } + let client = makeClient(customURL: "not a valid url %%%") #expect(client.makeManifestRequest(forceRefresh: false).url != nil) #expect(client.session.configuration.timeoutIntervalForRequest == 15) diff --git a/TableProTests/Core/Plugins/SQLDialectDescriptorTests.swift b/TableProTests/Core/Plugins/SQLDialectDescriptorTests.swift index 6fb7a786a9..307c411215 100644 --- a/TableProTests/Core/Plugins/SQLDialectDescriptorTests.swift +++ b/TableProTests/Core/Plugins/SQLDialectDescriptorTests.swift @@ -3,14 +3,16 @@ // TableProTests // +import Foundation @testable import TablePro import TableProPluginKit -import XCTest +import Testing -final class SQLDialectDescriptorTests: XCTestCase { +struct SQLDialectDescriptorTests { // MARK: - SQLDialectDescriptor Creation - func testDescriptorCreation() { + @Test + func descriptorCreation() { let descriptor = SQLDialectDescriptor( identifierQuote: "`", keywords: ["SELECT", "FROM", "WHERE"], @@ -18,13 +20,14 @@ final class SQLDialectDescriptorTests: XCTestCase { dataTypes: ["INT", "VARCHAR"] ) - XCTAssertEqual(descriptor.identifierQuote, "`") - XCTAssertEqual(descriptor.keywords, ["SELECT", "FROM", "WHERE"]) - XCTAssertEqual(descriptor.functions, ["COUNT", "SUM"]) - XCTAssertEqual(descriptor.dataTypes, ["INT", "VARCHAR"]) + #expect(descriptor.identifierQuote == "`") + #expect(descriptor.keywords == ["SELECT", "FROM", "WHERE"]) + #expect(descriptor.functions == ["COUNT", "SUM"]) + #expect(descriptor.dataTypes == ["INT", "VARCHAR"]) } - func testDescriptorWithEmptySets() { + @Test + func descriptorWithEmptySets() { let descriptor = SQLDialectDescriptor( identifierQuote: "\"", keywords: [], @@ -32,16 +35,17 @@ final class SQLDialectDescriptorTests: XCTestCase { dataTypes: [] ) - XCTAssertEqual(descriptor.identifierQuote, "\"") - XCTAssertTrue(descriptor.keywords.isEmpty) - XCTAssertTrue(descriptor.functions.isEmpty) - XCTAssertTrue(descriptor.dataTypes.isEmpty) + #expect(descriptor.identifierQuote == "\"") + #expect(descriptor.keywords.isEmpty) + #expect(descriptor.functions.isEmpty) + #expect(descriptor.dataTypes.isEmpty) } // MARK: - PluginDialectAdapter @MainActor - func testPluginDialectAdapterWrapsDescriptor() { + @Test + func pluginDialectAdapterWrapsDescriptor() { let descriptor = SQLDialectDescriptor( identifierQuote: "[", keywords: ["SELECT", "TOP", "NOLOCK"], @@ -51,14 +55,15 @@ final class SQLDialectDescriptorTests: XCTestCase { let adapter = PluginDialectAdapter(descriptor: descriptor) - XCTAssertEqual(adapter.identifierQuote, "[") - XCTAssertEqual(adapter.keywords, ["SELECT", "TOP", "NOLOCK"]) - XCTAssertEqual(adapter.functions, ["LEN", "GETDATE"]) - XCTAssertEqual(adapter.dataTypes, ["NVARCHAR", "BIT"]) + #expect(adapter.identifierQuote == "[") + #expect(adapter.keywords == ["SELECT", "TOP", "NOLOCK"]) + #expect(adapter.functions == ["LEN", "GETDATE"]) + #expect(adapter.dataTypes == ["NVARCHAR", "BIT"]) } @MainActor - func testPluginDialectAdapterConformsToSQLDialectProvider() { + @Test + func pluginDialectAdapterConformsToSQLDialectProvider() { let descriptor = SQLDialectDescriptor( identifierQuote: "`", keywords: ["SELECT", "FROM"], @@ -68,34 +73,36 @@ final class SQLDialectDescriptorTests: XCTestCase { let adapter: SQLDialectProvider = PluginDialectAdapter(descriptor: descriptor) - XCTAssertTrue(adapter.isKeyword("SELECT")) - XCTAssertTrue(adapter.isKeyword("select")) - XCTAssertFalse(adapter.isKeyword("NONEXISTENT")) + #expect(adapter.isKeyword("SELECT")) + #expect(adapter.isKeyword("select")) + #expect(!adapter.isKeyword("NONEXISTENT")) - XCTAssertTrue(adapter.isFunction("COUNT")) - XCTAssertTrue(adapter.isFunction("count")) - XCTAssertFalse(adapter.isFunction("NONEXISTENT")) + #expect(adapter.isFunction("COUNT")) + #expect(adapter.isFunction("count")) + #expect(!adapter.isFunction("NONEXISTENT")) - XCTAssertTrue(adapter.isDataType("INT")) - XCTAssertTrue(adapter.isDataType("int")) - XCTAssertFalse(adapter.isDataType("NONEXISTENT")) + #expect(adapter.isDataType("INT")) + #expect(adapter.isDataType("int")) + #expect(!adapter.isDataType("NONEXISTENT")) } - func testTextCastTypeNameDefaultsToNil() { + @Test + func textCastTypeNameDefaultsToNil() { let descriptor = SQLDialectDescriptor( identifierQuote: "`", keywords: [], functions: [], dataTypes: [] ) - XCTAssertNil(descriptor.textCastTypeName) + #expect(descriptor.textCastTypeName == nil) } - func testTextCastTypeNameSurvivesCaseSensitivityRestyle() { + @Test + func textCastTypeNameSurvivesCaseSensitivityRestyle() { let descriptor = SQLDialectDescriptor( identifierQuote: "\"", keywords: [], functions: [], dataTypes: [], caseSensitivityStyle: .ilikeOperator, textCastTypeName: "TEXT" ) let restyled = descriptor.withCaseSensitivityStyle(.caseFoldFunction) - XCTAssertEqual(descriptor.textCastTypeName, "TEXT") - XCTAssertEqual(restyled.textCastTypeName, "TEXT") - XCTAssertEqual(restyled.caseSensitivityStyle, .caseFoldFunction) + #expect(descriptor.textCastTypeName == "TEXT") + #expect(restyled.textCastTypeName == "TEXT") + #expect(restyled.caseSensitivityStyle == .caseFoldFunction) } } diff --git a/TableProTests/Core/Process/PipeReaderTests.swift b/TableProTests/Core/Process/PipeReaderTests.swift index 7f214c5e8d..ca96e68471 100644 --- a/TableProTests/Core/Process/PipeReaderTests.swift +++ b/TableProTests/Core/Process/PipeReaderTests.swift @@ -1,8 +1,3 @@ -// -// PipeReaderTests.swift -// TableProTests -// - import Darwin import Foundation import Testing @@ -38,16 +33,6 @@ struct PipeReaderTests { } } - private struct TimedOut: Error {} - - private func waitUntil(_ condition: () -> Bool) async throws { - let deadline = ContinuousClock.now + .seconds(10) - while !condition() { - guard ContinuousClock.now < deadline else { throw TimedOut() } - try await Task.sleep(for: .milliseconds(5)) - } - } - @Test("Everything written arrives in order and reading ends by itself at end of file", .timeLimit(.minutes(1))) func deliversInOrderUntilEndOfFile() async throws { let pipe = Pipe() diff --git a/TableProTests/Core/Process/SupervisedProcessRunnerTests.swift b/TableProTests/Core/Process/SupervisedProcessRunnerTests.swift index b98afcbe78..91482132ea 100644 --- a/TableProTests/Core/Process/SupervisedProcessRunnerTests.swift +++ b/TableProTests/Core/Process/SupervisedProcessRunnerTests.swift @@ -1,13 +1,8 @@ -// -// SupervisedProcessRunnerTests.swift -// TableProTests -// - import Darwin import Foundation -import Testing - @testable import TablePro +import TableProTestSupport +import Testing struct SupervisedProcessRunnerTests { private func runner(script: String) throws -> ProcessSupervisedRunner { @@ -101,15 +96,6 @@ struct SupervisedProcessRunnerTests { private func isAlive(_ pid: pid_t) -> Bool { kill(pid, 0) == 0 } - private func waitUntilGone(_ pid: pid_t, within seconds: Double) async -> Bool { - let deadline = Date().addingTimeInterval(seconds) - while Date() < deadline { - if !isAlive(pid) { return true } - try? await Task.sleep(nanoseconds: 50_000_000) - } - return !isAlive(pid) - } - /// The whole reason `stop()` may signal a negated pid: Foundation puts every child in a process /// group of its own. If a toolchain stopped doing that, the same call would signal this /// process's own group instead. @@ -139,6 +125,7 @@ struct SupervisedProcessRunnerTests { runner.stop() - #expect(await waitUntilGone(helper, within: 5)) + try await waitUntil(timeout: .seconds(5)) { !isAlive(helper) } + #expect(!isAlive(helper)) } } diff --git a/TableProTests/Core/RecentTabs/RecentTabOrderTests.swift b/TableProTests/Core/RecentTabs/RecentTabOrderTests.swift index d01ae5ac9c..62b44147c0 100644 --- a/TableProTests/Core/RecentTabs/RecentTabOrderTests.swift +++ b/TableProTests/Core/RecentTabs/RecentTabOrderTests.swift @@ -1,8 +1,3 @@ -// -// RecentTabOrderTests.swift -// TableProTests -// - import Foundation @testable import TablePro import Testing @@ -21,6 +16,14 @@ struct RecentTabOrderTests { manager.selectedTab.map { RecentTabReference(connectionId: connection, tabId: $0.id) } } + /// A selection is recorded on the main queue's next turn, and the queue runs blocks in order, so + /// a block queued after it runs once the record has landed. + private static func mainQueueTurn() async { + await withCheckedContinuation { continuation in + DispatchQueue.main.async { continuation.resume() } + } + } + /// The report: two tabs in use that are not neighbours, so stepping the strip from D reaches /// C. B rather than A, because A is first in the strip and a broken record would still reach it /// through the strip-order tail. @@ -35,9 +38,9 @@ struct RecentTabOrderTests { let tabB = manager.tabs[1] let tabD = try #require(manager.tabs.last) manager.selectedTabId = tabB.id - try? await Task.sleep(for: .milliseconds(20)) + await Self.mainQueueTurn() manager.selectedTabId = tabD.id - try? await Task.sleep(for: .milliseconds(20)) + await Self.mainQueueTurn() let order = RecentTabOrder.order( sources: [source(manager, connection: connection)], diff --git a/TableProTests/Core/RecentTabs/RecentTabSwitcherControllerTests.swift b/TableProTests/Core/RecentTabs/RecentTabSwitcherControllerTests.swift index 5fc0bb72cd..dcc108a98c 100644 --- a/TableProTests/Core/RecentTabs/RecentTabSwitcherControllerTests.swift +++ b/TableProTests/Core/RecentTabs/RecentTabSwitcherControllerTests.swift @@ -1,10 +1,6 @@ -// -// RecentTabSwitcherControllerTests.swift -// TableProTests -// - import AppKit @testable import TablePro +import TableProTestSupport import Testing @MainActor @@ -192,7 +188,7 @@ struct RecentTabSwitcherControllerTests { begin(controller, recorder, trigger: try controlTab()) #expect(presenter.presented == nil) - try await Task.sleep(for: .milliseconds(200)) + try await waitUntil { presenter.presented != nil } #expect(presenter.presented?.session.highlightedIndex == 1) controller.handleModifiersChanged([]) @@ -204,7 +200,7 @@ struct RecentTabSwitcherControllerTests { let recorder = Recorder() let controller = makeController(recorder, pickerDelay: 0) begin(controller, recorder, trigger: try controlTab()) - try await Task.sleep(for: .milliseconds(200)) + try await waitUntil { presenter.onClose != nil } let onClose = try #require(presenter.onClose) onClose() diff --git a/TableProTests/Core/SSH/Auth/AgentAuthenticationReportingTests.swift b/TableProTests/Core/SSH/Auth/AgentAuthenticationReportingTests.swift index c2cbe8d353..a9565c40e6 100644 --- a/TableProTests/Core/SSH/Auth/AgentAuthenticationReportingTests.swift +++ b/TableProTests/Core/SSH/Auth/AgentAuthenticationReportingTests.swift @@ -211,7 +211,6 @@ private func withTransportlessSession(_ body: (OpaquePointer) throws -> T) th return try body(session) } -@Suite("AgentAuthenticator failure reasons", .serialized) struct AgentAuthenticatorFailureTests { @Test("A socket path nothing is listening on reports the agent as unreachable (#2583)") func missingSocketReportsUnavailable() throws { diff --git a/TableProTests/Core/SchemaTracking/CheckConstraintStatementTests.swift b/TableProTests/Core/SchemaTracking/CheckConstraintStatementTests.swift index 8184477206..174d28e857 100644 --- a/TableProTests/Core/SchemaTracking/CheckConstraintStatementTests.swift +++ b/TableProTests/Core/SchemaTracking/CheckConstraintStatementTests.swift @@ -10,31 +10,16 @@ import Foundation import TableProPluginKit import Testing -private final class ConstraintDDLDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class ConstraintDDLDriver: PluginDriverStub, @unchecked Sendable { var supportsRename = true var isConnected: Bool { true } var currentSchema: String? { "public" } - func connect() async throws {} - func disconnect() {} func ping() async throws {} func execute(query: String) async throws -> PluginQueryResult { PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) } - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } func generateAddCheckConstraintSQL(table: String, constraint: PluginCheckConstraintDefinition) -> String? { "ALTER TABLE \(table) ADD CONSTRAINT \(constraint.name) CHECK (\(constraint.expression))" diff --git a/TableProTests/Core/SchemaTracking/CreateTableCommentCompositionTests.swift b/TableProTests/Core/SchemaTracking/CreateTableCommentCompositionTests.swift index f6f05ff051..48c482d890 100644 --- a/TableProTests/Core/SchemaTracking/CreateTableCommentCompositionTests.swift +++ b/TableProTests/Core/SchemaTracking/CreateTableCommentCompositionTests.swift @@ -8,7 +8,7 @@ import Foundation import TableProPluginKit import Testing -private final class CreateCommentDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class CreateCommentDriver: PluginDriverStub, @unchecked Sendable { private let writesComments: Bool private let createStatements: [String]? @@ -34,25 +34,10 @@ private final class CreateCommentDriver: PluginDatabaseDriver, @unchecked Sendab return "COMMENT ON \(objectType) \(schema ?? "-").\(name) IS '\(comment ?? "")'" } - func connect() async throws {} - func disconnect() {} func ping() async throws {} func execute(query: String) async throws -> PluginQueryResult { PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) } - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } @MainActor diff --git a/TableProTests/Core/SchemaTracking/IndexDecliningDriverTests.swift b/TableProTests/Core/SchemaTracking/IndexDecliningDriverTests.swift index 40d6cc819b..552e7e95e6 100644 --- a/TableProTests/Core/SchemaTracking/IndexDecliningDriverTests.swift +++ b/TableProTests/Core/SchemaTracking/IndexDecliningDriverTests.swift @@ -12,7 +12,7 @@ import Foundation import TableProPluginKit import Testing -private final class IndexDecliningDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class IndexDecliningDriver: PluginDriverStub, @unchecked Sendable { func quoteIdentifier(_ name: String) -> String { "`\(name)`" } func generateAddIndexSQL(table: String, index: PluginIndexDefinition) -> String? { nil } @@ -26,24 +26,9 @@ private final class IndexDecliningDriver: PluginDatabaseDriver, @unchecked Senda return "CREATE TABLE \(quoteIdentifier(definition.tableName)) (\(columns.joined(separator: ", ")))" } - func connect() async throws {} - func disconnect() {} func execute(query: String) async throws -> PluginQueryResult { PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) } - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } struct IndexDecliningDriverTests { diff --git a/TableProTests/Core/SchemaTracking/SchemaOperationRefusalTests.swift b/TableProTests/Core/SchemaTracking/SchemaOperationRefusalTests.swift index fe65cd308e..f92693eab3 100644 --- a/TableProTests/Core/SchemaTracking/SchemaOperationRefusalTests.swift +++ b/TableProTests/Core/SchemaTracking/SchemaOperationRefusalTests.swift @@ -8,30 +8,15 @@ import Foundation import TableProPluginKit import Testing -private final class RefusingDDLDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class RefusingDDLDriver: PluginDriverStub, @unchecked Sendable { var refuse: (PluginSchemaOperation) -> String? = { _ in nil } var currentSchema: String? { "public" } - func connect() async throws {} - func disconnect() {} func ping() async throws {} func execute(query: String) async throws -> PluginQueryResult { PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) } - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } func schemaOperationRefusal(_ operation: PluginSchemaOperation) -> String? { refuse(operation) } diff --git a/TableProTests/Core/SchemaTracking/SchemaStatementGeneratorPluginTests.swift b/TableProTests/Core/SchemaTracking/SchemaStatementGeneratorPluginTests.swift index b462fd5247..de5cb32e2d 100644 --- a/TableProTests/Core/SchemaTracking/SchemaStatementGeneratorPluginTests.swift +++ b/TableProTests/Core/SchemaTracking/SchemaStatementGeneratorPluginTests.swift @@ -12,7 +12,7 @@ import Testing /// Mock plugin driver that returns custom DDL for specific operations. /// Methods return nil by default (operation skipped), unless overridden via closures. -private final class MockPluginDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class MockPluginDriver: PluginDriverStub, @unchecked Sendable { var addColumnHandler: ((String, PluginColumnDefinition) -> String?)? var modifyColumnHandler: ((String, PluginColumnDefinition, PluginColumnDefinition) -> String?)? var dropColumnHandler: ((String, String) -> String?)? @@ -63,25 +63,10 @@ private final class MockPluginDriver: PluginDatabaseDriver, @unchecked Sendable // MARK: - Required Protocol Stubs - func connect() async throws {} - func disconnect() {} func ping() async throws {} func execute(query: String) async throws -> PluginQueryResult { PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) } - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } struct SchemaStatementGeneratorPluginTests { diff --git a/TableProTests/Core/SchemaTracking/TableCommentStatementTests.swift b/TableProTests/Core/SchemaTracking/TableCommentStatementTests.swift index f5522209ef..5fa16cdebe 100644 --- a/TableProTests/Core/SchemaTracking/TableCommentStatementTests.swift +++ b/TableProTests/Core/SchemaTracking/TableCommentStatementTests.swift @@ -15,7 +15,7 @@ private struct CommentRequest: Equatable { let comment: String? } -private final class CommentDDLDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class CommentDDLDriver: PluginDriverStub, @unchecked Sendable { private let writesComments: Bool private(set) var commentRequests: [CommentRequest] = [] @@ -42,25 +42,10 @@ private final class CommentDDLDriver: PluginDatabaseDriver, @unchecked Sendable "CREATE INDEX \(index.name) ON \(table)" } - func connect() async throws {} - func disconnect() {} func ping() async throws {} func execute(query: String) async throws -> PluginQueryResult { PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) } - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } struct TableCommentStatementTests { diff --git a/TableProTests/Core/Services/ClipboardInstallGuardTests.swift b/TableProTests/Core/Services/ClipboardInstallGuardTests.swift deleted file mode 100644 index ea5736c038..0000000000 --- a/TableProTests/Core/Services/ClipboardInstallGuardTests.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// ClipboardInstallGuardTests.swift -// TableProTests -// -// `ClipboardService.shared` is process-wide, so a test that installs a fake and never puts the -// real one back hands that fake to every test after it. A fake whose `writeText` does nothing -// turned every later copy into an empty string, and the suites that read the copy back failed -// on main for as long as the leak ran first. -// - -import Foundation -import Testing - -struct ClipboardInstallGuardTests { - @Test("Every test that installs a clipboard puts the real one back") - func everyInstallIsRestored() throws { - let offenders = try Self.unrestoredInstalls() - #expect( - offenders.isEmpty, - """ - Follow the install with `defer { ClipboardService.shared = NSPasteboardClipboardProvider() }`, \ - or keep the previous provider and restore it in a `defer`: \(offenders.sorted()) - """ - ) - } - - private static let install = "ClipboardService.shared = " - private static let restore = "defer { ClipboardService.shared = " - private static let reach = 3 - - private static func unrestoredInstalls() throws -> [String] { - let testRoot = try repoRoot().appendingPathComponent("TableProTests") - guard let enumerator = FileManager.default.enumerator( - at: testRoot, - includingPropertiesForKeys: [.isRegularFileKey] - ) else { return [] } - - var offenders: [String] = [] - for case let url as URL in enumerator - where url.pathExtension == "swift" && url.lastPathComponent != "ClipboardInstallGuardTests.swift" { - let lines = try String(contentsOf: url, encoding: .utf8).components(separatedBy: .newlines) - for (index, line) in lines.enumerated() where line.contains(install) && !line.contains(restore) { - let window = lines[max(0, index - reach) ... min(lines.count - 1, index + reach)] - guard !window.contains(where: { $0.contains(restore) }) else { continue } - offenders.append("\(url.lastPathComponent):\(index + 1)") - } - } - return offenders - } - - private static func repoRoot() throws -> URL { - var directory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() - for _ in 0 ..< 12 { - if FileManager.default.fileExists(atPath: directory.appendingPathComponent("project.yml").path) { - return directory - } - directory = directory.deletingLastPathComponent() - } - throw CocoaError(.fileNoSuchFile) - } -} diff --git a/TableProTests/Core/Services/ColumnTypeClassifierTests.swift b/TableProTests/Core/Services/ColumnTypeClassifierTests.swift index 605697e96e..1bc786781d 100644 --- a/TableProTests/Core/Services/ColumnTypeClassifierTests.swift +++ b/TableProTests/Core/Services/ColumnTypeClassifierTests.swift @@ -407,40 +407,16 @@ struct ColumnTypeClassifierTests { #expect(classifier.classify(rawTypeName: "BINARY(16)").isBlobType) } - @Test("DATETIME2 classifies as datetime") - func datetime2() { - #expect(isDatetime(classifier.classify(rawTypeName: "DATETIME2"))) + @Test("Classifies as datetime", arguments: ["DATETIME2", "DATETIMEOFFSET", "SMALLDATETIME"]) + func classifiesAsDatetime(_ value: String) { + #expect(isDatetime(classifier.classify(rawTypeName: value))) } - @Test("DATETIMEOFFSET classifies as datetime") - func datetimeoffset() { - #expect(isDatetime(classifier.classify(rawTypeName: "DATETIMEOFFSET"))) + @Test("Classifies as text", arguments: ["NVARCHAR(MAX)", "NTEXT", "UNIQUEIDENTIFIER", "SQL_VARIANT"]) + func classifiesAsText(_ value: String) { + #expect(isText(classifier.classify(rawTypeName: value))) } - @Test("SMALLDATETIME classifies as datetime") - func smalldatetime() { - #expect(isDatetime(classifier.classify(rawTypeName: "SMALLDATETIME"))) - } - - @Test("NVARCHAR(MAX) classifies as text") - func nvarcharMax() { - #expect(isText(classifier.classify(rawTypeName: "NVARCHAR(MAX)"))) - } - - @Test("NTEXT classifies as text") - func ntextType() { - #expect(isText(classifier.classify(rawTypeName: "NTEXT"))) - } - - @Test("UNIQUEIDENTIFIER classifies as text") - func uniqueidentifier() { - #expect(isText(classifier.classify(rawTypeName: "UNIQUEIDENTIFIER"))) - } - - @Test("SQL_VARIANT classifies as text") - func sqlVariant() { - #expect(isText(classifier.classify(rawTypeName: "SQL_VARIANT"))) - } } // MARK: - ClickHouse Types @@ -484,14 +460,9 @@ struct ColumnTypeClassifierTests { #expect(isDatetime(classifier.classify(rawTypeName: "DateTime64(3, 'UTC')"))) } - @Test("Enum8('a' = 1, 'b' = 2) classifies as enum") - func enum8() { - #expect(classifier.classify(rawTypeName: "Enum8('a' = 1, 'b' = 2)").isEnumType) - } - - @Test("Enum16('x' = 1) classifies as enum") - func enum16() { - #expect(classifier.classify(rawTypeName: "Enum16('x' = 1)").isEnumType) + @Test("Classifies as enum", arguments: ["Enum8('a' = 1, 'b' = 2)", "Enum16('x' = 1)"]) + func classifiesAsEnum(_ value: String) { + #expect(classifier.classify(rawTypeName: value).isEnumType) } @Test("Float32 classifies as decimal") @@ -509,64 +480,9 @@ struct ColumnTypeClassifierTests { #expect(isDecimal(classifier.classify(rawTypeName: "Decimal128(3)"))) } - @Test("Int8 classifies as integer") - func int8() { - #expect(isInteger(classifier.classify(rawTypeName: "Int8"))) - } - - @Test("Int16 classifies as integer") - func int16() { - #expect(isInteger(classifier.classify(rawTypeName: "Int16"))) - } - - @Test("Int32 classifies as integer") - func int32() { - #expect(isInteger(classifier.classify(rawTypeName: "Int32"))) - } - - @Test("Int64 classifies as integer") - func int64() { - #expect(isInteger(classifier.classify(rawTypeName: "Int64"))) - } - - @Test("Int128 classifies as integer") - func int128() { - #expect(isInteger(classifier.classify(rawTypeName: "Int128"))) - } - - @Test("Int256 classifies as integer") - func int256() { - #expect(isInteger(classifier.classify(rawTypeName: "Int256"))) - } - - @Test("UInt8 classifies as integer") - func uint8() { - #expect(isInteger(classifier.classify(rawTypeName: "UInt8"))) - } - - @Test("UInt16 classifies as integer") - func uint16() { - #expect(isInteger(classifier.classify(rawTypeName: "UInt16"))) - } - - @Test("UInt32 classifies as integer") - func uint32() { - #expect(isInteger(classifier.classify(rawTypeName: "UInt32"))) - } - - @Test("UInt64 classifies as integer") - func uint64() { - #expect(isInteger(classifier.classify(rawTypeName: "UInt64"))) - } - - @Test("UInt128 classifies as integer") - func uint128() { - #expect(isInteger(classifier.classify(rawTypeName: "UInt128"))) - } - - @Test("UInt256 classifies as integer") - func uint256() { - #expect(isInteger(classifier.classify(rawTypeName: "UInt256"))) + @Test("Classifies as integer", arguments: ["Int8", "Int16", "Int32", "Int64", "Int128", "Int256", "UInt8", "UInt16", "UInt32", "UInt64", "UInt128", "UInt256"]) + func classifiesAsInteger(_ value: String) { + #expect(isInteger(classifier.classify(rawTypeName: value))) } @Test("Date32 classifies as date") @@ -611,34 +527,9 @@ struct ColumnTypeClassifierTests { return false } - @Test("UTINYINT classifies as integer") - func utinyint() { - #expect(isInteger(classifier.classify(rawTypeName: "UTINYINT"))) - } - - @Test("USMALLINT classifies as integer") - func usmallint() { - #expect(isInteger(classifier.classify(rawTypeName: "USMALLINT"))) - } - - @Test("UINTEGER classifies as integer") - func uinteger() { - #expect(isInteger(classifier.classify(rawTypeName: "UINTEGER"))) - } - - @Test("UBIGINT classifies as integer") - func ubigint() { - #expect(isInteger(classifier.classify(rawTypeName: "UBIGINT"))) - } - - @Test("HUGEINT classifies as integer") - func hugeint() { - #expect(isInteger(classifier.classify(rawTypeName: "HUGEINT"))) - } - - @Test("UHUGEINT classifies as integer") - func uhugeint() { - #expect(isInteger(classifier.classify(rawTypeName: "UHUGEINT"))) + @Test("Classifies as integer", arguments: ["UTINYINT", "USMALLINT", "UINTEGER", "UBIGINT", "HUGEINT", "UHUGEINT"]) + func classifiesAsIntegerCases(_ value: String) { + #expect(isInteger(classifier.classify(rawTypeName: value))) } @Test("ENUM classifies as enum") @@ -656,19 +547,9 @@ struct ColumnTypeClassifierTests { #expect(classifier.classify(rawTypeName: "BLOB").isBlobType) } - @Test("TIMESTAMP_S classifies as timestamp") - func timestampS() { - #expect(isTimestamp(classifier.classify(rawTypeName: "TIMESTAMP_S"))) - } - - @Test("TIMESTAMP_MS classifies as timestamp") - func timestampMs() { - #expect(isTimestamp(classifier.classify(rawTypeName: "TIMESTAMP_MS"))) - } - - @Test("TIMESTAMP_NS classifies as timestamp") - func timestampNs() { - #expect(isTimestamp(classifier.classify(rawTypeName: "TIMESTAMP_NS"))) + @Test("Classifies as timestamp", arguments: ["TIMESTAMP_S", "TIMESTAMP_MS", "TIMESTAMP_NS"]) + func classifiesAsTimestamp(_ value: String) { + #expect(isTimestamp(classifier.classify(rawTypeName: value))) } @Test("TIMESTAMPTZ classifies as timestamp") diff --git a/TableProTests/Core/Services/DateFormattingServiceTests.swift b/TableProTests/Core/Services/DateFormattingServiceTests.swift index 7b71a6034a..19f1a07e98 100644 --- a/TableProTests/Core/Services/DateFormattingServiceTests.swift +++ b/TableProTests/Core/Services/DateFormattingServiceTests.swift @@ -6,6 +6,7 @@ import Foundation import TableProPluginKit @testable import TablePro +import TableProTestSupport import Testing @MainActor @@ -342,13 +343,11 @@ struct DateFormattingServiceTimeZoneTests { } @Test("A system time zone change raises the generation the grid caches on") - func systemTimeZoneChangeRaisesTheGeneration() async { + func systemTimeZoneChangeRaisesTheGeneration() async throws { let before = DateFormattingService.shared.systemTimeZoneGeneration NotificationCenter.default.post(name: .NSSystemTimeZoneDidChange, object: nil) - for _ in 0 ..< 100 where DateFormattingService.shared.systemTimeZoneGeneration == before { - try? await Task.sleep(for: .milliseconds(10)) - } + try await waitUntil { DateFormattingService.shared.systemTimeZoneGeneration != before } #expect(DateFormattingService.shared.systemTimeZoneGeneration > before) } diff --git a/TableProTests/Core/Services/ForeignApp/BeekeeperStudioImporterTests.swift b/TableProTests/Core/Services/ForeignApp/BeekeeperStudioImporterTests.swift index 5a081e052c..0848ad984e 100644 --- a/TableProTests/Core/Services/ForeignApp/BeekeeperStudioImporterTests.swift +++ b/TableProTests/Core/Services/ForeignApp/BeekeeperStudioImporterTests.swift @@ -9,7 +9,6 @@ import SQLite3 import TableProImport import Testing -@Suite("BeekeeperStudioImporter", .serialized) struct BeekeeperStudioImporterTests { private let tempDir: URL private var importer: BeekeeperStudioImporter diff --git a/TableProTests/Core/Services/ForeignApp/DBeaverImporterTests.swift b/TableProTests/Core/Services/ForeignApp/DBeaverImporterTests.swift index fd771c6ab7..21f2d573e3 100644 --- a/TableProTests/Core/Services/ForeignApp/DBeaverImporterTests.swift +++ b/TableProTests/Core/Services/ForeignApp/DBeaverImporterTests.swift @@ -10,7 +10,6 @@ import TableProPluginKit @testable import TablePro import Testing -@Suite("DBeaverImporter", .serialized) struct DBeaverImporterTests { private var tempDir: URL private var projectDir: URL diff --git a/TableProTests/Core/Services/ForeignApp/DataGripImporterTests.swift b/TableProTests/Core/Services/ForeignApp/DataGripImporterTests.swift index fc56e2da74..4299138162 100644 --- a/TableProTests/Core/Services/ForeignApp/DataGripImporterTests.swift +++ b/TableProTests/Core/Services/ForeignApp/DataGripImporterTests.swift @@ -8,7 +8,6 @@ import Foundation import TableProImport import Testing -@Suite("DataGripImporter", .serialized) struct DataGripImporterTests { private let root: URL private let optionsDir: URL diff --git a/TableProTests/Core/Services/ForeignApp/JetBrainsCredentialStoreTests.swift b/TableProTests/Core/Services/ForeignApp/JetBrainsCredentialStoreTests.swift index 10c694dc51..7b434f475c 100644 --- a/TableProTests/Core/Services/ForeignApp/JetBrainsCredentialStoreTests.swift +++ b/TableProTests/Core/Services/ForeignApp/JetBrainsCredentialStoreTests.swift @@ -7,7 +7,6 @@ import Foundation @testable import TablePro import Testing -@Suite("JetBrainsCredentialStore", .serialized) struct JetBrainsCredentialStoreTests { private let configDir: URL diff --git a/TableProTests/Core/Services/ForeignApp/NavicatImporterTests.swift b/TableProTests/Core/Services/ForeignApp/NavicatImporterTests.swift index 3ef05c3c40..d882ecc967 100644 --- a/TableProTests/Core/Services/ForeignApp/NavicatImporterTests.swift +++ b/TableProTests/Core/Services/ForeignApp/NavicatImporterTests.swift @@ -9,7 +9,6 @@ import TableProImport import Testing import UniformTypeIdentifiers -@Suite("NavicatImporter", .serialized) struct NavicatImporterTests { private var tempDir: URL private var importer: NavicatImporter diff --git a/TableProTests/Core/Services/ForeignApp/SequelAceImporterTests.swift b/TableProTests/Core/Services/ForeignApp/SequelAceImporterTests.swift index b0a678f348..afcd8c905a 100644 --- a/TableProTests/Core/Services/ForeignApp/SequelAceImporterTests.swift +++ b/TableProTests/Core/Services/ForeignApp/SequelAceImporterTests.swift @@ -9,7 +9,6 @@ import TableProPluginKit @testable import TablePro import Testing -@Suite("SequelAceImporter", .serialized) struct SequelAceImporterTests { private var tempDir: URL private var importer: SequelAceImporter diff --git a/TableProTests/Core/Services/ForeignApp/TablePlusImporterTests.swift b/TableProTests/Core/Services/ForeignApp/TablePlusImporterTests.swift index 61bc763703..371caad2de 100644 --- a/TableProTests/Core/Services/ForeignApp/TablePlusImporterTests.swift +++ b/TableProTests/Core/Services/ForeignApp/TablePlusImporterTests.swift @@ -9,7 +9,6 @@ import TableProPluginKit @testable import TablePro import Testing -@Suite("TablePlusImporter", .serialized) struct TablePlusImporterTests { private var tempDir: URL private var importer: TablePlusImporter @@ -1124,16 +1123,12 @@ struct TablePlusImporterTests { } @Test("The default ViewSetting reader reads the named app's defaults domain") - func testDefaultViewSettingReaderReadsTheForeignDomain() { - let domain = "com.TablePro.tests.TablePlusViewSetting.\(UUID().uuidString)" + func testDefaultViewSettingReaderReadsTheForeignDomain() throws { + let suite = try EphemeralDefaults() let key = "ViewSetting" as CFString - CFPreferencesSetAppValue(key, ["SharedQueryPath": "~/Queries"] as CFDictionary, domain as CFString) - defer { - CFPreferencesSetAppValue(key, nil, domain as CFString) - CFPreferencesAppSynchronize(domain as CFString) - } + CFPreferencesSetAppValue(key, ["SharedQueryPath": "~/Queries"] as CFDictionary, suite.suiteName as CFString) - let setting = TablePlusImporter().readViewSetting(domain) + let setting = TablePlusImporter().readViewSetting(suite.suiteName) #expect(setting?["SharedQueryPath"] as? String == "~/Queries") #expect(TablePlusImporter().readViewSetting("com.TablePro.tests.missing.\(UUID().uuidString)") == nil) diff --git a/TableProTests/Core/Services/Formatting/CellImageRendererTests.swift b/TableProTests/Core/Services/Formatting/CellImageRendererTests.swift index 728c27a7aa..1e0cd8d0d2 100644 --- a/TableProTests/Core/Services/Formatting/CellImageRendererTests.swift +++ b/TableProTests/Core/Services/Formatting/CellImageRendererTests.swift @@ -45,7 +45,6 @@ private func encodedPng(width: Int, height: Int) -> Data { } @MainActor -@Suite("CellImageRenderer", .serialized) struct CellImageRendererTests { @Test("a raster value renders and reports the stored pixel size") func rasterRendersAtStoredSize() async { diff --git a/TableProTests/Core/Services/Import/MacImportLibraryStoreTests.swift b/TableProTests/Core/Services/Import/MacImportLibraryStoreTests.swift index 58fea8e49e..014c5cbd6c 100644 --- a/TableProTests/Core/Services/Import/MacImportLibraryStoreTests.swift +++ b/TableProTests/Core/Services/Import/MacImportLibraryStoreTests.swift @@ -275,8 +275,9 @@ struct MacImportLibraryStoreTests { let root = FileManager.default.temporaryDirectory .appendingPathComponent("MacImportEnvironment-\(UUID().uuidString)", isDirectory: true) defer { try? FileManager.default.removeItem(at: root) } + let suite = try EphemeralDefaults() let manager = PluginManager( - userDefaults: try #require(UserDefaults(suiteName: "MacImportEnvironment.\(UUID().uuidString)")), + userDefaults: suite.defaults, builtInPluginsURL: nil, userPluginsDir: root.appendingPathComponent("Plugins", isDirectory: true) ) diff --git a/TableProTests/Core/Services/Infrastructure/AgentModeWindowTests.swift b/TableProTests/Core/Services/Infrastructure/AgentModeWindowTests.swift index a4e62791b8..0aebd89ab2 100644 --- a/TableProTests/Core/Services/Infrastructure/AgentModeWindowTests.swift +++ b/TableProTests/Core/Services/Infrastructure/AgentModeWindowTests.swift @@ -25,30 +25,28 @@ struct AgentModeWindowTests { /// lives exactly as long as the browse tree's identity does. A rebuild makes a new one. @Test("Toggling Agent mode on and off keeps the browse pane and the tree in it") func toggleKeepsTheBrowseTree() throws { - try AIFeatureScope.enabled { - let harness = try Harness() - defer { harness.tearDown() } - try harness.requireContent() - let detail = harness.selected.panes.detail - let accessor = try #require( - harness.settle { detail.view.firstDescendant(of: WindowAccessorView.self) }, - "The browse content never mounted, so the case proves nothing" - ) + let harness = try Harness() + defer { harness.tearDown() } + try harness.requireContent() + let detail = harness.selected.panes.detail + let accessor = try #require( + harness.settle { detail.view.firstDescendant(of: WindowAccessorView.self) }, + "The browse content never mounted, so the case proves nothing" + ) - harness.controller.setContentMode(.agent) + harness.controller.setContentMode(.agent) - #expect(harness.controller.detailPaneHost.shown === harness.selected.panes.agentConversation) - #expect(harness.controller.shownSidebarPane === harness.selected.panes.agentRail) - #expect(detail.view.superview == nil, "The browse pane is unparented, not rebuilt in place") - harness.drain() + #expect(harness.controller.detailPaneHost.shown === harness.selected.panes.agentConversation) + #expect(harness.controller.shownSidebarPane === harness.selected.panes.agentRail) + #expect(detail.view.superview == nil, "The browse pane is unparented, not rebuilt in place") + harness.drain() - harness.controller.setContentMode(.browse) + harness.controller.setContentMode(.browse) - #expect(harness.controller.detailPaneHost.shown === detail) - #expect(harness.controller.shownSidebarPane === harness.selected.panes.sidebar) - let remounted = harness.settle { detail.view.firstDescendant(of: WindowAccessorView.self) } - #expect(remounted === accessor, "The browse tree was rebuilt, and everything only it held went with it") - } + #expect(harness.controller.detailPaneHost.shown === detail) + #expect(harness.controller.shownSidebarPane === harness.selected.panes.sidebar) + let remounted = harness.settle { detail.view.firstDescendant(of: WindowAccessorView.self) } + #expect(remounted === accessor, "The browse tree was rebuilt, and everything only it held went with it") } // MARK: - Agent mode's panes are built once @@ -57,51 +55,47 @@ struct AgentModeWindowTests { /// long as the rail's identity does. @Test("Agent mode's panes are built once and kept across toggles") func agentPanesAreReused() throws { - try AIFeatureScope.enabled { - let harness = try Harness() - defer { harness.tearDown() } - try harness.requireContent() - let rail = harness.selected.panes.agentRail - let conversation = harness.selected.panes.agentConversation - - harness.controller.setContentMode(.agent) - #expect(harness.controller.shownSidebarPane === rail) - #expect(harness.controller.detailPaneHost.shown === conversation) - let list = try #require( - harness.settle { rail.view.firstDescendant(of: NSTableView.self) }, - "The session rail never listed its session" - ) - - harness.controller.setContentMode(.browse) - #expect(rail.view.superview == nil) - #expect(conversation.view.superview == nil) - harness.drain() - - harness.controller.setContentMode(.agent) - #expect(harness.controller.shownSidebarPane === rail) - #expect(harness.controller.detailPaneHost.shown === conversation) - #expect(harness.settle { rail.view.firstDescendant(of: NSTableView.self) } === list) - } + let harness = try Harness() + defer { harness.tearDown() } + try harness.requireContent() + let rail = harness.selected.panes.agentRail + let conversation = harness.selected.panes.agentConversation + + harness.controller.setContentMode(.agent) + #expect(harness.controller.shownSidebarPane === rail) + #expect(harness.controller.detailPaneHost.shown === conversation) + let list = try #require( + harness.settle { rail.view.firstDescendant(of: NSTableView.self) }, + "The session rail never listed its session" + ) + + harness.controller.setContentMode(.browse) + #expect(rail.view.superview == nil) + #expect(conversation.view.superview == nil) + harness.drain() + + harness.controller.setContentMode(.agent) + #expect(harness.controller.shownSidebarPane === rail) + #expect(harness.controller.detailPaneHost.shown === conversation) + #expect(harness.settle { rail.view.firstDescendant(of: NSTableView.self) } === list) } /// A session already exists, so a rail built while browsing would list it. The pane is mounted /// in a window of its own to look, because an unmounted pane shows nothing whatever it holds. @Test("Browsing builds nothing of Agent mode") func browsingBuildsNoAgentContent() throws { - try AIFeatureScope.enabled { - let harness = try Harness(startsAgentSession: true) - defer { harness.tearDown() } - try harness.requireContent() - let rail = harness.selected.panes.agentRail - - let probe = PaneProbeWindow(showing: rail.view) - probe.settle() - #expect(rail.view.firstDescendant(of: NSTableView.self) == nil, "The rail was built for a mode nobody entered") - probe.close() - - harness.controller.setContentMode(.agent) - #expect(harness.settle { rail.view.firstDescendant(of: NSTableView.self) } != nil) - } + let harness = try Harness(startsAgentSession: true) + defer { harness.tearDown() } + try harness.requireContent() + let rail = harness.selected.panes.agentRail + + let probe = PaneProbeWindow(showing: rail.view) + probe.settle() + #expect(rail.view.firstDescendant(of: NSTableView.self) == nil, "The rail was built for a mode nobody entered") + probe.close() + + harness.controller.setContentMode(.agent) + #expect(harness.settle { rail.view.firstDescendant(of: NSTableView.self) } != nil) } // MARK: - A background connection @@ -110,71 +104,65 @@ struct AgentModeWindowTests { /// screen or not, and parented only when it is selected. @Test("A connection put into Agent mode in the background shows its conversation once selected") func backgroundAgentModeIsParentedOnSelection() throws { - try AIFeatureScope.enabled { - let harness = TwoConnectionHarness() - defer { harness.tearDown() } - harness.controller.transition(to: .connecting, for: harness.background.connectionId) - try #require(harness.background.resolvedPane == .connecting) + let harness = TwoConnectionHarness() + defer { harness.tearDown() } + harness.controller.transition(to: .connecting, for: harness.background.connectionId) + try #require(harness.background.resolvedPane == .connecting) - harness.controller.setContentMode(.agent, for: harness.background.connectionId) + harness.controller.setContentMode(.agent, for: harness.background.connectionId) - #expect(harness.background.panes.renderedKey?.contentMode == .agent) - #expect(harness.controller.detailPaneHost.shown === harness.foreground.panes.detail) - #expect(harness.background.panes.agentConversation.parent == nil) + #expect(harness.background.panes.renderedKey?.contentMode == .agent) + #expect(harness.controller.detailPaneHost.shown === harness.foreground.panes.detail) + #expect(harness.background.panes.agentConversation.parent == nil) - harness.controller.workspaces.select(harness.background.connectionId) + harness.controller.workspaces.select(harness.background.connectionId) - #expect(harness.controller.detailPaneHost.shown === harness.background.panes.agentConversation) - #expect(harness.controller.shownSidebarPane === harness.background.panes.agentRail) - #expect(harness.controller.inspectorPaneHost.shown === harness.background.panes.agentResult) - } + #expect(harness.controller.detailPaneHost.shown === harness.background.panes.agentConversation) + #expect(harness.controller.shownSidebarPane === harness.background.panes.agentRail) + #expect(harness.controller.inspectorPaneHost.shown === harness.background.panes.agentResult) } // MARK: - The window describes what it draws @Test("Agent mode takes the tab strip down and names the window after the session") func agentModeDropsTheTabStripAndRenamesTheWindow() throws { - try AIFeatureScope.enabled { - let harness = try Harness() - defer { harness.tearDown() } - try harness.requireContent() - let tabManager = try #require(harness.selected.sessionState?.tabManager) - tabManager.addTab(initialQuery: "SELECT 1") - tabManager.addTab(initialQuery: "SELECT 2") - harness.controller.applyTabStripVisibility() - harness.controller.applyWindowTitle() - try #require(!harness.controller.tabStripAccessory.isHidden, "Two tabs over browse content show the strip") - let browseTitle = harness.controller.windowTitle - - harness.controller.setContentMode(.agent) - - #expect(harness.controller.tabStripAccessory.isHidden) - #expect(harness.controller.windowTitle == ConnectionWorkspaceContentMode.agent.localizedTitle) - #expect(harness.window.title == harness.controller.windowTitle) - - harness.controller.setContentMode(.browse) - - #expect(!harness.controller.tabStripAccessory.isHidden) - #expect(harness.controller.windowTitle == browseTitle) - } + let harness = try Harness() + defer { harness.tearDown() } + try harness.requireContent() + let tabManager = try #require(harness.selected.sessionState?.tabManager) + tabManager.addTab(initialQuery: "SELECT 1") + tabManager.addTab(initialQuery: "SELECT 2") + harness.controller.applyTabStripVisibility() + harness.controller.applyWindowTitle() + try #require(!harness.controller.tabStripAccessory.isHidden, "Two tabs over browse content show the strip") + let browseTitle = harness.controller.windowTitle + + harness.controller.setContentMode(.agent) + + #expect(harness.controller.tabStripAccessory.isHidden) + #expect(harness.controller.windowTitle == ConnectionWorkspaceContentMode.agent.localizedTitle) + #expect(harness.window.title == harness.controller.windowTitle) + + harness.controller.setContentMode(.browse) + + #expect(!harness.controller.tabStripAccessory.isHidden) + #expect(harness.controller.windowTitle == browseTitle) } /// A session names itself from its first question or reply, which lands after the mode came on. @Test("The window follows the session as it gets a name") func titleFollowsTheSession() async throws { - try await AIFeatureScope.enabled { - let harness = try Harness() - defer { harness.tearDown() } - try harness.requireContent() - harness.controller.setContentMode(.agent) - let session = try #require(harness.selected.displayedAgentSession) - #expect(harness.controller.windowTitle == ConnectionWorkspaceContentMode.agent.localizedTitle) - - session.viewModel.messages.append(ChatTurn(role: .user, blocks: [.text("Which orders shipped late?")])) - - #expect(await harness.suspend { harness.controller.windowTitle == "Which orders shipped late?" }) - #expect(harness.window.title == "Which orders shipped late?") - } + let harness = try Harness() + defer { harness.tearDown() } + try harness.requireContent() + harness.controller.setContentMode(.agent) + let session = try #require(harness.selected.displayedAgentSession) + #expect(harness.controller.windowTitle == ConnectionWorkspaceContentMode.agent.localizedTitle) + + session.viewModel.messages.append(ChatTurn(role: .user, blocks: [.text("Which orders shipped late?")])) + + #expect(await harness.suspend { harness.controller.windowTitle == "Which orders shipped late?" }) + #expect(harness.window.title == "Which orders shipped late?") } /// The proxy icon is the rest of what the titlebar says. The browse content wrote it straight to @@ -182,81 +170,75 @@ struct AgentModeWindowTests { /// session's name. @Test("Agent mode takes a file's proxy icon down and browsing puts it back") func agentModeDropsTheProxyIcon() throws { - try AIFeatureScope.enabled { - let harness = try Harness() - defer { harness.tearDown() } - try harness.requireContent() - let file = FileManager.default.temporaryDirectory - .appendingPathComponent("AgentModeWindowTests-\(UUID().uuidString).sql") - try Data("SELECT 1".utf8).write(to: file) - defer { try? FileManager.default.removeItem(at: file) } - let tabManager = try #require(harness.selected.sessionState?.tabManager) - tabManager.addTab(initialQuery: "SELECT 1", sourceFileURL: file) - harness.controller.applyWindowTitle() - try #require( - harness.window.representedURL == file, - "The file's tab never set the icon, so the case proves nothing" - ) - - harness.controller.setContentMode(.agent) - #expect(harness.window.representedURL == nil) - harness.drain() - #expect(harness.window.representedURL == nil, "The browse tree behind the conversation put its file back") - - harness.controller.setContentMode(.browse) - #expect(harness.window.representedURL == file) - } + let harness = try Harness() + defer { harness.tearDown() } + try harness.requireContent() + let file = FileManager.default.temporaryDirectory + .appendingPathComponent("AgentModeWindowTests-\(UUID().uuidString).sql") + try Data("SELECT 1".utf8).write(to: file) + defer { try? FileManager.default.removeItem(at: file) } + let tabManager = try #require(harness.selected.sessionState?.tabManager) + tabManager.addTab(initialQuery: "SELECT 1", sourceFileURL: file) + harness.controller.applyWindowTitle() + try #require( + harness.window.representedURL == file, + "The file's tab never set the icon, so the case proves nothing" + ) + + harness.controller.setContentMode(.agent) + #expect(harness.window.representedURL == nil) + harness.drain() + #expect(harness.window.representedURL == nil, "The browse tree behind the conversation put its file back") + + harness.controller.setContentMode(.browse) + #expect(harness.window.representedURL == file) } @Test("A Users & Roles tab behind the conversation does not set the detail column's floor") func agentModeKeepsTheDefaultDetailFloor() throws { - try AIFeatureScope.enabled { - let harness = try Harness() - defer { harness.tearDown() } - try harness.requireContent() - let tabManager = try #require(harness.selected.sessionState?.tabManager) - tabManager.adoptTab(QueryTab(id: UUID(), title: "Users & Roles", query: "", tabType: .usersRoles)) - harness.controller.updateDetailMinimumThickness(for: .usersRoles, connectionId: harness.connection.id) - try #require(harness.detailItem.minimumThickness == UsersRolesLayoutMetrics.tabMinimumWidth) - - harness.controller.setContentMode(.agent) - #expect(harness.detailItem.minimumThickness == MainSplitViewController.defaultDetailMinThickness) - - harness.controller.updateDetailMinimumThickness(for: .usersRoles, connectionId: harness.connection.id) - #expect( - harness.detailItem.minimumThickness == MainSplitViewController.defaultDetailMinThickness, - "The browse tree reporting its tab from behind the conversation raised it again" - ) - - harness.controller.setContentMode(.browse) - #expect(harness.detailItem.minimumThickness == UsersRolesLayoutMetrics.tabMinimumWidth) - } + let harness = try Harness() + defer { harness.tearDown() } + try harness.requireContent() + let tabManager = try #require(harness.selected.sessionState?.tabManager) + tabManager.adoptTab(QueryTab(id: UUID(), title: "Users & Roles", query: "", tabType: .usersRoles)) + harness.controller.updateDetailMinimumThickness(for: .usersRoles, connectionId: harness.connection.id) + try #require(harness.detailItem.minimumThickness == UsersRolesLayoutMetrics.tabMinimumWidth) + + harness.controller.setContentMode(.agent) + #expect(harness.detailItem.minimumThickness == MainSplitViewController.defaultDetailMinThickness) + + harness.controller.updateDetailMinimumThickness(for: .usersRoles, connectionId: harness.connection.id) + #expect( + harness.detailItem.minimumThickness == MainSplitViewController.defaultDetailMinThickness, + "The browse tree reporting its tab from behind the conversation raised it again" + ) + + harness.controller.setContentMode(.browse) + #expect(harness.detailItem.minimumThickness == UsersRolesLayoutMetrics.tabMinimumWidth) } /// The error, Retry and Manage Connections live on the browse side's unavailable screen, and a /// composer with none of them is a dead end. A reconnect hands the column back. @Test("A connection that drops in Agent mode shows the unavailable screen, and a reconnect the conversation") func droppedConnectionHandsTheColumnBack() throws { - try AIFeatureScope.enabled { - let harness = try Harness() - defer { harness.tearDown() } - try harness.requireContent() - harness.controller.setContentMode(.agent) - #expect(harness.controller.detailPaneHost.shown === harness.selected.panes.agentConversation) + let harness = try Harness() + defer { harness.tearDown() } + try harness.requireContent() + harness.controller.setContentMode(.agent) + #expect(harness.controller.detailPaneHost.shown === harness.selected.panes.agentConversation) - DatabaseManager.shared.removeSession(for: harness.connection.id) - harness.controller.refreshFromActiveSessions() + DatabaseManager.shared.removeSession(for: harness.connection.id) + harness.controller.refreshFromActiveSessions() - #expect(!harness.controller.currentPane.hasContent) - #expect(harness.controller.detailPaneHost.shown === harness.selected.panes.detail) - #expect(harness.controller.windowTitle == harness.connection.name) + #expect(!harness.controller.currentPane.hasContent) + #expect(harness.controller.detailPaneHost.shown === harness.selected.panes.detail) + #expect(harness.controller.windowTitle == harness.connection.name) - harness.inject(status: .connected) - harness.controller.refreshFromActiveSessions() + harness.inject(status: .connected) + harness.controller.refreshFromActiveSessions() - #expect(harness.controller.currentPane == .content) - #expect(harness.controller.detailPaneHost.shown === harness.selected.panes.agentConversation) - } + #expect(harness.controller.currentPane == .content) + #expect(harness.controller.detailPaneHost.shown === harness.selected.panes.agentConversation) } /// The conversation stays built, detached, behind the unavailable screen of a connection that @@ -264,30 +246,28 @@ struct AgentModeWindowTests { /// enabled and moved focus off Retry and onto the window. @Test("Focus Assistant is dimmed once the conversation has left the detail column") func focusAssistantFollowsTheConversationOffScreen() throws { - try AIFeatureScope.enabled { - let harness = try Harness() - defer { harness.tearDown() } - try harness.requireContent() - harness.controller.setContentMode(.agent) - /// Stands in for the composer the conversation draws once a session has a provider to - /// answer it, which a unit test has no way to configure. - let composer = ChatComposerNSTextView.make() - harness.selected.panes.agentConversation.view.addSubview(composer) - let item = Self.menuItem(#selector(MainSplitViewController.focusAssistant(_:))) - try #require( - harness.controller.validateMenuItem(item), - "The composer on screen was not reachable, so the case proves nothing" - ) - - DatabaseManager.shared.removeSession(for: harness.connection.id) - harness.controller.refreshFromActiveSessions() - try #require(harness.controller.detailPaneHost.shown === harness.selected.panes.detail) - try #require(composer.superview != nil, "The conversation was torn down rather than kept") - - #expect(!harness.controller.validateMenuItem(item)) - #expect(!harness.controller.focusAssistantPane()) - #expect(harness.window.firstResponder !== composer) - } + let harness = try Harness() + defer { harness.tearDown() } + try harness.requireContent() + harness.controller.setContentMode(.agent) + /// Stands in for the composer the conversation draws once a session has a provider to + /// answer it, which a unit test has no way to configure. + let composer = ChatComposerNSTextView.make() + harness.selected.panes.agentConversation.view.addSubview(composer) + let item = Self.menuItem(#selector(MainSplitViewController.focusAssistant(_:))) + try #require( + harness.controller.validateMenuItem(item), + "The composer on screen was not reachable, so the case proves nothing" + ) + + DatabaseManager.shared.removeSession(for: harness.connection.id) + harness.controller.refreshFromActiveSessions() + try #require(harness.controller.detailPaneHost.shown === harness.selected.panes.detail) + try #require(composer.superview != nil, "The conversation was torn down rather than kept") + + #expect(!harness.controller.validateMenuItem(item)) + #expect(!harness.controller.focusAssistantPane()) + #expect(harness.window.firstResponder !== composer) } // MARK: - One registry @@ -297,16 +277,14 @@ struct AgentModeWindowTests { /// workspace had been handed, so the two could name different sessions for one connection. @Test("The trailing pane state the window builds shares the workspace's registry") func builtPaneStateSharesTheWorkspaceRegistry() throws { - try AIFeatureScope.enabled { - let harness = try Harness(prebuildsPaneState: false) - defer { harness.tearDown() } - try harness.requireContent() - let paneState = try #require(harness.selected.trailingPaneState, "The window built no pane state") + let harness = try Harness(prebuildsPaneState: false) + defer { harness.tearDown() } + try harness.requireContent() + let paneState = try #require(harness.selected.trailingPaneState, "The window built no pane state") - let session = harness.agentSessions.startSession(for: harness.connection.id) + let session = harness.agentSessions.startSession(for: harness.connection.id) - #expect(paneState.assistant.session === session) - } + #expect(paneState.assistant.session === session) } /// Every toggle re-runs the conversation's tasks on the same view. A prompt held for the connect @@ -316,34 +294,32 @@ struct AgentModeWindowTests { /// case knows the toggle really re-ran it rather than passing because nothing ran at all. @Test("A toggle during the connect neither sends the held prompt nor starts a second session") func toggleDuringTheConnectKeepsTheHeldPrompt() async throws { - try await AIFeatureScope.enabled { - let harness = try Harness(sessionStatus: .connecting) - defer { harness.tearDown() } - try #require(harness.controller.currentPane == .connecting) - let session = harness.agentSessions.startSession(for: harness.connection.id) - session.pendingPrompt = "Which orders shipped late?" - - harness.controller.setContentMode(.agent) - #expect(harness.controller.detailPaneHost.shown === harness.selected.panes.agentConversation) - try #require( - await harness.suspend { session.viewModel.isAwaitingConnection }, - "The conversation never ran its flush" - ) - - harness.controller.setContentMode(.browse) - await harness.pause() - session.viewModel.isAwaitingConnection = false - harness.controller.setContentMode(.agent) - try #require( - await harness.suspend { session.viewModel.isAwaitingConnection }, - "Coming back did not run the flush again, so the case proves nothing" - ) - - #expect(session.pendingPrompt == "Which orders shipped late?") - #expect(session.viewModel.messages.isEmpty) - #expect(session.viewModel.activeConversationID == nil) - #expect(harness.agentSessions.sessions(for: harness.connection.id).map(\.id) == [session.id]) - } + let harness = try Harness(sessionStatus: .connecting) + defer { harness.tearDown() } + try #require(harness.controller.currentPane == .connecting) + let session = harness.agentSessions.startSession(for: harness.connection.id) + session.pendingPrompt = "Which orders shipped late?" + + harness.controller.setContentMode(.agent) + #expect(harness.controller.detailPaneHost.shown === harness.selected.panes.agentConversation) + try #require( + await harness.suspend { session.viewModel.isAwaitingConnection }, + "The conversation never ran its flush" + ) + + harness.controller.setContentMode(.browse) + await harness.pause() + session.viewModel.isAwaitingConnection = false + harness.controller.setContentMode(.agent) + try #require( + await harness.suspend { session.viewModel.isAwaitingConnection }, + "Coming back did not run the flush again, so the case proves nothing" + ) + + #expect(session.pendingPrompt == "Which orders shipped late?") + #expect(session.viewModel.messages.isEmpty) + #expect(session.viewModel.activeConversationID == nil) + #expect(harness.agentSessions.sessions(for: harness.connection.id).map(\.id) == [session.id]) } // MARK: - Safe Mode @@ -373,24 +349,22 @@ struct AgentModeWindowTests { /// The list had no checkmark and no entry in it did anything. @Test("The Safe Mode list works in a window opened straight into Agent mode") func safeModeWorksWithoutTheBrowseContent() throws { - try AIFeatureScope.enabled { - let harness = try Harness(contentMode: .agent) - defer { harness.tearDown() } - try harness.requireContent() - try #require( - harness.controller.commandActions == nil, - "The browse content mounted, so this is not the window the welcome route opens" - ) - let status = try #require(harness.controller.safeModeStatus, "The list had no level to check") - let pick = try #require(status.offeredLevels.first { status.accepts($0) }) - let item = Self.safeModeItem(pick) - #expect(harness.controller.validateMenuItem(item)) - - harness.controller.setSafeModeLevel(item) - - #expect(DatabaseManager.shared.session(for: harness.connection.id)?.safeModeLevel == pick) - #expect(harness.controller.safeModeStatus?.level == pick) - } + let harness = try Harness(contentMode: .agent) + defer { harness.tearDown() } + try harness.requireContent() + try #require( + harness.controller.commandActions == nil, + "The browse content mounted, so this is not the window the welcome route opens" + ) + let status = try #require(harness.controller.safeModeStatus, "The list had no level to check") + let pick = try #require(status.offeredLevels.first { status.accepts($0) }) + let item = Self.safeModeItem(pick) + #expect(harness.controller.validateMenuItem(item)) + + harness.controller.setSafeModeLevel(item) + + #expect(DatabaseManager.shared.session(for: harness.connection.id)?.safeModeLevel == pick) + #expect(harness.controller.safeModeStatus?.level == pick) } // MARK: - Session commands @@ -399,158 +373,146 @@ struct AgentModeWindowTests { /// no pane: the conversation column went on drawing it, with a composer that still took messages. @Test("Close Session stops the session and takes it off the window") func closeSessionTakesTheSessionOffTheWindow() async throws { - try await AIFeatureScope.enabled { - let harness = try Harness(startsAgentSession: true) - defer { harness.tearDown() } - try harness.requireContent() - harness.controller.setContentMode(.agent) - let session = try #require(harness.selected.displayedAgentSession) - let asked = ConfirmationRecorder() - harness.controller.confirmAgentSessionCommand = asked.answer(true) - - harness.controller.closeAgentSession(Self.sessionItem(Self.closeSession, session: session.id)) - - #expect(await harness.suspend { harness.selected.displayedAgentSession == nil }) - #expect(session.status == .stopped) - #expect(asked.confirmations.isEmpty, "An idle session loses nothing by stopping, so nothing is asked") - #expect( - harness.agentSessions.sessions(for: harness.connection.id).count == 1, - "Closing keeps the transcript, so the session stays in the rail" - ) - #expect( - harness.selected.panes.renderedKey?.agentSessionId == nil, - "The panes were not rebuilt for a window with no session open" - ) - } + let harness = try Harness(startsAgentSession: true) + defer { harness.tearDown() } + try harness.requireContent() + harness.controller.setContentMode(.agent) + let session = try #require(harness.selected.displayedAgentSession) + let asked = ConfirmationRecorder() + harness.controller.confirmAgentSessionCommand = asked.answer(true) + + harness.controller.closeAgentSession(Self.sessionItem(Self.closeSession, session: session.id)) + + #expect(await harness.suspend { harness.selected.displayedAgentSession == nil }) + #expect(session.status == .stopped) + #expect(asked.confirmations.isEmpty, "An idle session loses nothing by stopping, so nothing is asked") + #expect( + harness.agentSessions.sessions(for: harness.connection.id).count == 1, + "Closing keeps the transcript, so the session stays in the rail" + ) + #expect( + harness.selected.panes.renderedKey?.agentSessionId == nil, + "The panes were not rebuilt for a window with no session open" + ) } @Test("Delete Session asks first, and discards the session once it is answered") func deleteSessionAsksAndDiscards() async throws { - try await AIFeatureScope.enabled { - let harness = try Harness(startsAgentSession: true) - defer { harness.tearDown() } - try harness.requireContent() - harness.controller.setContentMode(.agent) - let session = try #require(harness.selected.displayedAgentSession) - let asked = ConfirmationRecorder() - harness.controller.confirmAgentSessionCommand = asked.answer(true) - - harness.controller.deleteAgentSession(Self.sessionItem(Self.deleteSession, session: session.id)) - - #expect(await harness.suspend { harness.agentSessions.session(id: session.id) == nil }) - #expect(asked.confirmations.count == 1) - #expect(asked.confirmations.first?.isDestructive == true) - #expect(asked.confirmations.first?.title.contains(session.displayTitle) == true) - #expect(harness.selected.displayedAgentSession == nil) - } + let harness = try Harness(startsAgentSession: true) + defer { harness.tearDown() } + try harness.requireContent() + harness.controller.setContentMode(.agent) + let session = try #require(harness.selected.displayedAgentSession) + let asked = ConfirmationRecorder() + harness.controller.confirmAgentSessionCommand = asked.answer(true) + + harness.controller.deleteAgentSession(Self.sessionItem(Self.deleteSession, session: session.id)) + + #expect(await harness.suspend { harness.agentSessions.session(id: session.id) == nil }) + #expect(asked.confirmations.count == 1) + #expect(asked.confirmations.first?.isDestructive == true) + #expect(asked.confirmations.first?.title.contains(session.displayTitle) == true) + #expect(harness.selected.displayedAgentSession == nil) } @Test("A refused question leaves the session where it was") func deleteSessionRefused() async throws { - try await AIFeatureScope.enabled { - let harness = try Harness(startsAgentSession: true) - defer { harness.tearDown() } - try harness.requireContent() - harness.controller.setContentMode(.agent) - let session = try #require(harness.selected.displayedAgentSession) - let asked = ConfirmationRecorder() - harness.controller.confirmAgentSessionCommand = asked.answer(false) - - harness.controller.deleteAgentSession(Self.sessionItem(Self.deleteSession, session: session.id)) - - #expect(await harness.suspend { asked.confirmations.count == 1 }) - #expect(harness.agentSessions.session(id: session.id) === session) - #expect(harness.selected.displayedAgentSession === session) - } + let harness = try Harness(startsAgentSession: true) + defer { harness.tearDown() } + try harness.requireContent() + harness.controller.setContentMode(.agent) + let session = try #require(harness.selected.displayedAgentSession) + let asked = ConfirmationRecorder() + harness.controller.confirmAgentSessionCommand = asked.answer(false) + + harness.controller.deleteAgentSession(Self.sessionItem(Self.deleteSession, session: session.id)) + + #expect(await harness.suspend { asked.confirmations.count == 1 }) + #expect(harness.agentSessions.session(id: session.id) === session) + #expect(harness.selected.displayedAgentSession === session) } @Test("New Session starts one and puts it on screen") func newSessionStartsAndShowsIt() throws { - try AIFeatureScope.enabled { - let harness = try Harness(startsAgentSession: true) - defer { harness.tearDown() } - try harness.requireContent() - harness.controller.setContentMode(.agent) - let first = try #require(harness.selected.displayedAgentSession) - - harness.controller.newAgentSession(nil) - - let started = try #require(harness.selected.displayedAgentSession) - #expect(started !== first) - #expect(harness.agentSessions.sessions(for: harness.connection.id).count == 2) - #expect(harness.selected.panes.renderedKey?.agentSessionId == started.id) - } + let harness = try Harness(startsAgentSession: true) + defer { harness.tearDown() } + try harness.requireContent() + harness.controller.setContentMode(.agent) + let first = try #require(harness.selected.displayedAgentSession) + + harness.controller.newAgentSession(nil) + + let started = try #require(harness.selected.displayedAgentSession) + #expect(started !== first) + #expect(harness.agentSessions.sessions(for: harness.connection.id).count == 2) + #expect(harness.selected.panes.renderedKey?.agentSessionId == started.id) } /// A menu that lists sessions names one in each item; every other route acts on the rail's /// highlight, which is the window's rather than the rail view's for exactly this reason. @Test("Open Session takes its session from the item, and from the rail when the item names none") func openSessionResolvesItsTarget() throws { - try AIFeatureScope.enabled { - let harness = try Harness(startsAgentSession: true) - defer { harness.tearDown() } - try harness.requireContent() - harness.controller.setContentMode(.agent) - let first = try #require(harness.selected.displayedAgentSession) - harness.controller.newAgentSession(nil) - let second = try #require(harness.selected.displayedAgentSession) - - harness.controller.openAgentSession(Self.sessionItem(Self.openSession, session: first.id)) - #expect(harness.selected.displayedAgentSession === first) - - harness.selected.agentRail.highlightedSessionId = second.id - harness.controller.openAgentSession(nil) - #expect(harness.selected.displayedAgentSession === second) - } + let harness = try Harness(startsAgentSession: true) + defer { harness.tearDown() } + try harness.requireContent() + harness.controller.setContentMode(.agent) + let first = try #require(harness.selected.displayedAgentSession) + harness.controller.newAgentSession(nil) + let second = try #require(harness.selected.displayedAgentSession) + + harness.controller.openAgentSession(Self.sessionItem(Self.openSession, session: first.id)) + #expect(harness.selected.displayedAgentSession === first) + + harness.selected.agentRail.highlightedSessionId = second.id + harness.controller.openAgentSession(nil) + #expect(harness.selected.displayedAgentSession === second) } @Test("The session commands follow the mode and the session they would act on") func sessionCommandsValidateAgainstTheirTarget() throws { - try AIFeatureScope.enabled { - let harness = try Harness(startsAgentSession: true) - defer { harness.tearDown() } - try harness.requireContent() - - for selector in [Self.newSession, Self.openSession, Self.closeSession, Self.deleteSession] { - #expect( - !harness.controller.validateMenuItem(Self.menuItem(selector)), - "Browsing draws no rail, so \(selector) has nothing to act on" - ) - } - - harness.controller.setContentMode(.agent) - let session = try #require(harness.selected.displayedAgentSession) - #expect(harness.controller.validateMenuItem(Self.menuItem(Self.newSession))) - - /// The rail highlights the session on screen as it appears, so a rail with nothing - /// highlighted is asked for here rather than waited for. - harness.selected.agentRail.highlightedSessionId = nil - for selector in [Self.openSession, Self.closeSession, Self.deleteSession] { - #expect( - !harness.controller.validateMenuItem(Self.menuItem(selector)), - "With no row highlighted there is no session for \(selector)" - ) - } + let harness = try Harness(startsAgentSession: true) + defer { harness.tearDown() } + try harness.requireContent() - harness.selected.agentRail.highlightedSessionId = session.id - for selector in [Self.openSession, Self.closeSession, Self.deleteSession] { - #expect(harness.controller.validateMenuItem(Self.menuItem(selector)), "\(selector)") - } + for selector in [Self.newSession, Self.openSession, Self.closeSession, Self.deleteSession] { #expect( - !harness.controller.validateMenuItem(Self.sessionItem(Self.openSession, session: UUID())), - "An item naming a session that is gone is answered by the item, not by the highlight" + !harness.controller.validateMenuItem(Self.menuItem(selector)), + "Browsing draws no rail, so \(selector) has nothing to act on" ) + } - harness.agentSessions.stopSession(id: session.id) - #expect( - !harness.controller.validateMenuItem(Self.menuItem(Self.closeSession)), - "A session that has already ended cannot be closed again" - ) + harness.controller.setContentMode(.agent) + let session = try #require(harness.selected.displayedAgentSession) + #expect(harness.controller.validateMenuItem(Self.menuItem(Self.newSession))) + + /// The rail highlights the session on screen as it appears, so a rail with nothing + /// highlighted is asked for here rather than waited for. + harness.selected.agentRail.highlightedSessionId = nil + for selector in [Self.openSession, Self.closeSession, Self.deleteSession] { #expect( - harness.controller.validateMenuItem(Self.menuItem(Self.deleteSession)), - "A stopped session keeps its transcript, so it is still there to delete" + !harness.controller.validateMenuItem(Self.menuItem(selector)), + "With no row highlighted there is no session for \(selector)" ) } + + harness.selected.agentRail.highlightedSessionId = session.id + for selector in [Self.openSession, Self.closeSession, Self.deleteSession] { + #expect(harness.controller.validateMenuItem(Self.menuItem(selector)), "\(selector)") + } + #expect( + !harness.controller.validateMenuItem(Self.sessionItem(Self.openSession, session: UUID())), + "An item naming a session that is gone is answered by the item, not by the highlight" + ) + + harness.agentSessions.stopSession(id: session.id) + #expect( + !harness.controller.validateMenuItem(Self.menuItem(Self.closeSession)), + "A session that has already ended cannot be closed again" + ) + #expect( + harness.controller.validateMenuItem(Self.menuItem(Self.deleteSession)), + "A stopped session keeps its transcript, so it is still there to delete" + ) } private static let newSession = #selector(MainSplitViewController.newAgentSession(_:)) @@ -601,8 +563,7 @@ struct AgentModeWindowTests { let agentSessions: AgentSessionRegistry let window: NSWindow let connection: DatabaseConnection - private let defaults: UserDefaults - private let suiteName: String + private let suite: EphemeralDefaults private let registryDirectory: URL var detailItem: NSSplitViewItem { @@ -622,8 +583,8 @@ struct AgentModeWindowTests { prebuildsPaneState: Bool = true ) throws { connection = TestFixtures.makeConnection(name: "Agent window", type: type) - suiteName = "AgentModeWindowTests.\(UUID().uuidString)" - defaults = try #require(UserDefaults(suiteName: suiteName)) + suite = try EphemeralDefaults() + let defaults = suite.defaults registryDirectory = FileManager.default.temporaryDirectory .appendingPathComponent("AgentModeWindowTests-\(UUID().uuidString)", isDirectory: true) agentSessions = AgentSessionRegistry(store: AgentSessionStore(directory: registryDirectory)) @@ -740,7 +701,7 @@ struct AgentModeWindowTests { window.contentViewController = nil selected.teardown() DatabaseManager.shared.removeSession(for: connection.id) - defaults.removePersistentDomain(forName: suiteName) + suite.defaults.removePersistentDomain(forName: suite.suiteName) try? FileManager.default.removeItem(at: registryDirectory) } } diff --git a/TableProTests/Core/Services/Infrastructure/AppLaunchCoordinatorTests.swift b/TableProTests/Core/Services/Infrastructure/AppLaunchCoordinatorTests.swift index c791be79a4..931581d759 100644 --- a/TableProTests/Core/Services/Infrastructure/AppLaunchCoordinatorTests.swift +++ b/TableProTests/Core/Services/Infrastructure/AppLaunchCoordinatorTests.swift @@ -1,166 +1,166 @@ -// -// AppLaunchCoordinatorTests.swift -// TableProTests -// - +import Foundation @testable import TablePro -import XCTest +import TableProTestSupport +import Testing @MainActor -final class AppLaunchCoordinatorTests: XCTestCase { - private var environment: RecordingLaunchEnvironment! - private var coordinator: AppLaunchCoordinator! +struct AppLaunchCoordinatorTests { + private let environment = RecordingLaunchEnvironment() + private let coordinator: AppLaunchCoordinator - override func setUp() async throws { - try await super.setUp() - environment = RecordingLaunchEnvironment() + init() { coordinator = AppLaunchCoordinator(environment: environment) } - override func tearDown() async throws { - coordinator = nil - environment = nil - try await super.tearDown() - } - - func testNothingIsRoutedBeforeTheFirstTurn() async { + @Test + func nothingIsRoutedBeforeTheFirstTurn() { let connectionId = UUID() coordinator.deliver([.openConnection(connectionId)]) coordinator.didFinishLaunching() - XCTAssertEqual(coordinator.phase, .collectingIntents) - XCTAssertTrue(environment.routedConnectionIds.isEmpty) - XCTAssertEqual(environment.pendingTurnCount, 1) + #expect(coordinator.phase == .collectingIntents) + #expect(environment.routedConnectionIds.isEmpty) + #expect(environment.pendingTurnCount == 1) } /// The gesture that launched the app reaches `application(_:open:)` before /// `applicationDidFinishLaunching`, so this is the ordinary case, not an edge one. - func testAnIntentDeliveredBeforeLaunchFinishesIsRoutedOnce() async { + @Test + func anIntentDeliveredBeforeLaunchFinishesIsRoutedOnce() async throws { let connectionId = UUID() coordinator.deliver([.openConnection(connectionId)]) coordinator.didFinishLaunching() - await environment.runNextTurnAndWaitForCompletion() + try await environment.runNextTurnAndWaitForCompletion() - XCTAssertEqual(environment.routedConnectionIds, [connectionId]) - XCTAssertEqual(coordinator.phase, .ready) + #expect(environment.routedConnectionIds == [connectionId]) + #expect(coordinator.phase == .ready) } /// A straggler from the same gesture lands within a few milliseconds of /// `applicationDidFinishLaunching` and before the first turn of the main queue, so it must join /// the same routing pass rather than opening a second window behind the first. - func testAnIntentDeliveredAfterLaunchFinishesJoinsTheSamePass() async { + @Test + func anIntentDeliveredAfterLaunchFinishesJoinsTheSamePass() async throws { let first = UUID() let second = UUID() coordinator.deliver([.openConnection(first)]) coordinator.didFinishLaunching() coordinator.deliver([.openConnection(second)]) - await environment.runNextTurnAndWaitForCompletion() + try await environment.runNextTurnAndWaitForCompletion() - XCTAssertEqual(environment.routedConnectionIds, [first, second]) - XCTAssertEqual(environment.startupBehaviorRuns, 1) - XCTAssertEqual(environment.completions, 1) + #expect(environment.routedConnectionIds == [first, second]) + #expect(environment.startupBehaviorRuns == 1) + #expect(environment.completions == 1) } - func testEveryIntentInOneDeliveryIsRoutedInOrder() async { + @Test + func everyIntentInOneDeliveryIsRoutedInOrder() async throws { let ids = [UUID(), UUID(), UUID()] coordinator.deliver(ids.map { .openConnection($0) }) coordinator.didFinishLaunching() - await environment.runNextTurnAndWaitForCompletion() + try await environment.runNextTurnAndWaitForCompletion() - XCTAssertEqual(environment.routedConnectionIds, ids) + #expect(environment.routedConnectionIds == ids) } - func testALaunchWithNoIntentsStillRunsTheStartupBehaviour() async { + @Test + func aLaunchWithNoIntentsStillRunsTheStartupBehaviour() async throws { coordinator.didFinishLaunching() - await environment.runNextTurnAndWaitForCompletion() + try await environment.runNextTurnAndWaitForCompletion() - XCTAssertTrue(environment.routedConnectionIds.isEmpty) - XCTAssertEqual(environment.startupBehaviorRuns, 1) - XCTAssertEqual(environment.welcomeFallbackRuns, 1) - XCTAssertEqual(coordinator.phase, .ready) + #expect(environment.routedConnectionIds.isEmpty) + #expect(environment.startupBehaviorRuns == 1) + #expect(environment.welcomeFallbackRuns == 1) + #expect(coordinator.phase == .ready) } /// A second scheduled turn would route the same intents twice. Nothing schedules one today, and /// this is what keeps that true. - func testASecondTurnRoutesNothingFurther() async { + @Test + func aSecondTurnRoutesNothingFurther() async throws { let connectionId = UUID() coordinator.deliver([.openConnection(connectionId)]) coordinator.didFinishLaunching() - await environment.runNextTurnAndWaitForCompletion() + try await environment.runNextTurnAndWaitForCompletion() environment.replayLastTurn() - await environment.settle() + await environment.runQueuedMainActorWork() - XCTAssertEqual(environment.routedConnectionIds, [connectionId]) - XCTAssertEqual(environment.completions, 1) + #expect(environment.routedConnectionIds == [connectionId]) + #expect(environment.completions == 1) } - func testAnIntentArrivingAfterReadyIsRoutedOnItsOwn() async { + @Test + func anIntentArrivingAfterReadyIsRoutedOnItsOwn() async throws { coordinator.didFinishLaunching() - await environment.runNextTurnAndWaitForCompletion() + try await environment.runNextTurnAndWaitForCompletion() let late = UUID() coordinator.deliver([.openConnection(late)]) - await environment.settle() + try await waitUntil { environment.dismissWelcomeRuns == 2 } - XCTAssertEqual(environment.routedConnectionIds, [late]) - XCTAssertEqual(environment.dismissWelcomeRuns, 2) + #expect(environment.routedConnectionIds == [late]) + #expect(environment.dismissWelcomeRuns == 2) } /// Routing suspends: `TabRouter.openTable` awaits `ensureConnected`. An intent that arrives /// during that wait must join the pass in flight, not start a second one, or two tasks can each /// find no session for the same connection and each open one. - func testAnIntentArrivingDuringARouteJoinsTheSameDrain() async { + @Test + func anIntentArrivingDuringARouteJoinsTheSameDrain() async throws { let first = UUID() let second = UUID() environment.holdsRoutes = true coordinator.deliver([.openConnection(first)]) coordinator.didFinishLaunching() environment.fireNextTurn() - await environment.settle() + try await waitUntil { environment.isHoldingARoute } - XCTAssertEqual(environment.routedConnectionIds, [first], "The first route should still be suspended") + #expect(environment.routedConnectionIds == [first], "The first route should still be suspended") coordinator.deliver([.openConnection(second)]) - await environment.settle() + await environment.runQueuedMainActorWork() - XCTAssertEqual(environment.routedConnectionIds, [first], "The second intent must wait, not race") - XCTAssertEqual(environment.concurrentRoutes, 1) + #expect(environment.routedConnectionIds == [first], "The second intent must wait, not race") + #expect(environment.concurrentRoutes == 1) environment.holdsRoutes = false environment.releaseRoute() - await environment.settle() + try await waitUntil { environment.completions == 1 } - XCTAssertEqual(environment.routedConnectionIds, [first, second]) - XCTAssertEqual(environment.concurrentRoutes, 1, "Only one route may be in flight at a time") - XCTAssertEqual(environment.completions, 1) + #expect(environment.routedConnectionIds == [first, second]) + #expect(environment.concurrentRoutes == 1, "Only one route may be in flight at a time") + #expect(environment.completions == 1) } - func testStartupBehaviourIsToldWhetherAnyIntentWasRouted() async { + @Test + func startupBehaviourIsToldWhetherAnyIntentWasRouted() async throws { coordinator.deliver([.openConnection(UUID())]) coordinator.didFinishLaunching() - await environment.runNextTurnAndWaitForCompletion() + try await environment.runNextTurnAndWaitForCompletion() - XCTAssertTrue(environment.startupBehaviorSawIntents) + #expect(environment.startupBehaviorSawIntents) } - func testReopenWithNoVisibleWindowsPresentsWelcome() { + @Test + func reopenWithNoVisibleWindowsPresentsWelcome() { let handled = coordinator.handleReopen(hasVisibleWindows: false) - XCTAssertFalse(handled) - XCTAssertEqual(environment.presentWelcomeRuns, 1) + #expect(!handled) + #expect(environment.presentWelcomeRuns == 1) } - func testReopenWithVisibleWindowsPresentsNothing() { + @Test + func reopenWithVisibleWindowsPresentsNothing() { let handled = coordinator.handleReopen(hasVisibleWindows: true) - XCTAssertTrue(handled) - XCTAssertEqual(environment.presentWelcomeRuns, 0) + #expect(handled) + #expect(environment.presentWelcomeRuns == 0) } } @@ -186,6 +186,7 @@ private final class RecordingLaunchEnvironment: LaunchEnvironment { private var lastTurn: (@MainActor () -> Void)? var pendingTurnCount: Int { turns.count } + var isHoldingARoute: Bool { routeGate != nil } func scheduleNextTurn(_ body: @escaping @MainActor () -> Void) { turns.append(body) @@ -228,27 +229,28 @@ private final class RecordingLaunchEnvironment: LaunchEnvironment { } func fireNextTurn() { - guard !turns.isEmpty else { return XCTFail("No turn was scheduled") } + guard !turns.isEmpty else { + Issue.record("No turn was scheduled") + return + } turns.removeFirst()() } - /// Fires the turn the coordinator scheduled, then drains the main queue until the routing task - /// it starts has finished. `launchDidComplete()` is the coordinator's own last step, so the - /// count moving is the signal, not a sleep. - func runNextTurnAndWaitForCompletion() async { - guard !turns.isEmpty else { return XCTFail("No turn was scheduled") } + /// `launchDidComplete()` is the coordinator's own last step, so the count moving is the signal. + func runNextTurnAndWaitForCompletion() async throws { + guard !turns.isEmpty else { + Issue.record("No turn was scheduled") + return + } let target = completions + 1 turns.removeFirst()() - for _ in 0 ..< 100 where completions < target { - await Task.yield() - } - XCTAssertEqual(completions, target, "The routing pass never completed") + try await waitUntil { self.completions >= target } + #expect(completions == target, "The routing pass ran more than once") } - /// Lets any already-started task finish without expecting a completion. - func settle() async { - for _ in 0 ..< 100 { - await Task.yield() - } + /// The main queue runs its jobs in order, so a job queued now runs after every routing task the + /// case has already started has reached its first suspension. + func runQueuedMainActorWork() async { + await Task { @MainActor in }.value } } diff --git a/TableProTests/Core/Services/Infrastructure/ConnectionFormDraftStoreTests.swift b/TableProTests/Core/Services/Infrastructure/ConnectionFormDraftStoreTests.swift index 267ba9b05c..2b6ee92d1a 100644 --- a/TableProTests/Core/Services/Infrastructure/ConnectionFormDraftStoreTests.swift +++ b/TableProTests/Core/Services/Infrastructure/ConnectionFormDraftStoreTests.swift @@ -7,7 +7,6 @@ import Foundation @testable import TablePro import Testing -@Suite("ConnectionFormDraftStore", .serialized) @MainActor struct ConnectionFormDraftStoreTests { private func parse(_ urlString: String) throws -> ParsedConnectionURL { diff --git a/TableProTests/Core/Services/Infrastructure/ConnectionLivenessTests.swift b/TableProTests/Core/Services/Infrastructure/ConnectionLivenessTests.swift index f78c5be113..2a743975a4 100644 --- a/TableProTests/Core/Services/Infrastructure/ConnectionLivenessTests.swift +++ b/TableProTests/Core/Services/Infrastructure/ConnectionLivenessTests.swift @@ -129,7 +129,6 @@ struct ConnectionLivenessReportingTests { } } -@Suite("Reconnect degradation threshold", .serialized) @MainActor struct ReconnectDegradationTests { private func injectLiveSession() -> UUID { diff --git a/TableProTests/Core/Services/Infrastructure/ConnectionStageObserverTests.swift b/TableProTests/Core/Services/Infrastructure/ConnectionStageObserverTests.swift index f19f8ab801..c6c64d4ee6 100644 --- a/TableProTests/Core/Services/Infrastructure/ConnectionStageObserverTests.swift +++ b/TableProTests/Core/Services/Infrastructure/ConnectionStageObserverTests.swift @@ -8,7 +8,6 @@ import Testing /// a connection dialling through an SSH jump host used to do, announcing "Opening the connection" /// for the whole of the tunnel handshake, because the surface that owns the observer was itself /// held back for the first half second of every connect. -@Suite("Connection stage observer", .serialized) @MainActor struct ConnectionStageObserverTests { @Test("A step reported before the observer existed is still what it reports") diff --git a/TableProTests/Core/Services/Infrastructure/ConnectionWindowChromeTests.swift b/TableProTests/Core/Services/Infrastructure/ConnectionWindowChromeTests.swift index d2bb3a7999..c1068ec8da 100644 --- a/TableProTests/Core/Services/Infrastructure/ConnectionWindowChromeTests.swift +++ b/TableProTests/Core/Services/Infrastructure/ConnectionWindowChromeTests.swift @@ -11,7 +11,6 @@ import Testing /// recording of a PostgreSQL connection through an SSH jump host showed: blank for 0.5s, chrome /// collapsed with a progress screen for 1.0s, then the chrome back with a dozen toolbar items /// arriving at once. -@Suite("Connection window chrome", .serialized) @MainActor struct ConnectionWindowChromeTests { @Test("No connection phase collapses the sidebar") @@ -116,6 +115,22 @@ struct ConnectionWindowChromeTests { #expect(!before.isEmpty) } + @Test("The toolbar's item set does not change when the connection goes away") + func toolbarItemsOutliveTheConnection() throws { + let harness = try Harness() + defer { harness.tearDown() } + + harness.attachRenderableSession() + harness.controller.transition(to: .connected, for: harness.selected.connectionId) + let connected = try #require(harness.window.toolbar).items.map(\.itemIdentifier) + + harness.controller.transition(to: .unavailable(.disconnectedByUser), for: harness.selected.connectionId) + let disconnected = try #require(harness.window.toolbar).items.map(\.itemIdentifier) + + #expect(disconnected == connected) + #expect(!connected.isEmpty) + } + /// Switch Connection reaches the window itself, so it needs no subject, and the toolbar's own /// validation says so for a window whose connection has not come up. @Test("Switch Connection answers with no coordinator behind the toolbar") @@ -205,21 +220,19 @@ struct ConnectionWindowChromeTests { @available(macOS 14.0, *) @Test("An assistant the user left open can still be closed from the toolbar with the session gone") func openAssistantStaysClosableFromTheToolbar() throws { - try AIFeatureScope.enabled { - let harness = try Harness() - defer { harness.tearDown() } + let harness = try Harness() + defer { harness.tearDown() } - harness.attachRenderableSession() - harness.controller.transition(to: .connected, for: harness.selected.connectionId) - harness.controller.showAssistant() - #expect(harness.controller.isAssistantVisible) + harness.attachRenderableSession() + harness.controller.transition(to: .connected, for: harness.selected.connectionId) + harness.controller.showAssistant() + #expect(harness.controller.isAssistantVisible) - harness.controller.transition(to: .unavailable(.disconnected(nil)), for: harness.selected.connectionId) + harness.controller.transition(to: .unavailable(.disconnected(nil)), for: harness.selected.connectionId) - #expect(harness.controller.validateMenuItem( - Self.item(for: #selector(MainSplitViewController.toggleAssistant(_:))) - )) - } + #expect(harness.controller.validateMenuItem( + Self.item(for: #selector(MainSplitViewController.toggleAssistant(_:))) + )) } /// The other half of the same rule: a pane the user never opened offers nothing to open. diff --git a/TableProTests/Core/Services/Infrastructure/ConnectionWindowInitialFocusTests.swift b/TableProTests/Core/Services/Infrastructure/ConnectionWindowInitialFocusTests.swift index 0bebfdfdaa..522ba57d03 100644 --- a/TableProTests/Core/Services/Infrastructure/ConnectionWindowInitialFocusTests.swift +++ b/TableProTests/Core/Services/Infrastructure/ConnectionWindowInitialFocusTests.swift @@ -11,7 +11,6 @@ import Testing /// SwiftUI and did not exist yet, so the pick fell to the connections strip's list, which was /// collapsed to zero width but never hidden. The strip answers Close itself, so Command W took the /// connection. -@Suite("Connection window initial focus", .serialized) @MainActor struct ConnectionWindowInitialFocusTests { @Test("A connections strip that has never been shown is not a key view") diff --git a/TableProTests/Core/Services/Infrastructure/ConnectionWindowToolbarTests.swift b/TableProTests/Core/Services/Infrastructure/ConnectionWindowToolbarTests.swift index 940114b178..9cccb062a5 100644 --- a/TableProTests/Core/Services/Infrastructure/ConnectionWindowToolbarTests.swift +++ b/TableProTests/Core/Services/Infrastructure/ConnectionWindowToolbarTests.swift @@ -8,7 +8,6 @@ import Foundation @testable import TablePro import Testing -@Suite("Connection window toolbar", .serialized) @MainActor internal struct ConnectionWindowToolbarTests { private static let pinnedWindowSize = CGSize(width: 1_512, height: 861) diff --git a/TableProTests/Core/Services/Infrastructure/ContentModeTests.swift b/TableProTests/Core/Services/Infrastructure/ContentModeTests.swift index fc512c2867..fd9420f3e2 100644 --- a/TableProTests/Core/Services/Infrastructure/ContentModeTests.swift +++ b/TableProTests/Core/Services/Infrastructure/ContentModeTests.swift @@ -48,17 +48,17 @@ struct ContentModeTests { /// The result pane belongs to the mode, not to a command, so it must never land in the stored /// per-connection surface preference and displace what the user chose for browsing. @Test("The result surface is not user-selectable and is never stored") - func resultSurfaceIsModeOwned() { + func resultSurfaceIsModeOwned() throws { #expect(TrailingPaneSurface.agentResult.isUserSelectable == false) #expect(TrailingPaneSurface.inspector.isUserSelectable) #expect(TrailingPaneSurface.assistant.isUserSelectable) - let defaults = UserDefaults(suiteName: "ContentModeTests-\(UUID().uuidString)") + let suite = try EphemeralDefaults() let connectionId = UUID() - let state = TrailingPaneState(connectionId: connectionId, defaults: defaults ?? .standard) + let state = TrailingPaneState(connectionId: connectionId, defaults: suite.defaults) state.surface = .agentResult - #expect(defaults?.string(forKey: TrailingPaneState.surfaceKey(connectionId)) == nil) + #expect(suite.defaults.string(forKey: TrailingPaneState.surfaceKey(connectionId)) == nil) } @Test("Turning the AI feature off takes every AI surface with it") @@ -81,55 +81,49 @@ struct ContentModeTests { /// session started or switched then would otherwise repaint panes nobody is looking at. @Test("The displayed agent session, and the render key, name a session only in Agent mode") func agentSessionIsNamedOnlyInAgentMode() { - AIFeatureScope.enabled { - let registry = AgentSessionRegistry(store: AgentSessionStore(directory: Self.temporaryDirectory())) - let workspace = Self.makeWorkspace(phase: .idle, agentSessions: registry) - let session = registry.startSession(for: workspace.connectionId) + let registry = AgentSessionRegistry(store: AgentSessionStore(directory: Self.temporaryDirectory())) + let workspace = Self.makeWorkspace(phase: .idle, agentSessions: registry) + let session = registry.startSession(for: workspace.connectionId) - #expect(workspace.displayedAgentSession == nil) - #expect(workspace.paneRenderKey.agentSessionId == nil) + #expect(workspace.displayedAgentSession == nil) + #expect(workspace.paneRenderKey.agentSessionId == nil) - workspace.contentMode = .agent + workspace.contentMode = .agent - #expect(workspace.displayedAgentSession === session) - #expect(workspace.paneRenderKey.agentSessionId == session.id) - } + #expect(workspace.displayedAgentSession === session) + #expect(workspace.paneRenderKey.agentSessionId == session.id) } @Test("A workspace reads its sessions from the registry it was given") func workspaceReadsItsOwnRegistry() { - AIFeatureScope.enabled { - let mine = AgentSessionRegistry(store: AgentSessionStore(directory: Self.temporaryDirectory())) - let other = AgentSessionRegistry(store: AgentSessionStore(directory: Self.temporaryDirectory())) - let workspace = Self.makeWorkspace(phase: .idle, agentSessions: mine) - workspace.contentMode = .agent - other.startSession(for: workspace.connectionId) - - #expect(workspace.displayedAgentSession == nil) - - let session = mine.startSession(for: workspace.connectionId) - #expect(workspace.displayedAgentSession === session) - } + let mine = AgentSessionRegistry(store: AgentSessionStore(directory: Self.temporaryDirectory())) + let other = AgentSessionRegistry(store: AgentSessionStore(directory: Self.temporaryDirectory())) + let workspace = Self.makeWorkspace(phase: .idle, agentSessions: mine) + workspace.contentMode = .agent + other.startSession(for: workspace.connectionId) + + #expect(workspace.displayedAgentSession == nil) + + let session = mine.startSession(for: workspace.connectionId) + #expect(workspace.displayedAgentSession === session) } /// The conversation is what Agent mode draws while the connection is up or coming up, and the /// unavailable screen, with its Retry, is what it draws over one that cannot be reached. @Test("The detail column follows the mode, except over a connection that cannot be reached") func detailModeFollowsTheModeAndThePane() { - AIFeatureScope.enabled { - let registry = AgentSessionRegistry(store: AgentSessionStore(directory: Self.temporaryDirectory())) - let connecting = Self.makeWorkspace(phase: .connecting, agentSessions: registry) - let failed = Self.makeWorkspace( - phase: .unavailable(.failed(ConnectionFailureInfo(message: "refused"))), - agentSessions: registry - ) - - #expect(connecting.detailMode == .browse) - connecting.contentMode = .agent - failed.contentMode = .agent - #expect(connecting.detailMode == .agent) - #expect(failed.detailMode == .browse) - } + let registry = AgentSessionRegistry(store: AgentSessionStore(directory: Self.temporaryDirectory())) + let connecting = Self.makeWorkspace(phase: .connecting, agentSessions: registry) + let failed = Self.makeWorkspace( + phase: .unavailable(.failed(ConnectionFailureInfo(message: "refused"))), + agentSessions: registry + ) + + #expect(connecting.detailMode == .browse) + connecting.contentMode = .agent + failed.contentMode = .agent + #expect(connecting.detailMode == .agent) + #expect(failed.detailMode == .browse) } /// The result column is one hosting controller per window, drawing whichever session is open, so @@ -137,16 +131,14 @@ struct ContentModeTests { /// is opened on its statements. @Test("Which result view is showing belongs to the session") func resultSegmentBelongsToTheSession() { - AIFeatureScope.enabled { - let registry = AgentSessionRegistry(store: AgentSessionStore(directory: Self.temporaryDirectory())) - let connectionId = UUID() - let first = registry.startSession(for: connectionId) - let second = registry.startSession(for: connectionId) + let registry = AgentSessionRegistry(store: AgentSessionStore(directory: Self.temporaryDirectory())) + let connectionId = UUID() + let first = registry.startSession(for: connectionId) + let second = registry.startSession(for: connectionId) - first.resultSegment = .results + first.resultSegment = .results - #expect(second.resultSegment == .sql) - } + #expect(second.resultSegment == .sql) } /// Nothing stores the choice, so a session comes back from disk on its statements. diff --git a/TableProTests/Core/Services/Infrastructure/ExternalConnectionGateTests.swift b/TableProTests/Core/Services/Infrastructure/ExternalConnectionGateTests.swift index b768eab9f1..3dbc86c75b 100644 --- a/TableProTests/Core/Services/Infrastructure/ExternalConnectionGateTests.swift +++ b/TableProTests/Core/Services/Infrastructure/ExternalConnectionGateTests.swift @@ -29,11 +29,14 @@ private final class SpyPrompt: ExternalConnectionPrompting { @MainActor struct ExternalConnectionGateTests { + private let suite: EphemeralDefaults + + init() throws { + suite = try EphemeralDefaults() + } + private func makeStore() throws -> ExternalConnectionTrustStore { - let suite = "ExternalConnectionGateTests.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) - return ExternalConnectionTrustStore(defaults: defaults) + ExternalConnectionTrustStore(defaults: suite.defaults) } private func ddevConnection(port: Int = 32_770) -> DatabaseConnection { diff --git a/TableProTests/Core/Services/Infrastructure/InspectorToolbarPlacementTests.swift b/TableProTests/Core/Services/Infrastructure/InspectorToolbarPlacementTests.swift index 7b56227289..3e8e2e8fe3 100644 --- a/TableProTests/Core/Services/Infrastructure/InspectorToolbarPlacementTests.swift +++ b/TableProTests/Core/Services/Infrastructure/InspectorToolbarPlacementTests.swift @@ -8,7 +8,6 @@ import Foundation @testable import TablePro import Testing -@Suite("Inspector toolbar placement", .serialized) @MainActor struct InspectorToolbarPlacementTests { private static let pinnedWindowSize = CGSize(width: 1_512, height: 861) diff --git a/TableProTests/Core/Services/Infrastructure/LaunchPhaseTests.swift b/TableProTests/Core/Services/Infrastructure/LaunchPhaseTests.swift index 64e25275d8..3a6934ec76 100644 --- a/TableProTests/Core/Services/Infrastructure/LaunchPhaseTests.swift +++ b/TableProTests/Core/Services/Infrastructure/LaunchPhaseTests.swift @@ -3,24 +3,28 @@ // TableProTests // +import Foundation @testable import TablePro -import XCTest +import Testing -final class LaunchPhaseTests: XCTestCase { - func testLaunchingAndCollectingAcceptIntents() { - XCTAssertTrue(LaunchPhase.launching.isAcceptingIntents) - XCTAssertTrue(LaunchPhase.collectingIntents.isAcceptingIntents) +struct LaunchPhaseTests { + @Test + func launchingAndCollectingAcceptIntents() { + #expect(LaunchPhase.launching.isAcceptingIntents) + #expect(LaunchPhase.collectingIntents.isAcceptingIntents) } - func testRoutingAndReadyRefuseIntents() { - XCTAssertFalse(LaunchPhase.routing.isAcceptingIntents) - XCTAssertFalse(LaunchPhase.ready.isAcceptingIntents) + @Test + func routingAndReadyRefuseIntents() { + #expect(!LaunchPhase.routing.isAcceptingIntents) + #expect(!LaunchPhase.ready.isAcceptingIntents) } - func testOnlyReadyReportsReady() { - XCTAssertFalse(LaunchPhase.launching.isReady) - XCTAssertFalse(LaunchPhase.collectingIntents.isReady) - XCTAssertFalse(LaunchPhase.routing.isReady) - XCTAssertTrue(LaunchPhase.ready.isReady) + @Test + func onlyReadyReportsReady() { + #expect(!LaunchPhase.launching.isReady) + #expect(!LaunchPhase.collectingIntents.isReady) + #expect(!LaunchPhase.routing.isReady) + #expect(LaunchPhase.ready.isReady) } } diff --git a/TableProTests/Core/Services/Infrastructure/MainSplitViewControllerPaneSynchronizationTests.swift b/TableProTests/Core/Services/Infrastructure/MainSplitViewControllerPaneSynchronizationTests.swift index ba0e61a057..a64d7b574a 100644 --- a/TableProTests/Core/Services/Infrastructure/MainSplitViewControllerPaneSynchronizationTests.swift +++ b/TableProTests/Core/Services/Infrastructure/MainSplitViewControllerPaneSynchronizationTests.swift @@ -2,6 +2,7 @@ import AppKit import Foundation @testable import TablePro import TableProEditorKit +import TableProTestSupport import Testing @Suite("Main split view background pane synchronization", .serialized) @@ -430,12 +431,11 @@ struct MainSplitViewControllerPaneSynchronizationTests { harness.background.queueMove(PendingTabMove(tabId: moving.id, source: source) { false }) - var saved = await TabDiskActor.shared.load(connectionId: harness.foregroundConnection.id) - let deadline = Date(timeIntervalSinceNow: 2) - while saved?.tabs.contains(where: { $0.id == moving.id }) == true, Date() < deadline { - try await Task.sleep(for: .milliseconds(20)) - saved = await TabDiskActor.shared.load(connectionId: harness.foregroundConnection.id) + let connectionId = harness.foregroundConnection.id + try await waitUntil { + await TabDiskActor.shared.load(connectionId: connectionId)?.tabs.contains { $0.id == moving.id } != true } + let saved = await TabDiskActor.shared.load(connectionId: connectionId) #expect(saved?.tabs.map(\.id) == [held.id]) } @@ -482,10 +482,7 @@ struct MainSplitViewControllerPaneSynchronizationTests { harness.background.queueMove(PendingTabMove(tabId: moving.id, source: source) { false }) #expect(source.tabManager.tabs.map(\.id) == [moving.id]) - let deadline = Date(timeIntervalSinceNow: 5) - while target.tabManager.tabs.isEmpty, Date() < deadline { - try await Task.sleep(for: .milliseconds(20)) - } + try await waitUntil { !target.tabManager.tabs.isEmpty } #expect(target.persistence.hasObservedTabs) #expect(target.tabManager.tabs.map(\.id) == [moving.id]) #expect(source.tabManager.tabs.isEmpty) diff --git a/TableProTests/Core/Services/Infrastructure/TrailingPaneRevealTests.swift b/TableProTests/Core/Services/Infrastructure/TrailingPaneRevealTests.swift index aecfa82e36..a87d1ec4d9 100644 --- a/TableProTests/Core/Services/Infrastructure/TrailingPaneRevealTests.swift +++ b/TableProTests/Core/Services/Infrastructure/TrailingPaneRevealTests.swift @@ -14,7 +14,6 @@ import Foundation @testable import TablePro import Testing -@Suite("Trailing pane reveal", .serialized) @MainActor struct TrailingPaneRevealTests { // MARK: - The decision @@ -87,51 +86,47 @@ struct TrailingPaneRevealTests { @Test("In Agent mode the pane toggle opens and closes the result column and stores nothing") func agentModePaneToggleActsOnTheResult() throws { - try AIFeatureScope.enabled { - let harness = try Harness() - defer { harness.tearDown() } - try harness.requireContent() - harness.paneState.surface = .assistant - harness.selected.contentMode = .agent - - let item = Harness.menuItem(#selector(MainSplitViewController.toggleInspector(_:))) - #expect(harness.controller.validateMenuItem(item)) - #expect(item.title == String(localized: "Show Result")) - - harness.controller.toggleInspector(nil) - #expect(harness.controller.isTrailingPaneOpen) - #expect(harness.controller.inspectorPaneHost.shown === harness.selected.panes.agentResult) - #expect(harness.paneState.surface == .assistant) - _ = harness.controller.validateMenuItem(item) - #expect(item.title == String(localized: "Hide Result")) - - harness.controller.toggleInspector(nil) - #expect(!harness.controller.isTrailingPaneOpen) - #expect(harness.paneState.surface == .assistant) - } + let harness = try Harness() + defer { harness.tearDown() } + try harness.requireContent() + harness.paneState.surface = .assistant + harness.selected.contentMode = .agent + + let item = Harness.menuItem(#selector(MainSplitViewController.toggleInspector(_:))) + #expect(harness.controller.validateMenuItem(item)) + #expect(item.title == String(localized: "Show Result")) + + harness.controller.toggleInspector(nil) + #expect(harness.controller.isTrailingPaneOpen) + #expect(harness.controller.inspectorPaneHost.shown === harness.selected.panes.agentResult) + #expect(harness.paneState.surface == .assistant) + _ = harness.controller.validateMenuItem(item) + #expect(item.title == String(localized: "Hide Result")) + + harness.controller.toggleInspector(nil) + #expect(!harness.controller.isTrailingPaneOpen) + #expect(harness.paneState.surface == .assistant) } /// Both routes: the View menu's command, and `showAssistant()`, which Explain with AI and Fix with /// AI reach from the Query menu whatever the mode. @Test("In Agent mode Show Assistant is dimmed and neither route stores a browse preference") func agentModeAssistantStoresNothing() throws { - try AIFeatureScope.enabled { - let harness = try Harness() - defer { harness.tearDown() } - try harness.requireContent() - harness.paneState.surface = .inspector - harness.selected.contentMode = .agent - - let item = Harness.menuItem(#selector(MainSplitViewController.toggleAssistant(_:))) - #expect(!harness.controller.validateMenuItem(item)) - #expect(item.title == String(localized: "Show Assistant")) - - harness.controller.toggleAssistant(nil) - harness.controller.showAssistant() - - #expect(harness.paneState.surface == .inspector) - #expect(!harness.controller.isTrailingPaneOpen) - } + let harness = try Harness() + defer { harness.tearDown() } + try harness.requireContent() + harness.paneState.surface = .inspector + harness.selected.contentMode = .agent + + let item = Harness.menuItem(#selector(MainSplitViewController.toggleAssistant(_:))) + #expect(!harness.controller.validateMenuItem(item)) + #expect(item.title == String(localized: "Show Assistant")) + + harness.controller.toggleAssistant(nil) + harness.controller.showAssistant() + + #expect(harness.paneState.surface == .inspector) + #expect(!harness.controller.isTrailingPaneOpen) } /// Show Assistant is dimmed in Agent mode on the strength of this command reaching the @@ -140,44 +135,40 @@ struct TrailingPaneRevealTests { /// validation that read them dimmed Focus Assistant too, leaving no command to the composer. @Test("Focus Assistant reaches the composer in a window that opened in Agent mode") func focusAssistantInAWindowOpenedInAgentMode() throws { - try AIFeatureScope.enabled { - let harness = try Harness(contentMode: .agent) - defer { harness.tearDown() } - try harness.requireContent() - try #require( - harness.controller.commandActions == nil, - "The browse content mounted, so this is not the window the welcome route opens" - ) - /// Stands in for the composer the conversation draws once a session has a provider to - /// answer it, which a unit test has no way to configure. The conversation is a pane of - /// its own, parented in place of the browse content, so that is where it goes. - let composer = ChatComposerNSTextView.make() - try #require(harness.controller.detailPaneHost.shown === harness.selected.panes.agentConversation) - harness.selected.panes.agentConversation.view.addSubview(composer) - - let item = Harness.menuItem(#selector(MainSplitViewController.focusAssistant(_:))) - #expect(harness.controller.validateMenuItem(item)) - - harness.controller.focusAssistant(nil) - #expect(harness.window.firstResponder === composer) - #expect(!harness.controller.isTrailingPaneOpen) - #expect(harness.paneState.surface == .inspector) - } + let harness = try Harness(contentMode: .agent) + defer { harness.tearDown() } + try harness.requireContent() + try #require( + harness.controller.commandActions == nil, + "The browse content mounted, so this is not the window the welcome route opens" + ) + /// Stands in for the composer the conversation draws once a session has a provider to + /// answer it, which a unit test has no way to configure. The conversation is a pane of + /// its own, parented in place of the browse content, so that is where it goes. + let composer = ChatComposerNSTextView.make() + try #require(harness.controller.detailPaneHost.shown === harness.selected.panes.agentConversation) + harness.selected.panes.agentConversation.view.addSubview(composer) + + let item = Harness.menuItem(#selector(MainSplitViewController.focusAssistant(_:))) + #expect(harness.controller.validateMenuItem(item)) + + harness.controller.focusAssistant(nil) + #expect(harness.window.firstResponder === composer) + #expect(!harness.controller.isTrailingPaneOpen) + #expect(harness.paneState.surface == .inspector) } @Test("A grid click leaves a pane closed on the assistant closed, and the choice stored") func gridClickKeepsTheAssistantChoice() throws { - try AIFeatureScope.enabled { - let harness = try Harness() - defer { harness.tearDown() } - try harness.requireContent() - harness.paneState.surface = .assistant + let harness = try Harness() + defer { harness.tearDown() } + try harness.requireContent() + harness.paneState.surface = .assistant - harness.controller.revealInspectorForSelection() + harness.controller.revealInspectorForSelection() - #expect(!harness.controller.isTrailingPaneOpen) - #expect(harness.paneState.surface == .assistant) - } + #expect(!harness.controller.isTrailingPaneOpen) + #expect(harness.paneState.surface == .assistant) } @Test("A grid click opens a closed pane on the inspector") @@ -199,92 +190,86 @@ struct TrailingPaneRevealTests { /// action, so the window follows on the next turn of the run loop. @Test("A surface picked in the header is parented after the picker's action returns") func headerChoiceIsParentedOnTheNextTurn() throws { - try AIFeatureScope.enabled { - let harness = try Harness() - defer { harness.tearDown() } - try harness.requireContent() - harness.controller.showInspector() - #expect(harness.controller.inspectorPaneHost.shown === harness.selected.panes.inspector) - /// A status event still queued from the injected session reparents the pane as well, as - /// any transition does, and would pass this case with no observer behind the picker. - Self.drainRunLoop() - - harness.paneState.surface = .assistant - - #expect( - harness.controller.inspectorPaneHost.shown === harness.selected.panes.inspector, - "The outgoing surface left the window from inside the write" - ) - #expect(Self.turnRunLoop { harness.controller.inspectorPaneHost.shown === harness.selected.panes.assistant }) - #expect(harness.controller.isAssistantVisible) - } + let harness = try Harness() + defer { harness.tearDown() } + try harness.requireContent() + harness.controller.showInspector() + #expect(harness.controller.inspectorPaneHost.shown === harness.selected.panes.inspector) + /// A status event still queued from the injected session reparents the pane as well, as + /// any transition does, and would pass this case with no observer behind the picker. + Self.drainRunLoop() + + harness.paneState.surface = .assistant + + #expect( + harness.controller.inspectorPaneHost.shown === harness.selected.panes.inspector, + "The outgoing surface left the window from inside the write" + ) + #expect(Self.turnRunLoop { harness.controller.inspectorPaneHost.shown === harness.selected.panes.assistant }) + #expect(harness.controller.isAssistantVisible) } @Test("Visibility follows the surface the pane draws, not the one stored") func visibilityFollowsTheDrawnSurface() throws { - try AIFeatureScope.enabled { - let harness = try Harness() - defer { harness.tearDown() } - try harness.requireContent() - harness.controller.showAssistant() - #expect(harness.controller.isAssistantVisible) - #expect(!harness.controller.isInspectorVisible) - - harness.selected.contentMode = .agent - - #expect(!harness.controller.isAssistantVisible) - #expect(!harness.controller.isInspectorVisible) - #expect(harness.paneState.surface == .assistant) - } + let harness = try Harness() + defer { harness.tearDown() } + try harness.requireContent() + harness.controller.showAssistant() + #expect(harness.controller.isAssistantVisible) + #expect(!harness.controller.isInspectorVisible) + + harness.selected.contentMode = .agent + + #expect(!harness.controller.isAssistantVisible) + #expect(!harness.controller.isInspectorVisible) + #expect(harness.paneState.surface == .assistant) } /// Table Info reads its numbers only while the inspector says it is drawn, so a background /// workspace or a pane on another surface must never say so. @Test("Only the selected workspace's inspector is on screen, and only while the pane is open") func inspectorIsOnScreenOnlyForTheSelectedOpenPane() throws { - try AIFeatureScope.enabled { - let harness = try Harness() - defer { harness.tearDown() } - try harness.requireContent() - let backgroundConnection = TestFixtures.makeConnection(name: "Background") - let backgroundState = TrailingPaneState() - let background = ConnectionWorkspace( - connectionId: backgroundConnection.id, - payload: nil, - autoConnect: false, - payloadConnection: backgroundConnection, - session: nil, - sessionState: nil, - trailingPaneState: backgroundState, - phase: .idle - ) - harness.controller.workspaces.insert(background, select: false) - defer { - harness.controller.workspaces.remove(background.connectionId) - background.teardown() - } - harness.paneState.surface = .inspector - #expect(!harness.paneState.inspector.isOnScreen) - - harness.controller.showInspector() - #expect(harness.paneState.inspector.isOnScreen) - #expect(!backgroundState.inspector.isOnScreen) - - harness.controller.showAssistant() - #expect(!harness.paneState.inspector.isOnScreen) - - harness.controller.showInspector() - harness.controller.workspaces.select(background.connectionId) - #expect(!harness.paneState.inspector.isOnScreen) - #expect(backgroundState.inspector.isOnScreen) - - harness.controller.workspaces.select(harness.selected.connectionId) - #expect(harness.paneState.inspector.isOnScreen) - #expect(!backgroundState.inspector.isOnScreen) - - harness.controller.hideTrailingPane() - #expect(!harness.paneState.inspector.isOnScreen) + let harness = try Harness() + defer { harness.tearDown() } + try harness.requireContent() + let backgroundConnection = TestFixtures.makeConnection(name: "Background") + let backgroundState = TrailingPaneState() + let background = ConnectionWorkspace( + connectionId: backgroundConnection.id, + payload: nil, + autoConnect: false, + payloadConnection: backgroundConnection, + session: nil, + sessionState: nil, + trailingPaneState: backgroundState, + phase: .idle + ) + harness.controller.workspaces.insert(background, select: false) + defer { + harness.controller.workspaces.remove(background.connectionId) + background.teardown() } + harness.paneState.surface = .inspector + #expect(!harness.paneState.inspector.isOnScreen) + + harness.controller.showInspector() + #expect(harness.paneState.inspector.isOnScreen) + #expect(!backgroundState.inspector.isOnScreen) + + harness.controller.showAssistant() + #expect(!harness.paneState.inspector.isOnScreen) + + harness.controller.showInspector() + harness.controller.workspaces.select(background.connectionId) + #expect(!harness.paneState.inspector.isOnScreen) + #expect(backgroundState.inspector.isOnScreen) + + harness.controller.workspaces.select(harness.selected.connectionId) + #expect(harness.paneState.inspector.isOnScreen) + #expect(!backgroundState.inspector.isOnScreen) + + harness.controller.hideTrailingPane() + #expect(!harness.paneState.inspector.isOnScreen) } // MARK: - Helpers @@ -337,15 +322,14 @@ struct TrailingPaneRevealTests { let paneState: TrailingPaneState let window: NSWindow private let connection: DatabaseConnection - private let defaults: UserDefaults - private let suiteName: String + private let suite: EphemeralDefaults /// `contentMode` is set before the window is built, which is how the welcome window's Open in /// Agent Mode lands: the mode is on before the connect, so the browse content never mounts. init(contentMode: ConnectionWorkspaceContentMode = .browse) throws { connection = TestFixtures.makeConnection(name: "Trailing", type: .mysql) - suiteName = "TrailingPaneRevealTests.\(UUID().uuidString)" - defaults = try #require(UserDefaults(suiteName: suiteName)) + suite = try EphemeralDefaults() + let defaults = suite.defaults let registryDirectory = FileManager.default.temporaryDirectory .appendingPathComponent("TrailingPaneRevealTests-\(UUID().uuidString)", isDirectory: true) paneState = TrailingPaneState( @@ -407,7 +391,7 @@ struct TrailingPaneRevealTests { window.contentViewController = nil selected.teardown() DatabaseManager.shared.removeSession(for: connection.id) - defaults.removePersistentDomain(forName: suiteName) + suite.defaults.removePersistentDomain(forName: suite.suiteName) } } } diff --git a/TableProTests/Core/Services/Infrastructure/URLClassifierTests.swift b/TableProTests/Core/Services/Infrastructure/URLClassifierTests.swift index a15a128d2f..b159db6397 100644 --- a/TableProTests/Core/Services/Infrastructure/URLClassifierTests.swift +++ b/TableProTests/Core/Services/Infrastructure/URLClassifierTests.swift @@ -7,7 +7,6 @@ import Foundation @testable import TablePro import Testing -@Suite("URLClassifier file extension routing", .serialized) @MainActor struct URLClassifierTests { @Test("Tabular files route to the data file window", arguments: [ diff --git a/TableProTests/Core/Services/Infrastructure/WelcomeRouterTests.swift b/TableProTests/Core/Services/Infrastructure/WelcomeRouterTests.swift index cc7bd3f09e..56ee100c6c 100644 --- a/TableProTests/Core/Services/Infrastructure/WelcomeRouterTests.swift +++ b/TableProTests/Core/Services/Infrastructure/WelcomeRouterTests.swift @@ -3,23 +3,18 @@ // TableProTests // +import Foundation @testable import TablePro -import XCTest +import Testing @MainActor -final class WelcomeRouterTests: XCTestCase { +final class WelcomeRouterTests { private var router: WelcomeRouter! - override func setUp() { - super.setUp() + init() { router = WelcomeRouter() } - override func tearDown() { - router = nil - super.tearDown() - } - private func makeChooserPayload( initialType: DatabaseType? = nil, onSelected: @escaping (DatabaseType) -> Void = { _ in } @@ -27,56 +22,66 @@ final class WelcomeRouterTests: XCTestCase { DatabaseTypeChooserPayload(initialType: initialType, onSelected: onSelected) } - func testRouteStoresRequestWithoutASubscriber() { + @Test + func routeStoresRequestWithoutASubscriber() { router.route(.exportConnections) guard case .exportConnections = router.pendingRequest else { - return XCTFail("Expected the request to survive without a mounted Welcome window") + Issue.record("Expected the request to survive without a mounted Welcome window") + return } } - func testConsumeReturnsRequestOnceThenNil() { + @Test + func consumeReturnsRequestOnceThenNil() { router.route(.importFromApp) guard case .importFromApp = router.consumePendingRequest() else { - return XCTFail("Expected the routed request") + Issue.record("Expected the routed request") + return } - XCTAssertNil(router.consumePendingRequest()) - XCTAssertNil(router.pendingRequest) + #expect(router.consumePendingRequest() == nil) + #expect(router.pendingRequest == nil) } - func testSecondRouteReplacesAnUnconsumedRequest() { + @Test + func secondRouteReplacesAnUnconsumedRequest() { router.route(.exportConnections) router.route(.openProjectFolder) guard case .openProjectFolder = router.consumePendingRequest() else { - return XCTFail("Expected the most recent request to win") + Issue.record("Expected the most recent request to win") + return } - XCTAssertNil(router.consumePendingRequest()) + #expect(router.consumePendingRequest() == nil) } - func testChooserRequestCarriesItsPayload() { + @Test + func chooserRequestCarriesItsPayload() { var selected: DatabaseType? let payload = makeChooserPayload(initialType: .postgresql) { selected = $0 } router.route(.chooseDatabaseType(payload)) guard case .chooseDatabaseType(let delivered) = router.consumePendingRequest() else { - return XCTFail("Expected a chooser request") + Issue.record("Expected a chooser request") + return } - XCTAssertEqual(delivered.initialType, .postgresql) + #expect(delivered.initialType == .postgresql) delivered.onSelected(.mysql) - XCTAssertEqual(selected, .mysql) + #expect(selected == .mysql) } - func testRequestSlotIsIndependentOfTheOtherPendingSlots() { + @Test + func requestSlotIsIndependentOfTheOtherPendingSlots() { let connection = DatabaseConnection(name: "Test", type: .mysql) router.routePluginInstall(connection) router.route(.importConnections) guard case .importConnections = router.consumePendingRequest() else { - return XCTFail("Expected the request slot to be untouched by routePluginInstall") + Issue.record("Expected the request slot to be untouched by routePluginInstall") + return } - XCTAssertEqual(router.consumePendingPluginInstall()?.id, connection.id) + #expect(router.consumePendingPluginInstall()?.id == connection.id) } } diff --git a/TableProTests/Core/Services/Infrastructure/WorkspacePanesFirewallTests.swift b/TableProTests/Core/Services/Infrastructure/WorkspacePanesFirewallTests.swift index bfb1e4731f..ccd62f7a58 100644 --- a/TableProTests/Core/Services/Infrastructure/WorkspacePanesFirewallTests.swift +++ b/TableProTests/Core/Services/Infrastructure/WorkspacePanesFirewallTests.swift @@ -38,7 +38,6 @@ private struct PaneMountProbe: NSViewRepresentable { } } -@Suite("Workspace panes firewall", .serialized) @MainActor struct WorkspacePanesFirewallTests { private static func storedPanes(of panes: WorkspacePanes) -> [(label: String, pane: NSHostingController)] { diff --git a/TableProTests/Core/Services/Infrastructure/WorkspaceSwitchKeyStateTests.swift b/TableProTests/Core/Services/Infrastructure/WorkspaceSwitchKeyStateTests.swift index 6b6a13a37a..15f02c6d28 100644 --- a/TableProTests/Core/Services/Infrastructure/WorkspaceSwitchKeyStateTests.swift +++ b/TableProTests/Core/Services/Infrastructure/WorkspaceSwitchKeyStateTests.swift @@ -12,7 +12,6 @@ import Foundation @testable import TablePro import Testing -@Suite("Workspace switch key state", .serialized) @MainActor struct WorkspaceSwitchKeyStateTests { @MainActor diff --git a/TableProTests/Core/Services/ManagedPolicyTests.swift b/TableProTests/Core/Services/ManagedPolicyTests.swift index 52ac62aa93..9b0e98b5f4 100644 --- a/TableProTests/Core/Services/ManagedPolicyTests.swift +++ b/TableProTests/Core/Services/ManagedPolicyTests.swift @@ -89,11 +89,14 @@ struct ManagedPolicyResolverTests { } struct ManagedPolicyReaderTests { + private let suite: EphemeralDefaults + + init() throws { + suite = try EphemeralDefaults() + } + private func makeReader(_ values: [String: Any]) -> ManagedPolicyReader { - let suiteName = "com.TablePro.tests.policy.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - fatalError("Could not create an isolated UserDefaults suite") - } + let defaults = suite.defaults for (key, value) in values { defaults.set(value, forKey: key) } return ManagedPolicyReader(defaults: defaults) } diff --git a/TableProTests/Core/Services/Query/CatalogEditFavoriteAdoptionTests.swift b/TableProTests/Core/Services/Query/CatalogEditFavoriteAdoptionTests.swift index 77832147f7..1a81394e9d 100644 --- a/TableProTests/Core/Services/Query/CatalogEditFavoriteAdoptionTests.swift +++ b/TableProTests/Core/Services/Query/CatalogEditFavoriteAdoptionTests.swift @@ -6,6 +6,8 @@ import Testing @MainActor struct CatalogEditFavoriteAdoptionTests { private let connection = TestFixtures.makeConnection(database: "shop") + private let suite: EphemeralDefaults + private let syncSuite: EphemeralDefaults private let metadata: SyncMetadataStorage private let favoriteTables: FavoriteTablesStorage private let favoriteDatabases: FavoriteDatabasesStorage @@ -14,10 +16,10 @@ struct CatalogEditFavoriteAdoptionTests { init() throws { let unique = UUID().uuidString - let defaults = try #require(UserDefaults(suiteName: "com.TablePro.tests.CatalogEditFavorites.\(unique)")) - metadata = SyncMetadataStorage( - userDefaults: try #require(UserDefaults(suiteName: "com.TablePro.tests.CatalogEditFavorites.sync.\(unique)")) - ) + suite = try EphemeralDefaults() + syncSuite = try EphemeralDefaults() + let defaults = suite.defaults + metadata = SyncMetadataStorage(userDefaults: syncSuite.defaults) let tracker = SyncChangeTracker(metadataStorage: metadata) favoriteTables = FavoriteTablesStorage(userDefaults: defaults, syncTracker: tracker) favoriteDatabases = FavoriteDatabasesStorage(defaults: defaults, syncTracker: tracker) diff --git a/TableProTests/Core/Services/Query/CatalogEditSchemaFilterAdoptionTests.swift b/TableProTests/Core/Services/Query/CatalogEditSchemaFilterAdoptionTests.swift index 6d46462249..303db1cfb1 100644 --- a/TableProTests/Core/Services/Query/CatalogEditSchemaFilterAdoptionTests.swift +++ b/TableProTests/Core/Services/Query/CatalogEditSchemaFilterAdoptionTests.swift @@ -11,15 +11,17 @@ import Testing @MainActor struct CatalogEditSchemaFilterAdoptionTests { private let connection = TestFixtures.makeConnection(database: "shop", type: .postgresql) + private let suite: EphemeralDefaults + private let syncSuite: EphemeralDefaults private let databaseManager: DatabaseManager private let adoption: CatalogEditAdoption init() throws { let unique = UUID().uuidString - let defaults = try #require(UserDefaults(suiteName: "com.TablePro.tests.CatalogEditSchemaFilter.\(unique)")) - let metadata = SyncMetadataStorage( - userDefaults: try #require(UserDefaults(suiteName: "com.TablePro.tests.CatalogEditSchemaFilter.sync.\(unique)")) - ) + suite = try EphemeralDefaults() + syncSuite = try EphemeralDefaults() + let defaults = suite.defaults + let metadata = SyncMetadataStorage(userDefaults: syncSuite.defaults) let tracker = SyncChangeTracker(metadataStorage: metadata) let connectionStorage = ConnectionStorage( fileURL: FileManager.default.temporaryDirectory diff --git a/TableProTests/Core/Services/Query/MetadataConnectionPoolCapacityTests.swift b/TableProTests/Core/Services/Query/MetadataConnectionPoolCapacityTests.swift index f52b7ede2d..983f92b642 100644 --- a/TableProTests/Core/Services/Query/MetadataConnectionPoolCapacityTests.swift +++ b/TableProTests/Core/Services/Query/MetadataConnectionPoolCapacityTests.swift @@ -10,6 +10,7 @@ import Foundation @testable import TablePro +import TableProTestSupport import Testing /// Stands in for opening a pooled connection, remembering each driver by the database it was @@ -79,21 +80,13 @@ struct MetadataConnectionPoolCapacityTests { DatabaseScope(connectionId: connection.id, database: database, schema: nil) } - /// Bounded, so a caller that never reaches the point being waited for fails the assertions after - /// it rather than hanging the suite. - private func waitUntil(_ condition: () -> Bool) async { - for _ in 0..<2_000 where !condition() { - try? await Task.sleep(for: .milliseconds(5)) - } - } - /// Starts one lease per database, each held open until its gate opens, and waits until every one /// of them is running its body. private func fill( _ pool: MetadataConnectionPool, _ connection: DatabaseConnection, databases: [String] - ) async -> (gates: [LeaseGate], leases: [Task]) { + ) async throws -> (gates: [LeaseGate], leases: [Task]) { let gates = databases.map { _ in LeaseGate() } let leases = zip(databases, gates).map { database, gate in Task { @MainActor in @@ -102,7 +95,7 @@ struct MetadataConnectionPoolCapacityTests { } } } - await waitUntil { gates.allSatisfy(\.hasArrived) } + try await waitUntil { gates.allSatisfy(\.hasArrived) } return (gates, leases) } @@ -146,7 +139,7 @@ struct MetadataConnectionPoolCapacityTests { defer { pool.closeAll(connectionId: connection.id) } let databases = (0..? opener.afterOpen = { opened in guard opened.database == "fresh", late == nil else { return } @@ -237,7 +230,6 @@ struct MetadataConnectionPoolCapacityTests { } } -@Suite("Session removal and the metadata pool", .serialized) @MainActor struct SessionRemovalClosesPoolTests { @Test("A connect that fails over an existing session closes the pooled connections it held") diff --git a/TableProTests/Core/Services/Query/MetadataConnectionPoolTests.swift b/TableProTests/Core/Services/Query/MetadataConnectionPoolTests.swift index 17ae41cf1e..0a184ad353 100644 --- a/TableProTests/Core/Services/Query/MetadataConnectionPoolTests.swift +++ b/TableProTests/Core/Services/Query/MetadataConnectionPoolTests.swift @@ -7,6 +7,7 @@ import Foundation @testable import TablePro +import TableProTestSupport import Testing @MainActor @@ -156,14 +157,6 @@ struct MetadataConnectionPoolTransportReplacementTests { return (connection, DatabaseScope(connectionId: connection.id, database: "shop", schema: nil)) } - /// Bounded, so a caller that never reaches the point being waited for fails the assertions after - /// it rather than hanging the suite. - private func waitUntil(_ condition: () -> Bool) async { - for _ in 0..<2_000 where !condition() { - try? await Task.sleep(for: .milliseconds(5)) - } - } - @Test("A lease waiting on an open the replacement withdrew takes a new connection once it ends") func withdrawnOpenIsRetriedAfterTheReplacement() async throws { let (connection, scope) = makeSession() @@ -175,11 +168,11 @@ struct MetadataConnectionPoolTransportReplacementTests { let lease = Task { @MainActor in try await pool.withDriver(scope: scope) { driver in ObjectIdentifier(driver) } } - await waitUntil { opener.opened.count == 1 } + try await waitUntil { opener.opened.count == 1 } #expect(opener.opened.count == 1) pool.beginTransportReplacement(connectionId: connection.id) - await waitUntil { pool.transportWaiterCount(for: connection.id) == 1 } + try await waitUntil { pool.transportWaiterCount(for: connection.id) == 1 } #expect(pool.transportWaiterCount(for: connection.id) == 1) #expect(opener.opened.count == 1) @@ -204,7 +197,7 @@ struct MetadataConnectionPoolTransportReplacementTests { let lease = Task { @MainActor in try await pool.withDriver(scope: scope) { _ in } } - await waitUntil { pool.transportWaiterCount(for: connection.id) == 1 } + try await waitUntil { pool.transportWaiterCount(for: connection.id) == 1 } #expect(pool.transportWaiterCount(for: connection.id) == 1) #expect(opener.opened.isEmpty) @@ -227,7 +220,7 @@ struct MetadataConnectionPoolTransportReplacementTests { let lease = Task { @MainActor in try await pool.withDriver(scope: scope) { _ in } } - await waitUntil { pool.transportWaiterCount(for: connection.id) == 1 } + try await waitUntil { pool.transportWaiterCount(for: connection.id) == 1 } pool.endTransportReplacement(connectionId: connection.id) #expect(pool.transportWaiterCount(for: connection.id) == 1) @@ -253,7 +246,7 @@ struct MetadataConnectionPoolTransportReplacementTests { let lease = Task { @MainActor in try await pool.withDriver(scope: scope) { _ in } } - await waitUntil { pool.transportWaiterCount(for: connection.id) == 1 } + try await waitUntil { pool.transportWaiterCount(for: connection.id) == 1 } #expect(pool.transportWaiterCount(for: connection.id) == 1) lease.cancel() @@ -278,7 +271,7 @@ struct MetadataConnectionPoolTransportReplacementTests { let lease = Task { @MainActor in try await pool.withDriver(scope: scope) { _ in } } - await waitUntil { opener.opened.count == 1 } + try await waitUntil { opener.opened.count == 1 } #expect(opener.opened.count == 1) pool.closeAll(connectionId: connection.id, database: "shop") @@ -303,7 +296,7 @@ struct MetadataConnectionPoolTransportReplacementTests { let lease = Task { @MainActor in try await pool.withDriver(scope: scope) { _ in } } - await waitUntil { pool.transportWaiterCount(for: connection.id) == 1 } + try await waitUntil { pool.transportWaiterCount(for: connection.id) == 1 } #expect(pool.transportWaiterCount(for: connection.id) == 1) pool.closeAll(connectionId: connection.id) @@ -332,7 +325,7 @@ struct MetadataConnectionPoolTransportReplacementTests { let reportsLease = Task { @MainActor in try await pool.withDriver(scope: reports) { _ in } } - await waitUntil { pool.transportWaiterCount(for: connection.id) == 2 } + try await waitUntil { pool.transportWaiterCount(for: connection.id) == 2 } #expect(pool.transportWaiterCount(for: connection.id) == 2) pool.closeAll(connectionId: connection.id, database: "shop") @@ -347,7 +340,6 @@ struct MetadataConnectionPoolTransportReplacementTests { } } -@Suite("MetadataConnectionPool idle eviction", .serialized) @MainActor struct MetadataConnectionPoolIdleEvictionTests { private func scope(_ connectionId: UUID, database: String) -> DatabaseScope { diff --git a/TableProTests/Core/Services/Query/SQLTableEditAdoptionTests.swift b/TableProTests/Core/Services/Query/SQLTableEditAdoptionTests.swift index 5e074df83a..50fdc1c819 100644 --- a/TableProTests/Core/Services/Query/SQLTableEditAdoptionTests.swift +++ b/TableProTests/Core/Services/Query/SQLTableEditAdoptionTests.swift @@ -55,6 +55,8 @@ struct SQLTableEditAdoptionTests { let store: RecordingStore let favorites: FavoriteTablesStorage let service: CatalogChangeService + // Holds the favorites' defaults suites for as long as the harness lives. + let suites: [EphemeralDefaults] func run( _ statements: [String], @@ -90,12 +92,11 @@ struct SQLTableEditAdoptionTests { session.browseSchema = browseSchema DatabaseManager.shared.injectSession(session, for: connection.id) - let suite = "SQLTableEditAdoptionTests.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - let syncDefaults = try #require(UserDefaults(suiteName: suite + ".sync")) + let defaults = try EphemeralDefaults() + let syncDefaults = try EphemeralDefaults() let favorites = FavoriteTablesStorage( - userDefaults: defaults, - syncTracker: SyncChangeTracker(metadataStorage: SyncMetadataStorage(userDefaults: syncDefaults)) + userDefaults: defaults.defaults, + syncTracker: SyncChangeTracker(metadataStorage: SyncMetadataStorage(userDefaults: syncDefaults.defaults)) ) let store = RecordingStore() let adoption = CatalogEditAdoption(settingsStores: [store], favoriteTables: favorites) @@ -105,7 +106,9 @@ struct SQLTableEditAdoptionTests { isSessionLive: { _ in true }, startupCommands: { _ in startupCommands } ) - return Harness(connection: connection, store: store, favorites: favorites, service: service) + return Harness( + connection: connection, store: store, favorites: favorites, service: service, suites: [defaults, syncDefaults] + ) } private func tearDown(_ harness: Harness) { diff --git a/TableProTests/Core/Services/Query/SavedConnectionDatabaseAdoptionTests.swift b/TableProTests/Core/Services/Query/SavedConnectionDatabaseAdoptionTests.swift index 14d035cb96..d31feebf23 100644 --- a/TableProTests/Core/Services/Query/SavedConnectionDatabaseAdoptionTests.swift +++ b/TableProTests/Core/Services/Query/SavedConnectionDatabaseAdoptionTests.swift @@ -13,6 +13,14 @@ import Testing /// nothing every time. @MainActor struct SavedConnectionDatabaseAdoptionTests { + private let defaults: EphemeralDefaults + private let syncDefaults: EphemeralDefaults + + init() throws { + defaults = try EphemeralDefaults() + syncDefaults = try EphemeralDefaults() + } + private func makeStorage() -> ConnectionStorage { let unique = UUID().uuidString let fileURL = FileManager.default.temporaryDirectory @@ -21,15 +29,10 @@ struct SavedConnectionDatabaseAdoptionTests { try? FileManager.default.createDirectory( at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true ) - guard let defaults = UserDefaults(suiteName: "com.TablePro.tests.SavedDatabase.\(unique)"), - let syncDefaults = UserDefaults(suiteName: "com.TablePro.tests.SavedDatabaseSync.\(unique)") - else { - fatalError("Failed to create isolated test user defaults") - } return ConnectionStorage( fileURL: fileURL, - userDefaults: defaults, - syncTracker: SyncChangeTracker(metadataStorage: SyncMetadataStorage(userDefaults: syncDefaults)), + userDefaults: defaults.defaults, + syncTracker: SyncChangeTracker(metadataStorage: SyncMetadataStorage(userDefaults: syncDefaults.defaults)), keychain: InMemoryKeychain() ) } diff --git a/TableProTests/Core/Services/Query/SchemaColumnStoreCancellationTests.swift b/TableProTests/Core/Services/Query/SchemaColumnStoreCancellationTests.swift index 084f926cc1..901c58d524 100644 --- a/TableProTests/Core/Services/Query/SchemaColumnStoreCancellationTests.swift +++ b/TableProTests/Core/Services/Query/SchemaColumnStoreCancellationTests.swift @@ -11,7 +11,6 @@ import Foundation @testable import TablePro import Testing -@Suite("Schema column store cancellation", .serialized) @MainActor struct SchemaColumnStoreCancellationTests { @Test("Cancelling the only waiter cancels the fetch") diff --git a/TableProTests/Core/Services/Query/SchemaRefreshAfterWriteTests.swift b/TableProTests/Core/Services/Query/SchemaRefreshAfterWriteTests.swift index cc8829b74f..d42c6456b9 100644 --- a/TableProTests/Core/Services/Query/SchemaRefreshAfterWriteTests.swift +++ b/TableProTests/Core/Services/Query/SchemaRefreshAfterWriteTests.swift @@ -32,7 +32,6 @@ private final class DirectMetadataProvider: ScopedMetadataProviding { } } -@Suite("SchemaRefreshService after a write", .serialized) @MainActor struct SchemaRefreshAfterWriteTests { private let orders = TableInfo(name: "orders", type: .table, rowCount: 0, schema: nil) @@ -91,7 +90,8 @@ struct SchemaRefreshAfterWriteTests { let postWrite = Task { await service.refreshAfterWrite(connection: connection) } for await _ in paused.stream { break } let joined = Task { await service.refresh(connection: connection) } - await Task.yield() + // The main queue runs jobs in order, so the second refresh has joined before the fetch resumes. + await Task { @MainActor in }.value driver.resumeFetchTables() await postWrite.value await joined.value diff --git a/TableProTests/Core/Services/Query/SidebarSchemaFilterTests.swift b/TableProTests/Core/Services/Query/SidebarSchemaFilterTests.swift index 8920bee4d3..0b34a2c6da 100644 --- a/TableProTests/Core/Services/Query/SidebarSchemaFilterTests.swift +++ b/TableProTests/Core/Services/Query/SidebarSchemaFilterTests.swift @@ -7,6 +7,7 @@ import Foundation import SwiftUI @testable import TablePro import TableProPluginKit +import TableProTestSupport import Testing /// PGlite keeps every read on the injected session driver and groups by schema. @@ -37,12 +38,6 @@ struct SidebarSchemaFilterTests { TestFixtures.makeTableInfo(name: name, schema: schema) } - private func waitUntil(_ condition: () -> Bool) async { - for _ in 0..<300 where !condition() { - try? await Task.sleep(nanoseconds: 10_000_000) - } - } - @Test("The kept set adds the browsed schema for the browsed database only") func browsedSchemaJoinsTheBrowsedDatabase() async { let (connection, _) = connect() @@ -82,7 +77,7 @@ struct SidebarSchemaFilterTests { } @Test("A sidebar search with a schema filter lists the kept schemas alone") - func searchListsKeptSchemasOnly() async { + func searchListsKeptSchemasOnly() async throws { let (connection, driver) = connect { driver in driver.schemaTablesToReturn = [ "public": [table("timesheet", "public")], @@ -101,7 +96,7 @@ struct SidebarSchemaFilterTests { ) viewModel.searchText = "timesheet" - await waitUntil { service.allSchemaTablesLoadState(connectionId: connection.id, database: "shop").value != nil } + try await waitUntil { service.allSchemaTablesLoadState(connectionId: connection.id, database: "shop").value != nil } let listed = service.allSchemaTablesLoadState(connectionId: connection.id, database: "shop").value #expect(Set(listed?.tables.compactMap(\.schema) ?? []) == ["attendance", "public"]) @@ -112,7 +107,7 @@ struct SidebarSchemaFilterTests { } @Test("Sidebar search and Open Quickly share one listing for one filter, read schema by schema") - func sidebarAndOpenQuicklyReadOnce() async { + func sidebarAndOpenQuicklyReadOnce() async throws { let (connection, driver) = connect { driver in driver.allSchemaTablesToReturn = [ table("timesheet", "public"), table("timesheet", "attendance"), table("timesheet", "payroll") @@ -135,10 +130,10 @@ struct SidebarSchemaFilterTests { let switcher = QuickSwitcherViewModel(connectionId: connection.id, services: .live) viewModel.searchText = "timesheet" - await waitUntil { service.allSchemaTablesLoadState(connectionId: connection.id, database: "shop").value != nil } + try await waitUntil { service.allSchemaTablesLoadState(connectionId: connection.id, database: "shop").value != nil } await switcher.loadItems(databaseType: .pglite) switcher.searchText = "timesheet" - await waitUntil { !switcher.isLoadingTables } + try await waitUntil { !switcher.isLoadingTables } #expect(driver.fetchTablesInAllSchemasCallCount == 0) #expect(driver.fetchSchemaTablesCalls.filter { $0 == "attendance" }.count == 1) diff --git a/TableProTests/Core/Services/RowOperationsFieldAbsenceTests.swift b/TableProTests/Core/Services/RowOperationsFieldAbsenceTests.swift index de6547ee70..b9311bca0a 100644 --- a/TableProTests/Core/Services/RowOperationsFieldAbsenceTests.swift +++ b/TableProTests/Core/Services/RowOperationsFieldAbsenceTests.swift @@ -9,147 +9,133 @@ import Foundation import TableProPluginKit import Testing -private final class AbsenceClipboard: ClipboardProvider { - var written: GridRowsClipboardPayload? - var gridRowsToRead: GridRowsClipboardPayload? - var textToRead: String? - - func readText() -> String? { textToRead } - func readGridRows() -> GridRowsClipboardPayload? { gridRowsToRead } - func writeText(_ text: String) {} - func writeCsv(_ csv: String) {} - func writeImage(_ image: NSImage) {} - func writeRows(tsv: String, html: String?, gridRows: GridRowsClipboardPayload) { written = gridRows } - var hasText: Bool { textToRead != nil } - var hasGridRows: Bool { gridRowsToRead != nil } -} - -@MainActor -struct RowOperationsFieldAbsenceTests { - private static let columns = ["_id", "name", "deletedAt"] - - private func makeManager(_ databaseType: DatabaseType = .mongodb) -> (RowOperationsManager, DataChangeManager) { - let changeManager = DataChangeManager() - changeManager.configureForTable( - tableName: "items", - columns: Self.columns, - primaryKeyColumns: ["_id"], - databaseType: databaseType, - generatedColumns: [] - ) - return (RowOperationsManager(changeManager: changeManager), changeManager) - } - - private func rows(_ queryRows: [[PluginCellValue]] = [], absentCells: [Int: Set] = [:]) -> TableRows { - TableRows.from( - queryRows: queryRows, - columns: Self.columns, - columnTypes: Array(repeating: .text(rawType: nil), count: Self.columns.count), - hasAuthoritativeSchema: true, - absentCells: absentCells - ) - } - - private func paste( - _ clipboard: AbsenceClipboard, - into manager: RowOperationsManager, - parser: RowDataParser? = nil - ) -> (TableRows, RowOperationsManager.PasteRowsResult) { - var tableRows = rows() - let result = manager.pasteRowsFromClipboard( - columns: Self.columns, - primaryKeyColumns: ["_id"], - tableRows: &tableRows, - clipboard: clipboard, - parser: parser - ) - return (tableRows, result) - } - - @Test("A new MongoDB row starts with its fields missing, and a SQL row with NULLs") - func newRowStartsMissingOnlyWhereFieldsCanBe() throws { - let (mongo, mongoChanges) = makeManager() - var mongoRows = rows() - let added = try #require(mongo.addNewRow(tableRows: &mongoRows)) - - #expect(mongoRows.rows[0].absentColumns == [0, 1, 2]) - #expect(mongoChanges.changes.first?.absentColumns == [0, 1, 2]) - #expect(added.values == [.null, .null, .null]) - - let (sql, sqlChanges) = makeManager(.mysql) - var sqlRows = rows() - _ = sql.addNewRow(tableRows: &sqlRows) - - #expect(sqlRows.rows[0].absentColumns.isEmpty) - #expect(sqlChanges.changes.first?.absentColumns.isEmpty == true) - } - - @Test("A duplicated document keeps its NULL fields and leaves its missing fields missing") - func duplicateCopiesAbsence() throws { - let (manager, changes) = makeManager() - var tableRows = rows([["1", "Ada", .null], ["2", "Bo", .null]], absentCells: [1: [2]]) - - let nullKept = try #require(manager.duplicateRow(sourceRowIndex: 0, tableRows: &tableRows)) - let missingKept = try #require(manager.duplicateRow(sourceRowIndex: 1, tableRows: &tableRows)) - - #expect(tableRows.row(withID: nullKept.rowID)?.absentColumns.isEmpty == true) - #expect(tableRows.row(withID: missingKept.rowID)?.absentColumns == [2]) - #expect(changes.pending.insertedAbsentColumns(forRow: missingKept.rowID) == [2]) - #expect(missingKept.values[0] == .text("__DEFAULT__")) - } - - @Test("Copying rows records which fields each lacked, in the copied column order") - func copyWritesAbsence() throws { - let (manager, _) = makeManager() - let clipboard = AbsenceClipboard() - ClipboardService.shared = clipboard - defer { ClipboardService.shared = NSPasteboardClipboardProvider() } - let tableRows = rows([["1", "Ada", .null], ["2", "Bo", .null]], absentCells: [1: [2]]) - - manager.copySelectedRowsToClipboard(selectedIndices: [0, 1], tableRows: tableRows, visibleColumnIndices: [2, 1]) - - let payload = try #require(clipboard.written) - #expect(payload.columns == ["deletedAt", "name"]) - #expect(payload.absentCells == [1: [0]]) - } - - @Test("Pasting copied rows leaves missing what the copy lacked, and what it did not carry") - func structuredPasteCopiesAbsence() throws { - let (manager, _) = makeManager() - let clipboard = AbsenceClipboard() - clipboard.gridRowsToRead = GridRowsClipboardPayload( - columns: ["_id", "name"], - rows: [["1", "Ada"], ["2", .null]], - absentCells: [1: [1]] - ) - - let (tableRows, result) = paste(clipboard, into: manager) - - #expect(result.pastedRows.count == 2) - #expect(tableRows.rows[0].absentColumns == [2]) - #expect(tableRows.rows[1].absentColumns == [1, 2]) - } - - @Test("A SQL paste leaves a column the copy did not carry NULL, as before") - func sqlPasteHasNoAbsence() { - let (manager, _) = makeManager(.mysql) - let clipboard = AbsenceClipboard() - clipboard.gridRowsToRead = GridRowsClipboardPayload(columns: ["_id", "name"], rows: [["1", "Ada"]]) - - let (tableRows, result) = paste(clipboard, into: manager) - - #expect(result.pastedRows.first?.values == [.text("__DEFAULT__"), "Ada", .null]) - #expect(tableRows.rows[0].absentColumns.isEmpty) - } - - @Test("Pasted text says nothing about fields, so a NULL in it leaves the field out of a document") - func textPasteReadsNullAsMissing() { - let (manager, _) = makeManager() - let clipboard = AbsenceClipboard() - clipboard.textToRead = "1\tAda\tNULL" - - let (tableRows, _) = paste(clipboard, into: manager, parser: TSVRowParser()) - - #expect(tableRows.rows[0].absentColumns == [2]) +extension GlobalStateSuites { + @Suite(.recordingClipboard) + @MainActor + struct RowOperationsFieldAbsenceTests { + private static let columns = ["_id", "name", "deletedAt"] + + private func makeManager(_ databaseType: DatabaseType = .mongodb) -> (RowOperationsManager, DataChangeManager) { + let changeManager = DataChangeManager() + changeManager.configureForTable( + tableName: "items", + columns: Self.columns, + primaryKeyColumns: ["_id"], + databaseType: databaseType, + generatedColumns: [] + ) + return (RowOperationsManager(changeManager: changeManager), changeManager) + } + + private func rows(_ queryRows: [[PluginCellValue]] = [], absentCells: [Int: Set] = [:]) -> TableRows { + TableRows.from( + queryRows: queryRows, + columns: Self.columns, + columnTypes: Array(repeating: .text(rawType: nil), count: Self.columns.count), + hasAuthoritativeSchema: true, + absentCells: absentCells + ) + } + + private func paste( + _ clipboard: RecordingClipboard, + into manager: RowOperationsManager, + parser: RowDataParser? = nil + ) -> (TableRows, RowOperationsManager.PasteRowsResult) { + var tableRows = rows() + let result = manager.pasteRowsFromClipboard( + columns: Self.columns, + primaryKeyColumns: ["_id"], + tableRows: &tableRows, + clipboard: clipboard, + parser: parser + ) + return (tableRows, result) + } + + @Test("A new MongoDB row starts with its fields missing, and a SQL row with NULLs") + func newRowStartsMissingOnlyWhereFieldsCanBe() throws { + let (mongo, mongoChanges) = makeManager() + var mongoRows = rows() + let added = try #require(mongo.addNewRow(tableRows: &mongoRows)) + + #expect(mongoRows.rows[0].absentColumns == [0, 1, 2]) + #expect(mongoChanges.changes.first?.absentColumns == [0, 1, 2]) + #expect(added.values == [.null, .null, .null]) + + let (sql, sqlChanges) = makeManager(.mysql) + var sqlRows = rows() + _ = sql.addNewRow(tableRows: &sqlRows) + + #expect(sqlRows.rows[0].absentColumns.isEmpty) + #expect(sqlChanges.changes.first?.absentColumns.isEmpty == true) + } + + @Test("A duplicated document keeps its NULL fields and leaves its missing fields missing") + func duplicateCopiesAbsence() throws { + let (manager, changes) = makeManager() + var tableRows = rows([["1", "Ada", .null], ["2", "Bo", .null]], absentCells: [1: [2]]) + + let nullKept = try #require(manager.duplicateRow(sourceRowIndex: 0, tableRows: &tableRows)) + let missingKept = try #require(manager.duplicateRow(sourceRowIndex: 1, tableRows: &tableRows)) + + #expect(tableRows.row(withID: nullKept.rowID)?.absentColumns.isEmpty == true) + #expect(tableRows.row(withID: missingKept.rowID)?.absentColumns == [2]) + #expect(changes.pending.insertedAbsentColumns(forRow: missingKept.rowID) == [2]) + #expect(missingKept.values[0] == .text("__DEFAULT__")) + } + + @Test("Copying rows records which fields each lacked, in the copied column order") + func copyWritesAbsence() throws { + let (manager, _) = makeManager() + let clipboard = try #require(RecordingClipboard.current) + let tableRows = rows([["1", "Ada", .null], ["2", "Bo", .null]], absentCells: [1: [2]]) + + manager.copySelectedRowsToClipboard(selectedIndices: [0, 1], tableRows: tableRows, visibleColumnIndices: [2, 1]) + + let payload = try #require(clipboard.gridRows) + #expect(payload.columns == ["deletedAt", "name"]) + #expect(payload.absentCells == [1: [0]]) + } + + @Test("Pasting copied rows leaves missing what the copy lacked, and what it did not carry") + func structuredPasteCopiesAbsence() throws { + let (manager, _) = makeManager() + let clipboard = RecordingClipboard() + clipboard.gridRows = GridRowsClipboardPayload( + columns: ["_id", "name"], + rows: [["1", "Ada"], ["2", .null]], + absentCells: [1: [1]] + ) + + let (tableRows, result) = paste(clipboard, into: manager) + + #expect(result.pastedRows.count == 2) + #expect(tableRows.rows[0].absentColumns == [2]) + #expect(tableRows.rows[1].absentColumns == [1, 2]) + } + + @Test("A SQL paste leaves a column the copy did not carry NULL, as before") + func sqlPasteHasNoAbsence() { + let (manager, _) = makeManager(.mysql) + let clipboard = RecordingClipboard() + clipboard.gridRows = GridRowsClipboardPayload(columns: ["_id", "name"], rows: [["1", "Ada"]]) + + let (tableRows, result) = paste(clipboard, into: manager) + + #expect(result.pastedRows.first?.values == [.text("__DEFAULT__"), "Ada", .null]) + #expect(tableRows.rows[0].absentColumns.isEmpty) + } + + @Test("Pasted text says nothing about fields, so a NULL in it leaves the field out of a document") + func textPasteReadsNullAsMissing() { + let (manager, _) = makeManager() + let clipboard = RecordingClipboard() + clipboard.text = "1\tAda\tNULL" + + let (tableRows, _) = paste(clipboard, into: manager, parser: TSVRowParser()) + + #expect(tableRows.rows[0].absentColumns == [2]) + } } } diff --git a/TableProTests/Core/Services/RowOperationsManagerBinaryCopyTests.swift b/TableProTests/Core/Services/RowOperationsManagerBinaryCopyTests.swift index 9073b2fb92..2bc2414ce0 100644 --- a/TableProTests/Core/Services/RowOperationsManagerBinaryCopyTests.swift +++ b/TableProTests/Core/Services/RowOperationsManagerBinaryCopyTests.swift @@ -9,84 +9,71 @@ import Foundation import TableProPluginKit import Testing -private final class BinaryCopyClipboard: ClipboardProvider { - var writtenRows: String? +extension GlobalStateSuites { + @Suite(.recordingClipboard) + @MainActor + struct RowOperationsManagerBinaryCopyTests { + private func makeManagerAndRows(binaryRow: [PluginCellValue]) -> (RowOperationsManager, TableRows) { + let changeManager = DataChangeManager() + changeManager.configureForTable( + tableName: "documents", + columns: ["id", "payload"], + primaryKeyColumns: ["id"], + databaseType: .postgresql, + generatedColumns: [] + ) + let rowOps = RowOperationsManager(changeManager: changeManager) + let tableRows = TableRows.from( + queryRows: [binaryRow], + columns: ["id", "payload"], + columnTypes: [.integer(rawType: "INT"), .blob(rawType: "BYTEA")] + ) + return (rowOps, tableRows) + } - func readText() -> String? { nil } - func readGridRows() -> GridRowsClipboardPayload? { nil } - func writeText(_ text: String) {} - func writeCsv(_ csv: String) {} - func writeImage(_ image: NSImage) {} - func writeRows(tsv: String, html: String?, gridRows: GridRowsClipboardPayload) { writtenRows = tsv } - var hasText: Bool { false } - var hasGridRows: Bool { false } -} + /// The text a row copy wrote, read only when it went out as rows. + private func copiedText(_ rowOps: RowOperationsManager, _ tableRows: TableRows) throws -> String { + let clipboard = try #require(RecordingClipboard.current) + rowOps.copySelectedRowsToClipboard(selectedIndices: [0], tableRows: tableRows) + return clipboard.hasGridRows ? clipboard.text ?? "" : "" + } -@MainActor -struct RowOperationsManagerBinaryCopyTests { - private func makeManagerAndRows(binaryRow: [PluginCellValue]) -> (RowOperationsManager, TableRows) { - let changeManager = DataChangeManager() - changeManager.configureForTable( - tableName: "documents", - columns: ["id", "payload"], - primaryKeyColumns: ["id"], - databaseType: .postgresql, - generatedColumns: [] - ) - let rowOps = RowOperationsManager(changeManager: changeManager) - let tableRows = TableRows.from( - queryRows: [binaryRow], - columns: ["id", "payload"], - columnTypes: [.integer(rawType: "INT"), .blob(rawType: "BYTEA")] - ) - return (rowOps, tableRows) - } + @Test("Issue #1188 row copies binary cell as 0xHEX, not NULL") + func issue1188CopyAsHex() throws { + let bytes = Data([ + 0xD3, 0x8C, 0xE5, 0x66, 0xB9, 0x67, 0x52, 0x0C, + 0xAF, 0x46, 0x17, 0x47, 0xAB, 0xC7, 0x7D, 0x27, + 0x5F, 0x08, 0x4F, 0x60, 0x16, 0x97, 0xD1, 0xEA, + 0x13, 0x5B, 0x03, 0x61, 0xCA, 0xBB, 0x53, 0x4F, + 0x70, 0x22, 0x02, 0xB9, 0x52, 0xE0, 0x04, 0x47, + 0xB6, 0x75, 0x68, 0x7A, 0xF8, 0xF5, 0xD4, 0x3B + ]) + let (rowOps, tableRows) = makeManagerAndRows(binaryRow: [.text("1"), .bytes(bytes)]) - /// The copy goes through `ClipboardService.shared`, and the system pasteboard reads back empty - /// on a headless runner, so the text is taken from a clipboard installed for the one copy. - private func copiedText(_ rowOps: RowOperationsManager, _ tableRows: TableRows) -> String { - let clipboard = BinaryCopyClipboard() - ClipboardService.shared = clipboard - defer { ClipboardService.shared = NSPasteboardClipboardProvider() } - rowOps.copySelectedRowsToClipboard(selectedIndices: [0], tableRows: tableRows) - return clipboard.writtenRows ?? "" - } + let copied = try copiedText(rowOps, tableRows) + #expect(copied.contains("0xD38CE566")) + #expect(!copied.contains("NULL")) + #expect(copied.contains("\t")) + } - @Test("Issue #1188 row copies binary cell as 0xHEX, not NULL") - func issue1188CopyAsHex() { - let bytes = Data([ - 0xD3, 0x8C, 0xE5, 0x66, 0xB9, 0x67, 0x52, 0x0C, - 0xAF, 0x46, 0x17, 0x47, 0xAB, 0xC7, 0x7D, 0x27, - 0x5F, 0x08, 0x4F, 0x60, 0x16, 0x97, 0xD1, 0xEA, - 0x13, 0x5B, 0x03, 0x61, 0xCA, 0xBB, 0x53, 0x4F, - 0x70, 0x22, 0x02, 0xB9, 0x52, 0xE0, 0x04, 0x47, - 0xB6, 0x75, 0x68, 0x7A, 0xF8, 0xF5, 0xD4, 0x3B - ]) - let (rowOps, tableRows) = makeManagerAndRows(binaryRow: [.text("1"), .bytes(bytes)]) + @Test("Empty bytes copies as 0x") + func emptyBytesCopiesAsZeroX() throws { + let (rowOps, tableRows) = makeManagerAndRows(binaryRow: [.text("1"), .bytes(Data())]) - let copied = copiedText(rowOps, tableRows) - #expect(copied.contains("0xD38CE566")) - #expect(!copied.contains("NULL")) - #expect(copied.contains("\t")) - } + let copied = try copiedText(rowOps, tableRows) - @Test("Empty bytes copies as 0x") - func emptyBytesCopiesAsZeroX() { - let (rowOps, tableRows) = makeManagerAndRows(binaryRow: [.text("1"), .bytes(Data())]) - - let copied = copiedText(rowOps, tableRows) - - #expect(copied.contains("0x") || copied.hasSuffix("\t")) - #expect(!copied.contains("NULL")) - } + #expect(copied.contains("0x") || copied.hasSuffix("\t")) + #expect(!copied.contains("NULL")) + } - @Test("Mixed null and binary preserves both") - func mixedNullAndBytes() { - let (rowOps, tableRows) = makeManagerAndRows(binaryRow: [.null, .bytes(Data([0xAA, 0xBB]))]) + @Test("Mixed null and binary preserves both") + func mixedNullAndBytes() throws { + let (rowOps, tableRows) = makeManagerAndRows(binaryRow: [.null, .bytes(Data([0xAA, 0xBB]))]) - let copied = copiedText(rowOps, tableRows) + let copied = try copiedText(rowOps, tableRows) - #expect(copied.contains("NULL")) - #expect(copied.contains("0xAABB")) + #expect(copied.contains("NULL")) + #expect(copied.contains("0xAABB")) + } } } diff --git a/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift b/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift index f04612fa84..26f8ff6dc4 100644 --- a/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift +++ b/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift @@ -4,357 +4,311 @@ import Foundation import TableProPluginKit import Testing -private final class MockClipboardProvider: ClipboardProvider { - var lastWrittenText: String? - var lastWrittenGridRows: GridRowsClipboardPayload? - var textToRead: String? - var gridRowsToRead: GridRowsClipboardPayload? - var lastWasGridRows = false - - func readText() -> String? { textToRead } - - func readGridRows() -> GridRowsClipboardPayload? { gridRowsToRead } - - func writeText(_ text: String) { - lastWrittenText = text - lastWasGridRows = false - } - - func writeCsv(_ csv: String) { - lastWrittenText = csv - lastWasGridRows = false - } - - var copiedImages: [NSImage] = [] - - func writeImage(_ image: NSImage) { - copiedImages.append(image) - } - - func writeRows(tsv: String, html: String?, gridRows: GridRowsClipboardPayload) { - lastWrittenText = tsv - lastWrittenGridRows = gridRows - lastWasGridRows = true - } - - var hasText: Bool { textToRead != nil } - var hasGridRows: Bool { lastWasGridRows } -} +extension GlobalStateSuites { + @Suite(.recordingClipboard) + @MainActor + struct RowOperationsManagerCopyTests { + private static let defaultColumns = ["id", "name", "email"] + + private func makeManager() -> (RowOperationsManager, DataChangeManager) { + let changeManager = DataChangeManager() + changeManager.configureForTable( + tableName: "users", + columns: Self.defaultColumns, + primaryKeyColumns: ["id"], + databaseType: .mysql, + generatedColumns: [] + ) + let manager = RowOperationsManager(changeManager: changeManager) + return (manager, changeManager) + } -@MainActor -struct RowOperationsManagerCopyTests { - private static let defaultColumns = ["id", "name", "email"] - - private func makeManager() -> (RowOperationsManager, DataChangeManager) { - let changeManager = DataChangeManager() - changeManager.configureForTable( - tableName: "users", - columns: Self.defaultColumns, - primaryKeyColumns: ["id"], - databaseType: .mysql, - generatedColumns: [] - ) - let manager = RowOperationsManager(changeManager: changeManager) - return (manager, changeManager) - } + private func makeTableRows(rows: [[String?]], columns: [String]? = nil) -> TableRows { + let cols = columns ?? Self.defaultColumns + let columnTypes: [ColumnType] = Array(repeating: .text(rawType: nil), count: cols.count) + return TableRows.from(queryRows: rows.map { row in row.map(PluginCellValue.fromOptional) }, columns: cols, columnTypes: columnTypes) + } - private func makeTableRows(rows: [[String?]], columns: [String]? = nil) -> TableRows { - let cols = columns ?? Self.defaultColumns - let columnTypes: [ColumnType] = Array(repeating: .text(rawType: nil), count: cols.count) - return TableRows.from(queryRows: rows.map { row in row.map(PluginCellValue.fromOptional) }, columns: cols, columnTypes: columnTypes) - } + private func copyAndCapture( + manager: RowOperationsManager, + indices: Set, + rows: [[String?]], + columns: [String]? = nil, + includeHeaders: Bool = false, + visibleColumnIndices: [Int]? = nil + ) throws -> String? { + let clipboard = try #require(RecordingClipboard.current) + let tableRows = makeTableRows(rows: rows, columns: columns ?? Self.defaultColumns) + manager.copySelectedRowsToClipboard( + selectedIndices: indices, + tableRows: tableRows, + includeHeaders: includeHeaders, + visibleColumnIndices: visibleColumnIndices + ) + return clipboard.writes.last + } - private func copyAndCapture( - manager: RowOperationsManager, - indices: Set, - rows: [[String?]], - columns: [String]? = nil, - includeHeaders: Bool = false, - visibleColumnIndices: [Int]? = nil - ) -> String? { - let clipboard = MockClipboardProvider() - ClipboardService.shared = clipboard - defer { ClipboardService.shared = NSPasteboardClipboardProvider() } - let tableRows = makeTableRows(rows: rows, columns: columns ?? Self.defaultColumns) - manager.copySelectedRowsToClipboard( - selectedIndices: indices, - tableRows: tableRows, - includeHeaders: includeHeaders, - visibleColumnIndices: visibleColumnIndices - ) - return clipboard.lastWrittenText - } + @Test("Single row copy produces tab-separated values") + func singleRowTSV() throws { + let (manager, _) = makeManager() + let rows: [[String?]] = [["1", "Alice", "alice@test.com"]] - @Test("Single row copy produces tab-separated values") - func singleRowTSV() { - let (manager, _) = makeManager() - let rows: [[String?]] = [["1", "Alice", "alice@test.com"]] + let result = try copyAndCapture(manager: manager, indices: [0], rows: rows) - let result = copyAndCapture(manager: manager, indices: [0], rows: rows) + #expect(result == "1\tAlice\talice@test.com") + } - #expect(result == "1\tAlice\talice@test.com") - } + @Test("Multiple rows separated by newlines in TSV format") + func multipleRowsTSV() throws { + let (manager, _) = makeManager() + let rows: [[String?]] = [ + ["1", "Alice", "a@test.com"], + ["2", "Bob", "b@test.com"], + ] - @Test("Multiple rows separated by newlines in TSV format") - func multipleRowsTSV() { - let (manager, _) = makeManager() - let rows: [[String?]] = [ - ["1", "Alice", "a@test.com"], - ["2", "Bob", "b@test.com"], - ] + let result = try copyAndCapture(manager: manager, indices: [0, 1], rows: rows) - let result = copyAndCapture(manager: manager, indices: [0, 1], rows: rows) + #expect(result == "1\tAlice\ta@test.com\n2\tBob\tb@test.com") + } - #expect(result == "1\tAlice\ta@test.com\n2\tBob\tb@test.com") - } + @Test("NULL values rendered as literal NULL string") + func nullValuesRenderedAsNullString() throws { + let (manager, _) = makeManager() + let rows: [[String?]] = [[nil, "Alice", nil]] - @Test("NULL values rendered as literal NULL string") - func nullValuesRenderedAsNullString() { - let (manager, _) = makeManager() - let rows: [[String?]] = [[nil, "Alice", nil]] + let result = try copyAndCapture(manager: manager, indices: [0], rows: rows) - let result = copyAndCapture(manager: manager, indices: [0], rows: rows) + #expect(result == "NULL\tAlice\tNULL") + } - #expect(result == "NULL\tAlice\tNULL") - } + @Test("Mixed NULL and non-NULL values in same row") + func mixedNullAndNonNull() throws { + let (manager, _) = makeManager() + let rows: [[String?]] = [ + ["1", nil, "a@test.com"], + [nil, "Bob", nil], + ] - @Test("Mixed NULL and non-NULL values in same row") - func mixedNullAndNonNull() { - let (manager, _) = makeManager() - let rows: [[String?]] = [ - ["1", nil, "a@test.com"], - [nil, "Bob", nil], - ] + let result = try copyAndCapture(manager: manager, indices: [0, 1], rows: rows) - let result = copyAndCapture(manager: manager, indices: [0, 1], rows: rows) + let lines = result?.components(separatedBy: "\n") + #expect(lines?.count == 2) + #expect(lines?[0] == "1\tNULL\ta@test.com") + #expect(lines?[1] == "NULL\tBob\tNULL") + } - let lines = result?.components(separatedBy: "\n") - #expect(lines?.count == 2) - #expect(lines?[0] == "1\tNULL\ta@test.com") - #expect(lines?[1] == "NULL\tBob\tNULL") - } + @Test("Empty selection produces no clipboard write") + func emptySelectionNoWrite() throws { + let (manager, _) = makeManager() + let rows = TestFixtures.makeRows(count: 3) + let clipboard = try #require(RecordingClipboard.current) + let tableRows = makeTableRows(rows: rows) - @Test("Empty selection produces no clipboard write") - func emptySelectionNoWrite() { - let (manager, _) = makeManager() - let rows = TestFixtures.makeRows(count: 3) - let clipboard = MockClipboardProvider() - ClipboardService.shared = clipboard - defer { ClipboardService.shared = NSPasteboardClipboardProvider() } - let tableRows = makeTableRows(rows: rows) - - manager.copySelectedRowsToClipboard( - selectedIndices: [], - tableRows: tableRows - ) - - #expect(clipboard.lastWrittenText == nil) - } + manager.copySelectedRowsToClipboard( + selectedIndices: [], + tableRows: tableRows + ) - @Test("Large row count produces correct first and last rows") - func largeRowCount() { - let (manager, _) = makeManager() - let count = 1_000 - let rows: [[String?]] = (0.. String? { textToRead } - func readGridRows() -> GridRowsClipboardPayload? { gridRowsToRead } - func writeText(_ text: String) {} - func writeCsv(_ csv: String) {} - - var copiedImages: [NSImage] = [] - - func writeImage(_ image: NSImage) { - copiedImages.append(image) - } - func writeRows(tsv: String, html: String?, gridRows: GridRowsClipboardPayload) {} - var hasText: Bool { textToRead != nil } - var hasGridRows: Bool { gridRowsToRead != nil } -} - @MainActor struct RowOperationsManagerPasteTests { private static let columns = ["id", "name", "email"] @@ -44,7 +25,7 @@ struct RowOperationsManagerPasteTests { return TableRows.from(queryRows: [], columns: Self.columns, columnTypes: types) } - private func pasteRows(_ clipboard: PasteMockClipboard, manager: RowOperationsManager) -> [[PluginCellValue]] { + private func pasteRows(_ clipboard: RecordingClipboard, manager: RowOperationsManager) -> [[PluginCellValue]] { var rows = emptyRows() let result = manager.pasteRowsFromClipboard( columns: Self.columns, @@ -55,14 +36,14 @@ struct RowOperationsManagerPasteTests { return result.pastedRows.map { $0.values } } - private func pasteValues(_ clipboard: PasteMockClipboard, manager: RowOperationsManager) -> [PluginCellValue] { + private func pasteValues(_ clipboard: RecordingClipboard, manager: RowOperationsManager) -> [PluginCellValue] { pasteRows(clipboard, manager: manager).first ?? [] } @Test("Structured grid rows preserve comma-containing values") func structuredPreservesCommas() { - let clipboard = PasteMockClipboard() - clipboard.gridRowsToRead = GridRowsClipboardPayload( + let clipboard = RecordingClipboard() + clipboard.gridRows = GridRowsClipboardPayload( columns: Self.columns, rows: [[.text("7"), .text("Smith, John"), .text("a@test.com")]] ) @@ -77,8 +58,8 @@ struct RowOperationsManagerPasteTests { @Test("Structured grid rows distinguish a real NULL from the literal string NULL") func structuredDistinguishesNull() { - let clipboard = PasteMockClipboard() - clipboard.gridRowsToRead = GridRowsClipboardPayload( + let clipboard = RecordingClipboard() + clipboard.gridRows = GridRowsClipboardPayload( columns: Self.columns, rows: [[.text("7"), .text("NULL"), .null]] ) @@ -91,12 +72,12 @@ struct RowOperationsManagerPasteTests { @Test("Structured payload wins over plain text when both are present") func structuredPreferredOverText() { - let clipboard = PasteMockClipboard() - clipboard.gridRowsToRead = GridRowsClipboardPayload( + let clipboard = RecordingClipboard() + clipboard.gridRows = GridRowsClipboardPayload( columns: Self.columns, rows: [[.text("7"), .text("structured"), .text("x@test.com")]] ) - clipboard.textToRead = "7\twrong\twrong@test.com" + clipboard.text = "7\twrong\twrong@test.com" let values = pasteValues(clipboard, manager: makeManager()) @@ -105,8 +86,8 @@ struct RowOperationsManagerPasteTests { @Test("Structured grid rows map source columns to destination by name when reordered") func structuredMapsByColumnName() { - let clipboard = PasteMockClipboard() - clipboard.gridRowsToRead = GridRowsClipboardPayload( + let clipboard = RecordingClipboard() + clipboard.gridRows = GridRowsClipboardPayload( columns: ["email", "id", "name"], rows: [[.text("e@x.com"), .text("9"), .text("Jo")]] ) @@ -120,8 +101,8 @@ struct RowOperationsManagerPasteTests { @Test("Destination columns absent from the source fill with NULL (e.g. a hidden column copy)") func structuredFillsMissingColumnsWithNull() { - let clipboard = PasteMockClipboard() - clipboard.gridRowsToRead = GridRowsClipboardPayload( + let clipboard = RecordingClipboard() + clipboard.gridRows = GridRowsClipboardPayload( columns: ["name", "email"], rows: [[.text("Jo"), .text("e@x.com")]] ) @@ -136,8 +117,8 @@ struct RowOperationsManagerPasteTests { @Test("Unrelated source columns fall back to positional mapping") func structuredFallsBackToPositional() { - let clipboard = PasteMockClipboard() - clipboard.gridRowsToRead = GridRowsClipboardPayload( + let clipboard = RecordingClipboard() + clipboard.gridRows = GridRowsClipboardPayload( columns: ["a", "b", "c"], rows: [[.text("1"), .text("2"), .text("3")]] ) @@ -151,8 +132,8 @@ struct RowOperationsManagerPasteTests { @Test("Structured payload pastes multiple rows") func structuredPastesMultipleRows() { - let clipboard = PasteMockClipboard() - clipboard.gridRowsToRead = GridRowsClipboardPayload( + let clipboard = RecordingClipboard() + clipboard.gridRows = GridRowsClipboardPayload( columns: Self.columns, rows: [ [.text("1"), .text("Alice"), .text("a@test.com")], @@ -169,8 +150,8 @@ struct RowOperationsManagerPasteTests { @Test("Plain-text TSV with commas inside a value parses as TSV, not CSV") func textTSVWithCommasParsesAsTSV() { - let clipboard = PasteMockClipboard() - clipboard.textToRead = "7\tMozilla/5.0 (KHTML, like Gecko) Safari\ta@test.com" + let clipboard = RecordingClipboard() + clipboard.text = "7\tMozilla/5.0 (KHTML, like Gecko) Safari\ta@test.com" let values = pasteValues(clipboard, manager: makeManager()) @@ -221,8 +202,8 @@ struct RowOperationsManagerPasteServerOwnedTests { columnIdentity: [String: IdentityKind] = [:], generatedColumns: Set = [] ) -> [PluginCellValue] { - let clipboard = PasteMockClipboard() - clipboard.gridRowsToRead = payload + let clipboard = RecordingClipboard() + clipboard.gridRows = payload var rows = TableRows.from( queryRows: [], columns: Self.columns, diff --git a/TableProTests/Core/Services/SQLFormatterServiceTests.swift b/TableProTests/Core/Services/SQLFormatterServiceTests.swift index 298a7c3c8f..d8983aefb4 100644 --- a/TableProTests/Core/Services/SQLFormatterServiceTests.swift +++ b/TableProTests/Core/Services/SQLFormatterServiceTests.swift @@ -638,6 +638,15 @@ struct SQLFormatterServiceTests { #expect(formatted.contains("'C:\\")) } + /// The editor closes a typed quote for the user, so a backtick is how this input reaches Format + /// Query from the keyboard. It takes the same tokenizer branch. + @Test("Formats a query whose backtick identifier is unterminated and ends in a backslash") + func unterminatedBacktickEndingInBackslash() throws { + let formatted = try format("select * from t where c like `C:\\") + #expect(formatted.contains("SELECT")) + #expect(formatted.contains("`C:\\")) + } + @Test("Formats a query whose block comment is unterminated") func unterminatedBlockComment() throws { let formatted = try format("select 1; /* note") diff --git a/TableProTests/Core/Services/TabPersistenceCoordinatorTests.swift b/TableProTests/Core/Services/TabPersistenceCoordinatorTests.swift index 552f273a76..f7add36136 100644 --- a/TableProTests/Core/Services/TabPersistenceCoordinatorTests.swift +++ b/TableProTests/Core/Services/TabPersistenceCoordinatorTests.swift @@ -1,10 +1,3 @@ -// -// TabPersistenceCoordinatorTests.swift -// TableProTests -// -// Tests for TabPersistenceCoordinator tab state persistence. -// - import Foundation import TableProPluginKit import Testing @@ -13,8 +6,6 @@ import Testing @MainActor struct TabPersistenceCoordinatorTests { - // MARK: - Helpers - /// A coordinator that has already consulted the disk, which is what every save path requires. /// The refusal to write before that is covered on its own by `TabPersistenceWriteGateTests`. private func makeCoordinator() -> TabPersistenceCoordinator { @@ -29,12 +20,6 @@ struct TabPersistenceCoordinatorTests { } } - private func sleep(milliseconds: Int = 200) async { - try? await Task.sleep(nanoseconds: UInt64(milliseconds) * 1_000_000) - } - - // MARK: - Tests - @Test("restoreFromDisk returns .none source when no saved state exists") func restoreFromDiskReturnsNoneWhenEmpty() async { let coordinator = makeCoordinator() @@ -53,7 +38,7 @@ struct TabPersistenceCoordinatorTests { let selectedId = tabs[1].id coordinator.saveNow(tabs: tabs, selectedTabId: selectedId) - await sleep() + await coordinator.saveTask?.value let result = await coordinator.restoreFromDisk() @@ -69,7 +54,6 @@ struct TabPersistenceCoordinatorTests { } coordinator.clearForUserClosedAllTabs() - await sleep() } @Test("clearSavedState + restoreFromDisk returns empty") @@ -78,10 +62,9 @@ struct TabPersistenceCoordinatorTests { let tabs = makeTabs(count: 2) coordinator.saveNow(tabs: tabs, selectedTabId: tabs[0].id) - await sleep() + await coordinator.saveTask?.value coordinator.clearForUserClosedAllTabs() - await sleep() let result = await coordinator.restoreFromDisk() @@ -105,7 +88,6 @@ struct TabPersistenceCoordinatorTests { #expect(result.source == .disk) coordinator.clearForUserClosedAllTabs() - await sleep() } @Test("A queued older snapshot cannot overwrite a newer synchronous save") @@ -147,8 +129,9 @@ struct TabPersistenceCoordinatorTests { newTab.content.query = "SELECT 180" coordinator.saveNow(tabs: oldTabs, selectedTabId: oldTabs[0].id) + let queued = coordinator.saveTask coordinator.saveNowSync(tabs: [newTab], selectedTabId: newTab.id) - await Task.yield() + await queued?.value let state = await TabDiskActor.shared.load(connectionId: coordinator.connectionId) #expect(state?.tabs.count == 1) @@ -184,7 +167,7 @@ struct TabPersistenceCoordinatorTests { tab.content.query = largeQuery coordinator.saveNow(tabs: [tab], selectedTabId: tab.id) - await sleep() + await coordinator.saveTask?.value let result = await coordinator.restoreFromDisk() @@ -193,7 +176,6 @@ struct TabPersistenceCoordinatorTests { #expect(result.tabs[0].title == "Big") coordinator.clearForUserClosedAllTabs() - await sleep() } @Test("restoreFromDisk returns .disk source when state exists") @@ -202,14 +184,13 @@ struct TabPersistenceCoordinatorTests { let tabs = makeTabs(count: 1) coordinator.saveNow(tabs: tabs, selectedTabId: nil) - await sleep() + await coordinator.saveTask?.value let result = await coordinator.restoreFromDisk() #expect(result.source == .disk) coordinator.clearForUserClosedAllTabs() - await sleep() } @Test("Multiple saves -- last save wins") @@ -220,10 +201,10 @@ struct TabPersistenceCoordinatorTests { let selectedId = secondTabs[2].id coordinator.saveNow(tabs: firstTabs, selectedTabId: firstTabs[0].id) - await sleep() + await coordinator.saveTask?.value coordinator.saveNow(tabs: secondTabs, selectedTabId: selectedId) - await sleep() + await coordinator.saveTask?.value let result = await coordinator.restoreFromDisk() @@ -235,7 +216,6 @@ struct TabPersistenceCoordinatorTests { } coordinator.clearForUserClosedAllTabs() - await sleep() } @Test("clearSavedState after saveNow clears state") @@ -244,14 +224,12 @@ struct TabPersistenceCoordinatorTests { let tabs = makeTabs(count: 2) coordinator.saveNow(tabs: tabs, selectedTabId: tabs[0].id) - await sleep() + await coordinator.saveTask?.value - // Verify state exists let beforeClear = await coordinator.restoreFromDisk() #expect(beforeClear.tabs.count == 2) coordinator.clearForUserClosedAllTabs() - await sleep() let afterClear = await coordinator.restoreFromDisk() #expect(afterClear.tabs.isEmpty) @@ -266,7 +244,7 @@ struct TabPersistenceCoordinatorTests { previewTab.isPreview = true coordinator.saveNow(tabs: [normalTab, previewTab], selectedTabId: normalTab.id) - await sleep() + await coordinator.saveTask?.value let result = await coordinator.restoreFromDisk() @@ -275,7 +253,6 @@ struct TabPersistenceCoordinatorTests { #expect(result.tabs.allSatisfy { !$0.isPreview }) coordinator.clearForUserClosedAllTabs() - await sleep() } @Test("A lone preview tab is persisted") @@ -285,7 +262,7 @@ struct TabPersistenceCoordinatorTests { previewTab.isPreview = true coordinator.saveNow(tabs: [previewTab], selectedTabId: previewTab.id) - await sleep() + await coordinator.saveTask?.value let result = await coordinator.restoreFromDisk() #expect(result.tabs.count == 1) @@ -293,7 +270,6 @@ struct TabPersistenceCoordinatorTests { #expect(result.tabs[0].isPreview == false) coordinator.clearForUserClosedAllTabs() - await sleep() } @Test("selectedTabId on a preview tab is preserved") @@ -304,13 +280,12 @@ struct TabPersistenceCoordinatorTests { previewTab.isPreview = true coordinator.saveNow(tabs: [normalTab, previewTab], selectedTabId: previewTab.id) - await sleep() + await coordinator.saveTask?.value let result = await coordinator.restoreFromDisk() #expect(result.selectedTabId == previewTab.id) coordinator.clearForUserClosedAllTabs() - await sleep() } @Test("Linked-favorite tab with sourceFileURL round-trips through persistence") @@ -321,7 +296,7 @@ struct TabPersistenceCoordinatorTests { tab.content.sourceFileURL = url coordinator.saveNow(tabs: [tab], selectedTabId: tab.id) - await sleep() + await coordinator.saveTask?.value let result = await coordinator.restoreFromDisk() @@ -330,7 +305,6 @@ struct TabPersistenceCoordinatorTests { #expect(result.tabs[0].id == tab.id) coordinator.clearForUserClosedAllTabs() - await sleep() } @Test("Three linked-favorite tabs all round-trip with distinct sourceFileURLs") @@ -344,7 +318,7 @@ struct TabPersistenceCoordinatorTests { } coordinator.saveNow(tabs: tabs, selectedTabId: tabs[1].id) - await sleep() + await coordinator.saveTask?.value let result = await coordinator.restoreFromDisk() @@ -356,7 +330,6 @@ struct TabPersistenceCoordinatorTests { } coordinator.clearForUserClosedAllTabs() - await sleep() } @Test("Tab properties preserved: tableName, isView, databaseName") @@ -368,7 +341,7 @@ struct TabPersistenceCoordinatorTests { tab.tableContext.databaseName = "production" coordinator.saveNow(tabs: [tab], selectedTabId: tab.id) - await sleep() + await coordinator.saveTask?.value let result = await coordinator.restoreFromDisk() @@ -381,7 +354,6 @@ struct TabPersistenceCoordinatorTests { #expect(restored.tabType == .table) coordinator.clearForUserClosedAllTabs() - await sleep() } // MARK: - Empty tab lists must never delete saved work (#1997) @@ -391,17 +363,16 @@ struct TabPersistenceCoordinatorTests { let coordinator = makeCoordinator() let tabs = makeTabs(count: 2) coordinator.saveNow(tabs: tabs, selectedTabId: tabs[0].id) - await sleep() + await coordinator.saveTask?.value coordinator.saveNow(tabs: [], selectedTabId: nil) - await sleep() + await coordinator.saveTask?.value let result = await coordinator.restoreFromDisk() #expect(result.tabs.count == 2) #expect(result.source == .disk) coordinator.clearForUserClosedAllTabs() - await sleep() } @Test("An empty quit-time save leaves saved tabs on disk") @@ -417,7 +388,6 @@ struct TabPersistenceCoordinatorTests { #expect(result.selectedTabId == tabs[1].id) coordinator.clearForUserClosedAllTabs() - await sleep() } @Test("An unsaved query survives a quit-time save that follows an empty one") @@ -429,14 +399,13 @@ struct TabPersistenceCoordinatorTests { coordinator.saveNowSync(tabs: [], selectedTabId: nil) coordinator.saveNow(tabs: [], selectedTabId: nil) - await sleep() + await coordinator.saveTask?.value let result = await coordinator.restoreFromDisk() #expect(result.tabs.count == 1) #expect(result.tabs[0].content.query == "SELECT * FROM never_saved_to_a_file") coordinator.clearForUserClosedAllTabs() - await sleep() } @Test("Closing every tab still discards the saved state") diff --git a/TableProTests/Core/Services/TableQueryBuilderFilterTests.swift b/TableProTests/Core/Services/TableQueryBuilderFilterTests.swift index f05bef847e..aef380c502 100644 --- a/TableProTests/Core/Services/TableQueryBuilderFilterTests.swift +++ b/TableProTests/Core/Services/TableQueryBuilderFilterTests.swift @@ -211,34 +211,17 @@ struct TableQueryBuilderPaginationTests { } } -private final class UnfilterableDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class UnfilterableDriver: PluginDriverStub, @unchecked Sendable { var supportsSchemas: Bool { false } var supportsTransactions: Bool { false } var currentSchema: String? { nil } var serverVersion: String? { nil } - func connect() async throws {} - func disconnect() {} func ping() async throws {} func execute(query: String) async throws -> PluginQueryResult { PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) } - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } - func buildBrowseQuery( table: String, sortColumns: [(columnIndex: Int, ascending: Bool)], diff --git a/TableProTests/Core/Services/TableQueryBuilderSortScopeTests.swift b/TableProTests/Core/Services/TableQueryBuilderSortScopeTests.swift index 9a72e6aa82..daeb5ab3a0 100644 --- a/TableProTests/Core/Services/TableQueryBuilderSortScopeTests.swift +++ b/TableProTests/Core/Services/TableQueryBuilderSortScopeTests.swift @@ -12,7 +12,7 @@ import Foundation import TableProPluginKit import Testing -private final class SortRecordingDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class SortRecordingDriver: PluginDriverStub, @unchecked Sendable { var receivedSortColumns: [(columnIndex: Int, ascending: Bool)] = [] var receivedColumns: [String] = [] @@ -21,28 +21,11 @@ private final class SortRecordingDriver: PluginDatabaseDriver, @unchecked Sendab var currentSchema: String? { nil } var serverVersion: String? { nil } - func connect() async throws {} - func disconnect() {} func ping() async throws {} func execute(query: String) async throws -> PluginQueryResult { PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) } - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } - func buildBrowseQuery( table: String, sortColumns: [(columnIndex: Int, ascending: Bool)], diff --git a/TableProTests/Core/Services/WindowLifecycleMonitorRegistrationTests.swift b/TableProTests/Core/Services/WindowLifecycleMonitorRegistrationTests.swift index 6f133c6293..768d7acb31 100644 --- a/TableProTests/Core/Services/WindowLifecycleMonitorRegistrationTests.swift +++ b/TableProTests/Core/Services/WindowLifecycleMonitorRegistrationTests.swift @@ -12,7 +12,6 @@ import Foundation @testable import TablePro import Testing -@Suite("Window lifecycle monitor registration", .serialized) @MainActor struct WindowLifecycleMonitorRegistrationTests { private func makeWindow() -> NSWindow { diff --git a/TableProTests/Core/Storage/AppSettingsStorageMigrationTests.swift b/TableProTests/Core/Storage/AppSettingsStorageMigrationTests.swift index 93bc0f4b4b..f35a5bd28d 100644 --- a/TableProTests/Core/Storage/AppSettingsStorageMigrationTests.swift +++ b/TableProTests/Core/Storage/AppSettingsStorageMigrationTests.swift @@ -11,11 +11,14 @@ import Testing struct AppSettingsStorageMigrationTests { private let generalKey = "com.TablePro.settings.general" + private let scratch: EphemeralDefaults + + init() throws { + scratch = try EphemeralDefaults() + } private func makeStorage() -> (storage: AppSettingsStorage, defaults: UserDefaults, suite: String) { - let suite = "StartupMigrationTests-\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suite)! - return (AppSettingsStorage(userDefaults: defaults), defaults, suite) + (AppSettingsStorage(userDefaults: scratch.defaults), scratch.defaults, scratch.suiteName) } @Test("A saved showWelcome default flips to reopenLast") diff --git a/TableProTests/Core/Storage/AppSettingsStorageResetTests.swift b/TableProTests/Core/Storage/AppSettingsStorageResetTests.swift index 42b87433cf..433da457a9 100644 --- a/TableProTests/Core/Storage/AppSettingsStorageResetTests.swift +++ b/TableProTests/Core/Storage/AppSettingsStorageResetTests.swift @@ -8,9 +8,11 @@ import Foundation import Testing struct AppSettingsStorageResetTests { + private let pool = EphemeralDefaultsPool() + @Test("Reset clears the selected settings pane and default sidebar layout") func resetClearsUIOrphans() throws { - let defaults = try #require(UserDefaults(suiteName: "settings-reset-\(UUID().uuidString)")) + let defaults = try pool.make().defaults let storage = AppSettingsStorage(userDefaults: defaults) defaults.set("account", forKey: PreferenceKeys.selectedSettingsPane.name) defaults.set("tree", forKey: SidebarPersistenceKey.defaultLayout) @@ -25,7 +27,7 @@ struct AppSettingsStorageResetTests { @Test("Reset clears every remembered inspector field height and the geometry field's mode") func resetClearsInspectorFieldPreferences() throws { - let defaults = try #require(UserDefaults(suiteName: "settings-reset-\(UUID().uuidString)")) + let defaults = try pool.make().defaults let storage = AppSettingsStorage(userDefaults: defaults) defaults.set(320.0, forKey: PreferenceKeys.rowInspectorJsonFieldHeight.name) defaults.set(240.0, forKey: PreferenceKeys.rowInspectorTextFieldHeight.name) diff --git a/TableProTests/Core/Storage/AppStorageEnvironmentTests.swift b/TableProTests/Core/Storage/AppStorageEnvironmentTests.swift index d5bbaf3b0d..4b278f69e0 100644 --- a/TableProTests/Core/Storage/AppStorageEnvironmentTests.swift +++ b/TableProTests/Core/Storage/AppStorageEnvironmentTests.swift @@ -69,15 +69,13 @@ struct AppStorageEnvironmentTests { @Test("A new sandbox directory starts with an empty suite, and a relaunch into it keeps the suite") func newSandboxEmptiesTheSuite() throws { - let suiteName = "com.TablePro.tests.AppStorageEnvironmentTests" - let defaults = try #require(UserDefaults(suiteName: suiteName)) + let suite = try EphemeralDefaults() + let defaults = suite.defaults + let suiteName = suite.suiteName let directory = FileManager.default.temporaryDirectory .appendingPathComponent("AppStorageEnvironmentTests-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - defer { - defaults.removePersistentDomain(forName: suiteName) - try? FileManager.default.removeItem(at: directory) - } + defer { try? FileManager.default.removeItem(at: directory) } defaults.set("left by an earlier sandbox", forKey: "probe") AppStorageEnvironment.emptyDefaultsIfNewSandbox(defaults, suiteName: suiteName, supportDirectory: directory) diff --git a/TableProTests/Core/Storage/CodableListPreferenceStoreTests.swift b/TableProTests/Core/Storage/CodableListPreferenceStoreTests.swift index c9d143a26a..1a4bf52310 100644 --- a/TableProTests/Core/Storage/CodableListPreferenceStoreTests.swift +++ b/TableProTests/Core/Storage/CodableListPreferenceStoreTests.swift @@ -13,9 +13,10 @@ struct CodableListPreferenceStoreTests { var label: String } + private let pool = EphemeralDefaultsPool() + private func makeStore() throws -> CodableListPreferenceStore { - let defaults = try #require(UserDefaults(suiteName: "codablelist-\(UUID().uuidString)")) - return CodableListPreferenceStore(key: DefaultsKey<[Item]>("com.TablePro.test.items"), store: defaults) + CodableListPreferenceStore(key: DefaultsKey<[Item]>("com.TablePro.test.items"), store: try pool.make().defaults) } @Test("Loading an unset key returns an empty list") diff --git a/TableProTests/Core/Storage/ColumnLayoutSyncTests.swift b/TableProTests/Core/Storage/ColumnLayoutSyncTests.swift index 13857033f6..fb4f8973ad 100644 --- a/TableProTests/Core/Storage/ColumnLayoutSyncTests.swift +++ b/TableProTests/Core/Storage/ColumnLayoutSyncTests.swift @@ -10,10 +10,12 @@ import Testing @MainActor struct ColumnLayoutSyncTests { + private let pool = EphemeralDefaultsPool() + private func makePersister() throws -> (FileColumnLayoutPersister, SyncChangeTracker) { let directory = FileManager.default.temporaryDirectory .appendingPathComponent("cl-sync-\(UUID().uuidString)", isDirectory: true) - let meta = try #require(UserDefaults(suiteName: "cl-sync-meta-\(UUID().uuidString)")) + let meta = try pool.make().defaults let tracker = SyncChangeTracker(metadataStorage: SyncMetadataStorage(userDefaults: meta)) return (FileColumnLayoutPersister(storageDirectory: directory, syncTracker: tracker), tracker) } @@ -21,7 +23,7 @@ struct ColumnLayoutSyncTests { private func makeTrackedPersister() throws -> (FileColumnLayoutPersister, SyncMetadataStorage, URL) { let directory = FileManager.default.temporaryDirectory .appendingPathComponent("cl-sync-\(UUID().uuidString)", isDirectory: true) - let meta = try #require(UserDefaults(suiteName: "cl-sync-meta-\(UUID().uuidString)")) + let meta = try pool.make().defaults let metadata = SyncMetadataStorage(userDefaults: meta) let tracker = SyncChangeTracker(metadataStorage: metadata) return (FileColumnLayoutPersister(storageDirectory: directory, syncTracker: tracker), metadata, directory) diff --git a/TableProTests/Core/Storage/ConnectionLocalStatePurgeTests.swift b/TableProTests/Core/Storage/ConnectionLocalStatePurgeTests.swift index cf32e7df2e..ee2543d04e 100644 --- a/TableProTests/Core/Storage/ConnectionLocalStatePurgeTests.swift +++ b/TableProTests/Core/Storage/ConnectionLocalStatePurgeTests.swift @@ -100,7 +100,7 @@ struct ConnectionLocalStatePurgeTests { #expect(await recordedQueries(storage, connectionId: second).isEmpty) } - private func makeFavorites() -> (SQLFavoriteManager, SyncMetadataStorage) { + private func makeFavorites(defaults: UserDefaults) -> (SQLFavoriteManager, SyncMetadataStorage) { let url = FileManager.default.temporaryDirectory .appendingPathComponent("tablepro-tests") .appendingPathComponent("purge_favorites_\(UUID().uuidString).db") @@ -108,7 +108,7 @@ struct ConnectionLocalStatePurgeTests { at: url.deletingLastPathComponent(), withIntermediateDirectories: true ) let metadata = SyncMetadataStorage( - userDefaults: UserDefaults(suiteName: "tablepro-purge-\(UUID().uuidString)") ?? .standard, + userDefaults: defaults, prefix: "tests.\(UUID().uuidString)" ) let manager = SQLFavoriteManager( @@ -129,8 +129,9 @@ struct ConnectionLocalStatePurgeTests { /// The device that deleted the connection already told CloudKit. A tombstone from here sends /// its own deletion back at it. @Test("A remote purge removes the SQL favorites without tombstoning them") - func remotePurgeLeavesNoFavoriteTombstone() async { - let (favorites, metadata) = makeFavorites() + func remotePurgeLeavesNoFavoriteTombstone() async throws { + let suite = try EphemeralDefaults() + let (favorites, metadata) = makeFavorites(defaults: suite.defaults) let (history, _) = makeHistory() let deleted = UUID() let favorite = await seedFavorite(favorites, connectionId: deleted) @@ -144,8 +145,9 @@ struct ConnectionLocalStatePurgeTests { } @Test("A local purge tombstones the SQL favorites it removes") - func localPurgeTombstonesFavorites() async { - let (favorites, metadata) = makeFavorites() + func localPurgeTombstonesFavorites() async throws { + let suite = try EphemeralDefaults() + let (favorites, metadata) = makeFavorites(defaults: suite.defaults) let (history, _) = makeHistory() let deleted = UUID() let favorite = await seedFavorite(favorites, connectionId: deleted) @@ -216,9 +218,8 @@ struct ConnectionLocalStatePurgeTests { @Test("Purging a connection takes its trailing pane preferences with it") @MainActor func purgeClearsTrailingPaneKeys() throws { - let suite = "tablepro-purge-pane-\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - defer { defaults.removePersistentDomain(forName: suite) } + let suite = try EphemeralDefaults() + let defaults = suite.defaults let deleted = UUID() defaults.set(TrailingPaneSurface.assistant.rawValue, forKey: TrailingPaneState.surfaceKey(deleted)) @@ -235,9 +236,8 @@ struct ConnectionLocalStatePurgeTests { @Test("Purging one connection leaves another connection's trailing pane alone") @MainActor func purgeLeavesOtherTrailingPanesAlone() throws { - let suite = "tablepro-purge-pane-\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - defer { defaults.removePersistentDomain(forName: suite) } + let suite = try EphemeralDefaults() + let defaults = suite.defaults let deleted = UUID() let kept = UUID() diff --git a/TableProTests/Core/Storage/ConnectionRecordCoverageTests.swift b/TableProTests/Core/Storage/ConnectionRecordCoverageTests.swift index 47f5bb924a..91a94b26b9 100644 --- a/TableProTests/Core/Storage/ConnectionRecordCoverageTests.swift +++ b/TableProTests/Core/Storage/ConnectionRecordCoverageTests.swift @@ -172,6 +172,7 @@ struct ConnectionDuplicateCoverageTests { /// source, and never a favorite or a sample because its source was. private static let resetByDuplicate: Set = ["id", "name", "sortOrder", "isFavorite", "isSample"] + private let pool = EphemeralDefaultsPool() private let storage: ConnectionStorage init() throws { @@ -183,8 +184,8 @@ struct ConnectionDuplicateCoverageTests { at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true ) - let defaults = try #require(UserDefaults(suiteName: "com.TablePro.tests.DuplicateCoverage.\(unique)")) - let syncDefaults = try #require(UserDefaults(suiteName: "com.TablePro.tests.DuplicateCoverage.sync.\(unique)")) + let defaults = try pool.make().defaults + let syncDefaults = try pool.make().defaults storage = ConnectionStorage( fileURL: fileURL, userDefaults: defaults, diff --git a/TableProTests/Core/Storage/ConnectionStorageAIFieldsTests.swift b/TableProTests/Core/Storage/ConnectionStorageAIFieldsTests.swift index 18b17d7ab3..a987feeb8d 100644 --- a/TableProTests/Core/Storage/ConnectionStorageAIFieldsTests.swift +++ b/TableProTests/Core/Storage/ConnectionStorageAIFieldsTests.swift @@ -11,9 +11,10 @@ import TableProSyncTransport @MainActor struct ConnectionStorageAIFieldsTests { + private let pool = EphemeralDefaultsPool() private let storage: ConnectionStorage - init() { + init() throws { let unique = UUID().uuidString let fileURL = FileManager.default.temporaryDirectory .appendingPathComponent("tablepro-tests") @@ -22,12 +23,8 @@ struct ConnectionStorageAIFieldsTests { at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true ) - let defaultsName = "com.TablePro.tests.ConnectionStorage.AI.\(unique)" - let syncName = "com.TablePro.tests.Sync.AI.\(unique)" - guard let defaults = UserDefaults(suiteName: defaultsName), - let syncDefaults = UserDefaults(suiteName: syncName) else { - fatalError("UserDefaults suite creation failed in test setup") - } + let defaults = try pool.make().defaults + let syncDefaults = try pool.make().defaults let metadata = SyncMetadataStorage(userDefaults: syncDefaults) let tracker = SyncChangeTracker(metadataStorage: metadata) self.storage = ConnectionStorage( diff --git a/TableProTests/Core/Storage/ConnectionStorageAdditionalFieldsTests.swift b/TableProTests/Core/Storage/ConnectionStorageAdditionalFieldsTests.swift index 0538e1f3f8..0b878ce35f 100644 --- a/TableProTests/Core/Storage/ConnectionStorageAdditionalFieldsTests.swift +++ b/TableProTests/Core/Storage/ConnectionStorageAdditionalFieldsTests.swift @@ -11,11 +11,11 @@ import TableProSyncTransport @MainActor struct ConnectionStorageAdditionalFieldsTests { + private let pool = EphemeralDefaultsPool() private let storage: ConnectionStorage - private let suiteName: String private let defaults: UserDefaults - init() { + init() throws { let unique = UUID().uuidString let fileURL = FileManager.default.temporaryDirectory .appendingPathComponent("tablepro-tests") @@ -24,9 +24,8 @@ struct ConnectionStorageAdditionalFieldsTests { at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true ) - self.suiteName = "com.TablePro.tests.ConnectionStorage.\(unique)" - self.defaults = UserDefaults(suiteName: suiteName)! - let syncDefaults = UserDefaults(suiteName: "com.TablePro.tests.Sync.\(unique)")! + self.defaults = try pool.make().defaults + let syncDefaults = try pool.make().defaults let metadata = SyncMetadataStorage(userDefaults: syncDefaults) let tracker = SyncChangeTracker(metadataStorage: metadata) self.storage = ConnectionStorage( diff --git a/TableProTests/Core/Storage/ConnectionStorageExternalAccessTests.swift b/TableProTests/Core/Storage/ConnectionStorageExternalAccessTests.swift index e8253dc05f..6c9d0c0180 100644 --- a/TableProTests/Core/Storage/ConnectionStorageExternalAccessTests.swift +++ b/TableProTests/Core/Storage/ConnectionStorageExternalAccessTests.swift @@ -11,9 +11,10 @@ import TableProSyncTransport @MainActor struct ConnectionStorageExternalAccessTests { + private let pool = EphemeralDefaultsPool() private let storage: ConnectionStorage - init() { + init() throws { let unique = UUID().uuidString let fileURL = FileManager.default.temporaryDirectory .appendingPathComponent("tablepro-tests") @@ -22,12 +23,8 @@ struct ConnectionStorageExternalAccessTests { at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true ) - let defaultsName = "com.TablePro.tests.ConnectionStorage.ExternalAccess.\(unique)" - let syncName = "com.TablePro.tests.Sync.ExternalAccess.\(unique)" - guard let defaults = UserDefaults(suiteName: defaultsName), - let syncDefaults = UserDefaults(suiteName: syncName) else { - fatalError("UserDefaults suite creation failed in test setup") - } + let defaults = try pool.make().defaults + let syncDefaults = try pool.make().defaults let metadata = SyncMetadataStorage(userDefaults: syncDefaults) let tracker = SyncChangeTracker(metadataStorage: metadata) self.storage = ConnectionStorage( diff --git a/TableProTests/Core/Storage/ConnectionStorageImportTests.swift b/TableProTests/Core/Storage/ConnectionStorageImportTests.swift index 0411db0a36..4b6a48675c 100644 --- a/TableProTests/Core/Storage/ConnectionStorageImportTests.swift +++ b/TableProTests/Core/Storage/ConnectionStorageImportTests.swift @@ -16,6 +16,8 @@ struct ConnectionStorageImportTests { private let unique = UUID().uuidString private let directory: URL private let keychain = InMemoryKeychain() + private let suite: EphemeralDefaults + private let syncSuite: EphemeralDefaults private let syncDefaults: DirtyWriteCountingDefaults private let metadata: SyncMetadataStorage private let events: AppEvents @@ -25,9 +27,9 @@ struct ConnectionStorageImportTests { .appendingPathComponent("tablepro-tests") .appendingPathComponent("connection-import-\(unique)", isDirectory: true) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - syncDefaults = try #require( - DirtyWriteCountingDefaults(suiteName: "com.TablePro.tests.ConnectionImport.sync.\(unique)") - ) + suite = try EphemeralDefaults() + syncSuite = try EphemeralDefaults() + syncDefaults = try #require(DirtyWriteCountingDefaults(suiteName: syncSuite.suiteName)) metadata = SyncMetadataStorage(userDefaults: syncDefaults) events = AppEvents() } @@ -39,7 +41,7 @@ struct ConnectionStorageImportTests { private func makeStorage() throws -> ConnectionStorage { ConnectionStorage( fileURL: fileURL, - userDefaults: try #require(UserDefaults(suiteName: "com.TablePro.tests.ConnectionImport.\(unique)")), + userDefaults: suite.defaults, syncTracker: SyncChangeTracker(metadataStorage: metadata), keychain: keychain, appEvents: events, diff --git a/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift b/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift index 8156ea7db4..3dab06d1c9 100644 --- a/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift +++ b/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift @@ -11,12 +11,13 @@ import Testing @MainActor struct ConnectionStoragePersistenceTests { + private let pool = EphemeralDefaultsPool() private let storage: ConnectionStorage private let syncTracker: SyncChangeTracker private let fileURL: URL private let defaults: UserDefaults - init() { + init() throws { let unique = UUID().uuidString self.fileURL = FileManager.default.temporaryDirectory .appendingPathComponent("tablepro-tests") @@ -25,12 +26,8 @@ struct ConnectionStoragePersistenceTests { at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true ) - let suiteName = "com.TablePro.tests.ConnectionStorage.\(unique)" - guard let defaults = UserDefaults(suiteName: suiteName), - let syncDefaults = UserDefaults(suiteName: "com.TablePro.tests.Sync.\(unique)") - else { - fatalError("Failed to create isolated test user defaults") - } + let defaults = try pool.make().defaults + let syncDefaults = try pool.make().defaults self.defaults = defaults let metadata = SyncMetadataStorage(userDefaults: syncDefaults) self.syncTracker = SyncChangeTracker(metadataStorage: metadata) diff --git a/TableProTests/Core/Storage/ConnectionStorageRemoveTagTests.swift b/TableProTests/Core/Storage/ConnectionStorageRemoveTagTests.swift index eee8bae4e7..f2a45eb5f9 100644 --- a/TableProTests/Core/Storage/ConnectionStorageRemoveTagTests.swift +++ b/TableProTests/Core/Storage/ConnectionStorageRemoveTagTests.swift @@ -10,9 +10,10 @@ import TableProSyncTransport @MainActor struct ConnectionStorageRemoveTagTests { + private let pool = EphemeralDefaultsPool() private let storage: ConnectionStorage - init() { + init() throws { let unique = UUID().uuidString let fileURL = FileManager.default.temporaryDirectory .appendingPathComponent("tablepro-tests") @@ -21,12 +22,8 @@ struct ConnectionStorageRemoveTagTests { at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true ) - let defaultsName = "com.TablePro.tests.ConnectionStorage.RemoveTag.\(unique)" - let syncName = "com.TablePro.tests.Sync.RemoveTag.\(unique)" - guard let defaults = UserDefaults(suiteName: defaultsName), - let syncDefaults = UserDefaults(suiteName: syncName) else { - fatalError("UserDefaults suite creation failed in test setup") - } + let defaults = try pool.make().defaults + let syncDefaults = try pool.make().defaults let metadata = SyncMetadataStorage(userDefaults: syncDefaults) let tracker = SyncChangeTracker(metadataStorage: metadata) self.storage = ConnectionStorage( diff --git a/TableProTests/Core/Storage/ConnectionStorageSyncDeleteTests.swift b/TableProTests/Core/Storage/ConnectionStorageSyncDeleteTests.swift index d037e31146..10af4ea251 100644 --- a/TableProTests/Core/Storage/ConnectionStorageSyncDeleteTests.swift +++ b/TableProTests/Core/Storage/ConnectionStorageSyncDeleteTests.swift @@ -12,11 +12,12 @@ import TableProSyncTransport @MainActor struct ConnectionStorageSyncDeleteTests { + private let pool = EphemeralDefaultsPool() private let storage: ConnectionStorage private let metadata: SyncMetadataStorage private let storageDirectory: URL - init() { + init() throws { let unique = UUID().uuidString let fileURL = FileManager.default.temporaryDirectory .appendingPathComponent("tablepro-tests") @@ -25,8 +26,8 @@ struct ConnectionStorageSyncDeleteTests { storageDirectory = fileURL.deletingLastPathComponent() try? FileManager.default.createDirectory(at: storageDirectory, withIntermediateDirectories: true) - let defaults = UserDefaults(suiteName: "com.TablePro.tests.ConnectionStorage.\(unique)")! - let syncDefaults = UserDefaults(suiteName: "com.TablePro.tests.Sync.\(unique)")! + let defaults = try pool.make().defaults + let syncDefaults = try pool.make().defaults metadata = SyncMetadataStorage(userDefaults: syncDefaults) storage = ConnectionStorage( fileURL: fileURL, diff --git a/TableProTests/Core/Storage/CredentialProfileImportTests.swift b/TableProTests/Core/Storage/CredentialProfileImportTests.swift index 1d2c9dcdaf..20c84f88d4 100644 --- a/TableProTests/Core/Storage/CredentialProfileImportTests.swift +++ b/TableProTests/Core/Storage/CredentialProfileImportTests.swift @@ -11,6 +11,7 @@ import Testing @MainActor struct CredentialProfileImportTests { + private let pool = EphemeralDefaultsPool() private let directory: URL private let keychain = InMemoryKeychain() private let metadata: SyncMetadataStorage @@ -25,13 +26,13 @@ struct CredentialProfileImportTests { .appendingPathComponent("credential-import-\(unique)", isDirectory: true) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) metadata = SyncMetadataStorage( - userDefaults: try #require(UserDefaults(suiteName: "com.TablePro.tests.CredentialImport.sync.\(unique)")) + userDefaults: try pool.make().defaults ) integrity = ConnectionStoreIntegrity(keySource: StoredIntegrityKeySource(store: keychain)) let tracker = SyncChangeTracker(metadataStorage: metadata) let connectionStorage = ConnectionStorage( fileURL: directory.appendingPathComponent("connections.json"), - userDefaults: try #require(UserDefaults(suiteName: "com.TablePro.tests.CredentialImport.\(unique)")), + userDefaults: try pool.make().defaults, syncTracker: tracker, keychain: keychain, integrity: integrity diff --git a/TableProTests/Core/Storage/CredentialProfileStorageTests.swift b/TableProTests/Core/Storage/CredentialProfileStorageTests.swift index 38bc6dad5c..01e812550c 100644 --- a/TableProTests/Core/Storage/CredentialProfileStorageTests.swift +++ b/TableProTests/Core/Storage/CredentialProfileStorageTests.swift @@ -11,6 +11,7 @@ import Testing @MainActor struct CredentialProfileStorageTests { + private let pool = EphemeralDefaultsPool() private let storage: CredentialProfileStorage private let connections: ConnectionStorage private let keychain: InMemoryKeychain @@ -20,7 +21,7 @@ struct CredentialProfileStorageTests { /// untrusted and the trust assertions here would pass or fail on the environment. private let integrity: ConnectionStoreIntegrity - init() { + init() throws { let unique = UUID().uuidString directory = FileManager.default.temporaryDirectory .appendingPathComponent("tablepro-tests") @@ -29,13 +30,13 @@ struct CredentialProfileStorageTests { keychain = InMemoryKeychain() metadata = SyncMetadataStorage( - userDefaults: UserDefaults(suiteName: "com.TablePro.tests.CredProfileSync.\(unique)")! + userDefaults: try pool.make().defaults ) let tracker = SyncChangeTracker(metadataStorage: metadata) integrity = ConnectionStoreIntegrity(keySource: StoredIntegrityKeySource(store: keychain)) let connectionStorage = ConnectionStorage( fileURL: directory.appendingPathComponent("connections.json"), - userDefaults: UserDefaults(suiteName: "com.TablePro.tests.CredProfileConn.\(unique)")!, + userDefaults: try pool.make().defaults, syncTracker: tracker, keychain: keychain, integrity: integrity @@ -140,7 +141,7 @@ struct CredentialProfileStorageTests { } @Test("A profile whose secrets cannot be read is not deleted") - func deleteRefusesWhenSecretsAreUnreadable() { + func deleteRefusesWhenSecretsAreUnreadable() throws { let unique = UUID().uuidString let lockedDirectory = FileManager.default.temporaryDirectory .appendingPathComponent("tablepro-tests") @@ -150,7 +151,7 @@ struct CredentialProfileStorageTests { let lockedKeychain = UnreadableSecretKeychain() let lockedConnections = ConnectionStorage( fileURL: lockedDirectory.appendingPathComponent("connections.json"), - userDefaults: UserDefaults(suiteName: "com.TablePro.tests.CredLockedConn.\(unique)")!, + userDefaults: try pool.make().defaults, keychain: lockedKeychain ) let lockedStorage = CredentialProfileStorage( diff --git a/TableProTests/Core/Storage/CustomSlashCommandStorageTests.swift b/TableProTests/Core/Storage/CustomSlashCommandStorageTests.swift index 2f5ea87a3a..38b6564482 100644 --- a/TableProTests/Core/Storage/CustomSlashCommandStorageTests.swift +++ b/TableProTests/Core/Storage/CustomSlashCommandStorageTests.swift @@ -10,18 +10,15 @@ import Testing @MainActor struct CustomSlashCommandStorageTests { - private func makeStorage() -> CustomSlashCommandStorage { - let suiteName = "com.TablePro.tests.CustomSlashCommandStorage.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - fatalError("UserDefaults suite creation failed") - } - defaults.removePersistentDomain(forName: suiteName) - return CustomSlashCommandStorage(defaults: defaults) + private let pool = EphemeralDefaultsPool() + + private func makeStorage() throws -> CustomSlashCommandStorage { + CustomSlashCommandStorage(defaults: try pool.make().defaults) } @Test("add stores a new command") func addStoresCommand() throws { - let storage = makeStorage() + let storage = try makeStorage() let command = CustomSlashCommand(name: "review", promptTemplate: "Review {{query}}") try storage.add(command) #expect(storage.commands.count == 1) @@ -30,7 +27,7 @@ struct CustomSlashCommandStorageTests { @Test("add throws on duplicate name regardless of case") func addRejectsDuplicateName() throws { - let storage = makeStorage() + let storage = try makeStorage() try storage.add(CustomSlashCommand(name: "review", promptTemplate: "x")) #expect(throws: CustomSlashCommandError.self) { @@ -41,7 +38,7 @@ struct CustomSlashCommandStorageTests { @Test("isDuplicate ignores the command being edited") func isDuplicateExcludesSelf() throws { - let storage = makeStorage() + let storage = try makeStorage() let command = CustomSlashCommand(name: "review", promptTemplate: "x") try storage.add(command) #expect(storage.isDuplicate("review", excluding: command.id) == false) @@ -50,7 +47,7 @@ struct CustomSlashCommandStorageTests { @Test("update rejects rename to an existing command's name") func updateRejectsCollidingRename() throws { - let storage = makeStorage() + let storage = try makeStorage() try storage.add(CustomSlashCommand(name: "review", promptTemplate: "x")) let second = CustomSlashCommand(name: "summarize", promptTemplate: "y") try storage.add(second) @@ -66,7 +63,7 @@ struct CustomSlashCommandStorageTests { @Test("update preserves the same command across rename without collision") func updateAllowsNonCollidingRename() throws { - let storage = makeStorage() + let storage = try makeStorage() let original = CustomSlashCommand(name: "review", promptTemplate: "x") try storage.add(original) diff --git a/TableProTests/Core/Storage/DatabaseTreeFilterStorageTests.swift b/TableProTests/Core/Storage/DatabaseTreeFilterStorageTests.swift index eb87a75575..c40f84b41e 100644 --- a/TableProTests/Core/Storage/DatabaseTreeFilterStorageTests.swift +++ b/TableProTests/Core/Storage/DatabaseTreeFilterStorageTests.swift @@ -4,11 +4,10 @@ import Testing @MainActor struct DatabaseTreeFilterStorageTests { + private let pool = EphemeralDefaultsPool() + private func makeStorage() throws -> DatabaseTreeFilterStorage { - let suite = "DatabaseTreeFilterStorageTests.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) - return DatabaseTreeFilterStorage(defaults: defaults) + DatabaseTreeFilterStorage(defaults: try pool.make().defaults) } @Test("Defaults to an empty selection") diff --git a/TableProTests/Core/Storage/ExternalConnectionTrustStoreTests.swift b/TableProTests/Core/Storage/ExternalConnectionTrustStoreTests.swift index 2c57b1e2c4..dd70d0aec3 100644 --- a/TableProTests/Core/Storage/ExternalConnectionTrustStoreTests.swift +++ b/TableProTests/Core/Storage/ExternalConnectionTrustStoreTests.swift @@ -4,11 +4,10 @@ import Testing @MainActor struct ExternalConnectionTrustStoreTests { + private let pool = EphemeralDefaultsPool() + private func makeStore() throws -> ExternalConnectionTrustStore { - let suite = "ExternalConnectionTrustStoreTests.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) - return ExternalConnectionTrustStore(defaults: defaults) + ExternalConnectionTrustStore(defaults: try pool.make().defaults) } private func loopbackKey(scopeName: String = "ddev-shop") -> ExternalConnectionTrustKey { @@ -80,9 +79,7 @@ struct ExternalConnectionTrustStoreTests { @Test("A poisoned defaults blob cannot make a remote host trusted") func poisonedDefaultsAreIgnored() throws { - let suite = "ExternalConnectionTrustStoreTests.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) + let defaults = try pool.make().defaults let remote = ExternalConnectionTrustKey( databaseType: "MySQL", @@ -212,9 +209,7 @@ struct ExternalConnectionTrustStoreTests { @Test("Entries saved before sockets still decode and stay trusted") func legacyEntriesDecode() throws { - let suite = "ExternalConnectionTrustStoreTests.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) + let defaults = try pool.make().defaults let legacy = """ [{"key":{"databaseType":"mysql","host":"127.0.0.1","database":"db","username":"db",\ "scopeName":"ddev-shop"},"trustedAt":0}] @@ -229,9 +224,7 @@ struct ExternalConnectionTrustStoreTests { @Test("A poisoned entry pairing a remote host with a socket is not trusted") func poisonedSocketEntryIsIgnored() throws { - let suite = "ExternalConnectionTrustStoreTests.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) + let defaults = try pool.make().defaults let poisoned = """ [{"key":{"databaseType":"mysql","host":"db.evil.example.com","database":"db","username":"db",\ "scopeName":"","socketPath":"/tmp/mysql.sock"},"trustedAt":0}] diff --git a/TableProTests/Core/Storage/FavoriteDatabasesStorageTests.swift b/TableProTests/Core/Storage/FavoriteDatabasesStorageTests.swift index ef021d007f..af4057611e 100644 --- a/TableProTests/Core/Storage/FavoriteDatabasesStorageTests.swift +++ b/TableProTests/Core/Storage/FavoriteDatabasesStorageTests.swift @@ -12,14 +12,11 @@ import Testing @MainActor struct FavoriteDatabasesStorageTests { private static let storageKey = "com.TablePro.favoriteDatabases" + private let pool = EphemeralDefaultsPool() private func makeStorage() throws -> (FavoriteDatabasesStorage, UserDefaults, SyncMetadataStorage) { - let favoritesSuite = "FavoriteDatabasesStorageTests.favorites.\(UUID().uuidString)" - let syncSuite = "FavoriteDatabasesStorageTests.sync.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: favoritesSuite)) - let syncDefaults = try #require(UserDefaults(suiteName: syncSuite)) - defaults.removePersistentDomain(forName: favoritesSuite) - syncDefaults.removePersistentDomain(forName: syncSuite) + let defaults = try pool.make().defaults + let syncDefaults = try pool.make().defaults let metadata = SyncMetadataStorage(userDefaults: syncDefaults) let storage = FavoriteDatabasesStorage( diff --git a/TableProTests/Core/Storage/FavoriteTablesStorageTests.swift b/TableProTests/Core/Storage/FavoriteTablesStorageTests.swift index ba373702d3..092e416845 100644 --- a/TableProTests/Core/Storage/FavoriteTablesStorageTests.swift +++ b/TableProTests/Core/Storage/FavoriteTablesStorageTests.swift @@ -6,14 +6,11 @@ import Testing @MainActor struct FavoriteTablesStorageTests { private static let storageKey = "com.TablePro.favoriteTables" + private let pool = EphemeralDefaultsPool() private func makeFixture() throws -> (storage: FavoriteTablesStorage, defaults: UserDefaults, metadata: SyncMetadataStorage) { - let favoritesSuite = "FavoriteTablesStorageTests.favorites.\(UUID().uuidString)" - let syncSuite = "FavoriteTablesStorageTests.sync.\(UUID().uuidString)" - let favoritesDefaults = try #require(UserDefaults(suiteName: favoritesSuite)) - let syncDefaults = try #require(UserDefaults(suiteName: syncSuite)) - favoritesDefaults.removePersistentDomain(forName: favoritesSuite) - syncDefaults.removePersistentDomain(forName: syncSuite) + let favoritesDefaults = try pool.make().defaults + let syncDefaults = try pool.make().defaults let metadata = SyncMetadataStorage(userDefaults: syncDefaults) let tracker = SyncChangeTracker(metadataStorage: metadata) diff --git a/TableProTests/Core/Storage/FilterSettingsStorageTests.swift b/TableProTests/Core/Storage/FilterSettingsStorageTests.swift index c5be61749b..8deeaad541 100644 --- a/TableProTests/Core/Storage/FilterSettingsStorageTests.swift +++ b/TableProTests/Core/Storage/FilterSettingsStorageTests.swift @@ -9,9 +9,10 @@ import Testing @MainActor struct FilterSettingsStorageTests { + private let pool = EphemeralDefaultsPool() + private func makeStorage() -> (storage: FilterSettingsStorage, directory: URL) { - let suiteName = "FilterSettingsStorageTests-\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { + guard let defaults = try? pool.make().defaults else { fatalError("Failed to create UserDefaults suite for tests") } let directory = FileManager.default.temporaryDirectory @@ -73,7 +74,7 @@ struct FilterSettingsStorageTests { /// per-table sweep would miss. A fresh reader proves the file went, not just the cache. @Test("Dropping a schema forgets a table this session never loaded") func dropContainerForgetsAnUnloadedTable() throws { - let defaults = try #require(UserDefaults(suiteName: "FilterSettingsStorageTests-\(UUID().uuidString)")) + let defaults = try pool.make().defaults let directory = FileManager.default.temporaryDirectory .appendingPathComponent("FilterSettingsStorageTests-\(UUID().uuidString)", isDirectory: true) defer { try? FileManager.default.removeItem(at: directory) } @@ -328,8 +329,7 @@ struct FilterSettingsStorageTests { @Test("Removed filters stay gone for a fresh storage instance") func removeFiltersDeletesFiles() { - let suiteName = "FilterSettingsStorageTests-\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { + guard let defaults = try? pool.make().defaults else { fatalError("Failed to create UserDefaults suite for tests") } let directory = FileManager.default.temporaryDirectory @@ -389,7 +389,7 @@ struct FilterSettingsStorageTests { behavior: FilterRestoreBehavior, alwaysShowPanel: Bool ) throws { - let defaults = try #require(UserDefaults(suiteName: "FilterSettingsStorageTests-\(UUID().uuidString)")) + let defaults = try pool.make().defaults let directory = FileManager.default.temporaryDirectory .appendingPathComponent("FilterSettingsStorageTests-\(UUID().uuidString)", isDirectory: true) defer { try? FileManager.default.removeItem(at: directory) } @@ -409,7 +409,7 @@ struct FilterSettingsStorageTests { /// again afterwards kept it. Reading it in the decoder holds for every launch instead. @Test("Reading a legacy settings file twice gives the same answer") func legacyPanelStateMigrationIsIdempotent() throws { - let defaults = try #require(UserDefaults(suiteName: "FilterSettingsStorageTests-\(UUID().uuidString)")) + let defaults = try pool.make().defaults let directory = FileManager.default.temporaryDirectory .appendingPathComponent("FilterSettingsStorageTests-\(UUID().uuidString)", isDirectory: true) defer { try? FileManager.default.removeItem(at: directory) } @@ -447,7 +447,7 @@ struct FilterSettingsStorageTests { /// Every file written before the flag existed held a set that was running. @Test("A saved file with no applied flag decodes as applied") func legacyFilterFileDecodesAsApplied() throws { - let defaults = try #require(UserDefaults(suiteName: "FilterSettingsStorageTests-\(UUID().uuidString)")) + let defaults = try pool.make().defaults let directory = FileManager.default.temporaryDirectory .appendingPathComponent("FilterSettingsStorageTests-\(UUID().uuidString)", isDirectory: true) defer { try? FileManager.default.removeItem(at: directory) } @@ -480,8 +480,7 @@ struct FilterSettingsStorageTests { @Test("Saved filters decode from disk in a fresh storage instance") func persistsAcrossInstances() { - let suiteName = "FilterSettingsStorageTests-\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { + guard let defaults = try? pool.make().defaults else { fatalError("Failed to create UserDefaults suite for tests") } let directory = FileManager.default.temporaryDirectory @@ -518,8 +517,7 @@ struct FilterSettingsStorageTests { @Test("A save followed by an immediate clear leaves no file on disk") func clearAfterSaveLeavesNothingOnDisk() { - let suiteName = "FilterSettingsStorageTests-\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { + guard let defaults = try? pool.make().defaults else { fatalError("Failed to create UserDefaults suite for tests") } let directory = FileManager.default.temporaryDirectory @@ -541,8 +539,7 @@ struct FilterSettingsStorageTests { @Test("Browse search persists to disk and clearing it leaves nothing") func browseSearchPersistsAndClears() { - let suiteName = "FilterSettingsStorageTests-\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { + guard let defaults = try? pool.make().defaults else { fatalError("Failed to create UserDefaults suite for tests") } let directory = FileManager.default.temporaryDirectory @@ -596,7 +593,7 @@ struct FilterSettingsStorageTests { @Test("The logic mode survives a fresh storage instance") func logicModePersistsAcrossInstances() throws { - let defaults = try #require(UserDefaults(suiteName: "FilterSettingsStorageTests-\(UUID().uuidString)")) + let defaults = try pool.make().defaults let directory = FileManager.default.temporaryDirectory .appendingPathComponent("FilterSettingsStorageTests-\(UUID().uuidString)", isDirectory: true) defer { try? FileManager.default.removeItem(at: directory) } @@ -619,7 +616,7 @@ struct FilterSettingsStorageTests { @Test("A legacy bare-array filter file loads with the default AND logic mode") func legacyArrayFileLoadsWithAndMode() throws { - let defaults = try #require(UserDefaults(suiteName: "FilterSettingsStorageTests-\(UUID().uuidString)")) + let defaults = try pool.make().defaults defaults.set(true, forKey: "com.TablePro.filterStateMigrationComplete") defaults.set(true, forKey: "com.TablePro.filterStateCompositeKeyMigrationComplete") let directory = FileManager.default.temporaryDirectory @@ -645,7 +642,7 @@ struct FilterSettingsStorageTests { @Test("A table rename moves its filters and browse search and leaves a longer name alone") func renameTableMovesFiltersAndBrowseSearch() throws { - let defaults = try #require(UserDefaults(suiteName: "FilterSettingsStorageTests-\(UUID().uuidString)")) + let defaults = try pool.make().defaults let directory = FileManager.default.temporaryDirectory .appendingPathComponent("FilterSettingsStorageTests-\(UUID().uuidString)", isDirectory: true) defer { try? FileManager.default.removeItem(at: directory) } @@ -691,7 +688,7 @@ struct FilterSettingsStorageTests { @Test("A schema rename moves browse search along with the filters") func renameContainerMovesBrowseSearch() throws { - let defaults = try #require(UserDefaults(suiteName: "FilterSettingsStorageTests-\(UUID().uuidString)")) + let defaults = try pool.make().defaults let directory = FileManager.default.temporaryDirectory .appendingPathComponent("FilterSettingsStorageTests-\(UUID().uuidString)", isDirectory: true) defer { try? FileManager.default.removeItem(at: directory) } diff --git a/TableProTests/Core/Storage/GroupStorageImportPathTests.swift b/TableProTests/Core/Storage/GroupStorageImportPathTests.swift index c3557200c4..04fa0ee920 100644 --- a/TableProTests/Core/Storage/GroupStorageImportPathTests.swift +++ b/TableProTests/Core/Storage/GroupStorageImportPathTests.swift @@ -13,16 +13,15 @@ import Testing @MainActor struct GroupStorageImportPathTests { private let unique = UUID().uuidString + private let pool = EphemeralDefaultsPool() private let defaults: UserDefaults private let metadata: SyncMetadataStorage private let events: AppEvents private let storage: GroupStorage init() throws { - defaults = try #require(UserDefaults(suiteName: "com.TablePro.tests.GroupImport.\(unique)")) - metadata = SyncMetadataStorage( - userDefaults: try #require(UserDefaults(suiteName: "com.TablePro.tests.GroupImport.sync.\(unique)")) - ) + defaults = try pool.make().defaults + metadata = SyncMetadataStorage(userDefaults: try pool.make().defaults) let appEvents = AppEvents() events = appEvents let tracker = SyncChangeTracker(metadataStorage: metadata) diff --git a/TableProTests/Core/Storage/GroupStorageTests.swift b/TableProTests/Core/Storage/GroupStorageTests.swift index e028e224cf..76dbca76b2 100644 --- a/TableProTests/Core/Storage/GroupStorageTests.swift +++ b/TableProTests/Core/Storage/GroupStorageTests.swift @@ -11,10 +11,10 @@ import XCTest @MainActor final class GroupStorageTests: XCTestCase { + private var suite: EphemeralDefaults! + private var syncSuite: EphemeralDefaults! private var defaults: UserDefaults! - private var suiteName: String! private var syncDefaults: UserDefaults! - private var syncSuiteName: String! private var storage: GroupStorage! private var tracker: SyncChangeTracker! private var connectionStorage: ConnectionStorage! @@ -26,10 +26,10 @@ final class GroupStorageTests: XCTestCase { override func setUp() async throws { try await super.setUp() let unique = UUID().uuidString - suiteName = "com.TablePro.tests.GroupStorage.\(unique)" - defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) - syncSuiteName = "com.TablePro.tests.Sync.\(unique)" - syncDefaults = try XCTUnwrap(UserDefaults(suiteName: syncSuiteName)) + suite = try EphemeralDefaults() + syncSuite = try EphemeralDefaults() + defaults = suite.defaults + syncDefaults = syncSuite.defaults let metadata = SyncMetadataStorage(userDefaults: syncDefaults) tracker = SyncChangeTracker(metadataStorage: metadata) connectionFileURL = FileManager.default.temporaryDirectory @@ -58,13 +58,11 @@ final class GroupStorageTests: XCTestCase { } override func tearDown() { - defaults.removePersistentDomain(forName: suiteName) - syncDefaults.removePersistentDomain(forName: syncSuiteName) try? FileManager.default.removeItem(at: connectionFileURL) defaults = nil - suiteName = nil syncDefaults = nil - syncSuiteName = nil + suite = nil + syncSuite = nil changeSubscription = nil appEvents = nil storage = nil diff --git a/TableProTests/Core/Storage/KeychainHelperTests.swift b/TableProTests/Core/Storage/KeychainHelperTests.swift index 746612d8a5..decb61ed09 100644 --- a/TableProTests/Core/Storage/KeychainHelperTests.swift +++ b/TableProTests/Core/Storage/KeychainHelperTests.swift @@ -1,15 +1,11 @@ -// -// KeychainHelperTests.swift -// TableProTests -// - import Foundation +@testable import TablePro import TableProPluginKit import Testing -@testable import TablePro struct KeychainHelperTests { - private let helper = KeychainHelper.shared + /// A service of its own, so nothing a case writes lands among the app's real items. + private let helper = KeychainHelper(service: "com.TablePro.tests.\(UUID().uuidString)") @Test("writeString and readString round trip") func writeAndReadStringRoundTrip() { diff --git a/TableProTests/Core/Storage/LinkedFolderStoreTests.swift b/TableProTests/Core/Storage/LinkedFolderStoreTests.swift index 1be1f06277..000331d433 100644 --- a/TableProTests/Core/Storage/LinkedFolderStoreTests.swift +++ b/TableProTests/Core/Storage/LinkedFolderStoreTests.swift @@ -8,9 +8,11 @@ import Foundation import Testing struct LinkedFolderStoreTests { + private let pool = EphemeralDefaultsPool() + @Test("LinkedFolderStorage adds and removes through the shared implementation") func linkedFolderRoundTrips() throws { - let defaults = try #require(UserDefaults(suiteName: "linkedfolder-\(UUID().uuidString)")) + let defaults = try pool.make().defaults let storage = LinkedFolderStorage(defaults: defaults) let folder = LinkedFolder(path: "/tmp/exports") storage.addFolder(folder) @@ -21,7 +23,7 @@ struct LinkedFolderStoreTests { @Test("LinkedSQLFolderStorage updates through the shared implementation") func linkedSQLFolderUpdates() throws { - let defaults = try #require(UserDefaults(suiteName: "linkedsqlfolder-\(UUID().uuidString)")) + let defaults = try pool.make().defaults let storage = LinkedSQLFolderStorage(defaults: defaults) var folder = LinkedSQLFolder(path: "/tmp/queries") storage.addFolder(folder) diff --git a/TableProTests/Core/Storage/LoadableExtensionApprovalStoreTests.swift b/TableProTests/Core/Storage/LoadableExtensionApprovalStoreTests.swift index baaed62f41..5f6ea09651 100644 --- a/TableProTests/Core/Storage/LoadableExtensionApprovalStoreTests.swift +++ b/TableProTests/Core/Storage/LoadableExtensionApprovalStoreTests.swift @@ -10,15 +10,15 @@ import Testing @MainActor struct LoadableExtensionApprovalStoreTests { + private let suite: EphemeralDefaults private let defaults: UserDefaults private let store: LoadableExtensionApprovalStore private let vec = LoadableExtension(path: "/opt/homebrew/lib/vec0.dylib") private let spatialite = LoadableExtension(path: "/opt/homebrew/lib/mod_spatialite.dylib") init() throws { - let suite = "LoadableExtensionApprovalStoreTests-\(UUID().uuidString)" - defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) + suite = try EphemeralDefaults() + defaults = suite.defaults store = LoadableExtensionApprovalStore(defaults: defaults) } diff --git a/TableProTests/Core/Storage/QueryHistoryInsightsTests.swift b/TableProTests/Core/Storage/QueryHistoryInsightsTests.swift index 0606e1d9ff..d62bbe8c62 100644 --- a/TableProTests/Core/Storage/QueryHistoryInsightsTests.swift +++ b/TableProTests/Core/Storage/QueryHistoryInsightsTests.swift @@ -574,4 +574,24 @@ struct QueryHistoryInsightsTests { #expect(grouped == snapshot.totals.totalCount) #expect(snapshot.totals.distinctShapeCount == 3) } + + /// The panels behind a missing license are a branch of `QueryInsightsView`'s body, so only the tab + /// is reachable here. + @Test("Opening Query Insights again selects the one tab it already opened") + @MainActor + func insightsTabIsOnePerConnection() throws { + let tabManager = QueryTabManager() + tabManager.addQueryInsightsTab() + let insights = try #require(tabManager.tabs.first { $0.tabType == .insights }) + #expect(tabManager.selectedTabId == insights.id) + + tabManager.addTab(initialQuery: "SELECT 1", databaseName: "main", claimFocus: true) + tabManager.selectedTabId = tabManager.tabs.last?.id + #expect(tabManager.selectedTabId != insights.id) + + tabManager.addQueryInsightsTab() + + #expect(tabManager.tabs.filter { $0.tabType == .insights }.map(\.id) == [insights.id]) + #expect(tabManager.selectedTabId == insights.id) + } } diff --git a/TableProTests/Core/Storage/RecentTablesStoreMigrationTests.swift b/TableProTests/Core/Storage/RecentTablesStoreMigrationTests.swift index 6c0522db1d..cd15fd4f62 100644 --- a/TableProTests/Core/Storage/RecentTablesStoreMigrationTests.swift +++ b/TableProTests/Core/Storage/RecentTablesStoreMigrationTests.swift @@ -9,9 +9,11 @@ import Testing @MainActor struct RecentTablesStoreMigrationTests { + private let pool = EphemeralDefaultsPool() + @Test("Migrates the legacy RecentTables.v1 key to the namespaced key") func migratesLegacyKey() throws { - let defaults = try #require(UserDefaults(suiteName: "recent-\(UUID().uuidString)")) + let defaults = try pool.make().defaults let store = RecentTablesStore(defaults: defaults) let conn = UUID() let entry = RecentTableEntry( @@ -32,7 +34,7 @@ struct RecentTablesStoreMigrationTests { @Test("Records and reads through the namespaced key") func recordRoundTrip() throws { - let defaults = try #require(UserDefaults(suiteName: "recent-\(UUID().uuidString)")) + let defaults = try pool.make().defaults let store = RecentTablesStore(defaults: defaults) let conn = UUID() store.record( @@ -68,7 +70,7 @@ struct RecentTablesStoreMigrationTests { /// recorded through `isView` alone said "Drop View" and issued one. @Test("A recorded kind is what the Recent row opens as") func recordedKindSurvivesARoundTrip() throws { - let defaults = try #require(UserDefaults(suiteName: "recent-\(UUID().uuidString)")) + let defaults = try pool.make().defaults let store = RecentTablesStore(defaults: defaults) let conn = UUID() store.record( diff --git a/TableProTests/Core/Storage/RecentTablesStoreSchemaClearTests.swift b/TableProTests/Core/Storage/RecentTablesStoreSchemaClearTests.swift index a3f4368a37..8a8f65bcfa 100644 --- a/TableProTests/Core/Storage/RecentTablesStoreSchemaClearTests.swift +++ b/TableProTests/Core/Storage/RecentTablesStoreSchemaClearTests.swift @@ -12,6 +12,8 @@ import Testing /// because they persist per connection in UserDefaults. @MainActor struct RecentTablesStoreSchemaClearTests { + private let pool = EphemeralDefaultsPool() + private func entry(database: String?, schema: String?, name: String) -> RecentTableEntry { RecentTableEntry( database: database, @@ -39,7 +41,7 @@ struct RecentTablesStoreSchemaClearTests { @Test("Clearing one schema removes only its entries") func clearsOnlyTheNamedSchema() throws { - let defaults = try #require(UserDefaults(suiteName: "recent-\(UUID().uuidString)")) + let defaults = try pool.make().defaults let store = RecentTablesStore(defaults: defaults) let conn = UUID() seed(store, connectionId: conn, [ @@ -56,7 +58,7 @@ struct RecentTablesStoreSchemaClearTests { @Test("A same-named schema in another database keeps its entries") func leavesOtherDatabasesAlone() throws { - let defaults = try #require(UserDefaults(suiteName: "recent-\(UUID().uuidString)")) + let defaults = try pool.make().defaults let store = RecentTablesStore(defaults: defaults) let conn = UUID() seed(store, connectionId: conn, [ @@ -71,7 +73,7 @@ struct RecentTablesStoreSchemaClearTests { @Test("Clearing a schema nothing was opened in changes nothing") func clearingAnUnusedSchemaIsANoOp() throws { - let defaults = try #require(UserDefaults(suiteName: "recent-\(UUID().uuidString)")) + let defaults = try pool.make().defaults let store = RecentTablesStore(defaults: defaults) let conn = UUID() seed(store, connectionId: conn, [entry(database: "shop", schema: "public", name: "users")]) diff --git a/TableProTests/Core/Storage/RewindSnapshotStorageTests.swift b/TableProTests/Core/Storage/RewindSnapshotStorageTests.swift index 4c3be4fe9a..9d51b5147f 100644 --- a/TableProTests/Core/Storage/RewindSnapshotStorageTests.swift +++ b/TableProTests/Core/Storage/RewindSnapshotStorageTests.swift @@ -8,23 +8,6 @@ import Foundation import TableProPluginKit import Testing -private final class FakeRewindKeychain: KeychainStoring, @unchecked Sendable { - private var values: [String: String] = [:] - - func writeString(_ value: String, forKey key: String) -> Bool { - values[key] = value - return true - } - - func readStringResult(forKey key: String) -> KeychainStringResult { - values[key].map { .found($0) } ?? .notFound - } - - func delete(forKey key: String) { - values.removeValue(forKey: key) - } -} - struct RewindSnapshotStorageTests { private let connectionId = UUID() @@ -68,7 +51,7 @@ struct RewindSnapshotStorageTests { @Test("A stored snapshot comes back with its rows intact") func roundTrip() async { let storage = makeStorage() - let cipher = RewindCipher(keychain: FakeRewindKeychain()) + let cipher = RewindCipher(keychain: InMemoryKeychain()) let record = makeRecord() #expect(await storage.recordRewindSnapshot(record, cipher: cipher)) @@ -79,7 +62,7 @@ struct RewindSnapshotStorageTests { @Test("Snapshots come back newest first, and only for the table asked for") func listsByTargetNewestFirst() async { let storage = makeStorage() - let cipher = RewindCipher(keychain: FakeRewindKeychain()) + let cipher = RewindCipher(keychain: InMemoryKeychain()) let older = makeRecord(capturedAt: Date(timeIntervalSince1970: 1_000)) let newer = makeRecord(capturedAt: Date(timeIntervalSince1970: 2_000)) let elsewhere = makeRecord(table: "orders") @@ -97,7 +80,7 @@ struct RewindSnapshotStorageTests { @Test("A snapshot for another schema is not returned for the unqualified table") func schemaIsPartOfTheKey() async { let storage = makeStorage() - let cipher = RewindCipher(keychain: FakeRewindKeychain()) + let cipher = RewindCipher(keychain: InMemoryKeychain()) await storage.recordRewindSnapshot(makeRecord(schema: "reporting"), cipher: cipher) let found = await storage.rewindSnapshots( @@ -109,7 +92,7 @@ struct RewindSnapshotStorageTests { @Test("Snapshots older than the retention window are pruned") func agePruning() async { let storage = makeStorage() - let cipher = RewindCipher(keychain: FakeRewindKeychain()) + let cipher = RewindCipher(keychain: InMemoryKeychain()) let now = Date(timeIntervalSince1970: 2_000_000) await storage.recordRewindSnapshot( makeRecord(capturedAt: now.addingTimeInterval(-8 * 24 * 60 * 60)), cipher: cipher @@ -127,7 +110,7 @@ struct RewindSnapshotStorageTests { @Test("One table cannot hold more than the per-table depth") func perTablePruning() async { let storage = makeStorage() - let cipher = RewindCipher(keychain: FakeRewindKeychain()) + let cipher = RewindCipher(keychain: InMemoryKeychain()) for offset in 0 ..< 6 { await storage.recordRewindSnapshot( makeRecord(capturedAt: Date(timeIntervalSince1970: TimeInterval(1_000 + offset))), cipher: cipher @@ -145,7 +128,7 @@ struct RewindSnapshotStorageTests { @Test("A single save larger than the whole byte budget is still kept") func oversizedSingleSnapshotSurvives() async { let storage = makeStorage() - let cipher = RewindCipher(keychain: FakeRewindKeychain()) + let cipher = RewindCipher(keychain: InMemoryKeychain()) await storage.recordRewindSnapshot(makeRecord(), cipher: cipher) await storage.pruneRewindSnapshots(now: Date(timeIntervalSince1970: 1_700_000_100), byteLimit: 1) @@ -159,7 +142,7 @@ struct RewindSnapshotStorageTests { @Test("Clearing removes every snapshot") func clearing() async { let storage = makeStorage() - let cipher = RewindCipher(keychain: FakeRewindKeychain()) + let cipher = RewindCipher(keychain: InMemoryKeychain()) await storage.recordRewindSnapshot(makeRecord(), cipher: cipher) await storage.clearRewindSnapshots() diff --git a/TableProTests/Core/Storage/SQLFavoriteDeletionSyncTests.swift b/TableProTests/Core/Storage/SQLFavoriteDeletionSyncTests.swift index b34eb43305..fcb13c9b32 100644 --- a/TableProTests/Core/Storage/SQLFavoriteDeletionSyncTests.swift +++ b/TableProTests/Core/Storage/SQLFavoriteDeletionSyncTests.swift @@ -13,11 +13,12 @@ import TableProSyncTransport import Testing struct SQLFavoriteDeletionSyncTests { + private let pool = EphemeralDefaultsPool() private let storage: SQLFavoriteStorage private let metadata: SyncMetadataStorage private let manager: SQLFavoriteManager - init() { + init() throws { let url = FileManager.default.temporaryDirectory .appendingPathComponent("tablepro-tests") .appendingPathComponent("sql_favorites_sync_\(UUID().uuidString).db") @@ -27,7 +28,7 @@ struct SQLFavoriteDeletionSyncTests { ) storage = SQLFavoriteStorage(databaseURL: url, removeDatabaseOnDeinit: true) metadata = SyncMetadataStorage( - userDefaults: UserDefaults(suiteName: "tablepro-favorite-sync-\(UUID().uuidString)") ?? .standard, + userDefaults: try pool.make().defaults, prefix: "tests.\(UUID().uuidString)" ) manager = SQLFavoriteManager( diff --git a/TableProTests/Core/Storage/SQLFavoriteManagerImportTests.swift b/TableProTests/Core/Storage/SQLFavoriteManagerImportTests.swift index e81e97111d..f7edd69002 100644 --- a/TableProTests/Core/Storage/SQLFavoriteManagerImportTests.swift +++ b/TableProTests/Core/Storage/SQLFavoriteManagerImportTests.swift @@ -17,6 +17,7 @@ struct SQLFavoriteManagerImportTests { private let databasePath: String private let storage: SQLFavoriteStorage private let syncDefaults: DirtyWriteRecordingDefaults + private let syncSuite: EphemeralDefaults private let metadata: SyncMetadataStorage private let events: AppEvents private let manager: SQLFavoriteManager @@ -32,9 +33,8 @@ struct SQLFavoriteManagerImportTests { ) databasePath = url.path(percentEncoded: false) storage = SQLFavoriteStorage(databaseURL: url, removeDatabaseOnDeinit: true) - syncDefaults = try #require( - DirtyWriteRecordingDefaults(suiteName: "com.TablePro.tests.FavoriteImport.sync.\(unique)") - ) + syncSuite = try EphemeralDefaults() + syncDefaults = try #require(DirtyWriteRecordingDefaults(suiteName: syncSuite.suiteName)) metadata = SyncMetadataStorage(userDefaults: syncDefaults, prefix: "tests.\(unique)") let appEvents = AppEvents() events = appEvents diff --git a/TableProTests/Core/Storage/SQLFavoriteRemoteApplyTests.swift b/TableProTests/Core/Storage/SQLFavoriteRemoteApplyTests.swift index a62a228a04..252aa6014f 100644 --- a/TableProTests/Core/Storage/SQLFavoriteRemoteApplyTests.swift +++ b/TableProTests/Core/Storage/SQLFavoriteRemoteApplyTests.swift @@ -9,6 +9,7 @@ import TableProSyncTransport import Testing struct SQLFavoriteRemoteApplyTests { + private let pool = EphemeralDefaultsPool() private let storage: SQLFavoriteStorage private let metadata: SyncMetadataStorage private let manager: SQLFavoriteManager @@ -16,7 +17,7 @@ struct SQLFavoriteRemoteApplyTests { private let older = Date(timeIntervalSince1970: 1_000) private let newer = Date(timeIntervalSince1970: 2_000) - init() { + init() throws { let url = FileManager.default.temporaryDirectory .appendingPathComponent("tablepro-tests") .appendingPathComponent("sql_favorites_remote_\(UUID().uuidString).db") @@ -26,7 +27,7 @@ struct SQLFavoriteRemoteApplyTests { ) storage = SQLFavoriteStorage(databaseURL: url, removeDatabaseOnDeinit: true) metadata = SyncMetadataStorage( - userDefaults: UserDefaults(suiteName: "tablepro-favorite-remote-\(UUID().uuidString)") ?? .standard, + userDefaults: try pool.make().defaults, prefix: "tests.\(UUID().uuidString)" ) manager = SQLFavoriteManager( diff --git a/TableProTests/Core/Storage/SQLFavoriteVersionTests.swift b/TableProTests/Core/Storage/SQLFavoriteVersionTests.swift index b7b48aa88a..48c658ae19 100644 --- a/TableProTests/Core/Storage/SQLFavoriteVersionTests.swift +++ b/TableProTests/Core/Storage/SQLFavoriteVersionTests.swift @@ -12,6 +12,7 @@ import Testing struct SQLFavoriteVersionTests { private let storage: SQLFavoriteStorage + private let suite: EphemeralDefaults private let defaults: UserDefaults private let tracker: SyncChangeTracker @@ -21,7 +22,8 @@ struct SQLFavoriteVersionTests { .appendingPathComponent("sql_favorites_versions_\(UUID().uuidString).db") try? FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) storage = SQLFavoriteStorage(databaseURL: url, removeDatabaseOnDeinit: true) - defaults = try #require(UserDefaults(suiteName: "com.TablePro.tests.SQLFavoriteVersions.\(UUID().uuidString)")) + suite = try EphemeralDefaults() + defaults = suite.defaults tracker = SyncChangeTracker(metadataStorage: SyncMetadataStorage(userDefaults: defaults)) } diff --git a/TableProTests/Core/Storage/SSHProfileStorageTests.swift b/TableProTests/Core/Storage/SSHProfileStorageTests.swift index ef5545cfeb..f85d062897 100644 --- a/TableProTests/Core/Storage/SSHProfileStorageTests.swift +++ b/TableProTests/Core/Storage/SSHProfileStorageTests.swift @@ -12,12 +12,13 @@ import Testing @MainActor struct SSHProfileStorageTests { + private let pool = EphemeralDefaultsPool() private let storage: SSHProfileStorage private let connections: ConnectionStorage private let keychain: InMemoryKeychain private let metadata: SyncMetadataStorage - init() { + init() throws { let unique = UUID().uuidString let fileURL = FileManager.default.temporaryDirectory .appendingPathComponent("tablepro-tests") @@ -28,9 +29,9 @@ struct SSHProfileStorageTests { withIntermediateDirectories: true ) - let connectionDefaults = UserDefaults(suiteName: "com.TablePro.tests.SSHProfileConn.\(unique)")! - let profileDefaults = UserDefaults(suiteName: "com.TablePro.tests.SSHProfile.\(unique)")! - let syncDefaults = UserDefaults(suiteName: "com.TablePro.tests.SSHProfileSync.\(unique)")! + let connectionDefaults = try pool.make().defaults + let profileDefaults = try pool.make().defaults + let syncDefaults = try pool.make().defaults keychain = InMemoryKeychain() metadata = SyncMetadataStorage(userDefaults: syncDefaults) @@ -155,10 +156,8 @@ struct SSHProfileStorageTests { } @Test("A save that cannot run reports the failure instead of returning silently") - func saveReportsFailureAfterFailedLoad() { - let profileDefaults = UserDefaults( - suiteName: "com.TablePro.tests.SSHProfileCorrupt.\(UUID().uuidString)" - )! + func saveReportsFailureAfterFailedLoad() throws { + let profileDefaults = try pool.make().defaults profileDefaults.set(Data("not json".utf8), forKey: "com.TablePro.sshProfiles") let corrupted = SSHProfileStorage( userDefaults: profileDefaults, @@ -196,7 +195,7 @@ struct SSHProfileStorageTests { } @Test("A profile whose secrets cannot be read is not deleted, and its connections keep the link") - func deleteRefusesWhenSecretsAreUnreadable() { + func deleteRefusesWhenSecretsAreUnreadable() throws { let unique = UUID().uuidString let fileURL = FileManager.default.temporaryDirectory .appendingPathComponent("tablepro-tests") @@ -209,11 +208,11 @@ struct SSHProfileStorageTests { let lockedKeychain = UnreadableSecretKeychain() let lockedConnections = ConnectionStorage( fileURL: fileURL, - userDefaults: UserDefaults(suiteName: "com.TablePro.tests.SSHProfileLockedConn.\(unique)")!, + userDefaults: try pool.make().defaults, keychain: lockedKeychain ) let lockedStorage = SSHProfileStorage( - userDefaults: UserDefaults(suiteName: "com.TablePro.tests.SSHProfileLocked.\(unique)")!, + userDefaults: try pool.make().defaults, keychain: lockedKeychain, syncTracker: SyncChangeTracker(metadataStorage: metadata), connectionStorage: lockedConnections diff --git a/TableProTests/Core/Storage/SafeModeMigrationTests.swift b/TableProTests/Core/Storage/SafeModeMigrationTests.swift index 8d6d5abf4f..9d523a812d 100644 --- a/TableProTests/Core/Storage/SafeModeMigrationTests.swift +++ b/TableProTests/Core/Storage/SafeModeMigrationTests.swift @@ -10,15 +10,17 @@ import Foundation @testable import TablePro import TableProPluginKit import TableProSyncTransport +import TableProTestSupport import Testing @MainActor struct SafeModeMigrationTests { + private let pool = EphemeralDefaultsPool() private let storage: ConnectionStorage private let defaults: UserDefaults private let tracker: SyncChangeTracker - init() { + init() throws { let unique = UUID().uuidString let fileURL = FileManager.default.temporaryDirectory .appendingPathComponent("tablepro-tests") @@ -27,12 +29,8 @@ struct SafeModeMigrationTests { at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true ) - let suiteName = "com.TablePro.tests.ConnectionStorage.\(unique)" - guard let defaults = UserDefaults(suiteName: suiteName), - let syncDefaults = UserDefaults(suiteName: "com.TablePro.tests.Sync.\(unique)") - else { - fatalError("Failed to create isolated test user defaults") - } + let defaults = try pool.make().defaults + let syncDefaults = try pool.make().defaults self.defaults = defaults let metadata = SyncMetadataStorage(userDefaults: syncDefaults) self.tracker = SyncChangeTracker(metadataStorage: metadata) @@ -451,7 +449,7 @@ struct SafeModeMigrationTests { } @Test("A connectionUpdated event reconciles the open session") - func connectionUpdatedEventReconcilesLiveSession() async { + func connectionUpdatedEventReconcilesLiveSession() async throws { let id = UUID() let connection = makeConnection(id: id, name: "Event Driven", safeModeLevel: .readOnly) storage.addConnection(connection) @@ -466,10 +464,7 @@ struct SafeModeMigrationTests { AppEvents.shared.connectionUpdated.send(id) - let deadline = Date().addingTimeInterval(2) - while Date() < deadline, manager.session(for: id)?.safeModeLevel != .silent { - try? await Task.sleep(nanoseconds: 5_000_000) - } + try await waitUntil { manager.session(for: id)?.safeModeLevel == .silent } #expect(manager.session(for: id)?.safeModeLevel == .silent) } diff --git a/TableProTests/Core/Storage/StoredSecretStateTests.swift b/TableProTests/Core/Storage/StoredSecretStateTests.swift index 300d375cd4..46828f6ae6 100644 --- a/TableProTests/Core/Storage/StoredSecretStateTests.swift +++ b/TableProTests/Core/Storage/StoredSecretStateTests.swift @@ -41,7 +41,9 @@ private final class ScriptedKeychain: KeychainStoring, @unchecked Sendable { @MainActor struct StoredSecretStateTests { - private func makeStorage(_ result: KeychainStringResult) -> ConnectionStorage { + private let pool = EphemeralDefaultsPool() + + private func makeStorage(_ result: KeychainStringResult) throws -> ConnectionStorage { let unique = UUID().uuidString let fileURL = FileManager.default.temporaryDirectory .appendingPathComponent("tablepro-tests") @@ -53,24 +55,24 @@ struct StoredSecretStateTests { ) return ConnectionStorage( fileURL: fileURL, - userDefaults: UserDefaults(suiteName: "com.TablePro.tests.SecretState.\(unique)")!, + userDefaults: try pool.make().defaults, keychain: ScriptedKeychain(result) ) } @Test("A stored password reads as stored") - func storedPassword() { - #expect(makeStorage(.found("hunter2")).passwordState(for: UUID()) == .stored) + func storedPassword() throws { + #expect(try makeStorage(.found("hunter2")).passwordState(for: UUID()) == .stored) } @Test("No keychain item reads as absent") - func absentPassword() { - #expect(makeStorage(.notFound).passwordState(for: UUID()) == .absent) + func absentPassword() throws { + #expect(try makeStorage(.notFound).passwordState(for: UUID()) == .absent) } @Test("An empty stored value reads as absent rather than stored") - func emptyStoredValueIsAbsent() { - #expect(makeStorage(.found("")).passwordState(for: UUID()) == .absent) + func emptyStoredValueIsAbsent() throws { + #expect(try makeStorage(.found("")).passwordState(for: UUID()) == .absent) } @Test( @@ -82,17 +84,17 @@ struct StoredSecretStateTests { .error(-25_300), ] ) - func unreadableKeychain(_ result: KeychainStringResult) { - #expect(makeStorage(result).passwordState(for: UUID()) == .unreadable) + func unreadableKeychain(_ result: KeychainStringResult) throws { + #expect(try makeStorage(result).passwordState(for: UUID()) == .unreadable) } @Test("The SSH namespaces answer the same way") - func sshNamespaces() { - let stored = makeStorage(.found("secret")) + func sshNamespaces() throws { + let stored = try makeStorage(.found("secret")) #expect(stored.sshPasswordState(for: UUID()) == .stored) #expect(stored.keyPassphraseState(for: UUID()) == .stored) - let locked = makeStorage(.locked) + let locked = try makeStorage(.locked) #expect(locked.sshPasswordState(for: UUID()) == .unreadable) #expect(locked.keyPassphraseState(for: UUID()) == .unreadable) } diff --git a/TableProTests/Core/Storage/SyncDirtyMarkingTests.swift b/TableProTests/Core/Storage/SyncDirtyMarkingTests.swift index aeb17eb409..399a6752ca 100644 --- a/TableProTests/Core/Storage/SyncDirtyMarkingTests.swift +++ b/TableProTests/Core/Storage/SyncDirtyMarkingTests.swift @@ -7,6 +7,7 @@ import Testing @MainActor struct SyncDirtyMarkingTests { private let unique = UUID().uuidString + private let pool = EphemeralDefaultsPool() private let metadata: SyncMetadataStorage private let tracker: SyncChangeTracker private let keychain = InMemoryKeychain() @@ -18,13 +19,11 @@ struct SyncDirtyMarkingTests { .appendingPathComponent("tablepro-tests") .appendingPathComponent("dirty-marking-\(unique)", isDirectory: true) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - metadata = SyncMetadataStorage( - userDefaults: try #require(UserDefaults(suiteName: "com.TablePro.tests.DirtyMarking.sync.\(unique)")) - ) + metadata = SyncMetadataStorage(userDefaults: try pool.make().defaults) tracker = SyncChangeTracker(metadataStorage: metadata) connections = ConnectionStorage( fileURL: directory.appendingPathComponent("connections.json"), - userDefaults: try #require(UserDefaults(suiteName: "com.TablePro.tests.DirtyMarking.conn.\(unique)")), + userDefaults: try pool.make().defaults, syncTracker: tracker, keychain: keychain, integrity: ConnectionStoreIntegrity(keySource: StoredIntegrityKeySource(store: keychain)) @@ -32,7 +31,7 @@ struct SyncDirtyMarkingTests { } private func defaults(_ name: String) throws -> UserDefaults { - try #require(UserDefaults(suiteName: "com.TablePro.tests.DirtyMarking.\(name).\(unique)")) + try pool.make(name).defaults } @Test("Saving the tag library marks the tag that changed and no other") diff --git a/TableProTests/Core/Storage/TableFolderStorageTests.swift b/TableProTests/Core/Storage/TableFolderStorageTests.swift index d65a267667..11f88f031f 100644 --- a/TableProTests/Core/Storage/TableFolderStorageTests.swift +++ b/TableProTests/Core/Storage/TableFolderStorageTests.swift @@ -10,6 +10,8 @@ import Testing @MainActor struct TableFolderStorageTests { + private let pool = EphemeralDefaultsPool() + private struct Harness { let storage: TableFolderStorage let metadata: SyncMetadataStorage @@ -19,12 +21,8 @@ struct TableFolderStorageTests { } private func makeHarness() throws -> Harness { - let foldersSuite = "TableFolderStorageTests.folders.\(UUID().uuidString)" - let syncSuite = "TableFolderStorageTests.sync.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: foldersSuite)) - let syncDefaults = try #require(UserDefaults(suiteName: syncSuite)) - defaults.removePersistentDomain(forName: foldersSuite) - syncDefaults.removePersistentDomain(forName: syncSuite) + let defaults = try pool.make().defaults + let syncDefaults = try pool.make().defaults let metadata = SyncMetadataStorage(userDefaults: syncDefaults) let tracker = SyncChangeTracker(metadataStorage: metadata) let notifications = NotificationCenter() diff --git a/TableProTests/Core/Storage/TagStorageImportTests.swift b/TableProTests/Core/Storage/TagStorageImportTests.swift index 6a09c0afb4..95087989bb 100644 --- a/TableProTests/Core/Storage/TagStorageImportTests.swift +++ b/TableProTests/Core/Storage/TagStorageImportTests.swift @@ -12,21 +12,19 @@ import Testing @MainActor struct TagStorageImportTests { - private let unique = UUID().uuidString + private let pool = EphemeralDefaultsPool() private let metadata: SyncMetadataStorage private let tracker: SyncChangeTracker private let events: AppEvents init() throws { - metadata = SyncMetadataStorage( - userDefaults: try #require(UserDefaults(suiteName: "com.TablePro.tests.TagImport.sync.\(unique)")) - ) + metadata = SyncMetadataStorage(userDefaults: try pool.make().defaults) tracker = SyncChangeTracker(metadataStorage: metadata) events = AppEvents() } - private func makeStorage(_ name: String = "tags", seed: Data? = nil) throws -> (TagStorage, UserDefaults) { - let defaults = try #require(UserDefaults(suiteName: "com.TablePro.tests.TagImport.\(name).\(unique)")) + private func makeStorage(seed: Data? = nil) throws -> (TagStorage, UserDefaults) { + let defaults = try pool.make().defaults if let seed { defaults.set(seed, forKey: "com.TablePro.tags") } @@ -121,7 +119,7 @@ struct TagStorageImportTests { @Test("An unreadable tag store throws and is left as it was") func unreadableStoreThrows() throws { let junk = Data([0x00, 0x01]) - let (storage, defaults) = try makeStorage("unreadable", seed: junk) + let (storage, defaults) = try makeStorage(seed: junk) #expect(throws: TagStorageError.storeUnreadable) { try storage.ensureTags([PlannedTag(name: "Billing", color: nil)]) diff --git a/TableProTests/Core/Storage/TagStorageTests.swift b/TableProTests/Core/Storage/TagStorageTests.swift index b59b826d63..dc94fc76d9 100644 --- a/TableProTests/Core/Storage/TagStorageTests.swift +++ b/TableProTests/Core/Storage/TagStorageTests.swift @@ -10,9 +10,9 @@ import XCTest @MainActor final class TagStorageTests: XCTestCase { - private var suiteName: String! + private var suite: EphemeralDefaults! + private var syncSuite: EphemeralDefaults! private var defaults: UserDefaults! - private var syncSuiteName: String! private var syncDefaults: UserDefaults! private var metadata: SyncMetadataStorage! private var tracker: SyncChangeTracker! @@ -23,11 +23,10 @@ final class TagStorageTests: XCTestCase { override func setUp() async throws { try await super.setUp() - let unique = UUID().uuidString - suiteName = "com.TablePro.tests.TagStorage.\(unique)" - syncSuiteName = "com.TablePro.tests.TagStorage.sync.\(unique)" - defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) - syncDefaults = try XCTUnwrap(UserDefaults(suiteName: syncSuiteName)) + suite = try EphemeralDefaults() + syncSuite = try EphemeralDefaults() + defaults = suite.defaults + syncDefaults = syncSuite.defaults metadata = SyncMetadataStorage(userDefaults: syncDefaults) tracker = SyncChangeTracker(metadataStorage: metadata) appEvents = AppEvents() @@ -40,8 +39,6 @@ final class TagStorageTests: XCTestCase { } override func tearDown() { - defaults.removePersistentDomain(forName: suiteName) - syncDefaults.removePersistentDomain(forName: syncSuiteName) changeSubscription = nil storage = nil appEvents = nil @@ -49,8 +46,8 @@ final class TagStorageTests: XCTestCase { metadata = nil defaults = nil syncDefaults = nil - suiteName = nil - syncSuiteName = nil + suite = nil + syncSuite = nil super.tearDown() } diff --git a/TableProTests/Core/Storage/TransferDialogStorageTests.swift b/TableProTests/Core/Storage/TransferDialogStorageTests.swift index 4880d38fb2..4a0fb8c5e0 100644 --- a/TableProTests/Core/Storage/TransferDialogStorageTests.swift +++ b/TableProTests/Core/Storage/TransferDialogStorageTests.swift @@ -8,10 +8,15 @@ import Testing @testable import TablePro struct TransferDialogStorageTests { - private let suiteName = "com.TablePro.tests.exportDialog.\(UUID().uuidString)" + private let suite: EphemeralDefaults + private var suiteName: String { suite.suiteName } + + init() throws { + suite = try EphemeralDefaults() + } private func makeDefaults() throws -> UserDefaults { - try #require(UserDefaults(suiteName: suiteName)) + suite.defaults } @Test("loadLastExportFormatId returns nil when nothing stored") diff --git a/TableProTests/Core/Storage/ValueDisplayFormatStorageTests.swift b/TableProTests/Core/Storage/ValueDisplayFormatStorageTests.swift index 7d50ea9da4..8b3d551c94 100644 --- a/TableProTests/Core/Storage/ValueDisplayFormatStorageTests.swift +++ b/TableProTests/Core/Storage/ValueDisplayFormatStorageTests.swift @@ -9,8 +9,10 @@ import Testing @MainActor struct ValueDisplayFormatStorageTests { + private let pool = EphemeralDefaultsPool() + private func makeStorage() throws -> (ValueDisplayFormatStorage, UserDefaults) { - let defaults = try #require(UserDefaults(suiteName: "vdf-\(UUID().uuidString)")) + let defaults = try pool.make().defaults return (ValueDisplayFormatStorage(defaults: defaults), defaults) } diff --git a/TableProTests/Core/Storage/WorkspaceRailOrderStoreTests.swift b/TableProTests/Core/Storage/WorkspaceRailOrderStoreTests.swift index a09985bf78..e6d9761291 100644 --- a/TableProTests/Core/Storage/WorkspaceRailOrderStoreTests.swift +++ b/TableProTests/Core/Storage/WorkspaceRailOrderStoreTests.swift @@ -5,14 +5,15 @@ import Testing @MainActor struct WorkspaceRailOrderStoreTests { + private let pool = EphemeralDefaultsPool() + private func makeId(container: String = "app") -> WorkspaceID { WorkspaceID(connectionId: UUID(), container: container) } private func makeStore() throws -> (WorkspaceRailOrderStore, UserDefaults, String) { - let suiteName = "com.TablePro.tests.workspaceRail.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suiteName)) - return (WorkspaceRailOrderStore(defaults: defaults), defaults, suiteName) + let suite = try pool.make() + return (WorkspaceRailOrderStore(defaults: suite.defaults), suite.defaults, suite.suiteName) } @Test("A fresh store has no stored arrangement") diff --git a/TableProTests/Core/Sync/SyncChangeTrackerTests.swift b/TableProTests/Core/Sync/SyncChangeTrackerTests.swift index 4943358c5b..dcf5269c2c 100644 --- a/TableProTests/Core/Sync/SyncChangeTrackerTests.swift +++ b/TableProTests/Core/Sync/SyncChangeTrackerTests.swift @@ -11,13 +11,13 @@ import Testing @MainActor struct SyncChangeTrackerTests { + private let suite: EphemeralDefaults private let metadata: SyncMetadataStorage private let tracker: SyncChangeTracker init() throws { - let unique = UUID().uuidString - let syncDefaults = try #require(UserDefaults(suiteName: "com.TablePro.tests.SyncChangeTracker.\(unique)")) - metadata = SyncMetadataStorage(userDefaults: syncDefaults) + suite = try EphemeralDefaults() + metadata = SyncMetadataStorage(userDefaults: suite.defaults) tracker = SyncChangeTracker(metadataStorage: metadata) } diff --git a/TableProTests/Core/Sync/SyncCoordinatorEchoTests.swift b/TableProTests/Core/Sync/SyncCoordinatorEchoTests.swift index 6bd973ba1a..b24170c8e6 100644 --- a/TableProTests/Core/Sync/SyncCoordinatorEchoTests.swift +++ b/TableProTests/Core/Sync/SyncCoordinatorEchoTests.swift @@ -2,6 +2,7 @@ import CloudKit import Foundation @testable import TablePro import TableProSyncTransport +import TableProTestSupport import Testing @MainActor @@ -432,10 +433,7 @@ struct SyncCoordinatorEchoTests { #expect(acknowledged) #expect(connections.loadConnection(id: localOnly.id)?.name == "Kept") #expect(connections.loadConnection(id: synced.id) == nil) - let deadline = ContinuousClock.now + .seconds(5) - while await favorites.fetchFavorite(id: removedQuery.id) != nil, ContinuousClock.now < deadline { - try await Task.sleep(for: .milliseconds(5)) - } + try await waitUntil(timeout: .seconds(5)) { await favorites.fetchFavorite(id: removedQuery.id) == nil } #expect(await favorites.fetchFavorite(id: removedQuery.id) == nil) #expect(await favorites.fetchFavorite(id: keptQuery.id) != nil) } diff --git a/TableProTests/Core/Sync/SyncCoordinatorSQLFavoritePullTests.swift b/TableProTests/Core/Sync/SyncCoordinatorSQLFavoritePullTests.swift index a56bba5d47..65cf65c9f5 100644 --- a/TableProTests/Core/Sync/SyncCoordinatorSQLFavoritePullTests.swift +++ b/TableProTests/Core/Sync/SyncCoordinatorSQLFavoritePullTests.swift @@ -17,15 +17,17 @@ struct SyncCoordinatorSQLFavoritePullTests { ) private let connectionId = UUID() + private let suite: EphemeralDefaults private let defaults: UserDefaults private let metadata: SyncMetadataStorage private let tracker: SyncChangeTracker private let recordCache: SyncRecordCache private let scratchDirectory: URL - init() { + init() throws { let unique = UUID().uuidString - defaults = UserDefaults(suiteName: "tablepro-favorite-pull-\(unique)") ?? .standard + suite = try EphemeralDefaults() + defaults = suite.defaults metadata = SyncMetadataStorage(userDefaults: defaults, prefix: "tests.\(unique)") tracker = SyncChangeTracker(metadataStorage: metadata) scratchDirectory = FileManager.default.temporaryDirectory diff --git a/TableProTests/Core/Sync/SyncMetadataStorageEnvironmentTests.swift b/TableProTests/Core/Sync/SyncMetadataStorageEnvironmentTests.swift index 7227f3f4aa..2c6f755920 100644 --- a/TableProTests/Core/Sync/SyncMetadataStorageEnvironmentTests.swift +++ b/TableProTests/Core/Sync/SyncMetadataStorageEnvironmentTests.swift @@ -16,9 +16,8 @@ struct SyncMetadataStorageEnvironmentTests { @Test("A storage built for a suite never touches the app's defaults") func injectedStorageStaysInItsSuite() throws { - let suiteName = "syncmetadata-\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suiteName)) - defer { defaults.removePersistentDomain(forName: suiteName) } + let suite = try EphemeralDefaults() + let defaults = suite.defaults let storage = SyncMetadataStorage(userDefaults: defaults) let id = UUID().uuidString diff --git a/TableProTests/Core/Sync/SyncScopeTests.swift b/TableProTests/Core/Sync/SyncScopeTests.swift index fdf16ddd19..24f5e4311e 100644 --- a/TableProTests/Core/Sync/SyncScopeTests.swift +++ b/TableProTests/Core/Sync/SyncScopeTests.swift @@ -19,7 +19,8 @@ struct SyncScopeTests { @Test("markDirty records a synced record type") func markDirtyRecordsSyncedType() throws { - let defaults = try #require(UserDefaults(suiteName: "syncscope-\(UUID().uuidString)")) + let suite = try EphemeralDefaults() + let defaults = suite.defaults let tracker = SyncChangeTracker(metadataStorage: SyncMetadataStorage(userDefaults: defaults)) tracker.markDirty(.settings, id: "general") #expect(tracker.dirtyRecords(for: .settings).contains("general")) diff --git a/TableProTests/Core/UsersRoles/PrincipalChangeManagerTests.swift b/TableProTests/Core/UsersRoles/PrincipalChangeManagerTests.swift index 5ddb0a5903..fcdd2c5053 100644 --- a/TableProTests/Core/UsersRoles/PrincipalChangeManagerTests.swift +++ b/TableProTests/Core/UsersRoles/PrincipalChangeManagerTests.swift @@ -9,7 +9,6 @@ import Testing @testable import TablePro -@Suite("Principal change manager", .serialized) @MainActor struct PrincipalChangeManagerTests { private let alice = PluginPrincipalRef(name: "alice") diff --git a/TableProTests/Core/Utilities/PasswordSourceResolverTests.swift b/TableProTests/Core/Utilities/PasswordSourceResolverTests.swift index 6792593352..3b7fc782b8 100644 --- a/TableProTests/Core/Utilities/PasswordSourceResolverTests.swift +++ b/TableProTests/Core/Utilities/PasswordSourceResolverTests.swift @@ -1,13 +1,8 @@ -// -// PasswordSourceResolverTests.swift -// TableProTests -// - import Foundation @testable import TablePro +import TableProTestSupport import Testing -@Suite("PasswordSourceResolver", .serialized) struct PasswordSourceResolverTests { @Test("Reads a password from a file") func fileHappyPath() async throws { @@ -72,18 +67,17 @@ struct PasswordSourceResolverTests { @Test("Reads a password from an environment variable") func envHappyPath() async throws { let name = uniqueEnvName() - setenv(name, "envsecret", 1) - defer { unsetenv(name) } - let password = try await PasswordSourceResolver.resolve(.env(variable: name)) + let password = try await PasswordSourceResolver.resolve(.env(variable: name), environment: [name: "envsecret"]) #expect(password == "envsecret") } @Test("Trims whitespace from an environment variable value") func envTrimsWhitespace() async throws { let name = uniqueEnvName() - setenv(name, " envsecret ", 1) - defer { unsetenv(name) } - let password = try await PasswordSourceResolver.resolve(.env(variable: name)) + let password = try await PasswordSourceResolver.resolve( + .env(variable: name), + environment: [name: " envsecret "] + ) #expect(password == "envsecret") } @@ -98,10 +92,8 @@ struct PasswordSourceResolverTests { @Test("Throws when the environment variable is empty") func envEmpty() async { let name = uniqueEnvName() - setenv(name, "", 1) - defer { unsetenv(name) } await #expect(throws: PasswordSourceResolver.ResolutionError.self) { - try await PasswordSourceResolver.resolve(.env(variable: name)) + try await PasswordSourceResolver.resolve(.env(variable: name), environment: [name: ""]) } } @@ -174,8 +166,14 @@ struct PasswordSourceResolverTests { @Test("Times out a slow command") func commandTimesOut() async { + let clock = ManualClock() + let resolution = Task { + try await PasswordSourceResolver.resolveCommand(shell: "sleep 5", timeoutSeconds: 1, clock: clock) + } + await clock.waitUntilSleeping() + clock.advance(by: .seconds(1)) do { - _ = try await PasswordSourceResolver.resolveCommand(shell: "sleep 5", timeoutSeconds: 1) + _ = try await resolution.value Issue.record("Expected resolveCommand to time out") } catch let error as PasswordSourceResolver.ResolutionError { guard case .commandTimedOut = error else { diff --git a/TableProTests/Core/Utilities/SQLParameterExtractorTests.swift b/TableProTests/Core/Utilities/SQLParameterExtractorTests.swift index 2ebe61995b..246918640b 100644 --- a/TableProTests/Core/Utilities/SQLParameterExtractorTests.swift +++ b/TableProTests/Core/Utilities/SQLParameterExtractorTests.swift @@ -3,157 +3,123 @@ // TableProTests // +import Foundation @testable import TablePro import TableProPluginKit -import XCTest +import Testing -final class SQLParameterExtractorTests: XCTestCase { +struct SQLParameterExtractorTests { // MARK: - extractParameters - func testNoParameters() { - XCTAssertEqual(SQLParameterExtractor.extractParameters(from: "SELECT 1"), []) + @Test + func noParameters() { + #expect(SQLParameterExtractor.extractParameters(from: "SELECT 1") == []) } - func testSingleParameter() { - XCTAssertEqual( - SQLParameterExtractor.extractParameters(from: "SELECT * FROM t WHERE id = :id"), - ["id"] - ) + @Test + func singleParameter() { + #expect(SQLParameterExtractor.extractParameters(from: "SELECT * FROM t WHERE id = :id") == ["id"]) } - func testMultipleParameters() { - XCTAssertEqual( - SQLParameterExtractor.extractParameters(from: "WHERE id = :id AND name = :name"), - ["id", "name"] - ) + @Test + func multipleParameters() { + #expect(SQLParameterExtractor.extractParameters(from: "WHERE id = :id AND name = :name") == ["id", "name"]) } - func testDuplicateParameters() { - XCTAssertEqual( - SQLParameterExtractor.extractParameters(from: "WHERE :id = :id OR :name = :id"), - ["id", "name"] - ) + @Test + func duplicateParameters() { + #expect(SQLParameterExtractor.extractParameters(from: "WHERE :id = :id OR :name = :id") == ["id", "name"]) } - func testUnderscoreInName() { - XCTAssertEqual( - SQLParameterExtractor.extractParameters(from: "WHERE user_id = :user_id"), - ["user_id"] - ) + @Test + func underscoreInName() { + #expect(SQLParameterExtractor.extractParameters(from: "WHERE user_id = :user_id") == ["user_id"]) } - func testParameterAtEnd() { - XCTAssertEqual( - SQLParameterExtractor.extractParameters(from: "WHERE id = :id"), - ["id"] - ) + @Test + func parameterAtEnd() { + #expect(SQLParameterExtractor.extractParameters(from: "WHERE id = :id") == ["id"]) } - func testDoubleColonCast() { - XCTAssertEqual( - SQLParameterExtractor.extractParameters(from: "SELECT col::integer FROM t"), - [] - ) + @Test + func doubleColonCast() { + #expect(SQLParameterExtractor.extractParameters(from: "SELECT col::integer FROM t") == []) } - func testCastFollowedByParameter() { - XCTAssertEqual( - SQLParameterExtractor.extractParameters(from: "SELECT col::int WHERE id = :id"), - ["id"] - ) + @Test + func castFollowedByParameter() { + #expect(SQLParameterExtractor.extractParameters(from: "SELECT col::int WHERE id = :id") == ["id"]) } - func testCastVarcharWithLength() { - XCTAssertEqual( - SQLParameterExtractor.extractParameters(from: "SELECT col::varchar(255) WHERE id = :id"), - ["id"] - ) + @Test + func castVarcharWithLength() { + #expect(SQLParameterExtractor.extractParameters(from: "SELECT col::varchar(255) WHERE id = :id") == ["id"]) } - func testParameterInSingleQuotes() { - XCTAssertEqual( - SQLParameterExtractor.extractParameters(from: "SELECT ':name' FROM t"), - [] - ) + @Test + func parameterInSingleQuotes() { + #expect(SQLParameterExtractor.extractParameters(from: "SELECT ':name' FROM t") == []) } - func testParameterInDoubleQuotes() { - XCTAssertEqual( - SQLParameterExtractor.extractParameters(from: "SELECT \":name\" FROM t"), - [] - ) + @Test + func parameterInDoubleQuotes() { + #expect(SQLParameterExtractor.extractParameters(from: "SELECT \":name\" FROM t") == []) } - func testParameterInBackticks() { - XCTAssertEqual( - SQLParameterExtractor.extractParameters(from: "SELECT `:name` FROM t"), - [] - ) + @Test + func parameterInBackticks() { + #expect(SQLParameterExtractor.extractParameters(from: "SELECT `:name` FROM t") == []) } - func testEscapedQuoteInString() { - XCTAssertEqual( - SQLParameterExtractor.extractParameters(from: "SELECT ':name''s value' FROM t"), - [] - ) + @Test + func escapedQuoteInString() { + #expect(SQLParameterExtractor.extractParameters(from: "SELECT ':name''s value' FROM t") == []) } - func testParameterInLineComment() { - XCTAssertEqual( - SQLParameterExtractor.extractParameters(from: "SELECT 1 -- :name"), - [] - ) + @Test + func parameterInLineComment() { + #expect(SQLParameterExtractor.extractParameters(from: "SELECT 1 -- :name") == []) } - func testParameterInBlockComment() { - XCTAssertEqual( - SQLParameterExtractor.extractParameters(from: "SELECT /* :name */ 1"), - [] - ) + @Test + func parameterInBlockComment() { + #expect(SQLParameterExtractor.extractParameters(from: "SELECT /* :name */ 1") == []) } - func testParameterAfterComment() { - XCTAssertEqual( - SQLParameterExtractor.extractParameters(from: "-- comment\nSELECT :name"), - ["name"] - ) + @Test + func parameterAfterComment() { + #expect(SQLParameterExtractor.extractParameters(from: "-- comment\nSELECT :name") == ["name"]) } - func testBareColon() { - XCTAssertEqual( - SQLParameterExtractor.extractParameters(from: "SELECT : FROM t"), - [] - ) + @Test + func bareColon() { + #expect(SQLParameterExtractor.extractParameters(from: "SELECT : FROM t") == []) } - func testColonFollowedByNumber() { - XCTAssertEqual( - SQLParameterExtractor.extractParameters(from: "SELECT :123 FROM t"), - [] - ) + @Test + func colonFollowedByNumber() { + #expect(SQLParameterExtractor.extractParameters(from: "SELECT :123 FROM t") == []) } - func testEmptyString() { - XCTAssertEqual(SQLParameterExtractor.extractParameters(from: ""), []) + @Test + func emptyString() { + #expect(SQLParameterExtractor.extractParameters(from: "") == []) } - func testMultipleStatementsParameters() { - XCTAssertEqual( - SQLParameterExtractor.extractParameters(from: "SELECT :a; SELECT :b"), - ["a", "b"] - ) + @Test + func multipleStatementsParameters() { + #expect(SQLParameterExtractor.extractParameters(from: "SELECT :a; SELECT :b") == ["a", "b"]) } - func testBackslashEscapeInString() { - XCTAssertEqual( - SQLParameterExtractor.extractParameters(from: "SELECT '\\:name' FROM t"), - [] - ) + @Test + func backslashEscapeInString() { + #expect(SQLParameterExtractor.extractParameters(from: "SELECT '\\:name' FROM t") == []) } // MARK: - convertToNativeStyle - func testConvertToQuestionMark() { + @Test + func convertToQuestionMark() { let params = [ QueryParameter(name: "id", value: "42", type: .integer), QueryParameter(name: "name", value: "Alice", type: .string) @@ -163,13 +129,14 @@ final class SQLParameterExtractorTests: XCTestCase { parameters: params, style: .questionMark ) - XCTAssertEqual(result.sql, "WHERE id = ? AND name = ?") - XCTAssertEqual(result.values.count, 2) - XCTAssertEqual(result.values[0] as? String, "42") - XCTAssertEqual(result.values[1] as? String, "Alice") + #expect(result.sql == "WHERE id = ? AND name = ?") + #expect(result.values.count == 2) + #expect((result.values[0] as? String) == "42") + #expect((result.values[1] as? String) == "Alice") } - func testConvertToDollar() { + @Test + func convertToDollar() { let params = [ QueryParameter(name: "id", value: "42"), QueryParameter(name: "name", value: "Alice") @@ -179,88 +146,87 @@ final class SQLParameterExtractorTests: XCTestCase { parameters: params, style: .dollar ) - XCTAssertEqual(result.sql, "WHERE id = $1 AND name = $2") - XCTAssertEqual(result.values.count, 2) + #expect(result.sql == "WHERE id = $1 AND name = $2") + #expect(result.values.count == 2) } - func testConvertDuplicateParameter() { + @Test + func convertDuplicateParameter() { let params = [QueryParameter(name: "id", value: "42")] let result = SQLParameterExtractor.convertToNativeStyle( sql: "WHERE :id = :id", parameters: params, style: .questionMark ) - XCTAssertEqual(result.sql, "WHERE ? = ?") - XCTAssertEqual(result.values.count, 2) - XCTAssertEqual(result.values[0] as? String, "42") - XCTAssertEqual(result.values[1] as? String, "42") + #expect(result.sql == "WHERE ? = ?") + #expect(result.values.count == 2) + #expect((result.values[0] as? String) == "42") + #expect((result.values[1] as? String) == "42") } - func testConvertNullParameter() { + @Test + func convertNullParameter() { let params = [QueryParameter(name: "id", value: "42", isNull: true)] let result = SQLParameterExtractor.convertToNativeStyle( sql: "WHERE id = :id", parameters: params, style: .questionMark ) - XCTAssertEqual(result.sql, "WHERE id = ?") - XCTAssertEqual(result.values.count, 1) - XCTAssertNil(result.values[0]) + #expect(result.sql == "WHERE id = ?") + #expect(result.values.count == 1) + #expect(result.values[0] == nil) } - func testConvertSkipsParameterInString() { + @Test + func convertSkipsParameterInString() { let params = [QueryParameter(name: "id", value: "42")] let result = SQLParameterExtractor.convertToNativeStyle( sql: "WHERE ':id' = :id", parameters: params, style: .questionMark ) - XCTAssertEqual(result.sql, "WHERE ':id' = ?") - XCTAssertEqual(result.values.count, 1) + #expect(result.sql == "WHERE ':id' = ?") + #expect(result.values.count == 1) } - func testConvertSkipsDoubleColon() { + @Test + func convertSkipsDoubleColon() { let params = [QueryParameter(name: "id", value: "42")] let result = SQLParameterExtractor.convertToNativeStyle( sql: "SELECT col::text WHERE id = :id", parameters: params, style: .dollar ) - XCTAssertEqual(result.sql, "SELECT col::text WHERE id = $1") - XCTAssertEqual(result.values.count, 1) + #expect(result.sql == "SELECT col::text WHERE id = $1") + #expect(result.values.count == 1) } // MARK: - Dollar-Quoted Strings - func testParameterInDollarQuotedString() { - XCTAssertEqual( - SQLParameterExtractor.extractParameters(from: "CREATE FUNCTION foo() AS $$ SELECT :name $$ LANGUAGE sql"), - [] - ) + @Test + func parameterInDollarQuotedString() { + #expect(SQLParameterExtractor.extractParameters(from: "CREATE FUNCTION foo() AS $$ SELECT :name $$ LANGUAGE sql") == []) } - func testParameterInTaggedDollarQuotedString() { - XCTAssertEqual( - SQLParameterExtractor.extractParameters(from: "DO $body$ SELECT :name $body$"), - [] - ) + @Test + func parameterInTaggedDollarQuotedString() { + #expect(SQLParameterExtractor.extractParameters(from: "DO $body$ SELECT :name $body$") == []) } - func testParameterAfterDollarQuotedString() { - XCTAssertEqual( - SQLParameterExtractor.extractParameters(from: "$$ body $$ SELECT :id"), - ["id"] - ) + @Test + func parameterAfterDollarQuotedString() { + #expect(SQLParameterExtractor.extractParameters(from: "$$ body $$ SELECT :id") == ["id"]) } - func testConvertSkipsDollarQuotedString() { + @Test + func convertSkipsDollarQuotedString() { let params = [QueryParameter(name: "id", value: "42")] let result = SQLParameterExtractor.convertToNativeStyle( sql: "$$ :id $$ WHERE id = :id", parameters: params, style: .questionMark ) - XCTAssertEqual(result.sql, "$$ :id $$ WHERE id = ?") - XCTAssertEqual(result.values.count, 1) + #expect(result.sql == "$$ :id $$ WHERE id = ?") + #expect(result.values.count == 1) } } diff --git a/TableProTests/Core/Utilities/SQLStatementScannerTests.swift b/TableProTests/Core/Utilities/SQLStatementScannerTests.swift index 5ca5ae4e9e..bcca58622a 100644 --- a/TableProTests/Core/Utilities/SQLStatementScannerTests.swift +++ b/TableProTests/Core/Utilities/SQLStatementScannerTests.swift @@ -3,353 +3,277 @@ // TableProTests // +import Foundation @testable import TablePro import TableProPluginKit import TableProSQLGrammar -import XCTest +import Testing -final class SQLStatementScannerTests: XCTestCase { +struct SQLStatementScannerTests { // MARK: - allStatements - func testEmptyInput() { - XCTAssertEqual(SQLStatementScanner.allStatements(in: ""), []) + @Test + func emptyInput() { + #expect(SQLStatementScanner.allStatements(in: "") == []) } - func testSingleStatement() { - XCTAssertEqual( - SQLStatementScanner.allStatements(in: "SELECT 1"), - ["SELECT 1"] - ) + @Test + func singleStatement() { + #expect(SQLStatementScanner.allStatements(in: "SELECT 1") == ["SELECT 1"]) } - func testSingleStatementWithTrailingSemicolon() { - XCTAssertEqual( - SQLStatementScanner.allStatements(in: "SELECT 1;"), - ["SELECT 1"] - ) + @Test + func singleStatementWithTrailingSemicolon() { + #expect(SQLStatementScanner.allStatements(in: "SELECT 1;") == ["SELECT 1"]) } - func testMultipleStatements() { + @Test + func multipleStatements() { let sql = "SELECT 1; SELECT 2; SELECT 3" - XCTAssertEqual( - SQLStatementScanner.allStatements(in: sql), - ["SELECT 1", "SELECT 2", "SELECT 3"] - ) + #expect(SQLStatementScanner.allStatements(in: sql) == ["SELECT 1", "SELECT 2", "SELECT 3"]) } - func testSemicolonInsideSingleQuotes() { + @Test + func semicolonInsideSingleQuotes() { let sql = "SELECT 'a;b'; SELECT 2" - XCTAssertEqual( - SQLStatementScanner.allStatements(in: sql), - ["SELECT 'a;b'", "SELECT 2"] - ) + #expect(SQLStatementScanner.allStatements(in: sql) == ["SELECT 'a;b'", "SELECT 2"]) } - func testSemicolonInsideDoubleQuotes() { + @Test + func semicolonInsideDoubleQuotes() { let sql = "SELECT \"a;b\"; SELECT 2" - XCTAssertEqual( - SQLStatementScanner.allStatements(in: sql), - ["SELECT \"a;b\"", "SELECT 2"] - ) + #expect(SQLStatementScanner.allStatements(in: sql) == ["SELECT \"a;b\"", "SELECT 2"]) } - func testSemicolonInsideBackticks() { + @Test + func semicolonInsideBackticks() { let sql = "SELECT `a;b`; SELECT 2" - XCTAssertEqual( - SQLStatementScanner.allStatements(in: sql, grammar: TestGrammar.mysql), - ["SELECT `a;b`", "SELECT 2"] - ) + #expect(SQLStatementScanner.allStatements(in: sql, grammar: TestGrammar.mysql) == ["SELECT `a;b`", "SELECT 2"]) } - func testSemicolonInsideLineComment() { + @Test + func semicolonInsideLineComment() { let sql = "SELECT 1 -- comment; still comment\n; SELECT 2" - XCTAssertEqual( - SQLStatementScanner.allStatements(in: sql), - ["SELECT 1 -- comment; still comment", "SELECT 2"] - ) + #expect(SQLStatementScanner.allStatements(in: sql) == ["SELECT 1 -- comment; still comment", "SELECT 2"]) } - func testSemicolonInsideBlockComment() { + @Test + func semicolonInsideBlockComment() { let sql = "SELECT 1 /* comment; */ ; SELECT 2" - XCTAssertEqual( - SQLStatementScanner.allStatements(in: sql), - ["SELECT 1 /* comment; */", "SELECT 2"] - ) + #expect(SQLStatementScanner.allStatements(in: sql) == ["SELECT 1 /* comment; */", "SELECT 2"]) } - func testBackslashEscape() { + @Test + func backslashEscape() { let sql = "SELECT 'it\\'s'; SELECT 2" - XCTAssertEqual( - SQLStatementScanner.allStatements(in: sql, grammar: TestGrammar.mysql), - ["SELECT 'it\\'s'", "SELECT 2"] - ) + #expect(SQLStatementScanner.allStatements(in: sql, grammar: TestGrammar.mysql) == ["SELECT 'it\\'s'", "SELECT 2"]) } - func testDoubledQuoteEscape() { + @Test + func doubledQuoteEscape() { let sql = "SELECT 'it''s'; SELECT 2" - XCTAssertEqual( - SQLStatementScanner.allStatements(in: sql), - ["SELECT 'it''s'", "SELECT 2"] - ) + #expect(SQLStatementScanner.allStatements(in: sql) == ["SELECT 'it''s'", "SELECT 2"]) } - func testWhitespaceOnlyStatements() { + @Test + func whitespaceOnlyStatements() { let sql = "SELECT 1; ; \n ; SELECT 2" - XCTAssertEqual( - SQLStatementScanner.allStatements(in: sql), - ["SELECT 1", "SELECT 2"] - ) + #expect(SQLStatementScanner.allStatements(in: sql) == ["SELECT 1", "SELECT 2"]) } - func testNestedBlockComment() { + @Test + func nestedBlockComment() { // SQL block comments don't nest — first */ closes let sql = "SELECT 1 /* outer /* inner */ ; SELECT 2" - XCTAssertEqual( - SQLStatementScanner.allStatements(in: sql), - ["SELECT 1 /* outer /* inner */", "SELECT 2"] - ) + #expect(SQLStatementScanner.allStatements(in: sql) == ["SELECT 1 /* outer /* inner */", "SELECT 2"]) } // MARK: - Comment-Only Segments - func testTrailingLineCommentAfterSemicolonIsDropped() { - XCTAssertEqual( - SQLStatementScanner.allStatements(in: "SELECT 1; -- note"), - ["SELECT 1"] - ) + @Test + func trailingLineCommentAfterSemicolonIsDropped() { + #expect(SQLStatementScanner.allStatements(in: "SELECT 1; -- note") == ["SELECT 1"]) } - func testTrailingBlockCommentAfterSemicolonIsDropped() { - XCTAssertEqual( - SQLStatementScanner.allStatements(in: "SELECT 1; /* note */"), - ["SELECT 1"] - ) + @Test + func trailingBlockCommentAfterSemicolonIsDropped() { + #expect(SQLStatementScanner.allStatements(in: "SELECT 1; /* note */") == ["SELECT 1"]) } - func testMiddleCommentOnlySegmentIsDropped() { - XCTAssertEqual( - SQLStatementScanner.allStatements(in: "SELECT 1; /* x */; SELECT 2"), - ["SELECT 1", "SELECT 2"] - ) + @Test + func middleCommentOnlySegmentIsDropped() { + #expect(SQLStatementScanner.allStatements(in: "SELECT 1; /* x */; SELECT 2") == ["SELECT 1", "SELECT 2"]) } - func testCommentOnlyWholeInputReturnsNoStatements() { - XCTAssertEqual(SQLStatementScanner.allStatements(in: "-- note"), []) - XCTAssertEqual(SQLStatementScanner.allStatements(in: "/* note */"), []) + @Test + func commentOnlyWholeInputReturnsNoStatements() { + #expect(SQLStatementScanner.allStatements(in: "-- note") == []) + #expect(SQLStatementScanner.allStatements(in: "/* note */") == []) } - func testMixedCommentAndWhitespaceMultilineSegmentIsDropped() { + @Test + func mixedCommentAndWhitespaceMultilineSegmentIsDropped() { let sql = "SELECT 1;\n -- first\n /* second */ \n; SELECT 2" - XCTAssertEqual( - SQLStatementScanner.allStatements(in: sql), - ["SELECT 1", "SELECT 2"] - ) + #expect(SQLStatementScanner.allStatements(in: sql) == ["SELECT 1", "SELECT 2"]) } - func testConsecutiveSemicolonsStillDropped() { - XCTAssertEqual( - SQLStatementScanner.allStatements(in: "SELECT 1;;"), - ["SELECT 1"] - ) + @Test + func consecutiveSemicolonsStillDropped() { + #expect(SQLStatementScanner.allStatements(in: "SELECT 1;;") == ["SELECT 1"]) } - func testUnclosedTrailingBlockCommentIsDropped() { - XCTAssertEqual( - SQLStatementScanner.allStatements(in: "SELECT 1; /* note"), - ["SELECT 1"] - ) + @Test + func unclosedTrailingBlockCommentIsDropped() { + #expect(SQLStatementScanner.allStatements(in: "SELECT 1; /* note") == ["SELECT 1"]) } - func testLineCommentAtEndOfInputIsDropped() { - XCTAssertEqual( - SQLStatementScanner.allStatements(in: "SELECT 1; --"), - ["SELECT 1"] - ) + @Test + func lineCommentAtEndOfInputIsDropped() { + #expect(SQLStatementScanner.allStatements(in: "SELECT 1; --") == ["SELECT 1"]) } - func testMySQLVersionedCommentSegmentIsKept() { - XCTAssertEqual( - SQLStatementScanner.allStatements(in: "/*!40101 SET @OLD=1 */; SELECT 1", grammar: TestGrammar.mysql), - ["/*!40101 SET @OLD=1 */", "SELECT 1"] - ) + @Test + func mySQLVersionedCommentSegmentIsKept() { + #expect(SQLStatementScanner.allStatements(in: "/*!40101 SET @OLD=1 */; SELECT 1", grammar: TestGrammar.mysql) == ["/*!40101 SET @OLD=1 */", "SELECT 1"]) } /// `/*! */` is SQL MySQL runs and a comment everywhere else, so what it is depends on the engine. - func testVersionedCommentRunsOnMySQLAndIsACommentElsewhere() { - XCTAssertEqual( - SQLStatementScanner.allStatements(in: "/*!40101 SET @OLD=1 */;", grammar: TestGrammar.mysql), - ["/*!40101 SET @OLD=1 */"] - ) - XCTAssertEqual( - SQLStatementScanner.allStatements(in: "/*!40101 SET @OLD=1 */;", grammar: TestGrammar.standard), - [] - ) + @Test + func versionedCommentRunsOnMySQLAndIsACommentElsewhere() { + #expect(SQLStatementScanner.allStatements(in: "/*!40101 SET @OLD=1 */;", grammar: TestGrammar.mysql) == ["/*!40101 SET @OLD=1 */"]) + #expect(SQLStatementScanner.allStatements(in: "/*!40101 SET @OLD=1 */;", grammar: TestGrammar.standard) == []) } - func testInStatementCommentsArePreserved() { - XCTAssertEqual( - SQLStatementScanner.allStatements(in: "SELECT 1 -- hint\n; SELECT 2 /* keep */"), - ["SELECT 1 -- hint", "SELECT 2 /* keep */"] - ) + @Test + func inStatementCommentsArePreserved() { + #expect(SQLStatementScanner.allStatements(in: "SELECT 1 -- hint\n; SELECT 2 /* keep */") == ["SELECT 1 -- hint", "SELECT 2 /* keep */"]) } - func testPreservingSemicolonsDropsCommentOnlySegment() { - XCTAssertEqual( - SQLStatementScanner.allStatementsPreservingSemicolons(in: "SELECT 1; -- note"), - ["SELECT 1;"] - ) + @Test + func preservingSemicolonsDropsCommentOnlySegment() { + #expect(SQLStatementScanner.allStatementsPreservingSemicolons(in: "SELECT 1; -- note") == ["SELECT 1;"]) } // MARK: - Dollar Quoting (PostgreSQL) - func testDollarQuotedDoBlockKeepsInternalSemicolons() { + @Test + func dollarQuotedDoBlockKeepsInternalSemicolons() { let sql = "DO $$ BEGIN PERFORM 1; PERFORM 2; END $$; SELECT 1;" - XCTAssertEqual( - SQLStatementScanner.allStatements(in: sql, grammar: TestGrammar.postgres), - ["DO $$ BEGIN PERFORM 1; PERFORM 2; END $$", "SELECT 1"] - ) + #expect(SQLStatementScanner.allStatements(in: sql, grammar: TestGrammar.postgres) == ["DO $$ BEGIN PERFORM 1; PERFORM 2; END $$", "SELECT 1"]) } - func testTaggedDollarQuoteKeepsInternalSemicolons() { + @Test + func taggedDollarQuoteKeepsInternalSemicolons() { let sql = "CREATE FUNCTION f() RETURNS int AS $body$ BEGIN RETURN 1; END $body$ LANGUAGE plpgsql; SELECT f();" - XCTAssertEqual( - SQLStatementScanner.allStatements(in: sql, grammar: TestGrammar.postgres), - [ + #expect(SQLStatementScanner.allStatements(in: sql, grammar: TestGrammar.postgres) == [ "CREATE FUNCTION f() RETURNS int AS $body$ BEGIN RETURN 1; END $body$ LANGUAGE plpgsql", "SELECT f()" - ] - ) + ]) } - func testNestedDifferentDollarTags() { + @Test + func nestedDifferentDollarTags() { let sql = "DO $outer$ SELECT $inner$ a;b $inner$; END $outer$; SELECT 1;" - XCTAssertEqual( - SQLStatementScanner.allStatements(in: sql, grammar: TestGrammar.postgres), - ["DO $outer$ SELECT $inner$ a;b $inner$; END $outer$", "SELECT 1"] - ) + #expect(SQLStatementScanner.allStatements(in: sql, grammar: TestGrammar.postgres) == ["DO $outer$ SELECT $inner$ a;b $inner$; END $outer$", "SELECT 1"]) } - func testPositionalParameterIsNotDollarQuote() { + @Test + func positionalParameterIsNotDollarQuote() { let sql = "SELECT $1; SELECT $2;" - XCTAssertEqual( - SQLStatementScanner.allStatements(in: sql, grammar: TestGrammar.postgres), - ["SELECT $1", "SELECT $2"] - ) + #expect(SQLStatementScanner.allStatements(in: sql, grammar: TestGrammar.postgres) == ["SELECT $1", "SELECT $2"]) } - func testDollarPairInsideIdentifierIsNotOpener() { + @Test + func dollarPairInsideIdentifierIsNotOpener() { let sql = "SELECT 1 AS a$$; SELECT 2;" - XCTAssertEqual( - SQLStatementScanner.allStatements(in: sql, grammar: TestGrammar.postgres), - ["SELECT 1 AS a$$", "SELECT 2"] - ) + #expect(SQLStatementScanner.allStatements(in: sql, grammar: TestGrammar.postgres) == ["SELECT 1 AS a$$", "SELECT 2"]) } - func testGenericDialectIgnoresDollarQuotes() { + @Test + func genericDialectIgnoresDollarQuotes() { let sql = "DO $$ SELECT 1; SELECT 2 $$;" - XCTAssertEqual( - SQLStatementScanner.allStatements(in: sql, grammar: TestGrammar.standard), - ["DO $$ SELECT 1", "SELECT 2 $$"] - ) + #expect(SQLStatementScanner.allStatements(in: sql, grammar: TestGrammar.standard) == ["DO $$ SELECT 1", "SELECT 2 $$"]) } - func testCursorInsideDollarBodyReturnsWholeStatement() { + @Test + func cursorInsideDollarBodyReturnsWholeStatement() { let sql = "DO $$ BEGIN PERFORM 1; END $$; SELECT 1;" - XCTAssertEqual( - SQLStatementScanner.statementAtCursor(in: sql, cursorPosition: 20, grammar: TestGrammar.postgres), - "DO $$ BEGIN PERFORM 1; END $$" - ) + #expect(SQLStatementScanner.statementAtCursor(in: sql, cursorPosition: 20, grammar: TestGrammar.postgres) == "DO $$ BEGIN PERFORM 1; END $$") } // MARK: - allStatementsPreservingSemicolons - func testPreservingSemicolons() { + @Test + func preservingSemicolons() { let sql = "SELECT 1; SELECT 2; SELECT 3" - XCTAssertEqual( - SQLStatementScanner.allStatementsPreservingSemicolons(in: sql), - ["SELECT 1;", "SELECT 2;", "SELECT 3"] - ) + #expect(SQLStatementScanner.allStatementsPreservingSemicolons(in: sql) == ["SELECT 1;", "SELECT 2;", "SELECT 3"]) } - func testPreservingSemicolonsFiltersEmpty() { + @Test + func preservingSemicolonsFiltersEmpty() { let sql = "SELECT 1; ; \n ; SELECT 2" - XCTAssertEqual( - SQLStatementScanner.allStatementsPreservingSemicolons(in: sql), - ["SELECT 1;", "SELECT 2"] - ) + #expect(SQLStatementScanner.allStatementsPreservingSemicolons(in: sql) == ["SELECT 1;", "SELECT 2"]) } // MARK: - statementAtCursor - func testCursorInFirstStatement() { + @Test + func cursorInFirstStatement() { let sql = "SELECT 1; SELECT 2" - XCTAssertEqual( - SQLStatementScanner.statementAtCursor(in: sql, cursorPosition: 3), - "SELECT 1" - ) + #expect(SQLStatementScanner.statementAtCursor(in: sql, cursorPosition: 3) == "SELECT 1") } - func testCursorInSecondStatement() { + @Test + func cursorInSecondStatement() { let sql = "SELECT 1; SELECT 2" - XCTAssertEqual( - SQLStatementScanner.statementAtCursor(in: sql, cursorPosition: 10), - "SELECT 2" - ) + #expect(SQLStatementScanner.statementAtCursor(in: sql, cursorPosition: 10) == "SELECT 2") } - func testCursorInLastStatementNoSemicolon() { + @Test + func cursorInLastStatementNoSemicolon() { let sql = "SELECT 1; SELECT 2" - XCTAssertEqual( - SQLStatementScanner.statementAtCursor(in: sql, cursorPosition: 15), - "SELECT 2" - ) + #expect(SQLStatementScanner.statementAtCursor(in: sql, cursorPosition: 15) == "SELECT 2") } - func testCursorAtSemicolon() { + @Test + func cursorAtSemicolon() { let sql = "SELECT 1; SELECT 2" - XCTAssertEqual( - SQLStatementScanner.statementAtCursor(in: sql, cursorPosition: 8), - "SELECT 1" - ) + #expect(SQLStatementScanner.statementAtCursor(in: sql, cursorPosition: 8) == "SELECT 1") } - func testCursorAtZero() { + @Test + func cursorAtZero() { let sql = "SELECT 1; SELECT 2" - XCTAssertEqual( - SQLStatementScanner.statementAtCursor(in: sql, cursorPosition: 0), - "SELECT 1" - ) + #expect(SQLStatementScanner.statementAtCursor(in: sql, cursorPosition: 0) == "SELECT 1") } - func testCursorBeyondEnd() { + @Test + func cursorBeyondEnd() { let sql = "SELECT 1; SELECT 2" - XCTAssertEqual( - SQLStatementScanner.statementAtCursor(in: sql, cursorPosition: 999), - "SELECT 2" - ) + #expect(SQLStatementScanner.statementAtCursor(in: sql, cursorPosition: 999) == "SELECT 2") } - func testNoSemicolonsWholeInputIsOneStatement() { + @Test + func noSemicolonsWholeInputIsOneStatement() { let sql = "SELECT * FROM users" - XCTAssertEqual( - SQLStatementScanner.statementAtCursor(in: sql, cursorPosition: 5), - "SELECT * FROM users" - ) + #expect(SQLStatementScanner.statementAtCursor(in: sql, cursorPosition: 5) == "SELECT * FROM users") } // MARK: - locatedStatementAtCursor - func testLocatedStatementOffset() { + @Test + func locatedStatementOffset() { let sql = "SELECT 1; SELECT 2" let located = SQLStatementScanner.locatedStatementAtCursor(in: sql, cursorPosition: 3) - XCTAssertEqual(located.sql, "SELECT 1;") - XCTAssertEqual(located.offset, 0) + #expect(located.sql == "SELECT 1;") + #expect(located.offset == 0) } - func testLocatedStatementOffsetSecondStatement() { + @Test + func locatedStatementOffsetSecondStatement() { let sql = "SELECT 1; SELECT 2" let located = SQLStatementScanner.locatedStatementAtCursor(in: sql, cursorPosition: 12) - XCTAssertEqual(located.sql, " SELECT 2") - XCTAssertEqual(located.offset, 9) + #expect(located.sql == " SELECT 2") + #expect(located.offset == 9) } } diff --git a/TableProTests/Core/Vim/VimEngineCaseChangeTests.swift b/TableProTests/Core/Vim/VimEngineCaseChangeTests.swift index 23f9a0cff7..46c2ad2997 100644 --- a/TableProTests/Core/Vim/VimEngineCaseChangeTests.swift +++ b/TableProTests/Core/Vim/VimEngineCaseChangeTests.swift @@ -5,27 +5,21 @@ // Specification tests for ~ toggle case and the gu / gU / g~ case operators. // -import XCTest -import TableProPluginKit +import Foundation @testable import TablePro +import TableProPluginKit +import Testing @MainActor -final class VimEngineCaseChangeTests: XCTestCase { +final class VimEngineCaseChangeTests { private var engine: VimEngine! private var buffer: VimTextBufferMock! - override func setUp() { - super.setUp() + init() { buffer = VimTextBufferMock(text: "Hello World\nsecond LINE\n") engine = VimEngine(buffer: buffer) } - override func tearDown() { - engine = nil - buffer = nil - super.tearDown() - } - private func keys(_ chars: String) { for char in chars { _ = engine.process(char, shift: false) } } @@ -38,125 +32,140 @@ final class VimEngineCaseChangeTests: XCTestCase { // MARK: - ~ Toggle Case - func testTildeTogglesSingleCharCase() { + @Test + func tildeTogglesSingleCharCase() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("~") - XCTAssertEqual(buffer.text, "hello World\nsecond LINE\n", + #expect(buffer.text == "hello World\nsecond LINE\n", "~ should flip case of the char under the cursor") } - func testTildeAdvancesCursor() { + @Test + func tildeAdvancesCursor() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("~") - XCTAssertEqual(pos, 1, "~ should advance cursor by one after toggling") + #expect(pos == 1, "~ should advance cursor by one after toggling") } - func testTildeWithCount() { + @Test + func tildeWithCount() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("5~") - XCTAssertEqual(buffer.text, "hELLO World\nsecond LINE\n", + #expect(buffer.text == "hELLO World\nsecond LINE\n", "5~ should toggle case of 5 chars starting at cursor") } - func testTildeDoesNotCrossNewline() { + @Test + func tildeDoesNotCrossNewline() { // Cursor at offset 8 ('r' in 'World'). 99~ should toggle from cursor to end of // line — 'r','l','d' → 'R','L','D'. The newline must not be consumed. buffer.setSelectedRange(NSRange(location: 8, length: 0)) keys("99~") - XCTAssertEqual(buffer.text, "Hello WoRLD\nsecond LINE\n", + #expect(buffer.text == "Hello WoRLD\nsecond LINE\n", "~ with count should clamp at end of current line") } - func testTildeOnNonLetterCharacterIsNoChange() { + @Test + func tildeOnNonLetterCharacterIsNoChange() { buffer = VimTextBufferMock(text: "a 1 b\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 1, length: 0)) keys("~") - XCTAssertEqual(buffer.text, "a 1 b\n", + #expect(buffer.text == "a 1 b\n", "~ on whitespace/digit should not change content") - XCTAssertEqual(pos, 2) + #expect(pos == 2) } // MARK: - g~ Toggle Case Operator - func testGTildeWordTogglesWord() { + @Test + func gTildeWordTogglesWord() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("g~w") - XCTAssertEqual(buffer.text, "hELLO World\nsecond LINE\n", + #expect(buffer.text == "hELLO World\nsecond LINE\n", "g~w should toggle case for the word range") } - func testGTildeTildeTogglesLine() { + @Test + func gTildeTildeTogglesLine() { buffer.setSelectedRange(NSRange(location: 3, length: 0)) keys("g~~") - XCTAssertEqual(buffer.text, "hELLO wORLD\nsecond LINE\n", + #expect(buffer.text == "hELLO wORLD\nsecond LINE\n", "g~~ should toggle case of the entire current line") } - func testGTildeDollarTogglesToEndOfLine() { + @Test + func gTildeDollarTogglesToEndOfLine() { buffer.setSelectedRange(NSRange(location: 6, length: 0)) keys("g~$") - XCTAssertEqual(buffer.text, "Hello wORLD\nsecond LINE\n", + #expect(buffer.text == "Hello wORLD\nsecond LINE\n", "g~$ should toggle case from cursor to end of line") } // MARK: - gu: Lowercase Operator - func testGUWordLowercasesWord() { + @Test + func gUWordLowercasesWord() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("guw") - XCTAssertEqual(buffer.text, "hello World\nsecond LINE\n", + #expect(buffer.text == "hello World\nsecond LINE\n", "guw should lowercase the word range") } - func testGUUlowercasesLine() { + @Test + func gUUlowercasesLine() { buffer.setSelectedRange(NSRange(location: 12, length: 0)) keys("guu") - XCTAssertEqual(buffer.text, "Hello World\nsecond line\n", + #expect(buffer.text == "Hello World\nsecond line\n", "guu should lowercase the entire current line") } - func testGUDollarLowercasesToEndOfLine() { + @Test + func gUDollarLowercasesToEndOfLine() { buffer.setSelectedRange(NSRange(location: 6, length: 0)) keys("gu$") - XCTAssertEqual(buffer.text, "Hello world\nsecond LINE\n", + #expect(buffer.text == "Hello world\nsecond LINE\n", "gu$ should lowercase from cursor to end of line") } // MARK: - gU: Uppercase Operator - func testGUUppercaseWordUppercasesWord() { + @Test + func gUUppercaseWordUppercasesWord() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("gU") keys("w") - XCTAssertEqual(buffer.text, "HELLO World\nsecond LINE\n", + #expect(buffer.text == "HELLO World\nsecond LINE\n", "gUw should uppercase the word range") } - func testGUUUppercasesLine() { + @Test + func gUUUppercasesLine() { buffer.setSelectedRange(NSRange(location: 12, length: 0)) keys("gU") keys("U") - XCTAssertEqual(buffer.text, "Hello World\nSECOND LINE\n", + #expect(buffer.text == "Hello World\nSECOND LINE\n", "gUU should uppercase the entire current line") } - func testGUUppercaseDollarUppercasesToEndOfLine() { + @Test + func gUUppercaseDollarUppercasesToEndOfLine() { buffer.setSelectedRange(NSRange(location: 6, length: 0)) keys("gU$") - XCTAssertEqual(buffer.text, "Hello WORLD\nsecond LINE\n", + #expect(buffer.text == "Hello WORLD\nsecond LINE\n", "gU$ should uppercase from cursor to end of line") } // MARK: - Pending Cancellation - func testGUEscapeCancels() { + @Test + func gUEscapeCancels() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("gu") _ = engine.process("\u{1B}", shift: false) // Now type a motion — should be a plain motion, not part of gu. keys("l") - XCTAssertEqual(pos, 1) - XCTAssertEqual(buffer.text, "Hello World\nsecond LINE\n") + #expect(pos == 1) + #expect(buffer.text == "Hello World\nsecond LINE\n") } } diff --git a/TableProTests/Core/Vim/VimEngineCommandLineTests.swift b/TableProTests/Core/Vim/VimEngineCommandLineTests.swift index c27cc3b548..38ae0ad925 100644 --- a/TableProTests/Core/Vim/VimEngineCommandLineTests.swift +++ b/TableProTests/Core/Vim/VimEngineCommandLineTests.swift @@ -6,18 +6,18 @@ // command-line buffer accumulation/dispatch behavior. // -import XCTest -import TableProPluginKit +import Foundation @testable import TablePro +import TableProPluginKit +import Testing @MainActor -final class VimEngineCommandLineTests: XCTestCase { +final class VimEngineCommandLineTests { private var engine: VimEngine! private var buffer: VimTextBufferMock! private var dispatchedCommand: String? - override func setUp() { - super.setUp() + init() { buffer = VimTextBufferMock(text: "hello world\nsecond line\nthird line\n") engine = VimEngine(buffer: buffer) engine.onCommand = { [weak self] command in @@ -25,13 +25,6 @@ final class VimEngineCommandLineTests: XCTestCase { } } - override func tearDown() { - engine = nil - buffer = nil - dispatchedCommand = nil - super.tearDown() - } - private func keys(_ chars: String) { for char in chars { _ = engine.process(char, shift: false) } } @@ -42,175 +35,195 @@ final class VimEngineCommandLineTests: XCTestCase { // MARK: - Entering Command-Line Mode - func testColonEntersCommandLineModeWithPrompt() { + @Test + func colonEntersCommandLineModeWithPrompt() { keys(":") if case .commandLine(let cmdBuffer) = engine.mode { - XCTAssertEqual(cmdBuffer, ":") + #expect(cmdBuffer == ":") } else { - XCTFail("Expected commandLine mode after ':'") + Issue.record("Expected commandLine mode after ':'") } } - func testSlashEntersSearchModeWithPrompt() { + @Test + func slashEntersSearchModeWithPrompt() { keys("/") if case .commandLine(let cmdBuffer) = engine.mode { - XCTAssertEqual(cmdBuffer, "/") + #expect(cmdBuffer == "/") } else { - XCTFail("Expected commandLine mode after '/'") + Issue.record("Expected commandLine mode after '/'") } } - func testQuestionMarkEntersReverseSearchMode() { + @Test + func questionMarkEntersReverseSearchMode() { keys("?") if case .commandLine(let cmdBuffer) = engine.mode { - XCTAssertEqual(cmdBuffer, "?", + #expect(cmdBuffer == "?", "? should start a reverse-search command-line buffer") } else { - XCTFail("Expected commandLine mode after '?'") + Issue.record("Expected commandLine mode after '?'") } } // MARK: - Buffer Accumulation - func testCharactersAccumulateInBuffer() { + @Test + func charactersAccumulateInBuffer() { keys(":wq") if case .commandLine(let cmdBuffer) = engine.mode { - XCTAssertEqual(cmdBuffer, ":wq") + #expect(cmdBuffer == ":wq") } else { - XCTFail("Expected commandLine mode") + Issue.record("Expected commandLine mode") } } - func testWhitespaceAccumulatesInBuffer() { + @Test + func whitespaceAccumulatesInBuffer() { keys(":set ") if case .commandLine(let cmdBuffer) = engine.mode { - XCTAssertEqual(cmdBuffer, ":set ") + #expect(cmdBuffer == ":set ") } else { - XCTFail("Expected commandLine mode") + Issue.record("Expected commandLine mode") } } - func testSearchPatternAccumulates() { + @Test + func searchPatternAccumulates() { keys("/hello") if case .commandLine(let cmdBuffer) = engine.mode { - XCTAssertEqual(cmdBuffer, "/hello") + #expect(cmdBuffer == "/hello") } else { - XCTFail("Expected commandLine mode") + Issue.record("Expected commandLine mode") } } // MARK: - Backspace - func testBackspaceRemovesLastChar() { + @Test + func backspaceRemovesLastChar() { keys(":wq") backspace() if case .commandLine(let cmdBuffer) = engine.mode { - XCTAssertEqual(cmdBuffer, ":w") + #expect(cmdBuffer == ":w") } else { - XCTFail("Expected commandLine mode after backspace") + Issue.record("Expected commandLine mode after backspace") } } - func testBackspaceOnLonePromptExitsToNormal() { + @Test + func backspaceOnLonePromptExitsToNormal() { keys(":") backspace() - XCTAssertEqual(engine.mode, .normal, + #expect(engine.mode == .normal, "Backspace on the prompt alone should exit to normal mode") } - func testBackspaceOnSearchPromptExitsToNormal() { + @Test + func backspaceOnSearchPromptExitsToNormal() { keys("/") backspace() - XCTAssertEqual(engine.mode, .normal) + #expect(engine.mode == .normal) } // MARK: - Escape - func testEscapeCancelsCommand() { + @Test + func escapeCancelsCommand() { keys(":wq") escape() - XCTAssertEqual(engine.mode, .normal) - XCTAssertNil(dispatchedCommand, "Cancelled command must not fire onCommand") + #expect(engine.mode == .normal) + #expect(dispatchedCommand == nil, "Cancelled command must not fire onCommand") } // MARK: - Enter Dispatches Command - func testEnterDispatchesColonCommand() { + @Test + func enterDispatchesColonCommand() { keys(":w") enter() - XCTAssertEqual(dispatchedCommand, "w", + #expect(dispatchedCommand == "w", "Enter should dispatch the buffer (without the ':' prefix)") - XCTAssertEqual(engine.mode, .normal) + #expect(engine.mode == .normal) } - func testEnterDispatchesMultiCharCommand() { + @Test + func enterDispatchesMultiCharCommand() { keys(":write file.sql") enter() - XCTAssertEqual(dispatchedCommand, "write file.sql") + #expect(dispatchedCommand == "write file.sql") } - func testSearchDoesNotDispatchToOnCommand() { + @Test + func searchDoesNotDispatchToOnCommand() { // The engine now runs search natively via runSearch instead of forwarding // the pattern to onCommand. onCommand is reserved for `:`-style ex commands. keys("/hello") enter() - XCTAssertNil(dispatchedCommand, + #expect(dispatchedCommand == nil, "/pattern should be handled internally and not surface to onCommand") } - func testEnterOnEmptyCommandDispatchesEmptyString() { + @Test + func enterOnEmptyCommandDispatchesEmptyString() { keys(":") // Buffer is just ":", backspace makes empty; let's enter directly. // Per typical command-line behavior, hitting Enter on ":" cancels. enter() - XCTAssertEqual(engine.mode, .normal) + #expect(engine.mode == .normal) } // MARK: - Standard Commands - func testCommandW() { + @Test + func commandW() { keys(":w") enter() - XCTAssertEqual(dispatchedCommand, "w") + #expect(dispatchedCommand == "w") } - func testCommandQ() { + @Test + func commandQ() { keys(":q") enter() - XCTAssertEqual(dispatchedCommand, "q") + #expect(dispatchedCommand == "q") } - func testCommandWQ() { + @Test + func commandWQ() { keys(":wq") enter() - XCTAssertEqual(dispatchedCommand, "wq") + #expect(dispatchedCommand == "wq") } - func testCommandX() { + @Test + func commandX() { keys(":x") enter() - XCTAssertEqual(dispatchedCommand, "x") + #expect(dispatchedCommand == "x") } // MARK: - Re-entry After Dispatch - func testCanReEnterAfterDispatch() { + @Test + func canReEnterAfterDispatch() { keys(":w") enter() - XCTAssertEqual(engine.mode, .normal) + #expect(engine.mode == .normal) // Reset captured command to test re-entry. dispatchedCommand = nil keys(":q") enter() - XCTAssertEqual(dispatchedCommand, "q", + #expect(dispatchedCommand == "q", "Engine should be ready for a fresh command-line entry after dispatch") } // MARK: - Display Label - func testDisplayLabelShowsBufferInCommandLineMode() { + @Test + func displayLabelShowsBufferInCommandLineMode() { keys(":wq") - XCTAssertEqual(engine.mode.displayLabel, ":wq", + #expect(engine.mode.displayLabel == ":wq", "displayLabel for commandLine should be the literal buffer text") } } diff --git a/TableProTests/Core/Vim/VimEngineControlKeyTests.swift b/TableProTests/Core/Vim/VimEngineControlKeyTests.swift index 4f397990cc..01b0edcf49 100644 --- a/TableProTests/Core/Vim/VimEngineControlKeyTests.swift +++ b/TableProTests/Core/Vim/VimEngineControlKeyTests.swift @@ -3,27 +3,21 @@ // TableProTests // +import Foundation @testable import TablePro -import XCTest +import Testing @MainActor -final class VimEngineControlKeyTests: XCTestCase { +final class VimEngineControlKeyTests { private var engine: VimEngine! private var buffer: VimTextBufferMock! - override func setUp() { - super.setUp() + init() { let lines = (0..<30).map { "line\($0) text" } buffer = VimTextBufferMock(text: lines.joined(separator: "\n") + "\n") engine = VimEngine(buffer: buffer) } - override func tearDown() { - engine = nil - buffer = nil - super.tearDown() - } - private func useText(_ text: String) { buffer = VimTextBufferMock(text: text) engine = VimEngine(buffer: buffer) @@ -49,165 +43,181 @@ final class VimEngineControlKeyTests: XCTestCase { // MARK: - Normal mode - func testControlRRedoesThroughTheEngine() { - XCTAssertTrue(control("r")) - XCTAssertEqual(buffer.redoCallCount, 1) + @Test + func controlRRedoesThroughTheEngine() { + #expect(control("r")) + #expect(buffer.redoCallCount == 1) } - func testControlRTakesACount() { + @Test + func controlRTakesACount() { keys("3") control("r") - XCTAssertEqual(buffer.redoCallCount, 3) + #expect(buffer.redoCallCount == 3) } - func testControlHMovesLeftLikeH() { + @Test + func controlHMovesLeftLikeH() { caret(atLine: 0, column: 5) let original = buffer.text - XCTAssertTrue(control("h")) - XCTAssertEqual(pos, 4) - XCTAssertEqual(buffer.text, original, "Ctrl+H must move the caret in Normal mode, never delete") + #expect(control("h")) + #expect(pos == 4) + #expect(buffer.text == original, "Ctrl+H must move the caret in Normal mode, never delete") } - func testControlHTakesACount() { + @Test + func controlHTakesACount() { caret(atLine: 0, column: 5) keys("3") control("h") - XCTAssertEqual(pos, 2) + #expect(pos == 2) } - func testControlNAndControlPMoveBetweenLines() { + @Test + func controlNAndControlPMoveBetweenLines() { caret(atLine: 4, column: 2) control("n") - XCTAssertEqual(line, 5) + #expect(line == 5) control("p") control("p") - XCTAssertEqual(line, 3) + #expect(line == 3) } - func testControlNCompletesAPendingOperatorLikeJ() { + @Test + func controlNCompletesAPendingOperatorLikeJ() { useText("one\ntwo\nthree\n") keys("d") control("n") - XCTAssertEqual(buffer.text, "three\n", "d followed by Ctrl+N deletes two lines, as dj does") + #expect(buffer.text == "three\n", "d followed by Ctrl+N deletes two lines, as dj does") } - func testScrollControlCancelsAPendingOperator() { + @Test + func scrollControlCancelsAPendingOperator() { useText("one two three\n") keys("d") control("d") keys("w") - XCTAssertEqual(buffer.text, "one two three\n", "Ctrl+D is no motion, so it abandons the pending d") - XCTAssertNil(engine.pendingOperator) + #expect(buffer.text == "one two three\n", "Ctrl+D is no motion, so it abandons the pending d") + #expect(engine.pendingOperator == nil) } - func testScrollControlUsesAndConsumesItsCount() { + @Test + func scrollControlUsesAndConsumesItsCount() { caret(atLine: 0) keys("4") control("d") - XCTAssertEqual(line, 4, "A count sets how many lines Ctrl+D moves") + #expect(line == 4, "A count sets how many lines Ctrl+D moves") keys("j") - XCTAssertEqual(line, 5, "The count must not carry over to the next command") + #expect(line == 5, "The count must not carry over to the next command") } - func testUnboundControlCharacterDoesNotEditInNormalMode() { + @Test + func unboundControlCharacterDoesNotEditInNormalMode() { caret(atLine: 0, column: 3) let original = buffer.text - XCTAssertTrue(control("k")) - XCTAssertTrue(control("o")) - XCTAssertTrue(control("t")) - XCTAssertEqual(buffer.text, original) - XCTAssertEqual(engine.mode, .normal) + #expect(control("k")) + #expect(control("o")) + #expect(control("t")) + #expect(buffer.text == original) + #expect(engine.mode == .normal) } - func testOptionTypedCharactersDoNotEditInNormalMode() { + @Test + func optionTypedCharactersDoNotEditInNormalMode() { caret(atLine: 0, column: 3) let original = buffer.text - XCTAssertTrue(engine.process("\u{02D9}", shift: false)) - XCTAssertTrue(engine.process("\u{00A0}", shift: false)) - XCTAssertEqual(buffer.text, original) + #expect(engine.process("\u{02D9}", shift: false)) + #expect(engine.process("\u{00A0}", shift: false)) + #expect(buffer.text == original) } - func testReplaceCharConsumesAControlCharacterWithoutWritingIt() { + @Test + func replaceCharConsumesAControlCharacterWithoutWritingIt() { useText("hello\n") keys("r") control("d") keys("x") - XCTAssertEqual(buffer.text, "ello\n", "r takes Ctrl+D as its argument, so the next x deletes") + #expect(buffer.text == "ello\n", "r takes Ctrl+D as its argument, so the next x deletes") } // MARK: - Visual mode - func testControlDExtendsTheVisualSelection() { + @Test + func controlDExtendsTheVisualSelection() { caret(atLine: 0) keys("v") control("d") let selection = buffer.selectedRange() - XCTAssertEqual(selection.location, 0) - XCTAssertEqual( - buffer.lineAndColumn(forOffset: selection.location + selection.length - 1).line, - 15, - "Ctrl+D moves the visual cursor half the visible lines down" - ) - XCTAssertEqual(engine.mode, .visual(linewise: false)) + #expect(selection.location == 0) + #expect(buffer.lineAndColumn(forOffset: selection.location + selection.length - 1).line == 15, + "Ctrl+D moves the visual cursor half the visible lines down") + #expect(engine.mode == .visual(linewise: false)) } - func testControlHMovesTheVisualCursor() { + @Test + func controlHMovesTheVisualCursor() { caret(atLine: 0) keys("vll") control("h") - XCTAssertEqual(buffer.selectedRange(), NSRange(location: 0, length: 2)) + #expect(buffer.selectedRange() == NSRange(location: 0, length: 2)) } - func testUnboundControlCharacterLeavesTheVisualSelection() { + @Test + func unboundControlCharacterLeavesTheVisualSelection() { caret(atLine: 0) keys("vl") let original = buffer.text control("k") - XCTAssertEqual(buffer.text, original) - XCTAssertEqual(buffer.selectedRange(), NSRange(location: 0, length: 2)) - XCTAssertEqual(engine.mode, .visual(linewise: false)) + #expect(buffer.text == original) + #expect(buffer.selectedRange() == NSRange(location: 0, length: 2)) + #expect(engine.mode == .visual(linewise: false)) } // MARK: - Insert and Replace mode - func testControlHInInsertModeIsLeftToTheTextView() { + @Test + func controlHInInsertModeIsLeftToTheTextView() { useText("a\u{1F600}b\n") buffer.setSelectedRange(NSRange(location: 3, length: 0)) keys("i") - XCTAssertFalse(control("h"), "Insert mode Ctrl+H is the text view's deleteBackward, as Delete is") - XCTAssertEqual(buffer.text, "a\u{1F600}b\n") - XCTAssertEqual(engine.mode, .insert) + #expect(!control("h"), "Insert mode Ctrl+H is the text view's deleteBackward, as Delete is") + #expect(buffer.text == "a\u{1F600}b\n") + #expect(engine.mode == .insert) } - func testControlHInReplaceModeRestoresLikeBackspace() { + @Test + func controlHInReplaceModeRestoresLikeBackspace() { useText("hello\n") _ = engine.process("R", shift: true) _ = engine.process("X", shift: true) _ = engine.process("Y", shift: true) control("h") - XCTAssertEqual(buffer.text, "Xello\n") - XCTAssertEqual(pos, 1) + #expect(buffer.text == "Xello\n") + #expect(pos == 1) } - func testOptionTypedCharacterOverwritesInReplaceMode() { + @Test + func optionTypedCharacterOverwritesInReplaceMode() { useText("hello\n") _ = engine.process("R", shift: true) _ = engine.process("\u{00E9}", shift: false) - XCTAssertEqual(buffer.text, "\u{00E9}ello\n") + #expect(buffer.text == "\u{00E9}ello\n") } // MARK: - Command line - func testControlHErasesInTheCommandLine() { + @Test + func controlHErasesInTheCommandLine() { keys(":ab") control("h") - XCTAssertEqual(engine.mode, .commandLine(buffer: ":a")) + #expect(engine.mode == .commandLine(buffer: ":a")) } - func testControlCharacterIsNotAppendedToTheCommandLine() { + @Test + func controlCharacterIsNotAppendedToTheCommandLine() { keys(":") control("d") control("k") - XCTAssertEqual(engine.mode, .commandLine(buffer: ":")) + #expect(engine.mode == .commandLine(buffer: ":")) } } diff --git a/TableProTests/Core/Vim/VimEngineCountPrefixTests.swift b/TableProTests/Core/Vim/VimEngineCountPrefixTests.swift index c33e7c329b..a24a558663 100644 --- a/TableProTests/Core/Vim/VimEngineCountPrefixTests.swift +++ b/TableProTests/Core/Vim/VimEngineCountPrefixTests.swift @@ -5,27 +5,21 @@ // Specification tests for count prefix parsing and behavior in Normal mode. // -import XCTest -import TableProPluginKit +import Foundation @testable import TablePro +import TableProPluginKit +import Testing @MainActor -final class VimEngineCountPrefixTests: XCTestCase { +final class VimEngineCountPrefixTests { private var engine: VimEngine! private var buffer: VimTextBufferMock! - override func setUp() { - super.setUp() + init() { buffer = VimTextBufferMock(text: "hello world\nsecond line\nthird line\n") engine = VimEngine(buffer: buffer) } - override func tearDown() { - engine = nil - buffer = nil - super.tearDown() - } - private func keys(_ chars: String) { for char in chars { _ = engine.process(char, shift: false) } } @@ -38,149 +32,166 @@ final class VimEngineCountPrefixTests: XCTestCase { // MARK: - Single Digit Count - func testSingleDigitCount() { + @Test + func singleDigitCount() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("3l") - XCTAssertEqual(pos, 3) + #expect(pos == 3) } - func testCountIsConsumed() { + @Test + func countIsConsumed() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("3l") // After the motion, the count is gone — next l moves only 1. keys("l") - XCTAssertEqual(pos, 4, "Count must be consumed by the motion it precedes") + #expect(pos == 4, "Count must be consumed by the motion it precedes") } // MARK: - Multi-Digit Count - func testTwoDigitCount() { + @Test + func twoDigitCount() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("10l") - XCTAssertEqual(pos, 10) + #expect(pos == 10) } - func testThreeDigitCount() { + @Test + func threeDigitCount() { buffer = VimTextBufferMock(text: String(repeating: "a", count: 200) + "\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("123l") - XCTAssertEqual(pos, 123) + #expect(pos == 123) } // MARK: - Zero Handling - func testZeroAsLineStartMotion() { + @Test + func zeroAsLineStartMotion() { // Leading 0 (not preceded by a digit) is the line-start motion, not a count. buffer.setSelectedRange(NSRange(location: 5, length: 0)) keys("0") - XCTAssertEqual(pos, 0) + #expect(pos == 0) } - func testZeroAfterDigitIsPartOfCount() { + @Test + func zeroAfterDigitIsPartOfCount() { // 10l should be ten-l, not zero-then-l. buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("10l") - XCTAssertEqual(pos, 10) + #expect(pos == 10) } - func testZeroInMiddleOfCount() { + @Test + func zeroInMiddleOfCount() { buffer = VimTextBufferMock(text: String(repeating: "a", count: 200) + "\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("102l") - XCTAssertEqual(pos, 102, "0 inside a count sequence must be a digit, not motion") + #expect(pos == 102, "0 inside a count sequence must be a digit, not motion") } // MARK: - Count Cleared by Escape - func testEscapeClearsCount() { + @Test + func escapeClearsCount() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("3") _ = engine.process("\u{1B}", shift: false) keys("l") - XCTAssertEqual(pos, 1, "Escape should clear the pending count") + #expect(pos == 1, "Escape should clear the pending count") } - func testEscapeClearsCountForOperator() { + @Test + func escapeClearsCountForOperator() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("3") _ = engine.process("\u{1B}", shift: false) keys("dd") - XCTAssertEqual(buffer.text, "second line\nthird line\n", + #expect(buffer.text == "second line\nthird line\n", "After Escape, count should not apply to the next operator") } // MARK: - Count Cleared by Unknown Key - func testUnknownKeyClearsCount() { + @Test + func unknownKeyClearsCount() { // Q is a genuinely unknown key in our engine. Using it after the count // prefix should consume the count without applying any motion. The next // `l` then moves by 1, not by 3. buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("3Q") keys("l") - XCTAssertEqual(pos, 1) + #expect(pos == 1) } // MARK: - Count Multiplication for Operators - func testOperatorTimesMotionMultiplies() { + @Test + func operatorTimesMotionMultiplies() { // 2d3w → delete 6 words buffer = VimTextBufferMock(text: "a b c d e f g h\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("2d3w") - XCTAssertEqual(buffer.text, "g h\n", "2 * 3 = 6 words deleted") + #expect(buffer.text == "g h\n", "2 * 3 = 6 words deleted") } // MARK: - Count Preserved Through Operator Doubling - func testCountAppliesToOperatorDoubling() { + @Test + func countAppliesToOperatorDoubling() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("2dd") - XCTAssertEqual(buffer.text, "third line\n", + #expect(buffer.text == "third line\n", "2dd should delete two lines") } - func testCountAppliesToYY() { + @Test + func countAppliesToYY() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("2yy") keys("p") - XCTAssertEqual(buffer.text, "hello world\nhello world\nsecond line\nsecond line\nthird line\n", + #expect(buffer.text == "hello world\nhello world\nsecond line\nsecond line\nthird line\n", "2yy should yank two lines for paste") } - func testCountAppliesToCC() { + @Test + func countAppliesToCC() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("2cc") - XCTAssertEqual(engine.mode, .insert) - XCTAssertEqual(buffer.text, "\nthird line\n", + #expect(engine.mode == .insert) + #expect(buffer.text == "\nthird line\n", "2cc should clear two lines and enter insert mode") } // MARK: - Overflow Protection - func testVeryLargeCountDoesNotCrash() { + @Test + func veryLargeCountDoesNotCrash() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("999999999l") // Should not crash; cursor clamped to end of line. - XCTAssertEqual(pos, 10) + #expect(pos == 10) } - func testCountCappedAtSafeMaximum() { + @Test + func countCappedAtSafeMaximum() { // 1_000_000 digits should be capped before arithmetic overflow. buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys(String(repeating: "9", count: 50) + "l") - XCTAssertEqual(pos, 10, "Engine must not overflow on extreme count values") + #expect(pos == 10, "Engine must not overflow on extreme count values") } // MARK: - Count Not Counted as Motion in Insert Mode - func testCountInsideInsertModePassesThrough() { + @Test + func countInsideInsertModePassesThrough() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("i") let consumed = engine.process("3", shift: false) - XCTAssertFalse(consumed, "Digits in insert mode must pass through to the text view") + #expect(!consumed, "Digits in insert mode must pass through to the text view") } } diff --git a/TableProTests/Core/Vim/VimEngineEdgeCasesTests.swift b/TableProTests/Core/Vim/VimEngineEdgeCasesTests.swift index 138060664f..fd8d89a069 100644 --- a/TableProTests/Core/Vim/VimEngineEdgeCasesTests.swift +++ b/TableProTests/Core/Vim/VimEngineEdgeCasesTests.swift @@ -7,23 +7,18 @@ // empty buffers, single-char buffers, unicode, very long content. // -import XCTest -import TableProPluginKit +import Foundation @testable import TablePro +import TableProPluginKit +import Testing // swiftlint:disable file_length type_body_length @MainActor -final class VimEngineEdgeCasesTests: XCTestCase { +final class VimEngineEdgeCasesTests { private var engine: VimEngine! private var buffer: VimTextBufferMock! - override func tearDown() { - engine = nil - buffer = nil - super.tearDown() - } - private func make(_ text: String, at offset: Int = 0) { buffer = VimTextBufferMock(text: text) engine = VimEngine(buffer: buffer) @@ -44,207 +39,227 @@ final class VimEngineEdgeCasesTests: XCTestCase { // MARK: - Empty Buffer - func testEmptyBufferAllMotionsAreSafe() { + @Test + func emptyBufferAllMotionsAreSafe() { make("") keys("hjklwbe0$^_") keys("gg") key("G", shift: true) - XCTAssertEqual(pos, 0) - XCTAssertEqual(buffer.text, "") + #expect(pos == 0) + #expect(buffer.text == "") } - func testEmptyBufferOperatorsAreSafe() { + @Test + func emptyBufferOperatorsAreSafe() { make("") keys("xdwddyycc") - XCTAssertEqual(buffer.text, "") + #expect(buffer.text == "") } - func testEmptyBufferInsertEntryThenEscape() { + @Test + func emptyBufferInsertEntryThenEscape() { make("") keys("i") escape() - XCTAssertEqual(engine.mode, .normal) - XCTAssertEqual(pos, 0) + #expect(engine.mode == .normal) + #expect(pos == 0) } - func testEmptyBufferVisualModeIsSafe() { + @Test + func emptyBufferVisualModeIsSafe() { make("") keys("v") - XCTAssertEqual(engine.mode, .visual(linewise: false)) - XCTAssertEqual(buffer.selectedRange().length, 0) + #expect(engine.mode == .visual(linewise: false)) + #expect(buffer.selectedRange().length == 0) keys("d") - XCTAssertEqual(buffer.text, "") - XCTAssertEqual(engine.mode, .normal) + #expect(buffer.text == "") + #expect(engine.mode == .normal) } // MARK: - Single-Character Buffer - func testSingleCharBufferMotionsClamp() { + @Test + func singleCharBufferMotionsClamp() { make("a", at: 0) keys("l") - XCTAssertEqual(pos, 0, "l on a single-char buffer should not move past the only char") + #expect(pos == 0, "l on a single-char buffer should not move past the only char") keys("h") - XCTAssertEqual(pos, 0) + #expect(pos == 0) keys("w") - XCTAssertEqual(pos, 0, "w on a single-char buffer should not move past the only char") + #expect(pos == 0, "w on a single-char buffer should not move past the only char") keys("$") - XCTAssertEqual(pos, 0) + #expect(pos == 0) keys("0") - XCTAssertEqual(pos, 0) + #expect(pos == 0) } - func testSingleCharBufferXLeavesEmpty() { + @Test + func singleCharBufferXLeavesEmpty() { make("a", at: 0) keys("x") - XCTAssertEqual(buffer.text, "") - XCTAssertEqual(pos, 0) + #expect(buffer.text == "") + #expect(pos == 0) } // MARK: - End-of-Buffer Cursor Positions - func testWAtVeryLastCharStaysOnIt() { + @Test + func wAtVeryLastCharStaysOnIt() { // Regression: reported visually as block cursor sitting past the ';'. make("SELECT * FROM users;", at: 19) keys("w") - XCTAssertEqual(pos, 19, "w on the last word of the buffer must stay on the last char") + #expect(pos == 19, "w on the last word of the buffer must stay on the last char") } - func testEscapeFromInsertAtEndOfNoNewlineBufferStepsBack() { + @Test + func escapeFromInsertAtEndOfNoNewlineBufferStepsBack() { // Regression: reported as Esc doing nothing visually after typing ';'. make("SELECT * FROM users;", at: 20) keys("i") escape() - XCTAssertEqual(engine.mode, .normal) - XCTAssertEqual(pos, 19, "Esc from insert at length should step back onto the last content char") + #expect(engine.mode == .normal) + #expect(pos == 19, "Esc from insert at length should step back onto the last content char") } - func testCursorClampedAfterDeleteAtEndOfBuffer() { + @Test + func cursorClampedAfterDeleteAtEndOfBuffer() { make("hello", at: 4) keys("x") - XCTAssertEqual(buffer.text, "hell") - XCTAssertEqual(pos, 3, "After deleting the last char, cursor must clamp to new last char") + #expect(buffer.text == "hell") + #expect(pos == 3, "After deleting the last char, cursor must clamp to new last char") } // MARK: - Trailing Newline Behaviour - func testJOnLineWithJustNewlineIsNoOp() { + @Test + func jOnLineWithJustNewlineIsNoOp() { make("\n", at: 0) key("J", shift: true) - XCTAssertEqual(buffer.text, "\n", "J with no line below should not modify the buffer") + #expect(buffer.text == "\n", "J with no line below should not modify the buffer") } - func testDollarOnEmptyLineStaysAtLineStart() { + @Test + func dollarOnEmptyLineStaysAtLineStart() { make("\n", at: 0) keys("$") - XCTAssertEqual(pos, 0, "$ on an empty line should stay at column 0") + #expect(pos == 0, "$ on an empty line should stay at column 0") } // MARK: - Multiple Trailing Newlines - func testMotionsOverConsecutiveEmptyLines() { + @Test + func motionsOverConsecutiveEmptyLines() { make("a\n\n\nb\n", at: 0) keys("j") - XCTAssertEqual(pos, 2, "j onto the first empty line lands at the line's start") + #expect(pos == 2, "j onto the first empty line lands at the line's start") keys("j") - XCTAssertEqual(pos, 3) + #expect(pos == 3) keys("j") - XCTAssertEqual(pos, 4, "j onto 'b' line should land on 'b'") + #expect(pos == 4, "j onto 'b' line should land on 'b'") } // MARK: - Very Long Single Line - func testMotionsAcrossVeryLongLineDoNotCrash() { + @Test + func motionsAcrossVeryLongLineDoNotCrash() { let payload = String(repeating: "a", count: 10_000) make(payload + "\n", at: 0) keys("$") - XCTAssertEqual(pos, 9999, "$ on a 10k-char line should land on the last content char") + #expect(pos == 9999, "$ on a 10k-char line should land on the last content char") keys("0") - XCTAssertEqual(pos, 0) + #expect(pos == 0) // Word motion across long content keys("w") // Single contiguous word, should land at end-of-content per the clamp rule. - XCTAssertEqual(pos, 9999) + #expect(pos == 9999) } // MARK: - Unicode - func testMotionsOnAsciiSafeUnicode() { + @Test + func motionsOnAsciiSafeUnicode() { // The engine uses UTF-16 offsets. Pure-ASCII strings are 1 unit per char. make("café\n", at: 0) let length = buffer.length keys("$") - XCTAssertEqual(pos, length - 2, "$ on 'café\\n' should land on the 'é' before the newline") + #expect(pos == length - 2, "$ on 'café\\n' should land on the 'é' before the newline") } - func testDoubleByteUnicodeBuffer() { + @Test + func doubleByteUnicodeBuffer() { // CJK chars are UTF-16 BMP (one code unit each). The engine's offsets should // match what NSString reports for the buffer. make("你好世界\n", at: 0) keys("l") - XCTAssertEqual(pos, 1, "l should advance one UTF-16 code unit") + #expect(pos == 1, "l should advance one UTF-16 code unit") keys("$") // '\n' at offset 4, content end at 3. - XCTAssertEqual(pos, 3) + #expect(pos == 3) } // MARK: - Mixed Line Endings - func testCRLFLineEndingsTreatedAsBoundary() { + @Test + func cRLFLineEndingsTreatedAsBoundary() { // Vim normally normalises CRLF, but our buffer mock preserves them. The // line motions should still treat the LF as the line terminator. make("hello\r\nworld\n", at: 0) keys("j") let (line, _) = buffer.lineAndColumn(forOffset: pos) - XCTAssertEqual(line, 1, "j across a CRLF should reach line 1") + #expect(line == 1, "j across a CRLF should reach line 1") } // MARK: - Count Cap - func testExtremeCountDoesNotOverflowOrCrash() { + @Test + func extremeCountDoesNotOverflowOrCrash() { make("hello\n", at: 0) // Type a million-digit count then a motion; engine must cap and execute safely. for _ in 0..<200 { _ = engine.process("9", shift: false) } keys("l") - XCTAssertEqual(pos, 4, "Count beyond the cap should still produce a clamped motion") + #expect(pos == 4, "Count beyond the cap should still produce a clamped motion") } // MARK: - Visual Mode Operator Composition - func testVisualSelectThenOperatorThenUndo() { + @Test + func visualSelectThenOperatorThenUndo() { make("hello world\n", at: 0) keys("v") keys("e") keys("d") - XCTAssertEqual(buffer.text, " world\n") + #expect(buffer.text == " world\n") keys("u") - XCTAssertEqual(buffer.undoCallCount, 1, "u should undo the visual delete") + #expect(buffer.undoCallCount == 1, "u should undo the visual delete") } // MARK: - Mode Switching Idempotency - func testRapidModeSwitchesPreserveState() { + @Test + func rapidModeSwitchesPreserveState() { make("hello\n", at: 0) // i → Esc → i → Esc many times. Cursor should remain stable. for _ in 0..<10 { keys("i") escape() } - XCTAssertEqual(engine.mode, .normal) + #expect(engine.mode == .normal) // After the first Esc the cursor sits at 0, so 'i'/Esc cycles keep it there. - XCTAssertEqual(pos, 0) + #expect(pos == 0) } // MARK: - Reset - func testResetClearsAllPendingState() { + @Test + func resetClearsAllPendingState() { make("hello world\n", at: 5) keys("3d") engine.reset() - XCTAssertEqual(engine.mode, .normal) + #expect(engine.mode == .normal) // After reset, plain l should advance by 1, not by 3. buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("l") - XCTAssertEqual(pos, 1, "reset() must clear count and pending operator") + #expect(pos == 1, "reset() must clear count and pending operator") } } diff --git a/TableProTests/Core/Vim/VimEngineFindCharTests.swift b/TableProTests/Core/Vim/VimEngineFindCharTests.swift index 867cb3ab34..d2449a1838 100644 --- a/TableProTests/Core/Vim/VimEngineFindCharTests.swift +++ b/TableProTests/Core/Vim/VimEngineFindCharTests.swift @@ -5,29 +5,23 @@ // Specification tests for f / F / t / T character search motions and ; / , repetition. // -import XCTest -import TableProPluginKit +import Foundation @testable import TablePro +import TableProPluginKit +import Testing @MainActor -final class VimEngineFindCharTests: XCTestCase { +final class VimEngineFindCharTests { private var engine: VimEngine! private var buffer: VimTextBufferMock! - override func setUp() { - super.setUp() + init() { // "hello world foo bar\nsecond line\n" // 0123456789012345678901234567890 buffer = VimTextBufferMock(text: "hello world foo bar\nsecond line\n") engine = VimEngine(buffer: buffer) } - override func tearDown() { - engine = nil - buffer = nil - super.tearDown() - } - private func keys(_ chars: String) { for char in chars { _ = engine.process(char, shift: false) } } @@ -40,248 +34,279 @@ final class VimEngineFindCharTests: XCTestCase { // MARK: - f: Find Forward (Inclusive) - func testFMovesToNextOccurrenceOnLine() { + @Test + func fMovesToNextOccurrenceOnLine() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("fo") - XCTAssertEqual(pos, 4, "fo from offset 0 should land on the 'o' in 'hello' (offset 4)") + #expect(pos == 4, "fo from offset 0 should land on the 'o' in 'hello' (offset 4)") } - func testFFromMidLineFindsNextOccurrence() { + @Test + func fFromMidLineFindsNextOccurrence() { buffer.setSelectedRange(NSRange(location: 5, length: 0)) keys("fo") - XCTAssertEqual(pos, 7, "fo from offset 5 should find the next 'o' at offset 7") + #expect(pos == 7, "fo from offset 5 should find the next 'o' at offset 7") } - func testFNotFoundStaysPut() { + @Test + func fNotFoundStaysPut() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("fz") - XCTAssertEqual(pos, 0, "f for a missing char should leave the cursor unchanged") + #expect(pos == 0, "f for a missing char should leave the cursor unchanged") } - func testFDoesNotCrossLineBoundary() { + @Test + func fDoesNotCrossLineBoundary() { // 's' from 'second' is on line 1 — f from line 0 must not jump there. buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("fs") - XCTAssertEqual(pos, 0, "f must search only within the current line") + #expect(pos == 0, "f must search only within the current line") } - func testFWithCount() { + @Test + func fWithCount() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("2fo") - XCTAssertEqual(pos, 7, "2fo should land on the second 'o' (offset 7 in 'world')") + #expect(pos == 7, "2fo should land on the second 'o' (offset 7 in 'world')") } - func testFThirdOccurrence() { + @Test + func fThirdOccurrence() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("3fo") - XCTAssertEqual(pos, 13, "3fo should land on the 'o' in 'foo' (offset 13)") + #expect(pos == 13, "3fo should land on the 'o' in 'foo' (offset 13)") } - func testFCountLargerThanAvailableStaysPut() { + @Test + func fCountLargerThanAvailableStaysPut() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("99fo") - XCTAssertEqual(pos, 0, "f with count beyond available matches is a no-op") + #expect(pos == 0, "f with count beyond available matches is a no-op") } // MARK: - F: Find Backward (Inclusive) - func testCapitalFMovesToPreviousOccurrence() { + @Test + func capitalFMovesToPreviousOccurrence() { buffer.setSelectedRange(NSRange(location: 10, length: 0)) key("F", shift: true) key("o") - XCTAssertEqual(pos, 7, "Fo from offset 10 should find the previous 'o' at offset 7") + #expect(pos == 7, "Fo from offset 10 should find the previous 'o' at offset 7") } - func testCapitalFNotFoundStaysPut() { + @Test + func capitalFNotFoundStaysPut() { buffer.setSelectedRange(NSRange(location: 10, length: 0)) key("F", shift: true) key("z") - XCTAssertEqual(pos, 10) + #expect(pos == 10) } - func testCapitalFDoesNotCrossLineBoundary() { + @Test + func capitalFDoesNotCrossLineBoundary() { // Start on line 1, look for 'h' (which is on line 0) — should not jump. buffer.setSelectedRange(NSRange(location: 20, length: 0)) key("F", shift: true) key("h") - XCTAssertEqual(pos, 20) + #expect(pos == 20) } - func testCapitalFWithCount() { + @Test + func capitalFWithCount() { buffer.setSelectedRange(NSRange(location: 18, length: 0)) keys("2") key("F", shift: true) key("o") - XCTAssertEqual(pos, 13, "2Fo from offset 18 should find the second prior 'o' at offset 13") + #expect(pos == 13, "2Fo from offset 18 should find the second prior 'o' at offset 13") } // MARK: - t: Till Forward (Stop Before) - func testTLandsOneCharBeforeMatch() { + @Test + func tLandsOneCharBeforeMatch() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("to") - XCTAssertEqual(pos, 3, "to from offset 0 should land at offset 3 (one before the 'o' at 4)") + #expect(pos == 3, "to from offset 0 should land at offset 3 (one before the 'o' at 4)") } - func testTAlreadyAdjacentSkipsToNext() { + @Test + func tAlreadyAdjacentSkipsToNext() { // Cursor at 3 ('l' just before 'o' at 4). t shouldn't be a no-op — it should // find the NEXT 'o' and land one char before it. buffer.setSelectedRange(NSRange(location: 3, length: 0)) keys("to") - XCTAssertEqual(pos, 6, "t when already adjacent should skip to the next match") + #expect(pos == 6, "t when already adjacent should skip to the next match") } - func testTNotFoundStaysPut() { + @Test + func tNotFoundStaysPut() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("tz") - XCTAssertEqual(pos, 0) + #expect(pos == 0) } - func testTDoesNotCrossLineBoundary() { + @Test + func tDoesNotCrossLineBoundary() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("ts") - XCTAssertEqual(pos, 0) + #expect(pos == 0) } - func testTWithCount() { + @Test + func tWithCount() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("2to") - XCTAssertEqual(pos, 6, "2to should land one char before the second 'o' (offset 6)") + #expect(pos == 6, "2to should land one char before the second 'o' (offset 6)") } // MARK: - T: Till Backward (Stop After) - func testCapitalTLandsOneCharAfterMatch() { + @Test + func capitalTLandsOneCharAfterMatch() { buffer.setSelectedRange(NSRange(location: 10, length: 0)) key("T", shift: true) key("o") - XCTAssertEqual(pos, 8, "To from offset 10 should land at offset 8 (one after 'o' at 7)") + #expect(pos == 8, "To from offset 10 should land at offset 8 (one after 'o' at 7)") } - func testCapitalTNotFoundStaysPut() { + @Test + func capitalTNotFoundStaysPut() { buffer.setSelectedRange(NSRange(location: 10, length: 0)) key("T", shift: true) key("z") - XCTAssertEqual(pos, 10) + #expect(pos == 10) } - func testCapitalTWithCount() { + @Test + func capitalTWithCount() { // From offset 18 ('r' in 'bar'), prior 'o' occurrences are at 14, 13, 7, 4. // 2T finds the 2nd backward (offset 13), till lands one after → offset 14. buffer.setSelectedRange(NSRange(location: 18, length: 0)) keys("2") key("T", shift: true) key("o") - XCTAssertEqual(pos, 14, "2To from offset 18 should land one after the second prior 'o' (offset 14)") + #expect(pos == 14, "2To from offset 18 should land one after the second prior 'o' (offset 14)") } // MARK: - ; (Repeat Last f/F/t/T) - func testSemicolonRepeatsForwardFind() { + @Test + func semicolonRepeatsForwardFind() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("fo") // → 4 keys(";") // → 7 - XCTAssertEqual(pos, 7) + #expect(pos == 7) keys(";") // → 13 (in 'foo') - XCTAssertEqual(pos, 13) + #expect(pos == 13) } - func testSemicolonRepeatsBackwardFind() { + @Test + func semicolonRepeatsBackwardFind() { buffer.setSelectedRange(NSRange(location: 18, length: 0)) key("F", shift: true) key("o") - XCTAssertEqual(pos, 14, "Fo from 18 should find 'o' at 14 ('foo')") + #expect(pos == 14, "Fo from 18 should find 'o' at 14 ('foo')") keys(";") - XCTAssertEqual(pos, 13, "; should repeat F backward to next 'o' at 13") + #expect(pos == 13, "; should repeat F backward to next 'o' at 13") } - func testSemicolonRepeatsTill() { + @Test + func semicolonRepeatsTill() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("to") - XCTAssertEqual(pos, 3) + #expect(pos == 3) keys(";") - XCTAssertEqual(pos, 6, "; should repeat t, landing before the next 'o'") + #expect(pos == 6, "; should repeat t, landing before the next 'o'") } - func testSemicolonWithCount() { + @Test + func semicolonWithCount() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("fo") - XCTAssertEqual(pos, 4) + #expect(pos == 4) keys("2;") - XCTAssertEqual(pos, 13, "2; should repeat fo twice forward") + #expect(pos == 13, "2; should repeat fo twice forward") } // MARK: - , (Reverse Last f/F/t/T) - func testCommaReversesForwardFind() { + @Test + func commaReversesForwardFind() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("fo") // forward → 4 keys("fo") // → 7 keys(",") // reverse → 4 - XCTAssertEqual(pos, 4) + #expect(pos == 4) } - func testCommaReversesBackwardFind() { + @Test + func commaReversesBackwardFind() { // From offset 18 ('r'), Fo finds 'o' at 14. There is no 'o' after offset 14 on // this line, so reverse-search (forward) is a no-op and the cursor stays at 14. buffer.setSelectedRange(NSRange(location: 18, length: 0)) key("F", shift: true) key("o") - XCTAssertEqual(pos, 14) + #expect(pos == 14) keys(",") - XCTAssertEqual(pos, 14, ", reverse from 14 has no later 'o' on the line so cursor stays") + #expect(pos == 14, ", reverse from 14 has no later 'o' on the line so cursor stays") } - func testCommaWithCount() { + @Test + func commaWithCount() { // From offset 13 ('o'), fo lands at 14. 2, reverses direction (backward) twice: // 14 → 13 → 7. Final cursor at 7. buffer.setSelectedRange(NSRange(location: 13, length: 0)) keys("fo") keys("2,") - XCTAssertEqual(pos, 7, "2, after fo should walk backward through two 'o' occurrences") + #expect(pos == 7, "2, after fo should walk backward through two 'o' occurrences") } // MARK: - Combined with Operators - func testDeleteUntilCharacter() { + @Test + func deleteUntilCharacter() { // dfo from 0 should delete "hello" (inclusive of 'o' at offset 4) buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("dfo") - XCTAssertEqual(buffer.text, " world foo bar\nsecond line\n", + #expect(buffer.text == " world foo bar\nsecond line\n", "dfo should delete from cursor through and including the matched 'o'") } - func testDeleteTillCharacter() { + @Test + func deleteTillCharacter() { // dto from 0 should delete "hell" (up to but not including 'o' at offset 4) buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("dto") - XCTAssertEqual(buffer.text, "o world foo bar\nsecond line\n", + #expect(buffer.text == "o world foo bar\nsecond line\n", "dto should delete up to (but not including) the matched 'o'") } - func testChangeUntilCharacter() { + @Test + func changeUntilCharacter() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("cfo") - XCTAssertEqual(engine.mode, .insert) - XCTAssertEqual(buffer.text, " world foo bar\nsecond line\n") + #expect(engine.mode == .insert) + #expect(buffer.text == " world foo bar\nsecond line\n") } - func testYankUntilCharacter() { + @Test + func yankUntilCharacter() { // yfo should yank "hello" without modifying buffer let original = buffer.text buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("yfo") - XCTAssertEqual(buffer.text, original) + #expect(buffer.text == original) } // MARK: - Pending Find Cancellation - func testFThenEscapeCancels() { + @Test + func fThenEscapeCancels() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("f") _ = engine.process("\u{1B}", shift: false) // Next key should be treated as a normal command, not the find-char target. keys("l") - XCTAssertEqual(pos, 1, "After Escape during f, next key should run as normal command") + #expect(pos == 1, "After Escape during f, next key should run as normal command") } } diff --git a/TableProTests/Core/Vim/VimEngineIndentTests.swift b/TableProTests/Core/Vim/VimEngineIndentTests.swift index d8ba6aadb9..96dc644409 100644 --- a/TableProTests/Core/Vim/VimEngineIndentTests.swift +++ b/TableProTests/Core/Vim/VimEngineIndentTests.swift @@ -5,12 +5,13 @@ // Specification tests for the indent operators >> and << and their motion forms. // -import XCTest -import TableProPluginKit +import Foundation @testable import TablePro +import TableProPluginKit +import Testing @MainActor -final class VimEngineIndentTests: XCTestCase { +final class VimEngineIndentTests { private var engine: VimEngine! private var buffer: VimTextBufferMock! @@ -19,112 +20,112 @@ final class VimEngineIndentTests: XCTestCase { /// be refactored to inject a width into the engine. private let indentString = " " - override func setUp() { - super.setUp() + init() { buffer = VimTextBufferMock(text: "one\ntwo\nthree\n") engine = VimEngine(buffer: buffer) } - override func tearDown() { - engine = nil - buffer = nil - super.tearDown() - } - private func keys(_ chars: String) { for char in chars { _ = engine.process(char, shift: false) } } // MARK: - >>: Indent Current Line - func testDoubleGreaterIndentsCurrentLine() { + @Test + func doubleGreaterIndentsCurrentLine() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys(">>") - XCTAssertEqual(buffer.text, "\(indentString)one\ntwo\nthree\n", + #expect(buffer.text == "\(indentString)one\ntwo\nthree\n", ">> should add one indent (4 spaces) to the current line") } - func testDoubleGreaterWithCount() { + @Test + func doubleGreaterWithCount() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("3>>") - XCTAssertEqual(buffer.text, - "\(indentString)one\n\(indentString)two\n\(indentString)three\n", + #expect(buffer.text == "\(indentString)one\n\(indentString)two\n\(indentString)three\n", "3>> should indent the current line and the next two lines") } - func testGreaterMotionIndentsRange() { + @Test + func greaterMotionIndentsRange() { // >j should indent the current line and the next line. buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys(">j") - XCTAssertEqual(buffer.text, - "\(indentString)one\n\(indentString)two\nthree\n", + #expect(buffer.text == "\(indentString)one\n\(indentString)two\nthree\n", ">j should indent the current line and the line below") } - func testGreaterGoesIndentsToEndOfBuffer() { + @Test + func greaterGoesIndentsToEndOfBuffer() { // >G from line 0 indents every line. buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys(">") _ = engine.process("G", shift: true) - XCTAssertEqual(buffer.text, - "\(indentString)one\n\(indentString)two\n\(indentString)three\n", + #expect(buffer.text == "\(indentString)one\n\(indentString)two\n\(indentString)three\n", ">G should indent from the current line through the end of the buffer") } // MARK: - <<: Outdent Current Line - func testDoubleLessOutdentsCurrentLine() { + @Test + func doubleLessOutdentsCurrentLine() { buffer = VimTextBufferMock(text: " one\ntwo\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("<<") - XCTAssertEqual(buffer.text, "\(indentString)one\ntwo\n", + #expect(buffer.text == "\(indentString)one\ntwo\n", "<< should remove one indent from the current line") } - func testDoubleLessWithCount() { + @Test + func doubleLessWithCount() { buffer = VimTextBufferMock(text: " one\n two\n three\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("3<<") - XCTAssertEqual(buffer.text, "one\ntwo\nthree\n", + #expect(buffer.text == "one\ntwo\nthree\n", "3<< should remove one indent from three consecutive lines") } - func testDoubleLessNoIndentIsNoOp() { + @Test + func doubleLessNoIndentIsNoOp() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("<<") - XCTAssertEqual(buffer.text, "one\ntwo\nthree\n", + #expect(buffer.text == "one\ntwo\nthree\n", "<< on an unindented line should be a no-op") } - func testDoubleLessLessIndentThanWidthRemovesAll() { + @Test + func doubleLessLessIndentThanWidthRemovesAll() { // If a line has only 2 leading spaces but indent width is 4, << // should remove all the leading whitespace it can (Vim behavior). buffer = VimTextBufferMock(text: " one\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("<<") - XCTAssertEqual(buffer.text, "one\n", + #expect(buffer.text == "one\n", "<< on under-indented line should remove the leading whitespace it has") } // MARK: - =: Auto-indent (just verify it consumes correctly) - func testEqualsOperatorConsumesPair() { + @Test + func equalsOperatorConsumesPair() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) let consumed = engine.process("=", shift: false) - XCTAssertTrue(consumed, "= should be a recognized operator prefix") + #expect(consumed, "= should be a recognized operator prefix") } // MARK: - Cursor Position After Indent - func testIndentMovesCursorToFirstNonBlank() { + @Test + func indentMovesCursorToFirstNonBlank() { // After >>, cursor should be on the first non-blank of the indented line. buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys(">>") let cursor = buffer.selectedRange().location - XCTAssertEqual(cursor, indentString.count, + #expect(cursor == indentString.count, "Cursor should land on the first non-blank after >>") } } diff --git a/TableProTests/Core/Vim/VimEngineInsertEntryTests.swift b/TableProTests/Core/Vim/VimEngineInsertEntryTests.swift index 21e46a6fcb..1e94fa94c1 100644 --- a/TableProTests/Core/Vim/VimEngineInsertEntryTests.swift +++ b/TableProTests/Core/Vim/VimEngineInsertEntryTests.swift @@ -5,27 +5,21 @@ // Specification tests for entering and leaving Insert mode (i, I, a, A, o, O, s, S, gi). // -import XCTest -import TableProPluginKit +import Foundation @testable import TablePro +import TableProPluginKit +import Testing @MainActor -final class VimEngineInsertEntryTests: XCTestCase { +final class VimEngineInsertEntryTests { private var engine: VimEngine! private var buffer: VimTextBufferMock! - override func setUp() { - super.setUp() + init() { buffer = VimTextBufferMock(text: "hello world\nsecond line\nthird line\n") engine = VimEngine(buffer: buffer) } - override func tearDown() { - engine = nil - buffer = nil - super.tearDown() - } - private func keys(_ chars: String) { for char in chars { _ = engine.process(char, shift: false) } } @@ -40,196 +34,221 @@ final class VimEngineInsertEntryTests: XCTestCase { // MARK: - i: Insert Before Cursor - func testILowerEntersInsertMode() { + @Test + func iLowerEntersInsertMode() { buffer.setSelectedRange(NSRange(location: 3, length: 0)) keys("i") - XCTAssertEqual(engine.mode, .insert) + #expect(engine.mode == .insert) } - func testILowerDoesNotMoveCursor() { + @Test + func iLowerDoesNotMoveCursor() { buffer.setSelectedRange(NSRange(location: 3, length: 0)) keys("i") - XCTAssertEqual(pos, 3, "i keeps cursor in place; insertions happen before it") + #expect(pos == 3, "i keeps cursor in place; insertions happen before it") } - func testILowerConsumesKey() { - XCTAssertTrue(engine.process("i", shift: false)) + @Test + func iLowerConsumesKey() { + #expect(engine.process("i", shift: false)) } // MARK: - I: Insert at First Non-Blank - func testICapitalEntersInsertModeAtFirstNonBlank() { + @Test + func iCapitalEntersInsertModeAtFirstNonBlank() { buffer = VimTextBufferMock(text: " hello\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 6, length: 0)) key("I", shift: true) - XCTAssertEqual(engine.mode, .insert) - XCTAssertEqual(pos, 3, "I should land at first non-blank, not column 0") + #expect(engine.mode == .insert) + #expect(pos == 3, "I should land at first non-blank, not column 0") } - func testICapitalAtLineStartWithoutLeadingSpace() { + @Test + func iCapitalAtLineStartWithoutLeadingSpace() { buffer.setSelectedRange(NSRange(location: 5, length: 0)) key("I", shift: true) - XCTAssertEqual(pos, 0) + #expect(pos == 0) } // MARK: - a: Append After Cursor - func testAAppendsAfterCursor() { + @Test + func aAppendsAfterCursor() { buffer.setSelectedRange(NSRange(location: 2, length: 0)) keys("a") - XCTAssertEqual(engine.mode, .insert) - XCTAssertEqual(pos, 3) + #expect(engine.mode == .insert) + #expect(pos == 3) } - func testAAtEndOfLineLandsAfterLastChar() { + @Test + func aAtEndOfLineLandsAfterLastChar() { // 'd' is at offset 10, '\n' is at 11. Append should land at 11 (between 'd' and '\n'). buffer.setSelectedRange(NSRange(location: 10, length: 0)) keys("a") - XCTAssertEqual(pos, 11, "a at end of line should land after last char, before newline") + #expect(pos == 11, "a at end of line should land after last char, before newline") } - func testAAtBufferEndStaysAtEnd() { + @Test + func aAtBufferEndStaysAtEnd() { buffer.setSelectedRange(NSRange(location: buffer.length, length: 0)) keys("a") - XCTAssertEqual(pos, buffer.length) + #expect(pos == buffer.length) } // MARK: - A: Append at End of Line - func testACapitalEntersInsertAtLineEnd() { + @Test + func aCapitalEntersInsertAtLineEnd() { buffer.setSelectedRange(NSRange(location: 3, length: 0)) key("A", shift: true) - XCTAssertEqual(engine.mode, .insert) - XCTAssertEqual(pos, 11, "A should land just before the newline of the current line") + #expect(engine.mode == .insert) + #expect(pos == 11, "A should land just before the newline of the current line") } - func testACapitalOnLineWithoutTrailingNewline() { + @Test + func aCapitalOnLineWithoutTrailingNewline() { buffer = VimTextBufferMock(text: "hello") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 0, length: 0)) key("A", shift: true) - XCTAssertEqual(pos, 5, "A on last line without newline should land at end-of-buffer") + #expect(pos == 5, "A on last line without newline should land at end-of-buffer") } // MARK: - o: Open Line Below - func testOOpensLineBelowAndEntersInsert() { + @Test + func oOpensLineBelowAndEntersInsert() { buffer.setSelectedRange(NSRange(location: 5, length: 0)) keys("o") - XCTAssertEqual(engine.mode, .insert) - XCTAssertTrue(buffer.text.hasPrefix("hello world\n\n"), + #expect(engine.mode == .insert) + #expect(buffer.text.hasPrefix("hello world\n\n"), "o should insert a newline after the current line") } - func testOPositionsCursorOnNewLine() { + @Test + func oPositionsCursorOnNewLine() { buffer.setSelectedRange(NSRange(location: 5, length: 0)) keys("o") - XCTAssertEqual(pos, 12, "o cursor should sit at the start of the new blank line") + #expect(pos == 12, "o cursor should sit at the start of the new blank line") } - func testOOnLastLineWithoutTrailingNewline() { + @Test + func oOnLastLineWithoutTrailingNewline() { buffer = VimTextBufferMock(text: "one\ntwo") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 5, length: 0)) keys("o") - XCTAssertEqual(buffer.text, "one\ntwo\n", + #expect(buffer.text == "one\ntwo\n", "o on the last line without a newline should append a newline") - XCTAssertEqual(pos, 8) + #expect(pos == 8) } // MARK: - O: Open Line Above - func testCapitalOOpensLineAbove() { + @Test + func capitalOOpensLineAbove() { buffer.setSelectedRange(NSRange(location: 14, length: 0)) keys("O") - XCTAssertEqual(engine.mode, .insert) - XCTAssertEqual(pos, 12, "O should place cursor at the new blank line above") + #expect(engine.mode == .insert) + #expect(pos == 12, "O should place cursor at the new blank line above") } - func testCapitalOOnFirstLine() { + @Test + func capitalOOnFirstLine() { buffer.setSelectedRange(NSRange(location: 3, length: 0)) keys("O") - XCTAssertEqual(pos, 0) - XCTAssertTrue(buffer.text.hasPrefix("\nhello world")) + #expect(pos == 0) + #expect(buffer.text.hasPrefix("\nhello world")) } // MARK: - s: Substitute Character - func testSLowerDeletesCharAndEntersInsert() { + @Test + func sLowerDeletesCharAndEntersInsert() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("s") - XCTAssertEqual(engine.mode, .insert) - XCTAssertEqual(buffer.text, "ello world\nsecond line\nthird line\n", + #expect(engine.mode == .insert) + #expect(buffer.text == "ello world\nsecond line\nthird line\n", "s should delete the char under the cursor and enter insert mode") } - func testSLowerWithCount() { + @Test + func sLowerWithCount() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("3s") - XCTAssertEqual(engine.mode, .insert) - XCTAssertEqual(buffer.text, "lo world\nsecond line\nthird line\n", + #expect(engine.mode == .insert) + #expect(buffer.text == "lo world\nsecond line\nthird line\n", "3s should delete three chars and enter insert mode") } - func testSLowerDoesNotCrossNewline() { + @Test + func sLowerDoesNotCrossNewline() { buffer.setSelectedRange(NSRange(location: 10, length: 0)) keys("9s") - XCTAssertEqual(buffer.text, "hello worl\nsecond line\nthird line\n", + #expect(buffer.text == "hello worl\nsecond line\nthird line\n", "s should not consume the newline even with large count") } // MARK: - S: Substitute Entire Line - func testCapitalSDeletesLineContentAndEntersInsert() { + @Test + func capitalSDeletesLineContentAndEntersInsert() { buffer.setSelectedRange(NSRange(location: 5, length: 0)) key("S", shift: true) - XCTAssertEqual(engine.mode, .insert) - XCTAssertEqual(buffer.text, "\nsecond line\nthird line\n", + #expect(engine.mode == .insert) + #expect(buffer.text == "\nsecond line\nthird line\n", "S should delete the entire line content but keep the newline") - XCTAssertEqual(pos, 0) + #expect(pos == 0) } - func testCapitalSWithCount() { + @Test + func capitalSWithCount() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("2") key("S", shift: true) - XCTAssertEqual(buffer.text, "\nthird line\n", + #expect(buffer.text == "\nthird line\n", "2S should delete two lines' content") } // MARK: - Escape: Insert → Normal - func testEscapeReturnsToNormalMode() { + @Test + func escapeReturnsToNormalMode() { keys("i") escape() - XCTAssertEqual(engine.mode, .normal) + #expect(engine.mode == .normal) } - func testEscapeMovesCursorBackOne() { + @Test + func escapeMovesCursorBackOne() { buffer.setSelectedRange(NSRange(location: 5, length: 0)) keys("i") escape() - XCTAssertEqual(pos, 4, "Escape from insert should move cursor back one (Vim convention)") + #expect(pos == 4, "Escape from insert should move cursor back one (Vim convention)") } - func testEscapeAtLineStartDoesNotCrossBoundary() { + @Test + func escapeAtLineStartDoesNotCrossBoundary() { buffer.setSelectedRange(NSRange(location: 12, length: 0)) keys("i") escape() - XCTAssertEqual(pos, 12, "Escape at line start should not move cursor onto previous line") + #expect(pos == 12, "Escape at line start should not move cursor onto previous line") } - func testEscapeAtBufferStartStays() { + @Test + func escapeAtBufferStartStays() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("i") escape() - XCTAssertEqual(pos, 0) + #expect(pos == 0) } // MARK: - Escape at End-of-Buffer (regression for ";" cursor-past-last-char bug) - func testEscapePastLastCharOfBufferWithoutTrailingNewline() { + @Test + func escapePastLastCharOfBufferWithoutTrailingNewline() { // Reproduces the reported bug: typing "SELECT * FROM users;" leaves the cursor // at offset == length (past the last char). Pressing Esc must still switch // to normal mode and step the cursor back onto the last char. @@ -237,13 +256,14 @@ final class VimEngineInsertEntryTests: XCTestCase { engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 20, length: 0)) keys("i") - XCTAssertEqual(engine.mode, .insert) + #expect(engine.mode == .insert) escape() - XCTAssertEqual(engine.mode, .normal, "Esc at end-of-buffer must switch to normal mode") - XCTAssertEqual(pos, 19, "Cursor should step back from end onto ';' at offset 19") + #expect(engine.mode == .normal, "Esc at end-of-buffer must switch to normal mode") + #expect(pos == 19, "Cursor should step back from end onto ';' at offset 19") } - func testEscapePastLastCharOfBufferWithTrailingNewline() { + @Test + func escapePastLastCharOfBufferWithTrailingNewline() { // Same buffer but with a trailing newline. Cursor lands between ';' (offset 19) // and '\n' (offset 20). That is the "end of last content line", not the phantom // line after the newline. @@ -252,11 +272,12 @@ final class VimEngineInsertEntryTests: XCTestCase { buffer.setSelectedRange(NSRange(location: 20, length: 0)) keys("i") escape() - XCTAssertEqual(engine.mode, .normal) - XCTAssertEqual(pos, 19, "Cursor steps back from line-end (just before '\\n') onto ';'") + #expect(engine.mode == .normal) + #expect(pos == 19, "Cursor steps back from line-end (just before '\\n') onto ';'") } - func testEscapeOnPhantomLineAfterTrailingNewline() { + @Test + func escapeOnPhantomLineAfterTrailingNewline() { // Cursor at offset == length on a buffer with a trailing newline ends up on // the phantom empty line after the '\n'. The Vim convention is that Esc still // switches mode and does NOT cross back over the newline (since the phantom @@ -266,11 +287,12 @@ final class VimEngineInsertEntryTests: XCTestCase { buffer.setSelectedRange(NSRange(location: 8, length: 0)) keys("i") escape() - XCTAssertEqual(engine.mode, .normal, "Esc on the phantom line must still switch to normal mode") - XCTAssertEqual(pos, 8, "Cursor stays on phantom line (no content to step onto)") + #expect(engine.mode == .normal, "Esc on the phantom line must still switch to normal mode") + #expect(pos == 8, "Cursor stays on phantom line (no content to step onto)") } - func testEscapeAfterTypingSemicolonAtEndIsConsumed() { + @Test + func escapeAfterTypingSemicolonAtEndIsConsumed() { // The interceptor's pass-through behavior depends on the engine returning // true (consumed) when Esc is processed in insert mode. Regression-safe. buffer = VimTextBufferMock(text: "SELECT * FROM users;") @@ -278,26 +300,29 @@ final class VimEngineInsertEntryTests: XCTestCase { buffer.setSelectedRange(NSRange(location: 20, length: 0)) keys("i") let consumed = engine.process("\u{1B}", shift: false) - XCTAssertTrue(consumed, "Escape must be consumed by the engine at end-of-buffer") + #expect(consumed, "Escape must be consumed by the engine at end-of-buffer") } // MARK: - Insert Mode Pass-Through - func testCharactersInInsertModeAreNotConsumed() { + @Test + func charactersInInsertModeAreNotConsumed() { keys("i") let consumed = engine.process("x", shift: false) - XCTAssertFalse(consumed, "Regular characters in insert mode must pass through to the text view") + #expect(!consumed, "Regular characters in insert mode must pass through to the text view") } - func testEscapeInInsertIsConsumed() { + @Test + func escapeInInsertIsConsumed() { keys("i") let consumed = engine.process("\u{1B}", shift: false) - XCTAssertTrue(consumed) + #expect(consumed) } // MARK: - gi: Resume Insert at Last Position - func testGIResumesInsertAtLastInsertLocation() { + @Test + func gIResumesInsertAtLastInsertLocation() { // Enter insert at offset 5, type implicitly via test, escape, move, then gi. buffer.setSelectedRange(NSRange(location: 5, length: 0)) keys("i") @@ -305,24 +330,26 @@ final class VimEngineInsertEntryTests: XCTestCase { // Move away buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("gi") - XCTAssertEqual(engine.mode, .insert) - XCTAssertEqual(pos, 5, "gi should re-enter insert mode at the previous insert position") + #expect(engine.mode == .insert) + #expect(pos == 5, "gi should re-enter insert mode at the previous insert position") } // MARK: - Insert Mode Callbacks - func testModeChangeCallbackFiresOnInsertEntry() { + @Test + func modeChangeCallbackFiresOnInsertEntry() { var capturedMode: VimMode? engine.onModeChange = { mode in capturedMode = mode } keys("i") - XCTAssertEqual(capturedMode, .insert) + #expect(capturedMode == .insert) } - func testModeChangeCallbackFiresOnInsertExit() { + @Test + func modeChangeCallbackFiresOnInsertExit() { keys("i") var capturedMode: VimMode? engine.onModeChange = { mode in capturedMode = mode } escape() - XCTAssertEqual(capturedMode, .normal) + #expect(capturedMode == .normal) } } diff --git a/TableProTests/Core/Vim/VimEngineInsertModeEditTests.swift b/TableProTests/Core/Vim/VimEngineInsertModeEditTests.swift index 996bf33ca3..b09ee53a2e 100644 --- a/TableProTests/Core/Vim/VimEngineInsertModeEditTests.swift +++ b/TableProTests/Core/Vim/VimEngineInsertModeEditTests.swift @@ -7,27 +7,21 @@ // left to the text view, whose deleteBackward it is. // -import XCTest -import TableProPluginKit +import Foundation @testable import TablePro +import TableProPluginKit +import Testing @MainActor -final class VimEngineInsertModeEditTests: XCTestCase { +final class VimEngineInsertModeEditTests { private var engine: VimEngine! private var buffer: VimTextBufferMock! - override func setUp() { - super.setUp() + init() { buffer = VimTextBufferMock(text: "hello world\n") engine = VimEngine(buffer: buffer) } - override func tearDown() { - engine = nil - buffer = nil - super.tearDown() - } - private func enterInsert(at offset: Int) { buffer.setSelectedRange(NSRange(location: offset, length: 0)) _ = engine.process("i", shift: false) @@ -35,106 +29,116 @@ final class VimEngineInsertModeEditTests: XCTestCase { // MARK: - Ctrl+W: Delete previous word - func testCtrlWDeletesPreviousWord() { + @Test + func ctrlWDeletesPreviousWord() { enterInsert(at: 11) _ = engine.process("\u{17}", shift: false) // Ctrl+W - XCTAssertEqual(buffer.text, "hello \n", + #expect(buffer.text == "hello \n", "Ctrl+W in insert mode should delete the previous word ('world')") } - func testCtrlWInMiddleOfWord() { + @Test + func ctrlWInMiddleOfWord() { // Cursor at offset 8 (inside 'world'). Ctrl+W should delete back to 'w'. enterInsert(at: 8) _ = engine.process("\u{17}", shift: false) - XCTAssertEqual(buffer.text, "hello rld\n", + #expect(buffer.text == "hello rld\n", "Ctrl+W mid-word should delete back to the start of the current word") } - func testCtrlWAtLineStartIsNoOp() { + @Test + func ctrlWAtLineStartIsNoOp() { enterInsert(at: 0) _ = engine.process("\u{17}", shift: false) - XCTAssertEqual(buffer.text, "hello world\n", + #expect(buffer.text == "hello world\n", "Ctrl+W at line start should not cross the line boundary") } - func testCtrlWConsumed() { + @Test + func ctrlWConsumed() { enterInsert(at: 11) let consumed = engine.process("\u{17}", shift: false) - XCTAssertTrue(consumed, "Ctrl+W in insert mode must be consumed by the engine") + #expect(consumed, "Ctrl+W in insert mode must be consumed by the engine") } // MARK: - Ctrl+U: Delete to line start - func testCtrlUDeletesToLineStart() { + @Test + func ctrlUDeletesToLineStart() { enterInsert(at: 11) _ = engine.process("\u{15}", shift: false) // Ctrl+U - XCTAssertEqual(buffer.text, "\n", + #expect(buffer.text == "\n", "Ctrl+U should delete everything from cursor back to the start of the line") } - func testCtrlUOnEmptyLineIsNoOp() { + @Test + func ctrlUOnEmptyLineIsNoOp() { buffer = VimTextBufferMock(text: "\n") engine = VimEngine(buffer: buffer) enterInsert(at: 0) _ = engine.process("\u{15}", shift: false) - XCTAssertEqual(buffer.text, "\n") + #expect(buffer.text == "\n") } - func testCtrlUInMiddleOfLine() { + @Test + func ctrlUInMiddleOfLine() { enterInsert(at: 6) _ = engine.process("\u{15}", shift: false) - XCTAssertEqual(buffer.text, "world\n", + #expect(buffer.text == "world\n", "Ctrl+U mid-line should delete just the part before the cursor") } // MARK: - Ctrl+H: Backspace - func testCtrlHIsLeftToTheTextViewBackspace() { + @Test + func ctrlHIsLeftToTheTextViewBackspace() { enterInsert(at: 5) let consumed = engine.process("\u{08}", shift: false) // Ctrl+H - XCTAssertFalse( - consumed, - "Ctrl+H in insert mode is the text view's deleteBackward, which handles selections and every cursor" - ) - XCTAssertEqual(buffer.text, "hello world\n") + #expect(!consumed, + "Ctrl+H in insert mode is the text view's deleteBackward, which handles selections and every cursor") + #expect(buffer.text == "hello world\n") } // MARK: - Ctrl+T / Ctrl+D: Indent / Outdent - func testCtrlTIndentsCurrentLine() { + @Test + func ctrlTIndentsCurrentLine() { enterInsert(at: 5) _ = engine.process("\u{14}", shift: false) // Ctrl+T - XCTAssertEqual(buffer.text, " hello world\n", + #expect(buffer.text == " hello world\n", "Ctrl+T in insert mode should add one indent level to the current line") } - func testCtrlDOutdentsCurrentLine() { + @Test + func ctrlDOutdentsCurrentLine() { buffer = VimTextBufferMock(text: " hello\n") engine = VimEngine(buffer: buffer) enterInsert(at: 10) _ = engine.process("\u{04}", shift: false) // Ctrl+D - XCTAssertEqual(buffer.text, " hello\n", + #expect(buffer.text == " hello\n", "Ctrl+D should remove one indent level from the current line") } // MARK: - Mode invariants - func testCtrlEditsKeepInsertMode() { + @Test + func ctrlEditsKeepInsertMode() { enterInsert(at: 11) _ = engine.process("\u{17}", shift: false) - XCTAssertEqual(engine.mode, .insert, "Ctrl+W should not exit insert mode") + #expect(engine.mode == .insert, "Ctrl+W should not exit insert mode") _ = engine.process("\u{15}", shift: false) - XCTAssertEqual(engine.mode, .insert, "Ctrl+U should not exit insert mode") + #expect(engine.mode == .insert, "Ctrl+U should not exit insert mode") } // MARK: - In Replace Mode - func testCtrlWInReplaceModeDeletesPreviousWord() { + @Test + func ctrlWInReplaceModeDeletesPreviousWord() { buffer.setSelectedRange(NSRange(location: 11, length: 0)) _ = engine.process("R", shift: true) - XCTAssertEqual(engine.mode, .replace) + #expect(engine.mode == .replace) _ = engine.process("\u{17}", shift: false) - XCTAssertEqual(buffer.text, "hello \n", + #expect(buffer.text == "hello \n", "Ctrl+W should work in replace mode too") } } diff --git a/TableProTests/Core/Vim/VimEngineJoinTests.swift b/TableProTests/Core/Vim/VimEngineJoinTests.swift index b8ef6bbdea..86b9dd1e4d 100644 --- a/TableProTests/Core/Vim/VimEngineJoinTests.swift +++ b/TableProTests/Core/Vim/VimEngineJoinTests.swift @@ -6,27 +6,21 @@ // Bug reference: https://github.com/TableProApp/TablePro/issues/1222 // -import XCTest -import TableProPluginKit +import Foundation @testable import TablePro +import TableProPluginKit +import Testing @MainActor -final class VimEngineJoinTests: XCTestCase { +final class VimEngineJoinTests { private var engine: VimEngine! private var buffer: VimTextBufferMock! - override func setUp() { - super.setUp() + init() { buffer = VimTextBufferMock(text: "hello\nworld\n") engine = VimEngine(buffer: buffer) } - override func tearDown() { - engine = nil - buffer = nil - super.tearDown() - } - private func process(_ char: Character, shift: Bool = false) { _ = engine.process(char, shift: shift) } @@ -41,154 +35,171 @@ final class VimEngineJoinTests: XCTestCase { // MARK: - J: Basic Join (Normal Mode) - func testJJoinsCurrentLineWithNextWithSingleSpace() { + @Test + func jJoinsCurrentLineWithNextWithSingleSpace() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) process("J", shift: true) - XCTAssertEqual(buffer.text, "hello world\n", + #expect(buffer.text == "hello world\n", "J should join the next line onto the current one with a single space") } - func testJCursorMovesToJoinPosition() { + @Test + func jCursorMovesToJoinPosition() { // Vim convention: cursor moves to the inserted space at the join point. // In "hello\nworld" -> "hello world", the inserted space is at offset 5. buffer.setSelectedRange(NSRange(location: 0, length: 0)) process("J", shift: true) - XCTAssertEqual(cursorPos, 5, "Cursor should land on the inserted space at the join") + #expect(cursorPos == 5, "Cursor should land on the inserted space at the join") } - func testJStaysInNormalMode() { + @Test + func jStaysInNormalMode() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) process("J", shift: true) - XCTAssertEqual(engine.mode, .normal) + #expect(engine.mode == .normal) } - func testJConsumesKey() { + @Test + func jConsumesKey() { let consumed = engine.process("J", shift: true) - XCTAssertTrue(consumed, "J must be consumed (not passed through to text view)") + #expect(consumed, "J must be consumed (not passed through to text view)") } - func testJOnLastLineIsNoOp() { + @Test + func jOnLastLineIsNoOp() { buffer = VimTextBufferMock(text: "only line\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 3, length: 0)) process("J", shift: true) - XCTAssertEqual(buffer.text, "only line\n", "J on last line is a no-op (no line below)") + #expect(buffer.text == "only line\n", "J on last line is a no-op (no line below)") } - func testJOnLastLineWithoutTrailingNewline() { + @Test + func jOnLastLineWithoutTrailingNewline() { buffer = VimTextBufferMock(text: "only line") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 0, length: 0)) process("J", shift: true) - XCTAssertEqual(buffer.text, "only line", "J on the single line should not modify the buffer") + #expect(buffer.text == "only line", "J on the single line should not modify the buffer") } // MARK: - J: Whitespace Handling - func testJStripsLeadingWhitespaceFromNextLine() { + @Test + func jStripsLeadingWhitespaceFromNextLine() { // Vim strips leading whitespace from the joined-in line. buffer = VimTextBufferMock(text: "hello\n world\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 0, length: 0)) process("J", shift: true) - XCTAssertEqual(buffer.text, "hello world\n", + #expect(buffer.text == "hello world\n", "J must strip leading whitespace from the next line before joining") } - func testJStripsLeadingTabsFromNextLine() { + @Test + func jStripsLeadingTabsFromNextLine() { buffer = VimTextBufferMock(text: "hello\n\t\tworld\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 0, length: 0)) process("J", shift: true) - XCTAssertEqual(buffer.text, "hello world\n", + #expect(buffer.text == "hello world\n", "J must strip leading tabs from the next line") } - func testJDoesNotAddSpaceWhenCurrentLineEndsWithSpace() { + @Test + func jDoesNotAddSpaceWhenCurrentLineEndsWithSpace() { buffer = VimTextBufferMock(text: "hello \nworld\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 0, length: 0)) process("J", shift: true) - XCTAssertEqual(buffer.text, "hello world\n", + #expect(buffer.text == "hello world\n", "J should not insert an extra space when current line already ends with one") } - func testJDoesNotAddSpaceBeforeClosingParen() { + @Test + func jDoesNotAddSpaceBeforeClosingParen() { // Vim special case: no space inserted before ) at the start of the next line. buffer = VimTextBufferMock(text: "func(arg\n)\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 0, length: 0)) process("J", shift: true) - XCTAssertEqual(buffer.text, "func(arg)\n", + #expect(buffer.text == "func(arg)\n", "J should not insert a space before a closing parenthesis") } - func testJOnEmptyNextLineRemovesNewline() { + @Test + func jOnEmptyNextLineRemovesNewline() { // Joining with an empty next line just removes the newline; no space added. buffer = VimTextBufferMock(text: "hello\n\nworld\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 0, length: 0)) process("J", shift: true) - XCTAssertEqual(buffer.text, "hello\nworld\n", + #expect(buffer.text == "hello\nworld\n", "J with an empty next line removes the newline; no space inserted") } - func testJOnEmptyCurrentLineKeepsNextLineContent() { + @Test + func jOnEmptyCurrentLineKeepsNextLineContent() { // Empty current line + content next line should result in just the next-line content. buffer = VimTextBufferMock(text: "\nworld\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 0, length: 0)) process("J", shift: true) - XCTAssertEqual(buffer.text, "world\n", + #expect(buffer.text == "world\n", "J on an empty current line should leave the next line content with no leading space") } // MARK: - J: Count Prefix - func testJWithCountTwoJoinsTwoLines() { + @Test + func jWithCountTwoJoinsTwoLines() { // [count]J joins [count] lines, minimum 2. 2J == J. buffer = VimTextBufferMock(text: "one\ntwo\nthree\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("2") process("J", shift: true) - XCTAssertEqual(buffer.text, "one two\nthree\n", + #expect(buffer.text == "one two\nthree\n", "2J should join 2 lines (same as plain J)") } - func testJWithCountThreeJoinsThreeLines() { + @Test + func jWithCountThreeJoinsThreeLines() { buffer = VimTextBufferMock(text: "one\ntwo\nthree\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("3") process("J", shift: true) - XCTAssertEqual(buffer.text, "one two three\n", + #expect(buffer.text == "one two three\n", "3J should join the current line plus the next two lines") } - func testJWithCountClampsAtLastLine() { + @Test + func jWithCountClampsAtLastLine() { // 5J on a 3-line buffer should join all 3 (clamped, no error). buffer = VimTextBufferMock(text: "one\ntwo\nthree\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("5") process("J", shift: true) - XCTAssertEqual(buffer.text, "one two three\n", + #expect(buffer.text == "one two three\n", "Count larger than remaining lines should clamp at the last line") } - func testJWithCountOneIsSameAsJ() { + @Test + func jWithCountOneIsSameAsJ() { // Per Vim docs: count of 1 still joins (minimum 2 lines). buffer = VimTextBufferMock(text: "one\ntwo\nthree\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("1") process("J", shift: true) - XCTAssertEqual(buffer.text, "one two\nthree\n", + #expect(buffer.text == "one two\nthree\n", "1J behaves like J (minimum two lines joined)") } - func testJWithCountClearsCountAfter() { + @Test + func jWithCountClearsCountAfter() { buffer = VimTextBufferMock(text: "one\ntwo\nthree\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 0, length: 0)) @@ -197,62 +208,68 @@ final class VimEngineJoinTests: XCTestCase { // Now type 'l' — should move 1, not lingering count of 2. buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("l") - XCTAssertEqual(cursorPos, 1, "Count prefix should be consumed by J") + #expect(cursorPos == 1, "Count prefix should be consumed by J") } // MARK: - gJ: Join Without Space - func testGJJoinsWithoutInsertingSpace() { + @Test + func gJJoinsWithoutInsertingSpace() { // gJ joins lines but does NOT insert a space between them. buffer = VimTextBufferMock(text: "hello\nworld\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("gJ") - XCTAssertEqual(buffer.text, "helloworld\n", + #expect(buffer.text == "helloworld\n", "gJ should join lines without inserting a space") } - func testGJPreservesLeadingWhitespaceOfNextLine() { + @Test + func gJPreservesLeadingWhitespaceOfNextLine() { // gJ does NOT strip leading whitespace from the next line. buffer = VimTextBufferMock(text: "hello\n world\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("gJ") - XCTAssertEqual(buffer.text, "hello world\n", + #expect(buffer.text == "hello world\n", "gJ should preserve leading whitespace on the joined line") } - func testGJCursorAtOriginalLineEnd() { + @Test + func gJCursorAtOriginalLineEnd() { // After gJ, cursor sits at the first character of the joined-in content // (i.e., right after the original line end). buffer = VimTextBufferMock(text: "hello\nworld\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("gJ") - XCTAssertEqual(cursorPos, 5, + #expect(cursorPos == 5, "Cursor should land at the start of what was the next line after gJ") } - func testGJWithCount() { + @Test + func gJWithCount() { buffer = VimTextBufferMock(text: "one\ntwo\nthree\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("3gJ") - XCTAssertEqual(buffer.text, "onetwothree\n", + #expect(buffer.text == "onetwothree\n", "3gJ should join 3 lines without any spaces") } - func testGJOnLastLineIsNoOp() { + @Test + func gJOnLastLineIsNoOp() { buffer = VimTextBufferMock(text: "only\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("gJ") - XCTAssertEqual(buffer.text, "only\n", "gJ on the last line is a no-op") + #expect(buffer.text == "only\n", "gJ on the last line is a no-op") } // MARK: - J in Visual Mode - func testVisualJJoinsSelectedLines() { + @Test + func visualJJoinsSelectedLines() { // V to select line, j to extend to next line, J to join. buffer = VimTextBufferMock(text: "one\ntwo\nthree\n") engine = VimEngine(buffer: buffer) @@ -260,44 +277,48 @@ final class VimEngineJoinTests: XCTestCase { process("V", shift: true) keys("j") process("J", shift: true) - XCTAssertEqual(buffer.text, "one two\nthree\n", + #expect(buffer.text == "one two\nthree\n", "Visual-line J should join all selected lines with single spaces") } - func testVisualJJoinsThreeSelectedLines() { + @Test + func visualJJoinsThreeSelectedLines() { buffer = VimTextBufferMock(text: "one\ntwo\nthree\nfour\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 0, length: 0)) process("V", shift: true) keys("jj") process("J", shift: true) - XCTAssertEqual(buffer.text, "one two three\nfour\n", + #expect(buffer.text == "one two three\nfour\n", "Visual J across three lines should join all three with spaces") } - func testVisualJReturnsToNormalMode() { + @Test + func visualJReturnsToNormalMode() { buffer = VimTextBufferMock(text: "one\ntwo\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 0, length: 0)) process("V", shift: true) keys("j") process("J", shift: true) - XCTAssertEqual(engine.mode, .normal, + #expect(engine.mode == .normal, "After visual J, the engine should return to normal mode") } - func testVisualJStripsLeadingWhitespace() { + @Test + func visualJStripsLeadingWhitespace() { buffer = VimTextBufferMock(text: "one\n two\n three\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 0, length: 0)) process("V", shift: true) keys("jj") process("J", shift: true) - XCTAssertEqual(buffer.text, "one two three\n", + #expect(buffer.text == "one two three\n", "Visual J should strip leading whitespace from each joined line") } - func testVisualCharacterwiseJJoinsCoveredLines() { + @Test + func visualCharacterwiseJJoinsCoveredLines() { // v across two lines, J should still join those lines. buffer = VimTextBufferMock(text: "one\ntwo\n") engine = VimEngine(buffer: buffer) @@ -305,40 +326,43 @@ final class VimEngineJoinTests: XCTestCase { keys("v") keys("j") process("J", shift: true) - XCTAssertEqual(buffer.text, "one two\n", + #expect(buffer.text == "one two\n", "Characterwise visual J should still join the lines covered by the selection") } // MARK: - gJ in Visual Mode - func testVisualGJJoinsWithoutSpace() { + @Test + func visualGJJoinsWithoutSpace() { buffer = VimTextBufferMock(text: "one\ntwo\nthree\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 0, length: 0)) process("V", shift: true) keys("jj") keys("gJ") - XCTAssertEqual(buffer.text, "onetwothree\n", + #expect(buffer.text == "onetwothree\n", "Visual gJ should concatenate without inserting spaces") } - func testVisualGJReturnsToNormalMode() { + @Test + func visualGJReturnsToNormalMode() { buffer = VimTextBufferMock(text: "one\ntwo\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 0, length: 0)) process("V", shift: true) keys("j") keys("gJ") - XCTAssertEqual(engine.mode, .normal) + #expect(engine.mode == .normal) } // MARK: - Undo - func testJIsUndoable() { + @Test + func jIsUndoable() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) process("J", shift: true) - XCTAssertEqual(buffer.text, "hello world\n") + #expect(buffer.text == "hello world\n") keys("u") - XCTAssertEqual(buffer.undoCallCount, 1, "u should undo the join") + #expect(buffer.undoCallCount == 1, "u should undo the join") } } diff --git a/TableProTests/Core/Vim/VimEngineMacroTests.swift b/TableProTests/Core/Vim/VimEngineMacroTests.swift index ad4a5609f5..f1774c9406 100644 --- a/TableProTests/Core/Vim/VimEngineMacroTests.swift +++ b/TableProTests/Core/Vim/VimEngineMacroTests.swift @@ -6,27 +6,21 @@ // replays a named macro, @@ replays the last replayed macro. // -import XCTest -import TableProPluginKit +import Foundation @testable import TablePro +import TableProPluginKit +import Testing @MainActor -final class VimEngineMacroTests: XCTestCase { +final class VimEngineMacroTests { private var engine: VimEngine! private var buffer: VimTextBufferMock! - override func setUp() { - super.setUp() + init() { buffer = VimTextBufferMock(text: "aaa bbb ccc ddd\n") engine = VimEngine(buffer: buffer) } - override func tearDown() { - engine = nil - buffer = nil - super.tearDown() - } - private func keys(_ chars: String) { for char in chars { _ = engine.process(char, shift: false) } } @@ -35,102 +29,111 @@ final class VimEngineMacroTests: XCTestCase { // MARK: - Recording - func testQStartsRecordingIntoNamedRegister() { + @Test + func qStartsRecordingIntoNamedRegister() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("qa") keys("dw") keys("q") // After recording, the buffer should reflect the recorded edit once. - XCTAssertEqual(buffer.text, "bbb ccc ddd\n", + #expect(buffer.text == "bbb ccc ddd\n", "Recording the macro should still execute the keys") } - func testQAtoQClosesRecording() { + @Test + func qAtoQClosesRecording() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("qa") keys("x") keys("q") // Now type 'x' again — it should NOT be recorded (recording is closed). keys("x") - XCTAssertEqual(buffer.text, "a bbb ccc ddd\n", + #expect(buffer.text == "a bbb ccc ddd\n", "Recording stops on the second q; subsequent keys must not be appended") } // MARK: - Playback - func testAtAReplaysNamedMacro() { + @Test + func atAReplaysNamedMacro() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("qa") keys("dw") keys("q") - XCTAssertEqual(buffer.text, "bbb ccc ddd\n") + #expect(buffer.text == "bbb ccc ddd\n") keys("@a") - XCTAssertEqual(buffer.text, "ccc ddd\n", + #expect(buffer.text == "ccc ddd\n", "@a should replay the recorded dw once") } - func testAtACanBeReplayedMultipleTimes() { + @Test + func atACanBeReplayedMultipleTimes() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("qa") keys("dw") keys("q") keys("@a") keys("@a") - XCTAssertEqual(buffer.text, "ddd\n", "Three deletions total: 1 recording + 2 replays") + #expect(buffer.text == "ddd\n", "Three deletions total: 1 recording + 2 replays") } - func testAtAtReplaysLastMacro() { + @Test + func atAtReplaysLastMacro() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("qa") keys("dw") keys("q") keys("@a") keys("@@") - XCTAssertEqual(buffer.text, "ddd\n", + #expect(buffer.text == "ddd\n", "@@ should replay the most recently invoked macro") } - func testAtAWithCount() { + @Test + func atAWithCount() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("qa") keys("dw") keys("q") keys("3@a") - XCTAssertEqual(buffer.text, "\n", + #expect(buffer.text == "\n", "3@a should replay the macro three times") } // MARK: - Multiple Macros - func testTwoIndependentMacros() { + @Test + func twoIndependentMacros() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("qa") keys("w") keys("q") - XCTAssertEqual(pos, 4) + #expect(pos == 4) keys("qb") keys("dw") keys("q") - XCTAssertEqual(buffer.text, "aaa ccc ddd\n", + #expect(buffer.text == "aaa ccc ddd\n", "Macro 'b' should run once during recording") keys("0@a") - XCTAssertEqual(pos, 4, "Macro 'a' should still advance by one word when invoked") + #expect(pos == 4, "Macro 'a' should still advance by one word when invoked") } // MARK: - Empty Macro - func testEmptyMacroReplayIsNoOp() { + @Test + func emptyMacroReplayIsNoOp() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("qa") keys("q") let snapshot = buffer.text keys("@a") - XCTAssertEqual(buffer.text, snapshot, "Replaying an empty macro should be a no-op") + #expect(buffer.text == snapshot, "Replaying an empty macro should be a no-op") } // MARK: - Recording During Visual Mode - func testRecordingCapturesVisualOperation() { + @Test + func recordingCapturesVisualOperation() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("qa") keys("v") @@ -138,17 +141,18 @@ final class VimEngineMacroTests: XCTestCase { keys("d") keys("q") // Buffer should reflect one visual delete. - XCTAssertEqual(buffer.text, " bbb ccc ddd\n") + #expect(buffer.text == " bbb ccc ddd\n") // Replay should perform another visual-style delete from the current cursor. keys("@a") // The exact result depends on cursor position, but it should mutate the buffer. - XCTAssertNotEqual(buffer.text, " bbb ccc ddd\n", + #expect(buffer.text != " bbb ccc ddd\n", "@a after recording a visual delete should re-execute and change the buffer") } // MARK: - Recursive Macro Safety - func testRecursiveMacroDoesNotInfiniteLoop() { + @Test + func recursiveMacroDoesNotInfiniteLoop() { // Set up a macro that includes a self-reference. buffer = VimTextBufferMock(text: "abcd\n") engine = VimEngine(buffer: buffer) @@ -158,6 +162,6 @@ final class VimEngineMacroTests: XCTestCase { keys("q") // The recording itself runs once. The trailing @a inside the macro should // not crash, even if not bounded — engines normally cap recursion depth. - XCTAssertNotNil(buffer.text) + #expect(buffer.text != nil) } } diff --git a/TableProTests/Core/Vim/VimEngineMarksAndRegistersTests.swift b/TableProTests/Core/Vim/VimEngineMarksAndRegistersTests.swift index abdd952520..23e7f2c96a 100644 --- a/TableProTests/Core/Vim/VimEngineMarksAndRegistersTests.swift +++ b/TableProTests/Core/Vim/VimEngineMarksAndRegistersTests.swift @@ -5,27 +5,21 @@ // Specification tests for marks (m / ' / `) and named/numbered registers ("{a-z}, "0-9). // -import XCTest -import TableProPluginKit +import Foundation @testable import TablePro +import TableProPluginKit +import Testing @MainActor -final class VimEngineMarksAndRegistersTests: XCTestCase { +final class VimEngineMarksAndRegistersTests { private var engine: VimEngine! private var buffer: VimTextBufferMock! - override func setUp() { - super.setUp() + init() { buffer = VimTextBufferMock(text: "hello world\nsecond line\nthird line\n") engine = VimEngine(buffer: buffer) } - override func tearDown() { - engine = nil - buffer = nil - super.tearDown() - } - private func keys(_ chars: String) { for char in chars { _ = engine.process(char, shift: false) } } @@ -38,16 +32,18 @@ final class VimEngineMarksAndRegistersTests: XCTestCase { // MARK: - Marks: Set and Jump - func testSetMarkAndJumpBackToExactPosition() { + @Test + func setMarkAndJumpBackToExactPosition() { // mma sets mark 'a' at cursor. Move away. `a jumps back to exact location. buffer.setSelectedRange(NSRange(location: 7, length: 0)) keys("ma") buffer.setSelectedRange(NSRange(location: 20, length: 0)) keys("`a") - XCTAssertEqual(pos, 7, "`a should restore the exact cursor offset where mark 'a' was set") + #expect(pos == 7, "`a should restore the exact cursor offset where mark 'a' was set") } - func testJumpToMarkLineWithSingleQuote() { + @Test + func jumpToMarkLineWithSingleQuote() { // 'a jumps to the FIRST non-blank of the marked line (not exact offset). buffer = VimTextBufferMock(text: " one\n two\n three\n") engine = VimEngine(buffer: buffer) @@ -55,10 +51,11 @@ final class VimEngineMarksAndRegistersTests: XCTestCase { keys("ma") buffer.setSelectedRange(NSRange(location: 15, length: 0)) keys("'a") - XCTAssertEqual(pos, 2, "'a should jump to first non-blank of the marked line (offset 2)") + #expect(pos == 2, "'a should jump to first non-blank of the marked line (offset 2)") } - func testMultipleMarks() { + @Test + func multipleMarks() { buffer.setSelectedRange(NSRange(location: 5, length: 0)) keys("ma") buffer.setSelectedRange(NSRange(location: 15, length: 0)) @@ -67,64 +64,70 @@ final class VimEngineMarksAndRegistersTests: XCTestCase { keys("mc") keys("`a") - XCTAssertEqual(pos, 5) + #expect(pos == 5) keys("`b") - XCTAssertEqual(pos, 15) + #expect(pos == 15) keys("`c") - XCTAssertEqual(pos, 25) + #expect(pos == 25) } - func testOverwritingMark() { + @Test + func overwritingMark() { buffer.setSelectedRange(NSRange(location: 5, length: 0)) keys("ma") buffer.setSelectedRange(NSRange(location: 20, length: 0)) keys("ma") buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("`a") - XCTAssertEqual(pos, 20, "Overwriting a mark should update its position") + #expect(pos == 20, "Overwriting a mark should update its position") } - func testJumpToUnsetMarkIsNoOp() { + @Test + func jumpToUnsetMarkIsNoOp() { buffer.setSelectedRange(NSRange(location: 5, length: 0)) keys("`z") - XCTAssertEqual(pos, 5, "Jumping to an unset mark should not move the cursor") + #expect(pos == 5, "Jumping to an unset mark should not move the cursor") } - func testMarksSurviveEdits() { + @Test + func marksSurviveEdits() { // Set mark, edit elsewhere, mark should still resolve. buffer.setSelectedRange(NSRange(location: 25, length: 0)) keys("ma") buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("x") // delete first char — mark should adjust offset accordingly keys("`a") - XCTAssertEqual(pos, 24, "Mark should adjust to compensate for earlier edits") + #expect(pos == 24, "Mark should adjust to compensate for earlier edits") } // MARK: - Special Mark: '' (Last Jump) - func testDoubleQuoteJumpsToPreviousPosition() { + @Test + func doubleQuoteJumpsToPreviousPosition() { buffer.setSelectedRange(NSRange(location: 5, length: 0)) // Jump to G (record previous position). _ = engine.process("G", shift: true) let landedAt = pos keys("``") - XCTAssertEqual(pos, 5, "`` should jump back to the position before the last jump") - XCTAssertNotEqual(landedAt, pos) + #expect(pos == 5, "`` should jump back to the position before the last jump") + #expect(landedAt != pos) } // MARK: - Named Registers: Yank - func testYankToNamedRegisterAndPaste() { + @Test + func yankToNamedRegisterAndPaste() { // "ayy yanks line into register 'a'. Move, then "ap pastes from 'a'. buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("\"ayy") keys("j") keys("\"ap") - XCTAssertEqual(buffer.text, "hello world\nsecond line\nhello world\nthird line\n", + #expect(buffer.text == "hello world\nsecond line\nhello world\nthird line\n", "Named register 'a' should preserve the yank across other yanks/deletes") } - func testNamedRegisterIndependentFromUnnamed() { + @Test + func namedRegisterIndependentFromUnnamed() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("\"ayy") // 'a' has line 0 keys("j") @@ -132,55 +135,60 @@ final class VimEngineMarksAndRegistersTests: XCTestCase { // "ap should still paste line 0; p should paste line 1. keys("k") keys("\"ap") - XCTAssertEqual(buffer.text, "hello world\nhello world\nsecond line\nthird line\n", + #expect(buffer.text == "hello world\nhello world\nsecond line\nthird line\n", "Named register 'a' should be unaffected by intervening unnamed yanks") } - func testDeleteToNamedRegister() { + @Test + func deleteToNamedRegister() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("\"add") // delete line 0 into 'a' - XCTAssertEqual(buffer.text, "second line\nthird line\n") + #expect(buffer.text == "second line\nthird line\n") keys("\"ap") - XCTAssertEqual(buffer.text, "second line\nhello world\nthird line\n", + #expect(buffer.text == "second line\nhello world\nthird line\n", "Deleted text should be retrievable from the named register") } // MARK: - Numbered Registers: Yank Cycle - func testYankPopulatesRegisterZero() { + @Test + func yankPopulatesRegisterZero() { // y populates register "0 (the yank register). buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("yy") // Delete a different line — unnamed register changes but "0 keeps the yank. keys("jdd") - XCTAssertEqual(buffer.text, "hello world\nthird line\n") + #expect(buffer.text == "hello world\nthird line\n") keys("\"0p") // paste from yank register, not from latest delete - XCTAssertEqual(buffer.text, "hello world\nthird line\nhello world\n", + #expect(buffer.text == "hello world\nthird line\nhello world\n", "\"0 should preserve the last YANKED text, not the last deleted text") } - func testDeletePopulatesRegisterOne() { + @Test + func deletePopulatesRegisterOne() { // d populates register "1; the next d pushes the previous "1 into "2. buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("dd") // line 0 → "1 keys("dd") // line 1 → "1, old "1 → "2 - XCTAssertEqual(buffer.text, "third line\n") + #expect(buffer.text == "third line\n") keys("\"2p") - XCTAssertEqual(buffer.text, "third line\nhello world\n", + #expect(buffer.text == "third line\nhello world\n", "\"2 should hold the previously deleted line after another deletion") } - func testRegistersDoNotApplyToMotions() { + @Test + func registersDoNotApplyToMotions() { // "a then a motion (no operator) should not crash and should consume the "a sequence. buffer.setSelectedRange(NSRange(location: 5, length: 0)) keys("\"a") keys("l") - XCTAssertEqual(pos, 6, "Motion after register selection should still execute as a motion") + #expect(pos == 6, "Motion after register selection should still execute as a motion") } // MARK: - Uppercase Named Register (Append) - func testUppercaseRegisterAppends() { + @Test + func uppercaseRegisterAppends() { // "Ayy appends to register 'a' (rather than overwriting). buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("\"ayy") // 'a' = "hello world\n" @@ -189,7 +197,7 @@ final class VimEngineMarksAndRegistersTests: XCTestCase { keys("yy") // append "second line\n" to 'a' // Now 'a' should contain both lines. keys("\"ap") - XCTAssertEqual(buffer.text.contains("hello world\nsecond line\nhello world\nsecond line\n"), true, + #expect(buffer.text.contains("hello world\nsecond line\nhello world\nsecond line\n") == true, "Uppercase register should append to lowercase counterpart") } } diff --git a/TableProTests/Core/Vim/VimEngineNormalMotionsTests.swift b/TableProTests/Core/Vim/VimEngineNormalMotionsTests.swift index 11a2851526..9a6f43a316 100644 --- a/TableProTests/Core/Vim/VimEngineNormalMotionsTests.swift +++ b/TableProTests/Core/Vim/VimEngineNormalMotionsTests.swift @@ -5,14 +5,15 @@ // Specification tests for cursor motions in Normal mode. // -import XCTest -import TableProPluginKit +import Foundation @testable import TablePro +import TableProPluginKit +import Testing // swiftlint:disable file_length type_body_length @MainActor -final class VimEngineNormalMotionsTests: XCTestCase { +final class VimEngineNormalMotionsTests { // Standard test buffer: // Line 0: "hello world\n" offsets 0..11 (newline at 11, content end at 10 = 'd') // Line 1: "second line\n" offsets 12..23 (newline at 23, content end at 22 = 'e') @@ -21,18 +22,11 @@ final class VimEngineNormalMotionsTests: XCTestCase { private var engine: VimEngine! private var buffer: VimTextBufferMock! - override func setUp() { - super.setUp() + init() { buffer = VimTextBufferMock(text: "hello world\nsecond line\nthird line\n") engine = VimEngine(buffer: buffer) } - override func tearDown() { - engine = nil - buffer = nil - super.tearDown() - } - private func keys(_ chars: String) { for char in chars { _ = engine.process(char, shift: false) } } @@ -45,115 +39,133 @@ final class VimEngineNormalMotionsTests: XCTestCase { // MARK: - h / l (Character Motions) - func testHMovesLeftByOne() { + @Test + func hMovesLeftByOne() { buffer.setSelectedRange(NSRange(location: 5, length: 0)) keys("h") - XCTAssertEqual(pos, 4) + #expect(pos == 4) } - func testHStopsAtLineStart() { + @Test + func hStopsAtLineStart() { buffer.setSelectedRange(NSRange(location: 12, length: 0)) keys("h") - XCTAssertEqual(pos, 12, "h must not cross line boundary backward") + #expect(pos == 12, "h must not cross line boundary backward") } - func testHStopsAtBufferStart() { + @Test + func hStopsAtBufferStart() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("h") - XCTAssertEqual(pos, 0) + #expect(pos == 0) } - func testHWithCount() { + @Test + func hWithCount() { buffer.setSelectedRange(NSRange(location: 8, length: 0)) keys("5h") - XCTAssertEqual(pos, 3) + #expect(pos == 3) } - func testHCountClampedToLineStart() { + @Test + func hCountClampedToLineStart() { buffer.setSelectedRange(NSRange(location: 14, length: 0)) keys("99h") - XCTAssertEqual(pos, 12, "h with large count must clamp to start of current line") + #expect(pos == 12, "h with large count must clamp to start of current line") } - func testLMovesRightByOne() { + @Test + func lMovesRightByOne() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("l") - XCTAssertEqual(pos, 1) + #expect(pos == 1) } - func testLStopsBeforeNewline() { + @Test + func lStopsBeforeNewline() { buffer.setSelectedRange(NSRange(location: 10, length: 0)) keys("l") - XCTAssertEqual(pos, 10, "l must not move onto or past the line-terminating newline") + #expect(pos == 10, "l must not move onto or past the line-terminating newline") } - func testLWithCount() { + @Test + func lWithCount() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("4l") - XCTAssertEqual(pos, 4) + #expect(pos == 4) } - func testLCountClampedToLineEnd() { + @Test + func lCountClampedToLineEnd() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("99l") - XCTAssertEqual(pos, 10, "l with large count clamps to last content char of line") + #expect(pos == 10, "l with large count clamps to last content char of line") } // MARK: - j / k (Line Motions) and Goal Column - func testJMovesDownOneLine() { + @Test + func jMovesDownOneLine() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("j") - XCTAssertEqual(pos, 12) + #expect(pos == 12) } - func testJPreservesColumn() { + @Test + func jPreservesColumn() { buffer.setSelectedRange(NSRange(location: 5, length: 0)) // line 0 col 5 keys("j") - XCTAssertEqual(pos, 17, "j should preserve current column when moving down") + #expect(pos == 17, "j should preserve current column when moving down") } - func testJStaysOnLastLine() { + @Test + func jStaysOnLastLine() { buffer.setSelectedRange(NSRange(location: 28, length: 0)) keys("j") let (line, _) = buffer.lineAndColumn(forOffset: pos) - XCTAssertEqual(line, 2, "j on last line stays on last line") + #expect(line == 2, "j on last line stays on last line") } - func testJWithCount() { + @Test + func jWithCount() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("2j") let (line, _) = buffer.lineAndColumn(forOffset: pos) - XCTAssertEqual(line, 2) + #expect(line == 2) } - func testKMovesUpOneLine() { + @Test + func kMovesUpOneLine() { buffer.setSelectedRange(NSRange(location: 12, length: 0)) keys("k") - XCTAssertEqual(pos, 0) + #expect(pos == 0) } - func testKPreservesColumn() { + @Test + func kPreservesColumn() { buffer.setSelectedRange(NSRange(location: 18, length: 0)) // line 1 col 6 keys("k") - XCTAssertEqual(pos, 6, "k should preserve current column when moving up") + #expect(pos == 6, "k should preserve current column when moving up") } - func testKStaysOnFirstLine() { + @Test + func kStaysOnFirstLine() { buffer.setSelectedRange(NSRange(location: 5, length: 0)) keys("k") let (line, _) = buffer.lineAndColumn(forOffset: pos) - XCTAssertEqual(line, 0) + #expect(line == 0) } - func testKWithCount() { + @Test + func kWithCount() { buffer.setSelectedRange(NSRange(location: 28, length: 0)) keys("2k") let (line, _) = buffer.lineAndColumn(forOffset: pos) - XCTAssertEqual(line, 0) + #expect(line == 0) } - func testJGoalColumnSurvivesShortLines() { + @Test + func jGoalColumnSurvivesShortLines() { // Move to col 10 on line 0, j onto a short line, then j onto a long line again. // Vim's "goal column" semantics: column 10 is remembered even when // intermediate lines are shorter than 10. @@ -163,382 +175,429 @@ final class VimEngineNormalMotionsTests: XCTestCase { keys("j") // line 1 is "short" — clamped to col 4 keys("j") // line 2 — should snap back to col 10 let (line, col) = buffer.lineAndColumn(forOffset: pos) - XCTAssertEqual(line, 2) - XCTAssertEqual(col, 10, "Goal column should snap back to 10 on a long-enough line") + #expect(line == 2) + #expect(col == 10, "Goal column should snap back to 10 on a long-enough line") } - func testHResetsGoalColumn() { + @Test + func hResetsGoalColumn() { // After h, the goal column should be cleared. buffer.setSelectedRange(NSRange(location: 10, length: 0)) keys("jh") // Now at line 1, col reduced by 1. Subsequent j should not snap back to col 10. keys("j") let (line, col) = buffer.lineAndColumn(forOffset: pos) - XCTAssertEqual(line, 2) - XCTAssertLessThan(col, 10, "h should reset goal column so subsequent j uses new column") + #expect(line == 2) + #expect(col < 10, "h should reset goal column so subsequent j uses new column") } // MARK: - Word Motions: w / W - func testWMovesToNextWordStart() { + @Test + func wMovesToNextWordStart() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("w") - XCTAssertEqual(pos, 6, "w should move from 'h' in 'hello' to 'w' in 'world'") + #expect(pos == 6, "w should move from 'h' in 'hello' to 'w' in 'world'") } - func testWAcrossLineBoundary() { + @Test + func wAcrossLineBoundary() { buffer.setSelectedRange(NSRange(location: 6, length: 0)) // 'w' in 'world' keys("w") - XCTAssertEqual(pos, 12, "w should cross newline to next word") + #expect(pos == 12, "w should cross newline to next word") } - func testWStopsAtPunctuation() { + @Test + func wStopsAtPunctuation() { buffer = VimTextBufferMock(text: "hello,world\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("w") - XCTAssertEqual(pos, 5, "w should stop at the punctuation as a new word") + #expect(pos == 5, "w should stop at the punctuation as a new word") } - func testWWithCount() { + @Test + func wWithCount() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("3w") - XCTAssertEqual(pos, 19, "3w advances three word-starts: hello → world → second → line (offset 19)") + #expect(pos == 19, "3w advances three word-starts: hello → world → second → line (offset 19)") } - func testWAtLastWordOfBufferStaysOnLastChar() { + @Test + func wAtLastWordOfBufferStaysOnLastChar() { // "SELECT * FROM users;" — no next word after ';'. Pressing w from ';' must // not advance past the last char; the cursor stays on ';'. buffer = VimTextBufferMock(text: "SELECT * FROM users;") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 19, length: 0)) keys("w") - XCTAssertEqual(pos, 19, "w from the last word of the buffer must stay on the last content char") + #expect(pos == 19, "w from the last word of the buffer must stay on the last content char") } - func testWAtLastWordSingleLineWithoutNewline() { + @Test + func wAtLastWordSingleLineWithoutNewline() { // Single-word buffer "hello" with no newline. w from 'h' should land on the // last content char 'o' (vim's last-word-on-last-line clamp). buffer = VimTextBufferMock(text: "hello") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("w") - XCTAssertEqual(pos, 4, "w with no next word should land on the last content char, not past it") + #expect(pos == 4, "w with no next word should land on the last content char, not past it") } - func testWClampsToLastCharOnLineWithTrailingNewline() { + @Test + func wClampsToLastCharOnLineWithTrailingNewline() { // "hello\n" — single word followed by newline, no further content. buffer = VimTextBufferMock(text: "hello\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("w") - XCTAssertEqual(pos, 4, "w on a single-word buffer with trailing newline lands on 'o'") + #expect(pos == 4, "w on a single-word buffer with trailing newline lands on 'o'") } - func testCapitalWTreatsPunctuationAsWordChar() { + @Test + func capitalWTreatsPunctuationAsWordChar() { // W (WORD) moves by whitespace-delimited tokens — punctuation is not a boundary. buffer = VimTextBufferMock(text: "hello,world foo\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 0, length: 0)) key("W", shift: true) - XCTAssertEqual(pos, 12, "W should skip past punctuation and land at next WORD") + #expect(pos == 12, "W should skip past punctuation and land at next WORD") } - func testCapitalWWithCount() { + @Test + func capitalWWithCount() { buffer = VimTextBufferMock(text: "a.b c.d e.f g.h\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("2") key("W", shift: true) - XCTAssertEqual(pos, 8, "2W should advance past two WORDs") + #expect(pos == 8, "2W should advance past two WORDs") } // MARK: - Word Motions: b / B - func testBMovesToPreviousWordStart() { + @Test + func bMovesToPreviousWordStart() { buffer.setSelectedRange(NSRange(location: 6, length: 0)) keys("b") - XCTAssertEqual(pos, 0) + #expect(pos == 0) } - func testBFromMidWordGoesToWordStart() { + @Test + func bFromMidWordGoesToWordStart() { buffer.setSelectedRange(NSRange(location: 3, length: 0)) // 'l' in 'hello' keys("b") - XCTAssertEqual(pos, 0, "b from mid-word should land at the start of the same word") + #expect(pos == 0, "b from mid-word should land at the start of the same word") } - func testBAtStartOfBufferStaysPut() { + @Test + func bAtStartOfBufferStaysPut() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("b") - XCTAssertEqual(pos, 0) + #expect(pos == 0) } - func testBAcrossLineBoundary() { + @Test + func bAcrossLineBoundary() { buffer.setSelectedRange(NSRange(location: 12, length: 0)) // start of 'second' keys("b") - XCTAssertEqual(pos, 6, "b from line start should cross newline to previous word") + #expect(pos == 6, "b from line start should cross newline to previous word") } - func testBWithCount() { + @Test + func bWithCount() { buffer.setSelectedRange(NSRange(location: 17, length: 0)) keys("3b") - XCTAssertEqual(pos, 0, "3b should skip back three word starts") + #expect(pos == 0, "3b should skip back three word starts") } - func testCapitalBTreatsPunctuationAsWordChar() { + @Test + func capitalBTreatsPunctuationAsWordChar() { buffer = VimTextBufferMock(text: "hello,world\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 8, length: 0)) key("B", shift: true) - XCTAssertEqual(pos, 0, "B should treat 'hello,world' as one WORD") + #expect(pos == 0, "B should treat 'hello,world' as one WORD") } // MARK: - Word End: e / E / ge / gE - func testEMovesToEndOfCurrentWord() { + @Test + func eMovesToEndOfCurrentWord() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("e") - XCTAssertEqual(pos, 4, "e from 'h' should land on 'o' (end of 'hello')") + #expect(pos == 4, "e from 'h' should land on 'o' (end of 'hello')") } - func testEAtWordEndJumpsToNextWordEnd() { + @Test + func eAtWordEndJumpsToNextWordEnd() { buffer.setSelectedRange(NSRange(location: 4, length: 0)) // 'o' at end of 'hello' keys("e") - XCTAssertEqual(pos, 10, "e from end of word should jump to end of next word") + #expect(pos == 10, "e from end of word should jump to end of next word") } - func testEWithCount() { + @Test + func eWithCount() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("3e") - XCTAssertEqual(pos, 17, "3e from 'hello' should land at end of 'second' (offset 17)") + #expect(pos == 17, "3e from 'hello' should land at end of 'second' (offset 17)") } - func testCapitalEIgnoresPunctuation() { + @Test + func capitalEIgnoresPunctuation() { buffer = VimTextBufferMock(text: "a.b.c word\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 0, length: 0)) key("E", shift: true) - XCTAssertEqual(pos, 4, "E should land at end of WORD 'a.b.c'") + #expect(pos == 4, "E should land at end of WORD 'a.b.c'") } - func testGEMovesToPreviousWordEnd() { + @Test + func gEMovesToPreviousWordEnd() { // ge is the backward analog of e. buffer.setSelectedRange(NSRange(location: 12, length: 0)) // start of 'second' keys("ge") - XCTAssertEqual(pos, 10, "ge should land at the end of the previous word ('d' in 'world')") + #expect(pos == 10, "ge should land at the end of the previous word ('d' in 'world')") } - func testGEWithCount() { + @Test + func gEWithCount() { buffer.setSelectedRange(NSRange(location: 17, length: 0)) keys("2ge") - XCTAssertEqual(pos, 4, "2ge from mid-word should skip two word-ends backward") + #expect(pos == 4, "2ge from mid-word should skip two word-ends backward") } - func testCapitalGEIgnoresPunctuation() { + @Test + func capitalGEIgnoresPunctuation() { buffer = VimTextBufferMock(text: "a.b.c word\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 6, length: 0)) // 'w' in 'word' keys("g") key("E", shift: true) - XCTAssertEqual(pos, 4, "gE should land at end of WORD 'a.b.c'") + #expect(pos == 4, "gE should land at end of WORD 'a.b.c'") } // MARK: - Line Motions: 0 / $ / ^ / _ - func testZeroGoesToLineStart() { + @Test + func zeroGoesToLineStart() { buffer.setSelectedRange(NSRange(location: 8, length: 0)) keys("0") - XCTAssertEqual(pos, 0) + #expect(pos == 0) } - func testZeroIgnoresLeadingWhitespace() { + @Test + func zeroIgnoresLeadingWhitespace() { buffer = VimTextBufferMock(text: " hello\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 5, length: 0)) keys("0") - XCTAssertEqual(pos, 0, "0 should land at column 0 even when leading whitespace exists") + #expect(pos == 0, "0 should land at column 0 even when leading whitespace exists") } - func testDollarGoesToLineEnd() { + @Test + func dollarGoesToLineEnd() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("$") - XCTAssertEqual(pos, 10, "$ on 'hello world\\n' should land on 'd' (offset 10)") + #expect(pos == 10, "$ on 'hello world\\n' should land on 'd' (offset 10)") } - func testDollarOnLineWithoutTrailingNewline() { + @Test + func dollarOnLineWithoutTrailingNewline() { buffer = VimTextBufferMock(text: "hello") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("$") - XCTAssertEqual(pos, 4, "$ on the last line without a newline should land on the last char") + #expect(pos == 4, "$ on the last line without a newline should land on the last char") } - func testCaretGoesToFirstNonBlank() { + @Test + func caretGoesToFirstNonBlank() { buffer = VimTextBufferMock(text: " hello world\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 10, length: 0)) keys("^") - XCTAssertEqual(pos, 3, "^ should skip leading whitespace and land on 'h'") + #expect(pos == 3, "^ should skip leading whitespace and land on 'h'") } - func testCaretOnBlankLineGoesToLineStart() { + @Test + func caretOnBlankLineGoesToLineStart() { buffer = VimTextBufferMock(text: "hello\n\nworld\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 6, length: 0)) keys("^") - XCTAssertEqual(pos, 6, "^ on blank line should stay at line start") + #expect(pos == 6, "^ on blank line should stay at line start") } - func testUnderscoreGoesToFirstNonBlank() { + @Test + func underscoreGoesToFirstNonBlank() { buffer = VimTextBufferMock(text: "\t\thello\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 5, length: 0)) keys("_") - XCTAssertEqual(pos, 2, "_ should skip tabs and land on 'h'") + #expect(pos == 2, "_ should skip tabs and land on 'h'") } // MARK: - Document Motions: gg / G - func testGGGoesToDocumentStart() { + @Test + func gGGoesToDocumentStart() { buffer.setSelectedRange(NSRange(location: 25, length: 0)) keys("gg") - XCTAssertEqual(pos, 0) + #expect(pos == 0) } - func testGGGoesToFirstNonBlankOnFirstLine() { + @Test + func gGGoesToFirstNonBlankOnFirstLine() { buffer = VimTextBufferMock(text: " hello\nworld\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 12, length: 0)) keys("gg") - XCTAssertEqual(pos, 3, "gg should land on the first non-blank of line 1") + #expect(pos == 3, "gg should land on the first non-blank of line 1") } - func testCapitalGGoesToLastLineFirstNonBlank() { + @Test + func capitalGGoesToLastLineFirstNonBlank() { buffer = VimTextBufferMock(text: "one\ntwo\n three\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 0, length: 0)) key("G", shift: true) - XCTAssertEqual(pos, 11, "G should land on first non-blank of last line ('t' at offset 11)") + #expect(pos == 11, "G should land on first non-blank of last line ('t' at offset 11)") } - func testCountGGoesToSpecificLine() { + @Test + func countGGoesToSpecificLine() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("2") key("G", shift: true) let (line, _) = buffer.lineAndColumn(forOffset: pos) - XCTAssertEqual(line, 1, "2G should land on line 2 (0-indexed line 1)") + #expect(line == 1, "2G should land on line 2 (0-indexed line 1)") } - func testCountGGGoesToSpecificLine() { + @Test + func countGGGoesToSpecificLine() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("2gg") let (line, _) = buffer.lineAndColumn(forOffset: pos) - XCTAssertEqual(line, 1) + #expect(line == 1) } - func testCountGClampedAtLastLine() { + @Test + func countGClampedAtLastLine() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("999") key("G", shift: true) let (line, _) = buffer.lineAndColumn(forOffset: pos) - XCTAssertEqual(line, 2, "Count beyond last line should clamp to last line") + #expect(line == 2, "Count beyond last line should clamp to last line") } // MARK: - Screen Motions: H / M / L - func testHHomeMovesToTopOfScreen() { + @Test + func hHomeMovesToTopOfScreen() { // In the engine (no scroll concept), H should move to the first line of the buffer. buffer.setSelectedRange(NSRange(location: 28, length: 0)) key("H", shift: true) let (line, _) = buffer.lineAndColumn(forOffset: pos) - XCTAssertEqual(line, 0, "H should move to the top of the visible/buffer range") + #expect(line == 0, "H should move to the top of the visible/buffer range") } - func testLLastMovesToBottomOfScreen() { + @Test + func lLastMovesToBottomOfScreen() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) key("L", shift: true) let (line, _) = buffer.lineAndColumn(forOffset: pos) - XCTAssertEqual(line, 2, "L should move to the bottom of the visible/buffer range") + #expect(line == 2, "L should move to the bottom of the visible/buffer range") } - func testMMiddleMovesToMiddleOfScreen() { + @Test + func mMiddleMovesToMiddleOfScreen() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) key("M", shift: true) let (line, _) = buffer.lineAndColumn(forOffset: pos) - XCTAssertEqual(line, 1, "M should move to the middle of the visible/buffer range") + #expect(line == 1, "M should move to the middle of the visible/buffer range") } // MARK: - Matching: % - func testPercentJumpsToMatchingParen() { + @Test + func percentJumpsToMatchingParen() { buffer = VimTextBufferMock(text: "(hello)\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("%") - XCTAssertEqual(pos, 6, "% on '(' should jump to matching ')'") + #expect(pos == 6, "% on '(' should jump to matching ')'") } - func testPercentJumpsBackToOpenParen() { + @Test + func percentJumpsBackToOpenParen() { buffer = VimTextBufferMock(text: "(hello)\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 6, length: 0)) keys("%") - XCTAssertEqual(pos, 0, "% on ')' should jump back to '('") + #expect(pos == 0, "% on ')' should jump back to '('") } - func testPercentMatchesNestedBrackets() { + @Test + func percentMatchesNestedBrackets() { buffer = VimTextBufferMock(text: "(a(b)c)\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("%") - XCTAssertEqual(pos, 6, "% on outer '(' should jump to matching outer ')'") + #expect(pos == 6, "% on outer '(' should jump to matching outer ')'") } - func testPercentMatchesCurlyBraces() { + @Test + func percentMatchesCurlyBraces() { buffer = VimTextBufferMock(text: "{a}\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("%") - XCTAssertEqual(pos, 2) + #expect(pos == 2) } - func testPercentMatchesSquareBrackets() { + @Test + func percentMatchesSquareBrackets() { buffer = VimTextBufferMock(text: "[a]\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("%") - XCTAssertEqual(pos, 2) + #expect(pos == 2) } // MARK: - Pending g Cancellation - func testPendingGCancelledByUnknownKey() { + @Test + func pendingGCancelledByUnknownKey() { buffer.setSelectedRange(NSRange(location: 5, length: 0)) keys("g") keys("z") // unknown — should consume and clear pending g // Subsequent l should move 1 (not be interpreted as part of a g sequence) keys("l") - XCTAssertEqual(pos, 6) + #expect(pos == 6) } - func testPendingGCancelledByEscape() { + @Test + func pendingGCancelledByEscape() { buffer.setSelectedRange(NSRange(location: 5, length: 0)) keys("g") _ = engine.process("\u{1B}", shift: false) keys("l") - XCTAssertEqual(pos, 6) + #expect(pos == 6) } // MARK: - Empty Buffer Safety - func testMotionsOnEmptyBufferDoNotCrash() { + @Test + func motionsOnEmptyBufferDoNotCrash() { buffer = VimTextBufferMock(text: "") engine = VimEngine(buffer: buffer) keys("hjklwbe0$^_") keys("gg") key("G", shift: true) - XCTAssertEqual(pos, 0) - XCTAssertEqual(buffer.text, "") + #expect(pos == 0) + #expect(buffer.text == "") } } diff --git a/TableProTests/Core/Vim/VimEngineNumberAdjustTests.swift b/TableProTests/Core/Vim/VimEngineNumberAdjustTests.swift index 86a6b5e9a0..1fedebfe95 100644 --- a/TableProTests/Core/Vim/VimEngineNumberAdjustTests.swift +++ b/TableProTests/Core/Vim/VimEngineNumberAdjustTests.swift @@ -6,23 +6,17 @@ // the cursor on the current line. // -import XCTest -import TableProPluginKit +import Foundation @testable import TablePro +import TableProPluginKit +import Testing @MainActor -final class VimEngineNumberAdjustTests: XCTestCase { +final class VimEngineNumberAdjustTests { private var engine: VimEngine! private var buffer: VimTextBufferMock! - override func setUp() { - super.setUp() - } - - override func tearDown() { - engine = nil - buffer = nil - super.tearDown() + init() { } private func make(_ text: String, at offset: Int) { @@ -33,101 +27,114 @@ final class VimEngineNumberAdjustTests: XCTestCase { // MARK: - Ctrl+A: Increment - func testCtrlAIncrementsNumberUnderCursor() { + @Test + func ctrlAIncrementsNumberUnderCursor() { make("x = 42\n", at: 4) _ = engine.process("\u{01}", shift: false) // Ctrl+A - XCTAssertEqual(buffer.text, "x = 43\n", "Ctrl+A should increment the number under the cursor") + #expect(buffer.text == "x = 43\n", "Ctrl+A should increment the number under the cursor") } - func testCtrlAFindsNumberAfterCursor() { + @Test + func ctrlAFindsNumberAfterCursor() { make("x = 42\n", at: 0) _ = engine.process("\u{01}", shift: false) - XCTAssertEqual(buffer.text, "x = 43\n", + #expect(buffer.text == "x = 43\n", "Ctrl+A should find the next number to the right of the cursor on the current line") } - func testCtrlAWithCount() { + @Test + func ctrlAWithCount() { make("x = 42\n", at: 4) _ = engine.process("5", shift: false) _ = engine.process("\u{01}", shift: false) - XCTAssertEqual(buffer.text, "x = 47\n", "5 should increment by 5") + #expect(buffer.text == "x = 47\n", "5 should increment by 5") } - func testCtrlANegativeNumber() { + @Test + func ctrlANegativeNumber() { make("x = -5\n", at: 4) _ = engine.process("\u{01}", shift: false) - XCTAssertEqual(buffer.text, "x = -4\n", + #expect(buffer.text == "x = -4\n", "Ctrl+A on a negative number should increment toward zero") } - func testCtrlAZeroToOne() { + @Test + func ctrlAZeroToOne() { make("x = 0\n", at: 4) _ = engine.process("\u{01}", shift: false) - XCTAssertEqual(buffer.text, "x = 1\n") + #expect(buffer.text == "x = 1\n") } - func testCtrlAOnLineWithoutNumberIsNoOp() { + @Test + func ctrlAOnLineWithoutNumberIsNoOp() { make("foo bar baz\n", at: 0) _ = engine.process("\u{01}", shift: false) - XCTAssertEqual(buffer.text, "foo bar baz\n", + #expect(buffer.text == "foo bar baz\n", "Ctrl+A on a line without any number should not modify the buffer") } // MARK: - Ctrl+X: Decrement - func testCtrlXDecrementsNumberUnderCursor() { + @Test + func ctrlXDecrementsNumberUnderCursor() { make("x = 42\n", at: 4) _ = engine.process("\u{18}", shift: false) // Ctrl+X - XCTAssertEqual(buffer.text, "x = 41\n", "Ctrl+X should decrement the number under the cursor") + #expect(buffer.text == "x = 41\n", "Ctrl+X should decrement the number under the cursor") } - func testCtrlXWithCount() { + @Test + func ctrlXWithCount() { make("x = 42\n", at: 4) _ = engine.process("1", shift: false) _ = engine.process("0", shift: false) _ = engine.process("\u{18}", shift: false) - XCTAssertEqual(buffer.text, "x = 32\n", "10 should decrement by 10") + #expect(buffer.text == "x = 32\n", "10 should decrement by 10") } - func testCtrlXOnZeroProducesNegative() { + @Test + func ctrlXOnZeroProducesNegative() { make("x = 0\n", at: 4) _ = engine.process("\u{18}", shift: false) - XCTAssertEqual(buffer.text, "x = -1\n") + #expect(buffer.text == "x = -1\n") } // MARK: - Hex Numbers - func testCtrlAOnHex() { + @Test + func ctrlAOnHex() { make("x = 0x1F\n", at: 4) _ = engine.process("\u{01}", shift: false) - XCTAssertEqual(buffer.text, "x = 0x20\n", + #expect(buffer.text == "x = 0x20\n", "Ctrl+A on a hex number should preserve the format and increment") } - func testCtrlXOnHex() { + @Test + func ctrlXOnHex() { make("x = 0x10\n", at: 4) _ = engine.process("\u{18}", shift: false) - XCTAssertEqual(buffer.text, "x = 0xf\n", + #expect(buffer.text == "x = 0xf\n", "Ctrl+X on hex should preserve the format and decrement") } // MARK: - Cursor Position After Adjust - func testCursorLandsOnLastDigitAfterIncrement() { + @Test + func cursorLandsOnLastDigitAfterIncrement() { make("x = 9\n", at: 4) _ = engine.process("\u{01}", shift: false) // After 9 → 10, cursor should land on '0' (the new last digit). - XCTAssertEqual(buffer.text, "x = 10\n") - XCTAssertEqual(buffer.selectedRange().location, 5, + #expect(buffer.text == "x = 10\n") + #expect(buffer.selectedRange().location == 5, "Cursor should land on the last digit of the new number after increment") } // MARK: - Multi-Digit Numbers - func testCtrlAOnLargeNumber() { + @Test + func ctrlAOnLargeNumber() { make("count = 999\n", at: 8) _ = engine.process("\u{01}", shift: false) - XCTAssertEqual(buffer.text, "count = 1000\n", + #expect(buffer.text == "count = 1000\n", "Ctrl+A across a digit-rollover should add a digit") } } diff --git a/TableProTests/Core/Vim/VimEngineOperatorsTests.swift b/TableProTests/Core/Vim/VimEngineOperatorsTests.swift index 2a2df88398..b957123ee9 100644 --- a/TableProTests/Core/Vim/VimEngineOperatorsTests.swift +++ b/TableProTests/Core/Vim/VimEngineOperatorsTests.swift @@ -6,29 +6,23 @@ // including doublings (dd/cc/yy), shortcuts (D/C/Y/x/X), and operator+motion combos. // -import XCTest -import TableProPluginKit +import Foundation @testable import TablePro +import TableProPluginKit +import Testing // swiftlint:disable file_length type_body_length @MainActor -final class VimEngineOperatorsTests: XCTestCase { +final class VimEngineOperatorsTests { private var engine: VimEngine! private var buffer: VimTextBufferMock! - override func setUp() { - super.setUp() + init() { buffer = VimTextBufferMock(text: "hello world\nsecond line\nthird line\n") engine = VimEngine(buffer: buffer) } - override func tearDown() { - engine = nil - buffer = nil - super.tearDown() - } - private func keys(_ chars: String) { for char in chars { _ = engine.process(char, shift: false) } } @@ -43,367 +37,414 @@ final class VimEngineOperatorsTests: XCTestCase { // MARK: - x: Delete Character Under Cursor - func testXDeletesCharUnderCursor() { + @Test + func xDeletesCharUnderCursor() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("x") - XCTAssertEqual(buffer.text, "ello world\nsecond line\nthird line\n") + #expect(buffer.text == "ello world\nsecond line\nthird line\n") } - func testXWithCount() { + @Test + func xWithCount() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("3x") - XCTAssertEqual(buffer.text, "lo world\nsecond line\nthird line\n") + #expect(buffer.text == "lo world\nsecond line\nthird line\n") } - func testXDoesNotCrossNewline() { + @Test + func xDoesNotCrossNewline() { buffer.setSelectedRange(NSRange(location: 10, length: 0)) keys("5x") - XCTAssertEqual(buffer.text, "hello worl\nsecond line\nthird line\n", + #expect(buffer.text == "hello worl\nsecond line\nthird line\n", "x with count should clamp at the line-terminating newline") } - func testXOnEmptyLineIsNoOp() { + @Test + func xOnEmptyLineIsNoOp() { buffer = VimTextBufferMock(text: "a\n\nb\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 2, length: 0)) keys("x") - XCTAssertEqual(buffer.text, "a\n\nb\n", "x on empty line should leave buffer unchanged") + #expect(buffer.text == "a\n\nb\n", "x on empty line should leave buffer unchanged") } - func testXOnLastCharOfLineMovesCursorBack() { + @Test + func xOnLastCharOfLineMovesCursorBack() { // After deleting last content char, cursor should sit on the new last content char. buffer.setSelectedRange(NSRange(location: 10, length: 0)) keys("x") - XCTAssertEqual(pos, 9, "After x deletes last content char, cursor moves left") + #expect(pos == 9, "After x deletes last content char, cursor moves left") } // MARK: - X: Delete Character Before Cursor - func testCapitalXDeletesCharBeforeCursor() { + @Test + func capitalXDeletesCharBeforeCursor() { buffer.setSelectedRange(NSRange(location: 5, length: 0)) key("X", shift: true) - XCTAssertEqual(buffer.text, "hell world\nsecond line\nthird line\n", + #expect(buffer.text == "hell world\nsecond line\nthird line\n", "X should delete the char to the left of the cursor") } - func testCapitalXWithCount() { + @Test + func capitalXWithCount() { buffer.setSelectedRange(NSRange(location: 5, length: 0)) keys("3") key("X", shift: true) - XCTAssertEqual(buffer.text, "he world\nsecond line\nthird line\n") + #expect(buffer.text == "he world\nsecond line\nthird line\n") } - func testCapitalXAtLineStartIsNoOp() { + @Test + func capitalXAtLineStartIsNoOp() { buffer.setSelectedRange(NSRange(location: 12, length: 0)) key("X", shift: true) - XCTAssertEqual(buffer.text, "hello world\nsecond line\nthird line\n", + #expect(buffer.text == "hello world\nsecond line\nthird line\n", "X must not cross line boundary backward") } - func testCapitalXAtBufferStartIsNoOp() { + @Test + func capitalXAtBufferStartIsNoOp() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) key("X", shift: true) - XCTAssertEqual(buffer.text, "hello world\nsecond line\nthird line\n") + #expect(buffer.text == "hello world\nsecond line\nthird line\n") } // MARK: - dd: Delete Line - func testDDDeletesCurrentLine() { + @Test + func dDDeletesCurrentLine() { buffer.setSelectedRange(NSRange(location: 5, length: 0)) keys("dd") - XCTAssertEqual(buffer.text, "second line\nthird line\n") + #expect(buffer.text == "second line\nthird line\n") } - func testDDWithCount() { + @Test + func dDWithCount() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("2dd") - XCTAssertEqual(buffer.text, "third line\n") + #expect(buffer.text == "third line\n") } - func testDDOnLastLineLeavesPreviousLineCursorOnIt() { + @Test + func dDOnLastLineLeavesPreviousLineCursorOnIt() { // After deleting last line, cursor should sit on the new last line. buffer.setSelectedRange(NSRange(location: 28, length: 0)) keys("dd") - XCTAssertEqual(buffer.text, "hello world\nsecond line\n") + #expect(buffer.text == "hello world\nsecond line\n") } - func testDDOnSoleLineEmptiesBuffer() { + @Test + func dDOnSoleLineEmptiesBuffer() { buffer = VimTextBufferMock(text: "only\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("dd") - XCTAssertEqual(buffer.text, "") + #expect(buffer.text == "") } - func testDDCountClampsAtBufferEnd() { + @Test + func dDCountClampsAtBufferEnd() { buffer.setSelectedRange(NSRange(location: 12, length: 0)) keys("99dd") - XCTAssertEqual(buffer.text, "hello world\n", "dd with large count clamps to remaining lines") + #expect(buffer.text == "hello world\n", "dd with large count clamps to remaining lines") } // MARK: - d + Motion - func testDWDeletesToNextWord() { + @Test + func dWDeletesToNextWord() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("dw") - XCTAssertEqual(buffer.text, "world\nsecond line\nthird line\n") + #expect(buffer.text == "world\nsecond line\nthird line\n") } - func testDEDeletesToWordEndInclusive() { + @Test + func dEDeletesToWordEndInclusive() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("de") - XCTAssertEqual(buffer.text, " world\nsecond line\nthird line\n", + #expect(buffer.text == " world\nsecond line\nthird line\n", "de should delete inclusive of the word-end character") } - func testDBDeletesBackwardWord() { + @Test + func dBDeletesBackwardWord() { buffer.setSelectedRange(NSRange(location: 6, length: 0)) keys("db") - XCTAssertEqual(buffer.text, "world\nsecond line\nthird line\n") + #expect(buffer.text == "world\nsecond line\nthird line\n") } - func testDDollarDeletesToLineEnd() { + @Test + func dDollarDeletesToLineEnd() { buffer.setSelectedRange(NSRange(location: 5, length: 0)) keys("d$") - XCTAssertEqual(buffer.text, "hello\nsecond line\nthird line\n") + #expect(buffer.text == "hello\nsecond line\nthird line\n") } - func testDZeroDeletesToLineStart() { + @Test + func dZeroDeletesToLineStart() { buffer.setSelectedRange(NSRange(location: 5, length: 0)) keys("d0") - XCTAssertEqual(buffer.text, " world\nsecond line\nthird line\n") + #expect(buffer.text == " world\nsecond line\nthird line\n") } - func testDCaretDeletesToFirstNonBlank() { + @Test + func dCaretDeletesToFirstNonBlank() { buffer = VimTextBufferMock(text: " hello\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 6, length: 0)) keys("d^") - XCTAssertEqual(buffer.text, " lo\n") + #expect(buffer.text == " lo\n") } - func testDGoesGoesAllToEndOfBuffer() { + @Test + func dGoesGoesAllToEndOfBuffer() { // dG from line 0 should delete all lines (linewise). buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("d") key("G", shift: true) - XCTAssertEqual(buffer.text, "", "dG should delete from current line to end-of-buffer") + #expect(buffer.text == "", "dG should delete from current line to end-of-buffer") } - func testDGGFromMidBufferDeletesToTop() { + @Test + func dGGFromMidBufferDeletesToTop() { buffer.setSelectedRange(NSRange(location: 14, length: 0)) keys("dgg") - XCTAssertEqual(buffer.text, "third line\n", + #expect(buffer.text == "third line\n", "dgg should delete from current line to first line (linewise)") } - func testDJDeletesTwoLines() { + @Test + func dJDeletesTwoLines() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("dj") - XCTAssertEqual(buffer.text, "third line\n", + #expect(buffer.text == "third line\n", "dj should delete current line and the line below (linewise)") } - func testDKDeletesTwoLines() { + @Test + func dKDeletesTwoLines() { buffer.setSelectedRange(NSRange(location: 12, length: 0)) keys("dk") - XCTAssertEqual(buffer.text, "third line\n", + #expect(buffer.text == "third line\n", "dk should delete current line and the line above (linewise)") } - func testDCountWordsDeletesMultipleWords() { + @Test + func dCountWordsDeletesMultipleWords() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("d3w") - XCTAssertEqual(buffer.text, "line\nthird line\n", + #expect(buffer.text == "line\nthird line\n", "d3w from offset 0 should delete 'hello world\\nsecond '") } // MARK: - D: Delete to End of Line - func testCapitalDDeletesToEndOfLine() { + @Test + func capitalDDeletesToEndOfLine() { buffer.setSelectedRange(NSRange(location: 5, length: 0)) key("D", shift: true) - XCTAssertEqual(buffer.text, "hello\nsecond line\nthird line\n", + #expect(buffer.text == "hello\nsecond line\nthird line\n", "D is shorthand for d$ and deletes through end-of-line content") } - func testCapitalDOnEmptyLineIsNoOp() { + @Test + func capitalDOnEmptyLineIsNoOp() { buffer = VimTextBufferMock(text: "\nfoo\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 0, length: 0)) key("D", shift: true) - XCTAssertEqual(buffer.text, "\nfoo\n", "D on an empty line should not delete the newline") + #expect(buffer.text == "\nfoo\n", "D on an empty line should not delete the newline") } // MARK: - yy: Yank Line - func testYYDoesNotModifyBuffer() { + @Test + func yYDoesNotModifyBuffer() { let original = buffer.text buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("yy") - XCTAssertEqual(buffer.text, original) + #expect(buffer.text == original) } - func testYYThenPasteRestoresLine() { + @Test + func yYThenPasteRestoresLine() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("yyp") - XCTAssertEqual(buffer.text, "hello world\nhello world\nsecond line\nthird line\n") + #expect(buffer.text == "hello world\nhello world\nsecond line\nthird line\n") } - func testYYWithCount() { + @Test + func yYWithCount() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("2yyp") - XCTAssertEqual(buffer.text, "hello world\nhello world\nsecond line\nsecond line\nthird line\n", + #expect(buffer.text == "hello world\nhello world\nsecond line\nsecond line\nthird line\n", "2yy should yank two lines, p pastes them after current line") } - func testYYCursorStaysAtLineStart() { + @Test + func yYCursorStaysAtLineStart() { buffer.setSelectedRange(NSRange(location: 5, length: 0)) keys("yy") // Yank doesn't move cursor across lines; column may be preserved or cursor returned to motion start. let (line, _) = buffer.lineAndColumn(forOffset: pos) - XCTAssertEqual(line, 0) + #expect(line == 0) } // MARK: - y + Motion - func testYWYanksWordIntoRegister() { + @Test + func yWYanksWordIntoRegister() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("yw") - XCTAssertEqual(buffer.text, "hello world\nsecond line\nthird line\n", + #expect(buffer.text == "hello world\nsecond line\nthird line\n", "yw must not modify the buffer") } - func testYWThenPasteInsertsWord() { + @Test + func yWThenPasteInsertsWord() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("ywp") - XCTAssertEqual(buffer.text, "hhello ello world\nsecond line\nthird line\n", + #expect(buffer.text == "hhello ello world\nsecond line\nthird line\n", "After yw, p pastes 'hello ' after the cursor at offset 0") } - func testYDollarYanksToLineEnd() { + @Test + func yDollarYanksToLineEnd() { buffer.setSelectedRange(NSRange(location: 5, length: 0)) keys("y$p") - XCTAssertEqual(buffer.text, "hello worldworld\nsecond line\nthird line\n", + #expect(buffer.text == "hello worldworld\nsecond line\nthird line\n", "y$ from offset 5 yanks ' world', p pastes it after cursor") } // MARK: - Y: Yank Line (synonym of yy) - func testCapitalYYanksLine() { + @Test + func capitalYYanksLine() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) key("Y", shift: true) keys("p") - XCTAssertEqual(buffer.text, "hello world\nhello world\nsecond line\nthird line\n", + #expect(buffer.text == "hello world\nhello world\nsecond line\nthird line\n", "Y is a synonym for yy and yanks the whole line linewise") } // MARK: - cc: Change Line - func testCCDeletesLineContentEntersInsert() { + @Test + func cCDeletesLineContentEntersInsert() { buffer.setSelectedRange(NSRange(location: 5, length: 0)) keys("cc") - XCTAssertEqual(engine.mode, .insert) - XCTAssertEqual(buffer.text, "\nsecond line\nthird line\n") - XCTAssertEqual(pos, 0) + #expect(engine.mode == .insert) + #expect(buffer.text == "\nsecond line\nthird line\n") + #expect(pos == 0) } - func testCCWithCount() { + @Test + func cCWithCount() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("2cc") - XCTAssertEqual(engine.mode, .insert) - XCTAssertEqual(buffer.text, "\nthird line\n", + #expect(engine.mode == .insert) + #expect(buffer.text == "\nthird line\n", "2cc should clear two lines' content and leave one newline") } // MARK: - c + Motion - func testCWChangesWord() { + @Test + func cWChangesWord() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("cw") - XCTAssertEqual(engine.mode, .insert) - XCTAssertEqual(buffer.text, "world\nsecond line\nthird line\n") + #expect(engine.mode == .insert) + #expect(buffer.text == "world\nsecond line\nthird line\n") } - func testCDollarChangesToLineEnd() { + @Test + func cDollarChangesToLineEnd() { buffer.setSelectedRange(NSRange(location: 5, length: 0)) keys("c$") - XCTAssertEqual(engine.mode, .insert) - XCTAssertEqual(buffer.text, "hello\nsecond line\nthird line\n") + #expect(engine.mode == .insert) + #expect(buffer.text == "hello\nsecond line\nthird line\n") } - func testCEChangesToWordEndInclusive() { + @Test + func cEChangesToWordEndInclusive() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("ce") - XCTAssertEqual(buffer.text, " world\nsecond line\nthird line\n", + #expect(buffer.text == " world\nsecond line\nthird line\n", "ce should delete through the word-end character") } // MARK: - C: Change to End of Line - func testCapitalCChangesToEndOfLine() { + @Test + func capitalCChangesToEndOfLine() { buffer.setSelectedRange(NSRange(location: 5, length: 0)) key("C", shift: true) - XCTAssertEqual(engine.mode, .insert) - XCTAssertEqual(buffer.text, "hello\nsecond line\nthird line\n", + #expect(engine.mode == .insert) + #expect(buffer.text == "hello\nsecond line\nthird line\n", "C is shorthand for c$") } // MARK: - Pending Operator Cancellation - func testPendingDCancelledByEscape() { + @Test + func pendingDCancelledByEscape() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("d") escape() keys("l") - XCTAssertEqual(pos, 1) - XCTAssertEqual(buffer.text, "hello world\nsecond line\nthird line\n", + #expect(pos == 1) + #expect(buffer.text == "hello world\nsecond line\nthird line\n", "Escape should cancel the pending operator without modifying the buffer") } - func testPendingCCancelledByEscape() { + @Test + func pendingCCancelledByEscape() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("c") escape() keys("l") - XCTAssertEqual(pos, 1) - XCTAssertEqual(buffer.text, "hello world\nsecond line\nthird line\n") + #expect(pos == 1) + #expect(buffer.text == "hello world\nsecond line\nthird line\n") } - func testPendingYCancelledByEscape() { + @Test + func pendingYCancelledByEscape() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("y") escape() keys("p") - XCTAssertEqual(buffer.text, "hello world\nsecond line\nthird line\n", + #expect(buffer.text == "hello world\nsecond line\nthird line\n", "Cancelled yank should leave the register untouched") } - func testPendingOperatorCancelledByUnknownKey() { + @Test + func pendingOperatorCancelledByUnknownKey() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("dz") - XCTAssertEqual(buffer.text, "hello world\nsecond line\nthird line\n") + #expect(buffer.text == "hello world\nsecond line\nthird line\n") } // MARK: - Count Multiplication - func testCountTimesCountMultipliesMotion() { + @Test + func countTimesCountMultipliesMotion() { // 2d3w should delete 6 words worth (counts multiply). buffer = VimTextBufferMock(text: "a b c d e f g\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("2d3w") - XCTAssertEqual(buffer.text, "g\n", "2d3w should delete 6 words (2*3)") + #expect(buffer.text == "g\n", "2d3w should delete 6 words (2*3)") } // MARK: - Register After Delete (Default Register) - func testDeleteAndPasteRoundTrip() { + @Test + func deleteAndPasteRoundTrip() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("dw") - XCTAssertEqual(buffer.text, "world\nsecond line\nthird line\n") + #expect(buffer.text == "world\nsecond line\nthird line\n") key("P", shift: true) - XCTAssertEqual(buffer.text, "hello world\nsecond line\nthird line\n", + #expect(buffer.text == "hello world\nsecond line\nthird line\n", "Deleted text should round-trip through P") } } diff --git a/TableProTests/Core/Vim/VimEnginePasteTests.swift b/TableProTests/Core/Vim/VimEnginePasteTests.swift index 2176f7c5b2..15b0c60b6d 100644 --- a/TableProTests/Core/Vim/VimEnginePasteTests.swift +++ b/TableProTests/Core/Vim/VimEnginePasteTests.swift @@ -6,27 +6,21 @@ // count repetition, and cursor positioning rules. // -import XCTest -import TableProPluginKit +import Foundation @testable import TablePro +import TableProPluginKit +import Testing @MainActor -final class VimEnginePasteTests: XCTestCase { +final class VimEnginePasteTests { private var engine: VimEngine! private var buffer: VimTextBufferMock! - override func setUp() { - super.setUp() + init() { buffer = VimTextBufferMock(text: "hello world\nsecond line\nthird line\n") engine = VimEngine(buffer: buffer) } - override func tearDown() { - engine = nil - buffer = nil - super.tearDown() - } - private func keys(_ chars: String) { for char in chars { _ = engine.process(char, shift: false) } } @@ -39,145 +33,161 @@ final class VimEnginePasteTests: XCTestCase { // MARK: - p: Paste After (Characterwise) - func testPPastesCharacterwiseAfterCursor() { + @Test + func pPastesCharacterwiseAfterCursor() { // yw yanks "hello ", p pastes it after the cursor (i.e., starting at pos+1). buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("ywp") - XCTAssertEqual(buffer.text, "hhello ello world\nsecond line\nthird line\n") + #expect(buffer.text == "hhello ello world\nsecond line\nthird line\n") } - func testPCursorLandsOnLastPastedChar() { + @Test + func pCursorLandsOnLastPastedChar() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("yw") // register: "hello " keys("p") // Inserted at offset 1, length 6. Cursor on last pasted char = offset 6. - XCTAssertEqual(pos, 6, "After characterwise p, cursor sits on the last pasted character") + #expect(pos == 6, "After characterwise p, cursor sits on the last pasted character") } - func testPAtEndOfLineInsertsBeforeNewline() { + @Test + func pAtEndOfLineInsertsBeforeNewline() { // Delete a char to load register, then paste at end of line. buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("x") // register: "h" // Now at offset 0 ('e'), move to end of line first keys("$") keys("p") - XCTAssertEqual(buffer.text, "ello worldh\nsecond line\nthird line\n", + #expect(buffer.text == "ello worldh\nsecond line\nthird line\n", "Characterwise p at end of line should insert just before the newline") } // MARK: - P: Paste Before (Characterwise) - func testCapitalPPastesCharacterwiseBeforeCursor() { + @Test + func capitalPPastesCharacterwiseBeforeCursor() { buffer.setSelectedRange(NSRange(location: 6, length: 0)) // 'w' in 'world' keys("x") // delete 'w', register: "w" key("P", shift: true) - XCTAssertEqual(buffer.text, "hello world\nsecond line\nthird line\n", + #expect(buffer.text == "hello world\nsecond line\nthird line\n", "P should restore deleted char before cursor") } - func testCapitalPCursorLandsOnLastPastedChar() { + @Test + func capitalPCursorLandsOnLastPastedChar() { // Yank "hello", move to start of line 1, P should paste before and land on 'o'. buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("ye") // yank "hello" keys("j0") key("P", shift: true) - XCTAssertEqual(buffer.text, "hello world\nhellosecond line\nthird line\n") - XCTAssertEqual(pos, 16, "After characterwise P, cursor sits on the last pasted char") + #expect(buffer.text == "hello world\nhellosecond line\nthird line\n") + #expect(pos == 16, "After characterwise P, cursor sits on the last pasted char") } // MARK: - p: Paste After (Linewise) - func testLinewisePPastesAsNewLineBelow() { + @Test + func linewisePPastesAsNewLineBelow() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("yyp") - XCTAssertEqual(buffer.text, "hello world\nhello world\nsecond line\nthird line\n") + #expect(buffer.text == "hello world\nhello world\nsecond line\nthird line\n") } - func testLinewisePCursorLandsOnFirstPastedLine() { + @Test + func linewisePCursorLandsOnFirstPastedLine() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("yyp") - XCTAssertEqual(pos, 12, "Cursor should land at start of the pasted line") + #expect(pos == 12, "Cursor should land at start of the pasted line") } - func testLinewisePOnLastLineWithoutTrailingNewline() { + @Test + func linewisePOnLastLineWithoutTrailingNewline() { buffer = VimTextBufferMock(text: "one\ntwo") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("yyp") - XCTAssertEqual(buffer.text, "one\none\ntwo") + #expect(buffer.text == "one\none\ntwo") } // MARK: - P: Paste Before (Linewise) - func testLinewiseCapitalPPastesAsNewLineAbove() { + @Test + func linewiseCapitalPPastesAsNewLineAbove() { buffer.setSelectedRange(NSRange(location: 12, length: 0)) // start of line 1 keys("yy") key("P", shift: true) - XCTAssertEqual(buffer.text, "hello world\nsecond line\nsecond line\nthird line\n") + #expect(buffer.text == "hello world\nsecond line\nsecond line\nthird line\n") } - func testLinewiseCapitalPCursorLandsOnFirstPastedLine() { + @Test + func linewiseCapitalPCursorLandsOnFirstPastedLine() { buffer.setSelectedRange(NSRange(location: 12, length: 0)) keys("yy") key("P", shift: true) - XCTAssertEqual(pos, 12, "Cursor should land at start of the pasted line") + #expect(pos == 12, "Cursor should land at start of the pasted line") } // MARK: - Paste with Count - func testPasteWithCountRepeatsPaste() { + @Test + func pasteWithCountRepeatsPaste() { // yw to yank "hello ", 3p to paste 3 times. buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("yw") keys("3p") - XCTAssertEqual(buffer.text, "hhello hello hello ello world\nsecond line\nthird line\n", + #expect(buffer.text == "hhello hello hello ello world\nsecond line\nthird line\n", "3p should paste the register 3 times") } - func testLinewisePasteWithCount() { + @Test + func linewisePasteWithCount() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("yy") keys("2p") - XCTAssertEqual(buffer.text, "hello world\nhello world\nhello world\nsecond line\nthird line\n", + #expect(buffer.text == "hello world\nhello world\nhello world\nsecond line\nthird line\n", "2p with linewise register should insert two copies after current line") } // MARK: - Empty Register - func testPasteWithEmptyRegisterIsNoOp() { + @Test + func pasteWithEmptyRegisterIsNoOp() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("p") - XCTAssertEqual(buffer.text, "hello world\nsecond line\nthird line\n", + #expect(buffer.text == "hello world\nsecond line\nthird line\n", "Pasting an empty register should not modify the buffer") } // MARK: - Cross-Operator Register Use - func testYankPasteThenDeletePasteOverwritesRegister() { + @Test + func yankPasteThenDeletePasteOverwritesRegister() { // yank line, paste it, then delete a line — the deleted line takes over as paste source. buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("yy") keys("j") keys("dd") - XCTAssertEqual(buffer.text, "hello world\nthird line\n", + #expect(buffer.text == "hello world\nthird line\n", "dd should delete the second line") keys("p") - XCTAssertEqual(buffer.text, "hello world\nthird line\nsecond line\n", + #expect(buffer.text == "hello world\nthird line\nsecond line\n", "After dd overwrites register, p should paste the deleted second line") } - func testXThenPasteRoundTrips() { + @Test + func xThenPasteRoundTrips() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("x") // delete 'h' - XCTAssertEqual(buffer.text, "ello world\nsecond line\nthird line\n") + #expect(buffer.text == "ello world\nsecond line\nthird line\n") key("P", shift: true) - XCTAssertEqual(buffer.text, "hello world\nsecond line\nthird line\n", + #expect(buffer.text == "hello world\nsecond line\nthird line\n", "x stores into the register; P should restore") } // MARK: - Visual Selection Paste Replaces Selection - func testPasteInVisualReplacesSelectionWithRegister() { + @Test + func pasteInVisualReplacesSelectionWithRegister() { // yw to yank "hello ", then v3l to select "wor" in "world", p to replace. buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("yw") // register: "hello " @@ -185,8 +195,8 @@ final class VimEnginePasteTests: XCTestCase { keys("v") keys("ll") keys("p") - XCTAssertEqual(buffer.text, "hello hello ld\nsecond line\nthird line\n", + #expect(buffer.text == "hello hello ld\nsecond line\nthird line\n", "Paste over a visual selection should replace the selection with the register") - XCTAssertEqual(engine.mode, .normal) + #expect(engine.mode == .normal) } } diff --git a/TableProTests/Core/Vim/VimEngineReplaceTests.swift b/TableProTests/Core/Vim/VimEngineReplaceTests.swift index 017a26ac70..2cadce22c1 100644 --- a/TableProTests/Core/Vim/VimEngineReplaceTests.swift +++ b/TableProTests/Core/Vim/VimEngineReplaceTests.swift @@ -5,27 +5,21 @@ // Specification tests for r{char} single-character replace and R overwrite mode. // -import XCTest -import TableProPluginKit +import Foundation @testable import TablePro +import TableProPluginKit +import Testing @MainActor -final class VimEngineReplaceTests: XCTestCase { +final class VimEngineReplaceTests { private var engine: VimEngine! private var buffer: VimTextBufferMock! - override func setUp() { - super.setUp() + init() { buffer = VimTextBufferMock(text: "hello world\nsecond line\n") engine = VimEngine(buffer: buffer) } - override func tearDown() { - engine = nil - buffer = nil - super.tearDown() - } - private func keys(_ chars: String) { for char in chars { _ = engine.process(char, shift: false) } } @@ -40,149 +34,166 @@ final class VimEngineReplaceTests: XCTestCase { // MARK: - r: Single Character Replace - func testRReplacesSingleCharacter() { + @Test + func rReplacesSingleCharacter() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("rH") - XCTAssertEqual(buffer.text, "Hello world\nsecond line\n", + #expect(buffer.text == "Hello world\nsecond line\n", "r should replace the char under the cursor") } - func testRStaysInNormalMode() { + @Test + func rStaysInNormalMode() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("rH") - XCTAssertEqual(engine.mode, .normal, + #expect(engine.mode == .normal, "r is a single-shot command; it must not enter insert mode") } - func testRDoesNotMoveCursor() { + @Test + func rDoesNotMoveCursor() { buffer.setSelectedRange(NSRange(location: 5, length: 0)) keys("rX") - XCTAssertEqual(pos, 5, "r should leave the cursor on the replaced char") + #expect(pos == 5, "r should leave the cursor on the replaced char") } - func testRWithCountReplacesMultipleChars() { + @Test + func rWithCountReplacesMultipleChars() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("3rX") - XCTAssertEqual(buffer.text, "XXXlo world\nsecond line\n", + #expect(buffer.text == "XXXlo world\nsecond line\n", "3rX should replace the next 3 chars with X") } - func testRWithCountCursorOnLastReplaced() { + @Test + func rWithCountCursorOnLastReplaced() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("3rX") - XCTAssertEqual(pos, 2, "Cursor should sit on the last replaced char") + #expect(pos == 2, "Cursor should sit on the last replaced char") } - func testRCountExceedingLineRemainderIsNoOp() { + @Test + func rCountExceedingLineRemainderIsNoOp() { // "hello world\n" — from offset 8 only 3 content chars remain ('rld'). 99rX must // not replace any chars (vim refuses to cross the newline). buffer.setSelectedRange(NSRange(location: 8, length: 0)) keys("99rX") - XCTAssertEqual(buffer.text, "hello world\nsecond line\n", + #expect(buffer.text == "hello world\nsecond line\n", "r with count exceeding line content should be a no-op (must not overwrite newline)") } - func testRWithNewlineReplacesCharWithNewline() { + @Test + func rWithNewlineReplacesCharWithNewline() { buffer.setSelectedRange(NSRange(location: 5, length: 0)) keys("r") _ = engine.process("\r", shift: false) - XCTAssertEqual(buffer.text, "hello\nworld\nsecond line\n", + #expect(buffer.text == "hello\nworld\nsecond line\n", "r should replace the char with a newline") } - func testREscapeCancelsReplace() { + @Test + func rEscapeCancelsReplace() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("r") escape() - XCTAssertEqual(buffer.text, "hello world\nsecond line\n", + #expect(buffer.text == "hello world\nsecond line\n", "Escape during r prompt should cancel without modifying buffer") // Cursor should remain at offset 0 and mode normal. - XCTAssertEqual(engine.mode, .normal) + #expect(engine.mode == .normal) } // MARK: - R: Overwrite Mode - func testCapitalREntersReplaceMode() { + @Test + func capitalREntersReplaceMode() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) key("R", shift: true) - XCTAssertEqual(engine.mode, .replace, + #expect(engine.mode == .replace, "R should enter the dedicated .replace mode (overwrite, distinct from insert)") } - func testCapitalROverwritesCharactersAsTyped() { + @Test + func capitalROverwritesCharactersAsTyped() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) key("R", shift: true) _ = engine.process("X", shift: true) _ = engine.process("Y", shift: true) - XCTAssertEqual(buffer.text, "XYllo world\nsecond line\n", + #expect(buffer.text == "XYllo world\nsecond line\n", "Replace mode should overwrite chars at the cursor as the user types") } - func testCapitalREscapeReturnsToNormal() { + @Test + func capitalREscapeReturnsToNormal() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) key("R", shift: true) escape() - XCTAssertEqual(engine.mode, .normal) + #expect(engine.mode == .normal) } // MARK: - Control characters never become text private let backspace: Character = "\u{7F}" - func testBackspaceInReplaceModeRestoresOverwrittenCharacters() { + @Test + func backspaceInReplaceModeRestoresOverwrittenCharacters() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) key("R", shift: true) _ = engine.process("X", shift: true) _ = engine.process("Y", shift: true) key(backspace) - XCTAssertEqual(buffer.text, "Xello world\nsecond line\n") - XCTAssertEqual(pos, 1) + #expect(buffer.text == "Xello world\nsecond line\n") + #expect(pos == 1) key(backspace) - XCTAssertEqual(buffer.text, "hello world\nsecond line\n") - XCTAssertEqual(pos, 0) + #expect(buffer.text == "hello world\nsecond line\n") + #expect(pos == 0) } - func testBackspaceInReplaceModeRemovesAppendedCharacters() { + @Test + func backspaceInReplaceModeRemovesAppendedCharacters() { buffer.setSelectedRange(NSRange(location: 11, length: 0)) key("R", shift: true) keys("!?") - XCTAssertEqual(buffer.text, "hello world!?\nsecond line\n") + #expect(buffer.text == "hello world!?\nsecond line\n") key(backspace) key(backspace) - XCTAssertEqual(buffer.text, "hello world\nsecond line\n") - XCTAssertEqual(pos, 11) + #expect(buffer.text == "hello world\nsecond line\n") + #expect(pos == 11) } - func testBackspaceBeforeReplaceStartOnlyMovesLeft() { + @Test + func backspaceBeforeReplaceStartOnlyMovesLeft() { buffer.setSelectedRange(NSRange(location: 3, length: 0)) key("R", shift: true) key(backspace) - XCTAssertEqual(buffer.text, "hello world\nsecond line\n") - XCTAssertEqual(pos, 2) + #expect(buffer.text == "hello world\nsecond line\n") + #expect(pos == 2) } - func testReplaceModeNeverWritesAControlCharacter() { + @Test + func replaceModeNeverWritesAControlCharacter() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) key("R", shift: true) key("\u{03}") key("\u{1D}") - XCTAssertEqual(buffer.text, "hello world\nsecond line\n") + #expect(buffer.text == "hello world\nsecond line\n") } - func testReplaceCharCancelsOnBackspace() { + @Test + func replaceCharCancelsOnBackspace() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("r") key(backspace) - XCTAssertEqual(buffer.text, "hello world\nsecond line\n") - XCTAssertEqual(engine.mode, .normal) + #expect(buffer.text == "hello world\nsecond line\n") + #expect(engine.mode == .normal) keys("rH") - XCTAssertEqual(buffer.text, "Hello world\nsecond line\n", "The cancelled r must not leave a pending replace") + #expect(buffer.text == "Hello world\nsecond line\n", "The cancelled r must not leave a pending replace") } - func testVisualReplaceCancelsOnBackspace() { + @Test + func visualReplaceCancelsOnBackspace() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("vlr") key(backspace) - XCTAssertEqual(buffer.text, "hello world\nsecond line\n") + #expect(buffer.text == "hello world\nsecond line\n") } } diff --git a/TableProTests/Core/Vim/VimEngineScrollTests.swift b/TableProTests/Core/Vim/VimEngineScrollTests.swift index 22d644adaf..b407e51f50 100644 --- a/TableProTests/Core/Vim/VimEngineScrollTests.swift +++ b/TableProTests/Core/Vim/VimEngineScrollTests.swift @@ -7,17 +7,17 @@ // the whole buffer so behaviour is well-defined for unit tests. // -import XCTest -import TableProPluginKit +import Foundation @testable import TablePro +import TableProPluginKit +import Testing @MainActor -final class VimEngineScrollTests: XCTestCase { +final class VimEngineScrollTests { private var engine: VimEngine! private var buffer: VimTextBufferMock! - override func setUp() { - super.setUp() + init() { // 30-line buffer so we can exercise half-page / full-page motions. var lines: [String] = [] for i in 0..<30 { lines.append("line\(i)") } @@ -25,12 +25,6 @@ final class VimEngineScrollTests: XCTestCase { engine = VimEngine(buffer: buffer) } - override func tearDown() { - engine = nil - buffer = nil - super.tearDown() - } - private func keys(_ chars: String) { for char in chars { _ = engine.process(char, shift: false) } } @@ -47,83 +41,94 @@ final class VimEngineScrollTests: XCTestCase { // MARK: - Ctrl+D / Ctrl+U: Half-Page Scroll - func testCtrlDScrollsHalfPageDown() { + @Test + func ctrlDScrollsHalfPageDown() { // Mock visibleLineRange returns (0, 29). Half is 15. Ctrl+D moves cursor down ~half. buffer.setSelectedRange(NSRange(location: 0, length: 0)) _ = ctrl("d") - XCTAssertEqual(line, 15, "Ctrl+D should move the cursor down by half the visible range") + #expect(line == 15, "Ctrl+D should move the cursor down by half the visible range") } - func testCtrlUScrollsHalfPageUp() { + @Test + func ctrlUScrollsHalfPageUp() { buffer.setSelectedRange(NSRange(location: buffer.offset(forLine: 25, column: 0), length: 0)) _ = ctrl("u") - XCTAssertEqual(line, 10, "Ctrl+U should move the cursor up by half the visible range") + #expect(line == 10, "Ctrl+U should move the cursor up by half the visible range") } // MARK: - Ctrl+F / Ctrl+B: Full-Page Scroll - func testCtrlFScrollsFullPageDown() { + @Test + func ctrlFScrollsFullPageDown() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) _ = ctrl("f") - XCTAssertEqual(line, 29, "Ctrl+F should move down by the full visible range (clamped at last line)") + #expect(line == 29, "Ctrl+F should move down by the full visible range (clamped at last line)") } - func testCtrlBScrollsFullPageUp() { + @Test + func ctrlBScrollsFullPageUp() { buffer.setSelectedRange(NSRange(location: buffer.offset(forLine: 29, column: 0), length: 0)) _ = ctrl("b") - XCTAssertEqual(line, 0, "Ctrl+B should move up by the full visible range (clamped at first line)") + #expect(line == 0, "Ctrl+B should move up by the full visible range (clamped at first line)") } // MARK: - Ctrl+E / Ctrl+Y: Scroll Lines Without Moving Cursor - func testCtrlEScrollsViewportDown() { + @Test + func ctrlEScrollsViewportDown() { // Engine cannot directly scroll a viewport via the mock — but it should // advance the cursor if the viewport carries it (vim's "stick to visible"). // For the mock (whole buffer visible), this is effectively a no-op. buffer.setSelectedRange(NSRange(location: 0, length: 0)) let consumed = ctrl("e") - XCTAssertTrue(consumed, "Ctrl+E should be consumed by the engine even when nothing scrolls") + #expect(consumed, "Ctrl+E should be consumed by the engine even when nothing scrolls") } - func testCtrlYScrollsViewportUp() { + @Test + func ctrlYScrollsViewportUp() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) let consumed = ctrl("y") - XCTAssertTrue(consumed) + #expect(consumed) } // MARK: - z Commands: Position Current Line in Viewport - func testZtPlacesCursorLineAtTop() { + @Test + func ztPlacesCursorLineAtTop() { buffer.setSelectedRange(NSRange(location: buffer.offset(forLine: 10, column: 0), length: 0)) keys("zt") // Cursor itself should not move; only the viewport is adjusted. - XCTAssertEqual(line, 10, "zt should not change the cursor's line") + #expect(line == 10, "zt should not change the cursor's line") } - func testZzCentersCursorLine() { + @Test + func zzCentersCursorLine() { buffer.setSelectedRange(NSRange(location: buffer.offset(forLine: 5, column: 0), length: 0)) keys("zz") - XCTAssertEqual(line, 5, "zz should not change the cursor's line") + #expect(line == 5, "zz should not change the cursor's line") } - func testZbPlacesCursorLineAtBottom() { + @Test + func zbPlacesCursorLineAtBottom() { buffer.setSelectedRange(NSRange(location: buffer.offset(forLine: 20, column: 0), length: 0)) keys("zb") - XCTAssertEqual(line, 20, "zb should not change the cursor's line") + #expect(line == 20, "zb should not change the cursor's line") } // MARK: - g0 / g$ / gj / gk (Display-Line Motions) - func testGJMovesByDisplayLine() { + @Test + func gJMovesByDisplayLine() { // For non-wrapped lines, gj == j. buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("gj") - XCTAssertEqual(line, 1, "gj on a non-wrapping line should behave like j") + #expect(line == 1, "gj on a non-wrapping line should behave like j") } - func testGKMovesByDisplayLine() { + @Test + func gKMovesByDisplayLine() { buffer.setSelectedRange(NSRange(location: buffer.offset(forLine: 5, column: 0), length: 0)) keys("gk") - XCTAssertEqual(line, 4, "gk on a non-wrapping line should behave like k") + #expect(line == 4, "gk on a non-wrapping line should behave like k") } } diff --git a/TableProTests/Core/Vim/VimEngineSentenceParagraphTests.swift b/TableProTests/Core/Vim/VimEngineSentenceParagraphTests.swift index cff07d9cd7..1eb66e78ab 100644 --- a/TableProTests/Core/Vim/VimEngineSentenceParagraphTests.swift +++ b/TableProTests/Core/Vim/VimEngineSentenceParagraphTests.swift @@ -7,21 +7,16 @@ // blank line is a paragraph boundary. // -import XCTest -import TableProPluginKit +import Foundation @testable import TablePro +import TableProPluginKit +import Testing @MainActor -final class VimEngineSentenceParagraphTests: XCTestCase { +final class VimEngineSentenceParagraphTests { private var engine: VimEngine! private var buffer: VimTextBufferMock! - override func tearDown() { - engine = nil - buffer = nil - super.tearDown() - } - private func make(_ text: String, at offset: Int = 0) { buffer = VimTextBufferMock(text: text) engine = VimEngine(buffer: buffer) @@ -36,100 +31,113 @@ final class VimEngineSentenceParagraphTests: XCTestCase { // MARK: - ) Forward Sentence - func testRightParenAdvancesToNextSentence() { + @Test + func rightParenAdvancesToNextSentence() { make("First sentence. Second sentence.\n", at: 0) keys(")") - XCTAssertEqual(pos, 16, ") should advance to the start of the next sentence") + #expect(pos == 16, ") should advance to the start of the next sentence") } - func testRightParenAcrossLines() { + @Test + func rightParenAcrossLines() { make("Line one.\nLine two.\n", at: 0) keys(")") - XCTAssertEqual(pos, 10, ") should cross line boundaries when sentences run across them") + #expect(pos == 10, ") should cross line boundaries when sentences run across them") } - func testRightParenWithCount() { + @Test + func rightParenWithCount() { // "First. Second. Third.\n" — offsets: F(0)…(5).(6) (7)S…(14).(15) T(16)… // 2) advances two sentences from 0 → past 'First. ' → 'S' at 7, then past // 'Second. ' → 'T' at 15. make("First. Second. Third.\n", at: 0) keys("2)") - XCTAssertEqual(pos, 15, "2) should advance two sentences (to 'T' at offset 15)") + #expect(pos == 15, "2) should advance two sentences (to 'T' at offset 15)") } // MARK: - ( Backward Sentence - func testLeftParenRetreatsToPreviousSentence() { + @Test + func leftParenRetreatsToPreviousSentence() { make("First sentence. Second sentence.\n", at: 16) keys("(") - XCTAssertEqual(pos, 0, "( should retreat to the start of the previous sentence") + #expect(pos == 0, "( should retreat to the start of the previous sentence") } // MARK: - } Forward Paragraph - func testRightBraceAdvancesPastBlankLine() { + @Test + func rightBraceAdvancesPastBlankLine() { make("para one\nstill one\n\npara two\n", at: 0) keys("}") - XCTAssertEqual(pos, 19, "} should land on the blank line that separates paragraphs") + #expect(pos == 19, "} should land on the blank line that separates paragraphs") } - func testRightBraceFromBlankLineAdvancesToNextParagraphEnd() { + @Test + func rightBraceFromBlankLineAdvancesToNextParagraphEnd() { // "para one\n\npara two\nstill two\n\npara three\n" // Offsets: 'p'(0) 'a'(1) 'r'(2) 'a'(3) ' '(4) 'o'(5) 'n'(6) 'e'(7) '\n'(8) '\n'(9) // 'p'(10) … 't'(15) 'w'(16) 'o'(17) '\n'(18) 's'(19) … 't'(28) 'w'(29) 'o'(30) '\n'(31) '\n'(32) // } from blank line at 9 advances to the next blank line at 32. make("para one\n\npara two\nstill two\n\npara three\n", at: 9) keys("}") - XCTAssertEqual(pos, 29, "} from a blank line should advance to the next paragraph break (offset 29 is the blank line)") + #expect(pos == 29, "} from a blank line should advance to the next paragraph break (offset 29 is the blank line)") } - func testRightBraceWithCount() { + @Test + func rightBraceWithCount() { make("p1\n\np2\n\np3\n", at: 0) keys("2}") - XCTAssertEqual(pos, 7, "2} should advance over two paragraph breaks") + #expect(pos == 7, "2} should advance over two paragraph breaks") } // MARK: - { Backward Paragraph - func testLeftBraceRetreatsToParagraphStart() { + @Test + func leftBraceRetreatsToParagraphStart() { make("para one\nstill one\n\npara two\n", at: 21) keys("{") - XCTAssertEqual(pos, 19, + #expect(pos == 19, "{ should retreat to the previous blank line (paragraph boundary)") } - func testLeftBraceFromFirstParagraphLandsAtBufferStart() { + @Test + func leftBraceFromFirstParagraphLandsAtBufferStart() { make("only paragraph\nstill one\n", at: 16) keys("{") - XCTAssertEqual(pos, 0, "{ with no prior paragraph break should land at offset 0") + #expect(pos == 0, "{ with no prior paragraph break should land at offset 0") } // MARK: - [[ and ]] Section Motions - func testDoubleRightBracketAdvancesToNextSection() { + @Test + func doubleRightBracketAdvancesToNextSection() { // Sections are delimited by `{` at column 0 in C-style code (or top of file). make("{\n body;\n}\n\n{\n next;\n}\n", at: 0) keys("]]") - XCTAssertEqual(pos, 13, "]] should advance to the next section's opening brace") + #expect(pos == 13, "]] should advance to the next section's opening brace") } - func testDoubleLeftBracketRetreatsToPreviousSection() { + @Test + func doubleLeftBracketRetreatsToPreviousSection() { make("{\n body;\n}\n\n{\n next;\n}\n", at: 13) keys("[[") - XCTAssertEqual(pos, 0, "[[ should retreat to the previous section's opening brace") + #expect(pos == 0, "[[ should retreat to the previous section's opening brace") } // MARK: - Sentence with Multiple Punctuation - func testSentenceBoundaryWithExclamation() { + @Test + func sentenceBoundaryWithExclamation() { make("Wow! Cool.\n", at: 0) keys(")") - XCTAssertEqual(pos, 5, ") after '!' should land at the start of the next sentence") + #expect(pos == 5, ") after '!' should land at the start of the next sentence") } - func testSentenceBoundaryWithQuestion() { + @Test + func sentenceBoundaryWithQuestion() { make("Why? Because.\n", at: 0) keys(")") - XCTAssertEqual(pos, 5, ") after '?' should land at the start of the next sentence") + #expect(pos == 5, ") after '?' should land at the start of the next sentence") } } diff --git a/TableProTests/Core/Vim/VimEngineTextObjectsTests.swift b/TableProTests/Core/Vim/VimEngineTextObjectsTests.swift index 0fa3927f6d..ae64f3ea79 100644 --- a/TableProTests/Core/Vim/VimEngineTextObjectsTests.swift +++ b/TableProTests/Core/Vim/VimEngineTextObjectsTests.swift @@ -6,96 +6,98 @@ // i(/a(, i{/a{, i[/a[, i (inside / around angle brackets) - func testDIAngleBracketDeletesInsideAngles() { + @Test + func dIAngleBracketDeletesInsideAngles() { buffer = VimTextBufferMock(text: "Vec v;\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 5, length: 0)) keys("di<") - XCTAssertEqual(buffer.text, "Vec<> v;\n") + #expect(buffer.text == "Vec<> v;\n") } // MARK: - it / at (HTML/XML tag) - func testDITDeletesInsideTag() { + @Test + func dITDeletesInsideTag() { buffer = VimTextBufferMock(text: "
hello
\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 7, length: 0)) keys("dit") - XCTAssertEqual(buffer.text, "
\n", + #expect(buffer.text == "
\n", "dit should delete the content between matching tags") } - func testDATDeletesIncludingTags() { + @Test + func dATDeletesIncludingTags() { buffer = VimTextBufferMock(text: "
hello
\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 7, length: 0)) keys("dat") - XCTAssertEqual(buffer.text, "\n", + #expect(buffer.text == "\n", "dat should delete the entire tag pair and contents") } // MARK: - ip / ap (inner / a paragraph) - func testDIPDeletesInnerParagraph() { + @Test + func dIPDeletesInnerParagraph() { buffer = VimTextBufferMock(text: "para one\nstill one\n\npara two\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 3, length: 0)) keys("dip") - XCTAssertEqual(buffer.text, "\n\npara two\n", + #expect(buffer.text == "\n\npara two\n", "dip should delete the paragraph at cursor (without surrounding blank lines)") } - func testDAPDeletesAroundParagraph() { + @Test + func dAPDeletesAroundParagraph() { buffer = VimTextBufferMock(text: "para one\nstill one\n\npara two\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 3, length: 0)) keys("dap") - XCTAssertEqual(buffer.text, "para two\n", + #expect(buffer.text == "para two\n", "dap should delete the paragraph plus the trailing blank line") } // MARK: - Text Objects in Visual Mode - func testVisualIWSelectsInnerWord() { + @Test + func visualIWSelectsInnerWord() { buffer.setSelectedRange(NSRange(location: 6, length: 0)) keys("viw") let sel = buffer.selectedRange() - XCTAssertEqual(buffer.string(in: sel), "world", + #expect(buffer.string(in: sel) == "world", "viw should select just the word at cursor") } - func testVisualIQSelectsInsideQuotes() { + @Test + func visualIQSelectsInsideQuotes() { buffer = VimTextBufferMock(text: "x = \"hello\"\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 6, length: 0)) keys("vi\"") let sel = buffer.selectedRange() - XCTAssertEqual(buffer.string(in: sel), "hello", + #expect(buffer.string(in: sel) == "hello", "vi\" should select the contents of the quotes") } // MARK: - Nested Brackets - func testDIParenSelectsInnermostPair() { + @Test + func dIParenSelectsInnermostPair() { buffer = VimTextBufferMock(text: "f(g(x))\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 4, length: 0)) keys("di(") - XCTAssertEqual(buffer.text, "f(g())\n", + #expect(buffer.text == "f(g())\n", "di( should select the INNERMOST enclosing pair, not the outermost") } // MARK: - Cursor on Bracket Itself - func testDIParenWithCursorOnOpenParen() { + @Test + func dIParenWithCursorOnOpenParen() { buffer = VimTextBufferMock(text: "f(arg)\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 1, length: 0)) keys("di(") - XCTAssertEqual(buffer.text, "f()\n", + #expect(buffer.text == "f()\n", "di( with cursor on the opening paren should still work") } - func testDIParenWithCursorOnCloseParen() { + @Test + func dIParenWithCursorOnCloseParen() { buffer = VimTextBufferMock(text: "f(arg)\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 5, length: 0)) keys("di(") - XCTAssertEqual(buffer.text, "f()\n", + #expect(buffer.text == "f()\n", "di( with cursor on the closing paren should still work") } } diff --git a/TableProTests/Core/Vim/VimEngineUndoRepeatTests.swift b/TableProTests/Core/Vim/VimEngineUndoRepeatTests.swift index e6dcd132d6..f998550c97 100644 --- a/TableProTests/Core/Vim/VimEngineUndoRepeatTests.swift +++ b/TableProTests/Core/Vim/VimEngineUndoRepeatTests.swift @@ -6,27 +6,21 @@ // the repeat command (.), and the line undo (U). // -import XCTest -import TableProPluginKit +import Foundation @testable import TablePro +import TableProPluginKit +import Testing @MainActor -final class VimEngineUndoRepeatTests: XCTestCase { +final class VimEngineUndoRepeatTests { private var engine: VimEngine! private var buffer: VimTextBufferMock! - override func setUp() { - super.setUp() + init() { buffer = VimTextBufferMock(text: "hello world\nsecond line\nthird line\n") engine = VimEngine(buffer: buffer) } - override func tearDown() { - engine = nil - buffer = nil - super.tearDown() - } - private func keys(_ chars: String) { for char in chars { _ = engine.process(char, shift: false) } } @@ -37,90 +31,101 @@ final class VimEngineUndoRepeatTests: XCTestCase { // MARK: - u: Undo - func testUCallsBufferUndo() { - XCTAssertEqual(buffer.undoCallCount, 0) + @Test + func uCallsBufferUndo() { + #expect(buffer.undoCallCount == 0) keys("u") - XCTAssertEqual(buffer.undoCallCount, 1) + #expect(buffer.undoCallCount == 1) } - func testUWithCountCallsUndoNTimes() { + @Test + func uWithCountCallsUndoNTimes() { keys("3u") - XCTAssertEqual(buffer.undoCallCount, 3, "3u should call undo three times") + #expect(buffer.undoCallCount == 3, "3u should call undo three times") } - func testUConsumesKey() { - XCTAssertTrue(engine.process("u", shift: false)) + @Test + func uConsumesKey() { + #expect(engine.process("u", shift: false)) } // MARK: - Ctrl+R: Redo - func testRedoCallsBufferRedo() { - XCTAssertEqual(buffer.redoCallCount, 0) + @Test + func redoCallsBufferRedo() { + #expect(buffer.redoCallCount == 0) key("\u{12}") - XCTAssertEqual(buffer.redoCallCount, 1) + #expect(buffer.redoCallCount == 1) } - func testMultipleRedoCalls() { + @Test + func multipleRedoCalls() { key("\u{12}") key("\u{12}") key("\u{12}") - XCTAssertEqual(buffer.redoCallCount, 3) + #expect(buffer.redoCallCount == 3) } // MARK: - U: Undo Line (undo all changes on the last edited line) - func testCapitalUUndoesLineChanges() { + @Test + func capitalUUndoesLineChanges() { // U undoes all changes made on the last edited line in one go. buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("x") // delete 'h' keys("x") // delete 'e' key("U", shift: true) - XCTAssertEqual(buffer.undoCallCount, 2, + #expect(buffer.undoCallCount == 2, "U should restore the original line — implemented via repeated undo") } // MARK: - . (Repeat Last Change) - func testDotRepeatsLastEdit() { + @Test + func dotRepeatsLastEdit() { // Delete a word, then dot should delete another word. buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("dw") // delete "hello " - XCTAssertEqual(buffer.text, "world\nsecond line\nthird line\n") + #expect(buffer.text == "world\nsecond line\nthird line\n") keys(".") - XCTAssertEqual(buffer.text, "\nsecond line\nthird line\n", + #expect(buffer.text == "\nsecond line\nthird line\n", ". should repeat the last change (delete word)") } - func testDotRepeatsXDelete() { + @Test + func dotRepeatsXDelete() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("x") - XCTAssertEqual(buffer.text, "ello world\nsecond line\nthird line\n") + #expect(buffer.text == "ello world\nsecond line\nthird line\n") keys(".") - XCTAssertEqual(buffer.text, "llo world\nsecond line\nthird line\n", + #expect(buffer.text == "llo world\nsecond line\nthird line\n", ". should repeat the last delete") } - func testDotRepeatsWithExplicitCount() { + @Test + func dotRepeatsWithExplicitCount() { // dw, then 3. should delete 3 more words. buffer = VimTextBufferMock(text: "a b c d e f\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("dw") // delete "a " keys("3.") // delete 3 more words - XCTAssertEqual(buffer.text, "e f\n", + #expect(buffer.text == "e f\n", "3. should repeat the last change three times") } - func testDotDoesNotRepeatMotions() { + @Test + func dotDoesNotRepeatMotions() { // Pure motions (no edit) should not be repeatable by dot. buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("l") keys("l") keys(".") // no last change → no-op - XCTAssertEqual(buffer.text, "hello world\nsecond line\nthird line\n") + #expect(buffer.text == "hello world\nsecond line\nthird line\n") } - func testDotRepeatsInsertedText() { + @Test + func dotRepeatsInsertedText() { // Inserted text should be repeatable. This requires the engine to record the // insert-mode text, then dot replays it. Hard to test purely at engine level — // we at least assert that dot is consumed. @@ -129,6 +134,6 @@ final class VimEngineUndoRepeatTests: XCTestCase { _ = engine.process("X", shift: false) // pass-through in insert mode _ = engine.process("\u{1B}", shift: false) // escape let consumed = engine.process(".", shift: false) - XCTAssertTrue(consumed, ". must be consumed in normal mode") + #expect(consumed, ". must be consumed in normal mode") } } diff --git a/TableProTests/Core/Vim/VimEngineVisualModeTests.swift b/TableProTests/Core/Vim/VimEngineVisualModeTests.swift index 4609e9f8a0..ee9cb6b856 100644 --- a/TableProTests/Core/Vim/VimEngineVisualModeTests.swift +++ b/TableProTests/Core/Vim/VimEngineVisualModeTests.swift @@ -6,14 +6,15 @@ // entry/exit, motions, operators (d/y/c/x/J/~), and the o swap-anchor command. // -import XCTest -import TableProPluginKit +import Foundation @testable import TablePro +import TableProPluginKit +import Testing // swiftlint:disable file_length type_body_length @MainActor -final class VimEngineVisualModeTests: XCTestCase { +final class VimEngineVisualModeTests { // Standard test buffer: // Line 0: "hello world\n" offsets 0..11 (length 12) // Line 1: "second line\n" offsets 12..23 (length 12) @@ -21,18 +22,11 @@ final class VimEngineVisualModeTests: XCTestCase { private var engine: VimEngine! private var buffer: VimTextBufferMock! - override func setUp() { - super.setUp() + init() { buffer = VimTextBufferMock(text: "hello world\nsecond line\nthird line\n") engine = VimEngine(buffer: buffer) } - override func tearDown() { - engine = nil - buffer = nil - super.tearDown() - } - private func keys(_ chars: String) { for char in chars { _ = engine.process(char, shift: false) } } @@ -47,193 +41,217 @@ final class VimEngineVisualModeTests: XCTestCase { // MARK: - v: Enter Characterwise Visual - func testVEntersVisualMode() { + @Test + func vEntersVisualMode() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("v") - XCTAssertEqual(engine.mode, .visual(linewise: false)) + #expect(engine.mode == .visual(linewise: false)) } - func testVSelectsSingleCharInitially() { + @Test + func vSelectsSingleCharInitially() { buffer.setSelectedRange(NSRange(location: 3, length: 0)) keys("v") - XCTAssertEqual(sel.location, 3) - XCTAssertEqual(sel.length, 1, "Initial v selection should cover one character") - XCTAssertEqual(buffer.string(in: sel), "l") + #expect(sel.location == 3) + #expect(sel.length == 1, "Initial v selection should cover one character") + #expect(buffer.string(in: sel) == "l") } - func testVOnEmptyBuffer() { + @Test + func vOnEmptyBuffer() { buffer = VimTextBufferMock(text: "") engine = VimEngine(buffer: buffer) keys("v") - XCTAssertEqual(engine.mode, .visual(linewise: false)) - XCTAssertEqual(sel.length, 0, "Empty buffer should yield zero-length selection on v") + #expect(engine.mode == .visual(linewise: false)) + #expect(sel.length == 0, "Empty buffer should yield zero-length selection on v") } // MARK: - V: Enter Linewise Visual - func testCapitalVEntersLinewiseVisual() { + @Test + func capitalVEntersLinewiseVisual() { buffer.setSelectedRange(NSRange(location: 5, length: 0)) key("V", shift: true) - XCTAssertEqual(engine.mode, .visual(linewise: true)) + #expect(engine.mode == .visual(linewise: true)) } - func testCapitalVSelectsEntireLine() { + @Test + func capitalVSelectsEntireLine() { buffer.setSelectedRange(NSRange(location: 5, length: 0)) key("V", shift: true) - XCTAssertEqual(sel.location, 0) - XCTAssertEqual(sel.length, 12, "V should select the entire line 0 ('hello world\\n')") + #expect(sel.location == 0) + #expect(sel.length == 12, "V should select the entire line 0 ('hello world\\n')") } // MARK: - Toggle: v <-> V <-> Normal - func testVTogglesOffToNormal() { + @Test + func vTogglesOffToNormal() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("vv") - XCTAssertEqual(engine.mode, .normal) - XCTAssertEqual(sel.length, 0) + #expect(engine.mode == .normal) + #expect(sel.length == 0) } - func testCapitalVTogglesOffToNormal() { + @Test + func capitalVTogglesOffToNormal() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) key("V", shift: true) key("V", shift: true) - XCTAssertEqual(engine.mode, .normal) + #expect(engine.mode == .normal) } - func testVThenCapitalVSwitchesToLinewise() { + @Test + func vThenCapitalVSwitchesToLinewise() { buffer.setSelectedRange(NSRange(location: 5, length: 0)) keys("v") key("V", shift: true) - XCTAssertEqual(engine.mode, .visual(linewise: true)) + #expect(engine.mode == .visual(linewise: true)) } - func testCapitalVThenVSwitchesToCharacterwise() { + @Test + func capitalVThenVSwitchesToCharacterwise() { buffer.setSelectedRange(NSRange(location: 5, length: 0)) key("V", shift: true) keys("v") - XCTAssertEqual(engine.mode, .visual(linewise: false)) + #expect(engine.mode == .visual(linewise: false)) } // MARK: - Escape Exits Visual - func testEscapeExitsToNormal() { + @Test + func escapeExitsToNormal() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("v") escape() - XCTAssertEqual(engine.mode, .normal) - XCTAssertEqual(sel.length, 0) + #expect(engine.mode == .normal) + #expect(sel.length == 0) } // MARK: - Motions Extend Selection - func testLExtendsRightInclusive() { + @Test + func lExtendsRightInclusive() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("vl") - XCTAssertEqual(sel.location, 0) - XCTAssertEqual(sel.length, 2) - XCTAssertEqual(buffer.string(in: sel), "he") + #expect(sel.location == 0) + #expect(sel.length == 2) + #expect(buffer.string(in: sel) == "he") } - func testHExtendsLeftInclusive() { + @Test + func hExtendsLeftInclusive() { buffer.setSelectedRange(NSRange(location: 5, length: 0)) keys("vh") - XCTAssertEqual(sel.location, 4) - XCTAssertEqual(sel.length, 2) + #expect(sel.location == 4) + #expect(sel.length == 2) } - func testWExtendsByWord() { + @Test + func wExtendsByWord() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("vw") - XCTAssertEqual(sel.location, 0) - XCTAssertEqual(buffer.string(in: sel), "hello w") + #expect(sel.location == 0) + #expect(buffer.string(in: sel) == "hello w") } - func testEExtendsToWordEnd() { + @Test + func eExtendsToWordEnd() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("ve") - XCTAssertEqual(sel.location, 0) - XCTAssertEqual(sel.length, 5) - XCTAssertEqual(buffer.string(in: sel), "hello") + #expect(sel.location == 0) + #expect(sel.length == 5) + #expect(buffer.string(in: sel) == "hello") } - func testBExtendsBackwardByWord() { + @Test + func bExtendsBackwardByWord() { buffer.setSelectedRange(NSRange(location: 8, length: 0)) keys("vb") - XCTAssertEqual(sel.location, 6) - XCTAssertEqual(buffer.string(in: sel), "wor") + #expect(sel.location == 6) + #expect(buffer.string(in: sel) == "wor") } - func testDollarExtendsToLineEnd() { + @Test + func dollarExtendsToLineEnd() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("v$") - XCTAssertEqual(sel.location, 0) - XCTAssertEqual(sel.length, 12, "v$ should extend through the line including newline") + #expect(sel.location == 0) + #expect(sel.length == 12, "v$ should extend through the line including newline") } - func testZeroExtendsToLineStart() { + @Test + func zeroExtendsToLineStart() { buffer.setSelectedRange(NSRange(location: 5, length: 0)) keys("v0") - XCTAssertEqual(sel.location, 0) - XCTAssertEqual(sel.length, 6, "v0 should extend selection to the start of line") + #expect(sel.location == 0) + #expect(sel.length == 6, "v0 should extend selection to the start of line") } - func testCaretExtendsToFirstNonBlank() { + @Test + func caretExtendsToFirstNonBlank() { buffer = VimTextBufferMock(text: " hello\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 7, length: 0)) keys("v^") - XCTAssertEqual(sel.location, 3) - XCTAssertEqual(sel.length, 5) + #expect(sel.location == 3) + #expect(sel.length == 5) } // MARK: - Multi-line Motions - func testJExtendsToNextLine() { + @Test + func jExtendsToNextLine() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("vj") - XCTAssertEqual(sel.location, 0) - XCTAssertEqual(buffer.string(in: sel), "hello world\ns") + #expect(sel.location == 0) + #expect(buffer.string(in: sel) == "hello world\ns") } - func testKExtendsToPreviousLine() { + @Test + func kExtendsToPreviousLine() { buffer.setSelectedRange(NSRange(location: 15, length: 0)) keys("vk") - XCTAssertEqual(sel.location, 3) - XCTAssertEqual(sel.length, 13) + #expect(sel.location == 3) + #expect(sel.length == 13) } - func testGGExtendsToBufferStart() { + @Test + func gGExtendsToBufferStart() { buffer.setSelectedRange(NSRange(location: 15, length: 0)) keys("vgg") - XCTAssertEqual(sel.location, 0) - XCTAssertEqual(sel.length, 16) + #expect(sel.location == 0) + #expect(sel.length == 16) } - func testCapitalGExtendsToBufferEnd() { + @Test + func capitalGExtendsToBufferEnd() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("v") key("G", shift: true) - XCTAssertEqual(sel.location, 0) - XCTAssertEqual(sel.location + sel.length, buffer.length) + #expect(sel.location == 0) + #expect(sel.location + sel.length == buffer.length) } // MARK: - o: Swap Anchor and Cursor - func testOSwapsAnchorAndCursor() { + @Test + func oSwapsAnchorAndCursor() { // v at 5 selects ' ' (5,1), l → (5,2), l → (5,3) — cursor at 7. // o swaps anchor/cursor — now anchor=7, cursor=5. Selection still (5,3). // Then h should shrink from the LEFT (cursor) end. buffer.setSelectedRange(NSRange(location: 5, length: 0)) keys("vll") - XCTAssertEqual(sel.location, 5) - XCTAssertEqual(sel.length, 3) + #expect(sel.location == 5) + #expect(sel.length == 3) keys("o") // After o, h moves the LEFT side outward (extend left). keys("h") - XCTAssertEqual(sel.location, 4, "After o, h should extend from the new cursor (left side)") + #expect(sel.location == 4, "After o, h should extend from the new cursor (left side)") } - func testOInLinewise() { + @Test + func oInLinewise() { buffer.setSelectedRange(NSRange(location: 12, length: 0)) key("V", shift: true) keys("j") @@ -241,208 +259,231 @@ final class VimEngineVisualModeTests: XCTestCase { keys("o") // Now extending with k should grow upward from the new cursor. keys("k") - XCTAssertEqual(sel.location, 0, "After o in linewise mode, k should extend upward") + #expect(sel.location == 0, "After o in linewise mode, k should extend upward") } // MARK: - Anchor Stays Fixed - func testAnchorStaysFixedAcrossExtendingAndShrinking() { + @Test + func anchorStaysFixedAcrossExtendingAndShrinking() { buffer.setSelectedRange(NSRange(location: 5, length: 0)) keys("v") keys("lll") // extend right - XCTAssertEqual(sel.location, 5) - XCTAssertEqual(sel.length, 4) + #expect(sel.location == 5) + #expect(sel.length == 4) keys("hhh") // shrink back - XCTAssertEqual(sel.location, 5) - XCTAssertEqual(sel.length, 1) + #expect(sel.location == 5) + #expect(sel.length == 1) } - func testCursorCanCrossAnchorLeftward() { + @Test + func cursorCanCrossAnchorLeftward() { buffer.setSelectedRange(NSRange(location: 5, length: 0)) keys("vhhh") // anchor=5, cursor=2 → selection (2,4) - XCTAssertEqual(sel.location, 2) - XCTAssertEqual(sel.length, 4) + #expect(sel.location == 2) + #expect(sel.length == 4) } // MARK: - Operators in Visual Mode - func testDDeletesSelection() { + @Test + func dDeletesSelection() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("vlld") - XCTAssertEqual(buffer.text, "lo world\nsecond line\nthird line\n") - XCTAssertEqual(engine.mode, .normal) + #expect(buffer.text == "lo world\nsecond line\nthird line\n") + #expect(engine.mode == .normal) } - func testXIsAliasForDInVisual() { + @Test + func xIsAliasForDInVisual() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("vllx") - XCTAssertEqual(buffer.text, "lo world\nsecond line\nthird line\n") - XCTAssertEqual(engine.mode, .normal) + #expect(buffer.text == "lo world\nsecond line\nthird line\n") + #expect(engine.mode == .normal) } - func testYYanksSelectionWithoutModifyingBuffer() { + @Test + func yYanksSelectionWithoutModifyingBuffer() { let original = buffer.text buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("vey") - XCTAssertEqual(buffer.text, original) - XCTAssertEqual(engine.mode, .normal) + #expect(buffer.text == original) + #expect(engine.mode == .normal) } - func testYReturnsCursorToAnchor() { + @Test + func yReturnsCursorToAnchor() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("vey") - XCTAssertEqual(sel.location, 0, "Yank should leave cursor at the start of the yanked region") + #expect(sel.location == 0, "Yank should leave cursor at the start of the yanked region") } - func testCChangesSelectionAndEntersInsert() { + @Test + func cChangesSelectionAndEntersInsert() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("vlc") - XCTAssertEqual(engine.mode, .insert) - XCTAssertEqual(buffer.text, "llo world\nsecond line\nthird line\n") + #expect(engine.mode == .insert) + #expect(buffer.text == "llo world\nsecond line\nthird line\n") } - func testDeleteThenPasteRoundTrip() { + @Test + func deleteThenPasteRoundTrip() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("vlld") // Selection was "hel" — should be in register. key("P", shift: true) - XCTAssertEqual(buffer.text, "hello world\nsecond line\nthird line\n", + #expect(buffer.text == "hello world\nsecond line\nthird line\n", "Deleted selection should round-trip through P") } // MARK: - Linewise Operations - func testLinewiseDDeletesWholeLine() { + @Test + func linewiseDDeletesWholeLine() { buffer.setSelectedRange(NSRange(location: 5, length: 0)) key("V", shift: true) keys("d") - XCTAssertEqual(buffer.text, "second line\nthird line\n") + #expect(buffer.text == "second line\nthird line\n") } - func testLinewiseDMultipleLines() { + @Test + func linewiseDMultipleLines() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) key("V", shift: true) keys("jd") - XCTAssertEqual(buffer.text, "third line\n") + #expect(buffer.text == "third line\n") } - func testLinewiseYThenP() { + @Test + func linewiseYThenP() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) key("V", shift: true) keys("yp") - XCTAssertEqual(buffer.text, "hello world\nhello world\nsecond line\nthird line\n") + #expect(buffer.text == "hello world\nhello world\nsecond line\nthird line\n") } - func testLinewiseCDeletesContentEntersInsert() { + @Test + func linewiseCDeletesContentEntersInsert() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) key("V", shift: true) keys("c") - XCTAssertEqual(engine.mode, .insert) - XCTAssertEqual(buffer.text, "\nsecond line\nthird line\n", + #expect(engine.mode == .insert) + #expect(buffer.text == "\nsecond line\nthird line\n", "Linewise change should delete content but keep one newline") } // MARK: - ~ Toggle Case in Visual - func testTildeTogglesCaseInVisual() { + @Test + func tildeTogglesCaseInVisual() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("ve") keys("~") - XCTAssertEqual(buffer.text, "HELLO world\nsecond line\nthird line\n", + #expect(buffer.text == "HELLO world\nsecond line\nthird line\n", "~ in visual should toggle case of the selection and exit to normal") - XCTAssertEqual(engine.mode, .normal) + #expect(engine.mode == .normal) } - func testGuLowercasesSelection() { + @Test + func guLowercasesSelection() { buffer = VimTextBufferMock(text: "Hello World\n") engine = VimEngine(buffer: buffer) buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("v$") keys("u") - XCTAssertEqual(buffer.text, "hello world\n", + #expect(buffer.text == "hello world\n", "Selecting and pressing u in visual should lowercase the selection") } - func testGUUppercaseSelection() { + @Test + func gUUppercaseSelection() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("v") keys("e") key("U", shift: true) - XCTAssertEqual(buffer.text, "HELLO world\nsecond line\nthird line\n", + #expect(buffer.text == "HELLO world\nsecond line\nthird line\n", "Selecting and pressing U in visual should uppercase the selection") } // MARK: - r in Visual - func testRReplacesSelectionWithSingleChar() { + @Test + func rReplacesSelectionWithSingleChar() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("ve") keys("rX") - XCTAssertEqual(buffer.text, "XXXXX world\nsecond line\nthird line\n", + #expect(buffer.text == "XXXXX world\nsecond line\nthird line\n", "r in visual should replace every char in selection with the given char") - XCTAssertEqual(engine.mode, .normal) + #expect(engine.mode == .normal) } // MARK: - Edge Cases - func testVisualAtBufferStart() { + @Test + func visualAtBufferStart() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("vh") - XCTAssertEqual(sel.location, 0) - XCTAssertEqual(sel.length, 1) + #expect(sel.location == 0) + #expect(sel.length == 1) } - func testVisualAtBufferEnd() { + @Test + func visualAtBufferEnd() { buffer.setSelectedRange(NSRange(location: 34, length: 0)) keys("v") - XCTAssertEqual(sel.location, 34) - XCTAssertEqual(sel.length, 1) + #expect(sel.location == 34) + #expect(sel.length == 1) keys("l") - XCTAssertEqual(sel.location, 34, "l at buffer end should not extend past length") + #expect(sel.location == 34, "l at buffer end should not extend past length") } - func testVisualDeleteAllContent() { + @Test + func visualDeleteAllContent() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("v") key("G", shift: true) keys("d") - XCTAssertEqual(buffer.text, "") - XCTAssertEqual(engine.mode, .normal) + #expect(buffer.text == "") + #expect(engine.mode == .normal) } // MARK: - Unknown Keys - func testUnknownKeyConsumedInVisual() { + @Test + func unknownKeyConsumedInVisual() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("v") let consumed = engine.process("z", shift: false) - XCTAssertTrue(consumed) - XCTAssertEqual(engine.mode, .visual(linewise: false), + #expect(consumed) + #expect(engine.mode == .visual(linewise: false), "Unknown keys in visual must be consumed but not exit visual mode") } // MARK: - Insert Mode From Visual - func testIInVisualBlockShouldInsertAtSelectionStart() { + @Test + func iInVisualBlockShouldInsertAtSelectionStart() { // This is more meaningful in block visual; in regular visual, I just enters insert // at the cursor position. We assert the engine does not crash and exits visual. buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("ve") key("I", shift: true) - XCTAssertEqual(engine.mode, .insert) + #expect(engine.mode == .insert) } // MARK: - Mode Display - func testVisualDisplayLabel() { + @Test + func visualDisplayLabel() { keys("v") - XCTAssertEqual(engine.mode.displayLabel, "VISUAL") + #expect(engine.mode.displayLabel == "VISUAL") } - func testVisualLineDisplayLabel() { + @Test + func visualLineDisplayLabel() { key("V", shift: true) - XCTAssertEqual(engine.mode.displayLabel, "VISUAL LINE") + #expect(engine.mode.displayLabel == "VISUAL LINE") } } diff --git a/TableProTests/Core/Vim/VimEngineVisualReselectionTests.swift b/TableProTests/Core/Vim/VimEngineVisualReselectionTests.swift index 1bfdb0b412..6c811cfbda 100644 --- a/TableProTests/Core/Vim/VimEngineVisualReselectionTests.swift +++ b/TableProTests/Core/Vim/VimEngineVisualReselectionTests.swift @@ -6,27 +6,21 @@ // the most recent visual selection. // -import XCTest -import TableProPluginKit +import Foundation @testable import TablePro +import TableProPluginKit +import Testing @MainActor -final class VimEngineVisualReselectionTests: XCTestCase { +final class VimEngineVisualReselectionTests { private var engine: VimEngine! private var buffer: VimTextBufferMock! - override func setUp() { - super.setUp() + init() { buffer = VimTextBufferMock(text: "hello world\nsecond line\nthird line\n") engine = VimEngine(buffer: buffer) } - override func tearDown() { - engine = nil - buffer = nil - super.tearDown() - } - private func keys(_ chars: String) { for char in chars { _ = engine.process(char, shift: false) } } @@ -39,28 +33,30 @@ final class VimEngineVisualReselectionTests: XCTestCase { // MARK: - gv: Reselect Last Visual - func testGVReselectsLastCharacterwiseVisual() { + @Test + func gVReselectsLastCharacterwiseVisual() { // Make a selection in visual mode and exit. buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("v") keys("lll") let originalSel = buffer.selectedRange() escape() - XCTAssertEqual(engine.mode, .normal) + #expect(engine.mode == .normal) // Move cursor somewhere else. keys("0") keys("j") // gv should restore the original visual selection. keys("gv") - XCTAssertEqual(engine.mode, .visual(linewise: false), + #expect(engine.mode == .visual(linewise: false), "gv should re-enter visual mode") - XCTAssertEqual(buffer.selectedRange().location, originalSel.location, + #expect(buffer.selectedRange().location == originalSel.location, "gv should restore the original selection location") - XCTAssertEqual(buffer.selectedRange().length, originalSel.length, + #expect(buffer.selectedRange().length == originalSel.length, "gv should restore the original selection length") } - func testGVReselectsLastLinewiseVisual() { + @Test + func gVReselectsLastLinewiseVisual() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) key("V", shift: true) keys("j") @@ -68,28 +64,30 @@ final class VimEngineVisualReselectionTests: XCTestCase { escape() keys("G") keys("gv") - XCTAssertEqual(engine.mode, .visual(linewise: true), + #expect(engine.mode == .visual(linewise: true), "gv after V should re-enter linewise visual mode") - XCTAssertEqual(buffer.selectedRange().location, originalSel.location) - XCTAssertEqual(buffer.selectedRange().length, originalSel.length) + #expect(buffer.selectedRange().location == originalSel.location) + #expect(buffer.selectedRange().length == originalSel.length) } - func testGVAfterDeleteOperationStillReselectable() { + @Test + func gVAfterDeleteOperationStillReselectable() { // Even after running an operator on the selection, gv should reselect. // The selection bounds may shift to track the edit, but the visual region // (now containing whatever replaced the deleted text) should be reselectable. buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("vlll") keys("d") - XCTAssertEqual(engine.mode, .normal) + #expect(engine.mode == .normal) keys("gv") - XCTAssertEqual(engine.mode, .visual(linewise: false), + #expect(engine.mode == .visual(linewise: false), "gv should still enter visual mode after a delete operation") } // MARK: - '< and '> Jump Marks - func testJumpToLastSelectionStart() { + @Test + func jumpToLastSelectionStart() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("v") keys("lll") @@ -97,11 +95,12 @@ final class VimEngineVisualReselectionTests: XCTestCase { escape() keys("G") keys("`<") - XCTAssertEqual(buffer.selectedRange().location, start, + #expect(buffer.selectedRange().location == start, "`< should jump to the start of the last visual selection") } - func testJumpToLastSelectionEnd() { + @Test + func jumpToLastSelectionEnd() { buffer.setSelectedRange(NSRange(location: 0, length: 0)) keys("v") keys("lll") @@ -110,17 +109,18 @@ final class VimEngineVisualReselectionTests: XCTestCase { escape() keys("G") keys("`>") - XCTAssertEqual(buffer.selectedRange().location, inclusiveEnd, + #expect(buffer.selectedRange().location == inclusiveEnd, "`> should jump to the inclusive-end of the last visual selection") } // MARK: - gv Without Prior Selection - func testGVWithNoPriorSelectionIsNoOp() { + @Test + func gVWithNoPriorSelectionIsNoOp() { // Fresh engine, no previous visual selection. buffer.setSelectedRange(NSRange(location: 5, length: 0)) keys("gv") - XCTAssertEqual(engine.mode, .normal, + #expect(engine.mode == .normal, "gv with no prior selection should remain in normal mode (no-op)") } } diff --git a/TableProTests/Database/NativeDumpBatchTests.swift b/TableProTests/Database/NativeDumpBatchTests.swift index 01f27f709b..d30ad94f67 100644 --- a/TableProTests/Database/NativeDumpBatchTests.swift +++ b/TableProTests/Database/NativeDumpBatchTests.swift @@ -69,7 +69,6 @@ private final class FakeDumpService: NativeDumpRunning { } } -@Suite("Native dump batch", .serialized) @MainActor struct NativeDumpBatchTests { private let root = URL(fileURLWithPath: NSTemporaryDirectory()) diff --git a/TableProTests/Database/NativeDumpServiceTests.swift b/TableProTests/Database/NativeDumpServiceTests.swift index e395fe8cf7..4c30497e9b 100644 --- a/TableProTests/Database/NativeDumpServiceTests.swift +++ b/TableProTests/Database/NativeDumpServiceTests.swift @@ -340,7 +340,6 @@ private final class FakeDumpRunner: NativeDumpRunner, @unchecked Sendable { } } -@Suite("NativeDumpService state machine", .serialized) @MainActor struct NativeDumpServiceStateMachineTests { private func fakeJob(exitPolicy: NativeDumpExitPolicy = .zeroExitOnly) -> NativeDumpJob { diff --git a/TableProTests/Extensions/ObservedValueChangeTests.swift b/TableProTests/Extensions/ObservedValueChangeTests.swift index 2cdbbb2cd6..d8a9564ef3 100644 --- a/TableProTests/Extensions/ObservedValueChangeTests.swift +++ b/TableProTests/Extensions/ObservedValueChangeTests.swift @@ -1,11 +1,6 @@ -// -// ObservedValueChangeTests.swift -// TableProTests -// -// A view that observes a parent and reacts to `parent.child.value` never hears the child change, -// because the child's `@Published` values publish on the child alone. The main window reacts to -// the sidebar selection and the inspector's view mode that way, each held by a child object. -// +// A view that observes a parent and reacts to `parent.child.value` never hears the child change, +// because the child's `@Published` values publish on the child alone. The main window reacts to +// the sidebar selection and the inspector's view mode that way, each held by a child object. import AppKit import Combine @@ -13,6 +8,7 @@ import SwiftUI import Testing @testable import TablePro +import TableProTestSupport @MainActor struct ObservedValueChangeTests { @@ -27,6 +23,7 @@ struct ObservedValueChangeTests { private final class Changes { var pairs: [[Int]] = [] + var appeared = false } private struct ObservedProbe: View { @@ -34,9 +31,11 @@ struct ObservedValueChangeTests { let changes: Changes var body: some View { - Color.clear.onValueChange(of: \.value, in: parent.child) { old, new in - changes.pairs.append([old, new]) - } + Color.clear + .onValueChange(of: \.value, in: parent.child) { old, new in + changes.pairs.append([old, new]) + } + .onAppear { changes.appeared = true } } } @@ -45,13 +44,30 @@ struct ObservedValueChangeTests { let changes: Changes var body: some View { - Color.clear.onValueChange(of: parent.child.value) { old, new in - changes.pairs.append([old, new]) - } + Color.clear + .onValueChange(of: parent.child.value) { old, new in + changes.pairs.append([old, new]) + } + .onAppear { changes.appeared = true } } } - private func host(_ view: some View) -> NSWindow { + /// A sibling the window does hear. Its change lands in the same update a change to the probe + /// would have, so once it is reported, a probe that stayed silent heard nothing. + private struct BarrierProbe: View { + @ObservedObject var barrier: Child + let changes: Changes + + var body: some View { + Color.clear + .onValueChange(of: barrier.value) { old, new in + changes.pairs.append([old, new]) + } + .onAppear { changes.appeared = true } + } + } + + private func host(_ view: some View, appeared: [Changes]) async throws -> NSWindow { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 40, height: 40), styleMask: [.titled], @@ -60,41 +76,51 @@ struct ObservedValueChangeTests { ) window.isReleasedWhenClosed = false window.contentView = NSHostingView(rootView: view) - settle(window) + try await settle(window) { appeared.allSatisfy(\.appeared) } return window } - private func settle(_ window: NSWindow) { - for _ in 0 ..< 10 { + /// The window is never shown, so nothing draws it: the hosting view updates on layout. + private func settle(_ window: NSWindow, until condition: () -> Bool) async throws { + try await waitUntil { window.contentView?.layoutSubtreeIfNeeded() - RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.02)) + return condition() } } @Test("a change inside the child reaches the action with both values") - func childChangeReachesAction() { + func childChangeReachesAction() async throws { let parent = Parent() let changes = Changes() - let window = host(ObservedProbe(parent: parent, changes: changes)) + let window = try await host(ObservedProbe(parent: parent, changes: changes), appeared: [changes]) defer { window.orderOut(nil) } parent.child.value = 1 - settle(window) + try await settle(window) { changes.pairs.count == 1 } parent.child.value = 2 - settle(window) + try await settle(window) { changes.pairs.count == 2 } #expect(changes.pairs == [[0, 1], [1, 2]]) } @Test("reading the value through the parent misses the change") - func readingThroughParentMissesTheChange() { + func readingThroughParentMissesTheChange() async throws { let parent = Parent() let changes = Changes() - let window = host(ReadThroughProbe(parent: parent, changes: changes)) + let barrier = Child() + let barrierChanges = Changes() + let window = try await host( + VStack { + ReadThroughProbe(parent: parent, changes: changes) + BarrierProbe(barrier: barrier, changes: barrierChanges) + }, + appeared: [changes, barrierChanges] + ) defer { window.orderOut(nil) } parent.child.value = 1 - settle(window) + barrier.value = 1 + try await settle(window) { barrierChanges.pairs == [[0, 1]] } #expect(changes.pairs.isEmpty) } diff --git a/TableProTests/Extensions/OnValueChangeReparentTests.swift b/TableProTests/Extensions/OnValueChangeReparentTests.swift index 3e0ff2ea4b..57b609396c 100644 --- a/TableProTests/Extensions/OnValueChangeReparentTests.swift +++ b/TableProTests/Extensions/OnValueChangeReparentTests.swift @@ -1,12 +1,7 @@ -// -// OnValueChangeReparentTests.swift -// TableProTests -// -// A workspace switch takes the outgoing pane out of the window and puts it back on return. -// SwiftUI runs the re-attach's onAppear before a change the pane missed while detached, so a -// modifier that reseeded its previous value there reported that change as old == new and -// dropped it. The main window's tab selection reaches `handleTabChange` this way. -// +// A workspace switch takes the outgoing pane out of the window and puts it back on return. +// SwiftUI runs the re-attach's onAppear before a change the pane missed while detached, so a +// modifier that reseeded its previous value there reported that change as old == new and +// dropped it. The main window's tab selection reaches `handleTabChange` this way. import AppKit import Combine @@ -14,6 +9,7 @@ import SwiftUI import Testing @testable import TablePro +import TableProTestSupport @MainActor struct OnValueChangeReparentTests { @@ -23,6 +19,8 @@ struct OnValueChangeReparentTests { private final class Changes { var pairs: [[Int]] = [] + var appearances = 0 + var disappearances = 0 } private struct Probe: View { @@ -30,9 +28,12 @@ struct OnValueChangeReparentTests { let changes: Changes var body: some View { - Color.clear.onValueChange(of: model.value) { old, new in - changes.pairs.append([old, new]) - } + Color.clear + .onValueChange(of: model.value) { old, new in + changes.pairs.append([old, new]) + } + .onAppear { changes.appearances += 1 } + .onDisappear { changes.disappearances += 1 } } } @@ -42,17 +43,17 @@ struct OnValueChangeReparentTests { } @Test("a change made while the pane is detached reaches the action on return") - func changeWhileDetachedReachesAction() { - #expect(switchAwayAndBack(changing: .whileDetached) == [[0, 1], [1, 2], [2, 3]]) + func changeWhileDetachedReachesAction() async throws { + #expect(try await switchAwayAndBack(changing: .whileDetached) == [[0, 1], [1, 2], [2, 3]]) } @Test("a change made in the same turn as the re-attach reaches the action") - func changeInReattachTurnReachesAction() { - #expect(switchAwayAndBack(changing: .afterReattachInSameTurn) == [[0, 1], [1, 2], [2, 3]]) + func changeInReattachTurnReachesAction() async throws { + #expect(try await switchAwayAndBack(changing: .afterReattachInSameTurn) == [[0, 1], [1, 2], [2, 3]]) } /// Drives the pane through `WorkspacePaneHost.show`, the call a workspace switch makes. - private func switchAwayAndBack(changing timing: ChangeTiming) -> [[Int]] { + private func switchAwayAndBack(changing timing: ChangeTiming) async throws -> [[Int]] { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 200, height: 100), styleMask: [.titled], @@ -73,12 +74,12 @@ struct OnValueChangeReparentTests { otherPane.view = NSView() host.show(pane) - settle(window) + try await settle(window) { changes.appearances == 1 } model.value = 1 - settle(window) + try await settle(window) { changes.pairs.count == 1 } host.show(otherPane) - settle(window) + try await settle(window) { changes.disappearances == 1 } #expect(pane.view.window == nil) switch timing { @@ -89,17 +90,18 @@ struct OnValueChangeReparentTests { host.show(pane) model.value = 2 } - settle(window) + try await settle(window) { changes.pairs.count == 2 } model.value = 3 - settle(window) + try await settle(window) { changes.pairs.count == 3 } return changes.pairs } - private func settle(_ window: NSWindow) { - for _ in 0 ..< 10 { + /// The window is never shown, so nothing draws it: the hosting view updates on layout. + private func settle(_ window: NSWindow, until condition: () -> Bool) async throws { + try await waitUntil { window.contentView?.layoutSubtreeIfNeeded() - RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.02)) + return condition() } } } diff --git a/TableProTests/Extensions/StringHexDumpTests.swift b/TableProTests/Extensions/StringHexDumpTests.swift index 15805ba471..c392902461 100644 --- a/TableProTests/Extensions/StringHexDumpTests.swift +++ b/TableProTests/Extensions/StringHexDumpTests.swift @@ -151,40 +151,20 @@ struct StringHexDumpTests { // MARK: - Parse Hex - @Test("Parse space-separated hex") + @Test("Parse hex space-separated, continuous and with a 0x prefix", arguments: [ + "48 65 6C 6C 6F", "48656C6C6F", "0x48656C6C6F", + ]) @MainActor - func parseHexSpaceSeparated() { - #expect(BlobFormattingService.shared.parseHex("48 65 6C 6C 6F") == "Hello") + func parseHex(_ input: String) { + #expect(BlobFormattingService.shared.parseHex(input) == "Hello") } - @Test("Parse continuous hex") + @Test("Parse hex rejects odd-length input, invalid characters and an empty string", arguments: [ + "486", "ZZZZ", "", + ]) @MainActor - func parseHexContinuous() { - #expect(BlobFormattingService.shared.parseHex("48656C6C6F") == "Hello") - } - - @Test("Parse hex with 0x prefix") - @MainActor - func parseHexWithPrefix() { - #expect(BlobFormattingService.shared.parseHex("0x48656C6C6F") == "Hello") - } - - @Test("Parse hex rejects odd-length input") - @MainActor - func parseHexInvalidOddLength() { - #expect(BlobFormattingService.shared.parseHex("486") == nil) - } - - @Test("Parse hex rejects invalid characters") - @MainActor - func parseHexInvalidChars() { - #expect(BlobFormattingService.shared.parseHex("ZZZZ") == nil) - } - - @Test("Parse hex rejects empty string") - @MainActor - func parseHexEmpty() { - #expect(BlobFormattingService.shared.parseHex("") == nil) + func parseHexRejects(_ input: String) { + #expect(BlobFormattingService.shared.parseHex(input) == nil) } @Test("Round-trip: raw → editable hex → parse back to raw") diff --git a/TableProTests/Extensions/StringJsonTests.swift b/TableProTests/Extensions/StringJsonTests.swift index b14b92e177..9c8b29cd15 100644 --- a/TableProTests/Extensions/StringJsonTests.swift +++ b/TableProTests/Extensions/StringJsonTests.swift @@ -36,20 +36,9 @@ struct StringJsonTests { #expect(result == expected) } - @Test("Invalid JSON returns nil") - func invalidJson() { - let input = "not valid json at all" - let result = input.prettyPrintedAsJson() - - #expect(result == nil) - } - - @Test("Empty string returns nil") - func emptyString() { - let input = "" - let result = input.prettyPrintedAsJson() - - #expect(result == nil) + @Test("Invalid JSON and an empty string return nil", arguments: ["not valid json at all", ""]) + func notJson(_ input: String) { + #expect(input.prettyPrintedAsJson() == nil) } @Test("Nested objects are correctly indented") diff --git a/TableProTests/Helpers/AIFeatureScope.swift b/TableProTests/Helpers/AIFeatureScope.swift deleted file mode 100644 index 217e4ec3d2..0000000000 --- a/TableProTests/Helpers/AIFeatureScope.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// AIFeatureScope.swift -// TableProTests -// -// The assistant, Agent mode and every surface that depends on them are drawn only with the AI -// feature on, which is the setting's default. A case that needs it runs inside this scope. -// - -import Foundation -@testable import TablePro - -@MainActor -enum AIFeatureScope { - /// Turns the feature on for the body and puts the setting back after. A machine where it is - /// already on, which is every fresh runner, is never written to: the setting is persisted and - /// synced, so a case that wrote it unconditionally would touch the developer's own preferences. - static func enabled(_ body: () throws -> T) rethrows -> T { - let previous = AppSettingsManager.shared.ai - guard !previous.enabled else { return try body() } - var enabled = previous - enabled.enabled = true - AppSettingsManager.shared.ai = enabled - defer { AppSettingsManager.shared.ai = previous } - return try body() - } - - /// The same scope for a case that has to suspend, which is how it lets another main-actor task - /// run: a synchronous spin of the run loop from inside the case does not. - static func enabled(_ body: () async throws -> T) async rethrows -> T { - let previous = AppSettingsManager.shared.ai - guard !previous.enabled else { return try await body() } - var enabled = previous - enabled.enabled = true - AppSettingsManager.shared.ai = enabled - defer { AppSettingsManager.shared.ai = previous } - return try await body() - } -} diff --git a/TableProTests/Helpers/AppSettingsTrait.swift b/TableProTests/Helpers/AppSettingsTrait.swift new file mode 100644 index 0000000000..81b45d3045 --- /dev/null +++ b/TableProTests/Helpers/AppSettingsTrait.swift @@ -0,0 +1,79 @@ +@testable import TablePro +import Testing + +/// Applies a settings change for one case and puts every group back after it. Only groups that +/// differ are written: each write persists, syncs and fires its observers. A user nests under +/// `GlobalStateSuites`, since the change and the case body are not one step on the main actor. +struct AppSettingsTrait: TestTrait, SuiteTrait, TestScoping { + struct Values { + var general: GeneralSettings + var appearance: AppearanceSettings + var editor: EditorSettings + var typography: TypographySettings + var notifications: NotificationSettings + var dataGrid: DataGridSettings + var history: HistorySettings + var tabs: TabSettings + var keyboard: KeyboardSettings + var ai: AISettings + var sync: SyncSettings + var mcp: MCPSettings + + @MainActor + init(_ manager: AppSettingsManager) { + general = manager.general + appearance = manager.appearance + editor = manager.editor + typography = manager.typography + notifications = manager.notifications + dataGrid = manager.dataGrid + history = manager.history + tabs = manager.tabs + keyboard = manager.keyboard + ai = manager.ai + sync = manager.sync + mcp = manager.mcp + } + + @MainActor + func write(to manager: AppSettingsManager) { + if manager.general != general { manager.general = general } + if manager.appearance != appearance { manager.appearance = appearance } + if manager.editor != editor { manager.editor = editor } + if manager.typography != typography { manager.typography = typography } + if manager.notifications != notifications { manager.notifications = notifications } + if manager.dataGrid != dataGrid { manager.dataGrid = dataGrid } + if manager.history != history { manager.history = history } + if manager.tabs != tabs { manager.tabs = tabs } + if manager.keyboard != keyboard { manager.keyboard = keyboard } + if manager.ai != ai { manager.ai = ai } + if manager.sync != sync { manager.sync = sync } + if manager.mcp != mcp { manager.mcp = mcp } + } + } + + let change: @Sendable (inout Values) -> Void + var isRecursive: Bool { true } + + @MainActor + func provideScope( + for test: Test, + testCase: Test.Case?, + performing function: @concurrent @Sendable () async throws -> Void + ) async throws { + let manager = AppSettingsManager.shared + let original = Values(manager) + var changed = original + change(&changed) + changed.write(to: manager) + defer { original.write(to: manager) } + try await function() + } +} + +extension Trait where Self == AppSettingsTrait { + /// `@Test(.appSettings { $0.dataGrid.defaultSortBehavior = .none })` + static func appSettings(_ change: @escaping @Sendable (inout AppSettingsTrait.Values) -> Void) -> Self { + Self(change: change) + } +} diff --git a/TableProTests/Helpers/CoordinatorFixture.swift b/TableProTests/Helpers/CoordinatorFixture.swift new file mode 100644 index 0000000000..5edcff795b --- /dev/null +++ b/TableProTests/Helpers/CoordinatorFixture.swift @@ -0,0 +1,46 @@ +@testable import TablePro + +/// Tears the coordinator down when the fixture goes away: an untorn coordinator keeps its +/// termination observer and schema load task alive into later cases. +@MainActor +final class CoordinatorFixture { + let connection: DatabaseConnection + let tabManager = QueryTabManager() + let changeManager = DataChangeManager() + let coordinator: MainContentCoordinator + private let injectedSession: Bool + + init( + connection: DatabaseConnection = TestFixtures.makeConnection(), + driver: (any DatabaseDriver)? = nil, + services: AppServices = .live + ) { + self.connection = connection + if let driver { + var session = ConnectionSession(connection: connection, driver: driver) + session.status = .connected + DatabaseManager.shared.injectSession(session, for: connection.id) + } + injectedSession = driver != nil + coordinator = MainContentCoordinator( + connection: connection, + tabManager: tabManager, + changeManager: changeManager, + toolbarState: ConnectionToolbarState(), + services: services + ) + } + + @discardableResult + func addTab(_ tab: QueryTab, rows: TableRows? = nil, select: Bool = true) -> QueryTab { + tabManager.tabs.append(tab) + if select { tabManager.selectedTabId = tab.id } + if let rows { coordinator.setActiveTableRows(rows, for: tab.id) } + return tab + } + + isolated deinit { + if !coordinator.isTearingDown { coordinator.teardown() } + if injectedSession { DatabaseManager.shared.removeSession(for: connection.id) } + } +} diff --git a/TableProTests/Helpers/EphemeralDefaults.swift b/TableProTests/Helpers/EphemeralDefaults.swift new file mode 100644 index 0000000000..0a4554020d --- /dev/null +++ b/TableProTests/Helpers/EphemeralDefaults.swift @@ -0,0 +1,37 @@ +import Foundation +import Testing + +/// A defaults suite that lives and dies with its owner. The suite is named by a path in a temporary +/// directory, so its plist lands there: a named suite leaves a 42-byte plist in +/// ~/Library/Preferences, which cfprefsd writes back seconds after the file is deleted. +final class EphemeralDefaults { + let defaults: UserDefaults + let suiteName: String + private let directory: URL + + init(_ label: String = #fileID) throws { + directory = FileManager.default.temporaryDirectory + .appendingPathComponent("EphemeralDefaults-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + suiteName = directory + .appendingPathComponent(label.replacingOccurrences(of: "/", with: "."), isDirectory: false) + .path + defaults = try #require(UserDefaults(suiteName: suiteName)) + } + + deinit { + defaults.removePersistentDomain(forName: suiteName) + try? FileManager.default.removeItem(at: directory) + } +} + +/// For a factory that makes a fresh suite on every call: each one lives as long as the pool. +final class EphemeralDefaultsPool { + private var suites: [EphemeralDefaults] = [] + + func make(_ label: String = #fileID) throws -> EphemeralDefaults { + let suite = try EphemeralDefaults(label) + suites.append(suite) + return suite + } +} diff --git a/TableProTests/Helpers/FakeMSSQLPlugin.swift b/TableProTests/Helpers/FakeMSSQLPlugin.swift index 0996cd7a1c..9a3a6f9a6c 100644 --- a/TableProTests/Helpers/FakeMSSQLPlugin.swift +++ b/TableProTests/Helpers/FakeMSSQLPlugin.swift @@ -1,12 +1,3 @@ -// -// FakeMSSQLPlugin.swift -// TableProTests -// -// Minimal MSSQL driver stub registered with PluginManager so tests that -// resolve the SQL Server plugin (queryBuildingDriver, sqlDialect lookups) -// succeed without bundling the real MSSQLDriverPlugin. -// - import Foundation import os @testable import TablePro @@ -46,6 +37,7 @@ actor FakeMSSQLConnectHold { } } +// Registered with PluginManager, so a case that resolves the SQL Server plugin needs no real bundle. final class FakeMSSQLPlugin: NSObject, TableProPlugin, DriverPlugin { struct CreatedDriverConfiguration: Equatable, Sendable { let host: String @@ -159,7 +151,7 @@ final class FakeMSSQLPlugin: NSObject, TableProPlugin, DriverPlugin { } } -final class FakeMSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { +final class FakeMSSQLPluginDriver: PluginDriverStub, @unchecked Sendable { var supportsSchemas: Bool { true } var currentSchema: String? { "dbo" } var parameterStyle: ParameterStyle { .questionMark } @@ -205,24 +197,6 @@ final class FakeMSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { queryTimeoutValues.withLock { $0.append(seconds) } } - func execute(query: String) async throws -> PluginQueryResult { - PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) - } - - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } - func quoteIdentifier(_ name: String) -> String { let escaped = name.replacingOccurrences(of: "]", with: "]]") return "[\(escaped)]" diff --git a/TableProTests/Helpers/GlobalStateSuites.swift b/TableProTests/Helpers/GlobalStateSuites.swift new file mode 100644 index 0000000000..ccc444fa4d --- /dev/null +++ b/TableProTests/Helpers/GlobalStateSuites.swift @@ -0,0 +1,7 @@ +import Testing + +/// Suites that keep a process-wide value swapped across a suspension nest here. `.serialized` on +/// this parent orders every nested suite against the others; on a sibling suite it would only +/// order its own cases. +@Suite(.serialized) +enum GlobalStateSuites {} diff --git a/TableProTests/Helpers/ImportLibraryFixture.swift b/TableProTests/Helpers/ImportLibraryFixture.swift index 06e3c64334..50e5ae305e 100644 --- a/TableProTests/Helpers/ImportLibraryFixture.swift +++ b/TableProTests/Helpers/ImportLibraryFixture.swift @@ -1,8 +1,3 @@ -// -// ImportLibraryFixture.swift -// TableProTests -// - import Foundation @testable import TablePro import TableProImport @@ -29,7 +24,8 @@ final class ImportLibraryFixture { let sshProfiles: SSHProfileStorage let favorites: SQLFavoriteManager - private let suiteNames: [String] + private let syncSuite: EphemeralDefaults + private let storeSuite: EphemeralDefaults init(damage: Set = []) throws { let unique = UUID().uuidString @@ -43,14 +39,10 @@ final class ImportLibraryFixture { self.directory = directory try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - let syncSuite = "com.TablePro.tests.ImportLibrary.sync.\(unique)" - let storeSuite = "com.TablePro.tests.ImportLibrary.store.\(unique)" - suiteNames = [syncSuite, storeSuite] - guard let syncDefaults = UserDefaults(suiteName: syncSuite), - let defaults = UserDefaults(suiteName: storeSuite) else { - throw CocoaError(.featureUnsupported) - } - let metadata = SyncMetadataStorage(userDefaults: syncDefaults) + syncSuite = try EphemeralDefaults("ImportLibrary.sync") + storeSuite = try EphemeralDefaults("ImportLibrary.store") + let defaults = storeSuite.defaults + let metadata = SyncMetadataStorage(userDefaults: syncSuite.defaults) self.metadata = metadata let tracker = SyncChangeTracker(metadataStorage: metadata) let integrity = ConnectionStoreIntegrity(keySource: StoredIntegrityKeySource(store: keychain)) @@ -160,8 +152,5 @@ final class ImportLibraryFixture { func cleanUp() { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: directory.path) try? FileManager.default.removeItem(at: directory) - for suite in suiteNames { - UserDefaults(suiteName: suite)?.removePersistentDomain(forName: suite) - } } } diff --git a/TableProTests/Helpers/InMemoryColumnLayoutStore.swift b/TableProTests/Helpers/InMemoryColumnLayoutStore.swift new file mode 100644 index 0000000000..6d6f887d13 --- /dev/null +++ b/TableProTests/Helpers/InMemoryColumnLayoutStore.swift @@ -0,0 +1,13 @@ +@testable import TablePro + +@MainActor +final class InMemoryColumnLayoutStore: ColumnLayoutPersisting { + var layouts: [ColumnLayoutTableKey: ColumnLayoutState] = [:] + var hidden: [ColumnLayoutTableKey: Set] = [:] + + func load(for key: ColumnLayoutTableKey) -> ColumnLayoutState? { layouts[key] } + func save(_ layout: ColumnLayoutState, for key: ColumnLayoutTableKey) { layouts[key] = layout } + func clear(for key: ColumnLayoutTableKey) { layouts[key] = nil } + func loadHiddenColumns(for key: ColumnLayoutTableKey) -> Set { hidden[key] ?? [] } + func saveHiddenColumns(_ hidden: Set, for key: ColumnLayoutTableKey) { self.hidden[key] = hidden } +} diff --git a/TableProTests/Helpers/IndexStatementStubDriver.swift b/TableProTests/Helpers/IndexStatementStubDriver.swift index beae5653b8..b5c5cbf223 100644 --- a/TableProTests/Helpers/IndexStatementStubDriver.swift +++ b/TableProTests/Helpers/IndexStatementStubDriver.swift @@ -1,13 +1,8 @@ -// -// IndexStatementStubDriver.swift -// TableProTests -// - import Foundation @testable import TablePro import TableProPluginKit -internal final class IndexStatementStubDriver: PluginDatabaseDriver, @unchecked Sendable { +internal final class IndexStatementStubDriver: PluginDriverStub, @unchecked Sendable { internal let indexSchema: String internal let writesIndexes: Bool @@ -34,29 +29,4 @@ internal final class IndexStatementStubDriver: PluginDatabaseDriver, @unchecked guard writesIndexes else { return nil } return "DROP INDEX \(quoteIdentifier(indexSchema)).\(quoteIdentifier(indexName))" } - - internal func connect() async throws {} - internal func disconnect() {} - internal var isConnected: Bool { true } - - internal func execute(query: String) async throws -> PluginQueryResult { - PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) - } - - internal func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - internal func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - internal func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - internal func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - internal func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - internal func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - - internal func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - - internal func fetchDatabases() async throws -> [String] { [] } - - internal func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } diff --git a/TableProTests/Helpers/OfflineRegistry.swift b/TableProTests/Helpers/OfflineRegistry.swift new file mode 100644 index 0000000000..0b2c6a564e --- /dev/null +++ b/TableProTests/Helpers/OfflineRegistry.swift @@ -0,0 +1,67 @@ +import Foundation +@testable import TablePro + +final class OfflineRegistryProtocol: URLProtocol, @unchecked Sendable { + private static let lock = NSLock() + nonisolated(unsafe) private static var requestedURLs: [URL] = [] + + static func requestCount(for url: URL) -> Int { + lock.withLock { requestedURLs.filter { $0 == url }.count } + } + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func stopLoading() {} + + override func startLoading() { + guard let url = request.url, + let response = HTTPURLResponse( + url: url, statusCode: 200, httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + ) + else { + client?.urlProtocol(self, didFailWithError: URLError(.badURL)) + return + } + Self.lock.withLock { Self.requestedURLs.append(url) } + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data(#"{"schemaVersion": 2, "plugins": []}"#.utf8)) + client?.urlProtocolDidFinishLoading(self) + } +} + +/// A registry client for a view that fetches on appear. Its own URL, defaults and cache file keep one +/// test's requests countable apart from another's, and its session never reaches the network. +@MainActor +final class OfflineRegistry { + let client: RegistryClient + let manifestURL: URL + private let defaults: EphemeralDefaults + private let directory: URL + + init() throws { + guard let manifestURL = URL(string: "https://registry.invalid/\(UUID().uuidString)/plugins.json") else { + throw URLError(.badURL) + } + self.manifestURL = manifestURL + directory = FileManager.default.temporaryDirectory + .appendingPathComponent("OfflineRegistry-\(UUID().uuidString)", isDirectory: true) + defaults = try EphemeralDefaults() + defaults.defaults.set(manifestURL.absoluteString, forKey: RegistryClient.customRegistryURLKey) + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [OfflineRegistryProtocol.self] + client = RegistryClient( + userDefaults: defaults.defaults, + session: URLSession(configuration: configuration), + manifestCacheURL: directory.appendingPathComponent("registry-manifest.json") + ) + } + + var requestCount: Int { + OfflineRegistryProtocol.requestCount(for: manifestURL) + } + + deinit { + try? FileManager.default.removeItem(at: directory) + } +} diff --git a/TableProTests/Helpers/PluginDriverStub.swift b/TableProTests/Helpers/PluginDriverStub.swift new file mode 100644 index 0000000000..7e4c294206 --- /dev/null +++ b/TableProTests/Helpers/PluginDriverStub.swift @@ -0,0 +1,30 @@ +import Foundation +import TableProPluginKit + +/// The members `PluginDatabaseDriver` has no default for. A double conforms and writes only the +/// behavior its case needs. This is a protocol, not a base class: the conformance of a base class +/// binds each requirement it leaves out to the PluginKit default, so a subclass's version of that +/// requirement would never be called. +protocol PluginDriverStub: PluginDatabaseDriver {} + +extension PluginDriverStub { + func connect() async throws {} + func disconnect() {} + func execute(query: String) async throws -> PluginQueryResult { .empty } + func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } + func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } + func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } + func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } + + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { + PluginTableMetadata(tableName: table) + } + + func fetchDatabases() async throws -> [String] { [] } + + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { + PluginDatabaseMetadata(name: database) + } +} diff --git a/TableProTests/Helpers/RecordingClipboard.swift b/TableProTests/Helpers/RecordingClipboard.swift new file mode 100644 index 0000000000..149a826f1f --- /dev/null +++ b/TableProTests/Helpers/RecordingClipboard.swift @@ -0,0 +1,65 @@ +import AppKit +@testable import TablePro +import Testing + +/// Each write replaces what the clipboard held, as `clearContents()` does in the real provider. +@MainActor +final class RecordingClipboard: ClipboardProvider { + @TaskLocal static var current: RecordingClipboard? + + var text: String? + var gridRows: GridRowsClipboardPayload? + private(set) var html: String? + private(set) var writes: [String] = [] + private(set) var images: [NSImage] = [] + + var hasText: Bool { text != nil } + var hasGridRows: Bool { gridRows != nil } + + func readText() -> String? { text } + func readGridRows() -> GridRowsClipboardPayload? { gridRows } + + func writeText(_ text: String) { replace(text: text) } + func writeCsv(_ csv: String) { replace(text: csv) } + + func writeImage(_ image: NSImage) { + replace(text: nil) + images.append(image) + } + + func writeRows(tsv: String, html: String?, gridRows: GridRowsClipboardPayload) { + replace(text: tsv, html: html, gridRows: gridRows) + } + + private func replace(text: String?, html: String? = nil, gridRows: GridRowsClipboardPayload? = nil) { + self.text = text + self.html = html + self.gridRows = gridRows + if let text { writes.append(text) } + } +} + +/// Swaps the real global, so AppKit callbacks such as menu actions write to the fake too; the +/// task-local only hands the fake to the case body. A user nests under `GlobalStateSuites`. +struct RecordingClipboardTrait: TestTrait, SuiteTrait, TestScoping { + var isRecursive: Bool { true } + + @MainActor + func provideScope( + for test: Test, + testCase: Test.Case?, + performing function: @concurrent @Sendable () async throws -> Void + ) async throws { + let fake = RecordingClipboard() + let previous = ClipboardService.shared + ClipboardService.shared = fake + defer { ClipboardService.shared = previous } + try await RecordingClipboard.$current.withValue(fake) { + try await function() + } + } +} + +extension Trait where Self == RecordingClipboardTrait { + static var recordingClipboard: Self { Self() } +} diff --git a/TableProTests/Helpers/RowWriteStubDrivers.swift b/TableProTests/Helpers/RowWriteStubDrivers.swift index 2dfc96f32b..f3923b4cd9 100644 --- a/TableProTests/Helpers/RowWriteStubDrivers.swift +++ b/TableProTests/Helpers/RowWriteStubDrivers.swift @@ -1,14 +1,9 @@ -// -// RowWriteStubDrivers.swift -// TableProTests -// - import Foundation @testable import TablePro import TableProPluginKit /// A driver that writes its own statements through `generateRowWrites`. -internal final class RowWriteStubDriver: PluginDatabaseDriver, @unchecked Sendable { +internal final class RowWriteStubDriver: PluginDriverStub, @unchecked Sendable { internal typealias Writer = ( _ changes: [PluginRowChange], _ insertedRowData: [Int: [PluginCellValue]], @@ -38,9 +33,6 @@ internal final class RowWriteStubDriver: PluginDatabaseDriver, @unchecked Sendab internal func quoteIdentifier(_ name: String) -> String { "\"\(name)\"" } - internal func connect() async throws {} - internal func disconnect() {} - internal func execute(query: String) async throws -> PluginQueryResult { executedQueries.append(query) return PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) @@ -50,28 +42,11 @@ internal final class RowWriteStubDriver: PluginDatabaseDriver, @unchecked Sendab executedQueries.append(query) return PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) } - - internal func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - internal func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - internal func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - internal func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - internal func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - internal func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - - internal func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - - internal func fetchDatabases() async throws -> [String] { [] } - - internal func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } /// A driver built before `generateRowWrites` existed: it implements only `generateStatements`, so /// the host reaches it through the PluginKit default. -internal final class LegacyStatementStubDriver: PluginDatabaseDriver, @unchecked Sendable { +internal final class LegacyStatementStubDriver: PluginDriverStub, @unchecked Sendable { internal typealias Generator = ( _ changes: [PluginRowChange], _ insertedRowData: [Int: [PluginCellValue]], @@ -105,30 +80,6 @@ internal final class LegacyStatementStubDriver: PluginDatabaseDriver, @unchecked } internal func quoteIdentifier(_ name: String) -> String { "\"\(name)\"" } - - internal func connect() async throws {} - internal func disconnect() {} - - internal func execute(query: String) async throws -> PluginQueryResult { - PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) - } - - internal func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - internal func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - internal func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - internal func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - internal func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - internal func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - - internal func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - - internal func fetchDatabases() async throws -> [String] { [] } - - internal func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } /// The way MongoDB's generator behaves: one statement per change it can write, one `deleteMany` diff --git a/TableProTests/Helpers/SharedHelperTests.swift b/TableProTests/Helpers/SharedHelperTests.swift new file mode 100644 index 0000000000..0fe71aa85b --- /dev/null +++ b/TableProTests/Helpers/SharedHelperTests.swift @@ -0,0 +1,128 @@ +import Combine +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +extension GlobalStateSuites { + @MainActor + struct AppSettingsTraitTests { + @Test("The trait writes a group only when it differs, then puts it back", arguments: [true, false]) + func writesOnlyWhatDiffers(enabled: Bool) async throws { + let manager = AppSettingsManager.shared + let before = manager.ai + var writes = 0 + let subscription = AppEvents.shared.aiSettingsChanged.sink { writes += 1 } + defer { subscription.cancel() } + + try await AppSettingsTrait.appSettings { $0.ai.enabled = enabled } + .provideScope(for: try #require(Test.current), testCase: Test.Case.current) { + let inside = await MainActor.run { AppSettingsManager.shared.ai.enabled } + #expect(inside == enabled) + } + + #expect(manager.ai == before) + #expect(writes == (before.enabled == enabled ? 0 : 2)) + } + } + + @Suite(.recordingClipboard) + @MainActor + struct RecordingClipboardTraitTests { + @Test("Every copy path reaches the fake the trait installed") + func installsTheFake() throws { + let clipboard = try #require(RecordingClipboard.current) + ClipboardService.shared.writeText("SELECT 1") + #expect(clipboard.writes == ["SELECT 1"]) + #expect(clipboard.readText() == "SELECT 1") + } + + @Test("A write replaces the rows an earlier copy left") + func writeReplacesEarlierRows() throws { + let clipboard = try #require(RecordingClipboard.current) + let rows = GridRowsClipboardPayload(columns: ["id"], rows: [[.text("1")]]) + clipboard.writeRows(tsv: "1", html: "1", gridRows: rows) + #expect(clipboard.hasGridRows) + #expect(clipboard.html == "1") + + clipboard.writeText("plain") + #expect(!clipboard.hasGridRows) + #expect(clipboard.html == nil) + #expect(clipboard.writes == ["1", "plain"]) + } + } + + @MainActor + struct RecordingClipboardRestoreTests { + @Test("The real clipboard is back once the scope ends") + func restoresTheRealClipboard() async throws { + #expect(!(ClipboardService.shared is RecordingClipboard)) + try await RecordingClipboardTrait.recordingClipboard + .provideScope(for: try #require(Test.current), testCase: Test.Case.current) { + let swapped = await MainActor.run { ClipboardService.shared is RecordingClipboard } + #expect(swapped) + } + #expect(!(ClipboardService.shared is RecordingClipboard)) + } + } +} + +struct EphemeralDefaultsTests { + @Test("The suite lives in a temporary directory that goes away with it") + func leavesNothingBehind() throws { + let directory = try Self.useASuite() + #expect(!FileManager.default.fileExists(atPath: directory.path)) + } + + private static func useASuite() throws -> URL { + let suite = try EphemeralDefaults() + #expect(suite.suiteName.hasPrefix(FileManager.default.temporaryDirectory.path)) + suite.defaults.set("value", forKey: "key") + #expect(suite.defaults.string(forKey: "key") == "value") + #expect(try EphemeralDefaults().defaults.string(forKey: "key") == nil) + return URL(fileURLWithPath: suite.suiteName).deletingLastPathComponent() + } +} + +struct PluginDriverStubTests { + private final class QuotingStub: PluginDriverStub, @unchecked Sendable { + func quoteIdentifier(_ name: String) -> String { "<\(name)>" } + } + + @Test("A double's own member answers through the protocol, the rest come from the stub") + func ownMemberWins() async throws { + let driver: any PluginDatabaseDriver = QuotingStub() + #expect(driver.quoteIdentifier("users") == "") + #expect(try await driver.fetchDatabases().isEmpty) + #expect(try await driver.execute(query: "SELECT 1").rows.isEmpty) + #expect(try await driver.fetchTableMetadata(table: "users", schema: nil).tableName == "users") + } +} + +@MainActor +struct CoordinatorFixtureTests { + @Test("Releasing the fixture tears the coordinator down and drops the injected session") + func releaseTearsDown() throws { + let connection = TestFixtures.makeConnection() + var fixture: CoordinatorFixture? = CoordinatorFixture( + connection: connection, + driver: ScriptAnsweringDriver(connection: connection) + ) + let coordinator = try #require(fixture?.coordinator) + #expect(DatabaseManager.shared.session(for: connection.id) != nil) + #expect(!coordinator.isTearingDown) + + fixture = nil + + #expect(coordinator.isTearingDown) + #expect(DatabaseManager.shared.session(for: connection.id) == nil) + } + + @Test("An added tab becomes the selected tab") + func addTabSelects() { + let fixture = CoordinatorFixture() + let tab = fixture.addTab(QueryTab(title: "Q1", query: "SELECT 1", tabType: .query)) + #expect(fixture.tabManager.selectedTabId == tab.id) + #expect(fixture.tabManager.tabs.map(\.id) == [tab.id]) + } +} diff --git a/TableProTests/Helpers/SourceDefinitionStubDriver.swift b/TableProTests/Helpers/SourceDefinitionStubDriver.swift index e0cd664bb6..d82f559488 100644 --- a/TableProTests/Helpers/SourceDefinitionStubDriver.swift +++ b/TableProTests/Helpers/SourceDefinitionStubDriver.swift @@ -1,8 +1,3 @@ -// -// SourceDefinitionStubDriver.swift -// TableProTests -// - import Foundation @testable import TablePro import TableProPluginKit @@ -13,7 +8,7 @@ internal struct DefinitionReadStubError: LocalizedError { internal var errorDescription: String? { message } } -internal final class SourceDefinitionStubDriver: PluginDatabaseDriver, @unchecked Sendable { +internal final class SourceDefinitionStubDriver: PluginDriverStub, @unchecked Sendable { private let lock = NSLock() private var calls: [String] = [] @@ -66,28 +61,6 @@ internal final class SourceDefinitionStubDriver: PluginDatabaseDriver, @unchecke return try (triggerDDL[trigger.name] ?? .failure(PluginObjectSourceError.notFound(trigger.name))).get() } - internal func connect() async throws {} - internal func disconnect() {} - internal var isConnected: Bool { true } - - internal func execute(query: String) async throws -> PluginQueryResult { - PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) - } - - internal func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - internal func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - internal func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - internal func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } internal func fetchTableDDL(table: String, schema: String?) async throws -> String { tableDDL[table] ?? "" } internal func fetchIndexDDL(table: String, schema: String?) async throws -> [String] { indexDDL[table] ?? [] } - - internal func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - - internal func fetchDatabases() async throws -> [String] { [] } - - internal func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } diff --git a/TableProTests/Helpers/SyncTestEnvironment.swift b/TableProTests/Helpers/SyncTestEnvironment.swift index 5e48739b05..fd85e37c20 100644 --- a/TableProTests/Helpers/SyncTestEnvironment.swift +++ b/TableProTests/Helpers/SyncTestEnvironment.swift @@ -14,6 +14,8 @@ final class SyncTestEnvironment { let keychain = InMemoryKeychain() let directory: URL let defaults: UserDefaults + private let storeSuite: EphemeralDefaults + private let syncSuite: EphemeralDefaults let metadata: SyncMetadataStorage let tracker: SyncChangeTracker let recordCache: SyncRecordCache @@ -32,12 +34,12 @@ final class SyncTestEnvironment { .appendingPathComponent("tablepro-tests") .appendingPathComponent("\(label)-\(unique)", isDirectory: true) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - defaults = try #require(UserDefaults(suiteName: "com.TablePro.tests.\(label).\(unique)")) - metadata = SyncMetadataStorage( - userDefaults: try #require(UserDefaults(suiteName: "com.TablePro.tests.\(label).sync.\(unique)")) - ) - /// The account the scripted transport reports, recorded as already adopted so a run does not - /// start the server side over as it would for a new account. + storeSuite = try EphemeralDefaults("SyncTestEnvironment.\(label)") + syncSuite = try EphemeralDefaults("SyncTestEnvironment.\(label).sync") + defaults = storeSuite.defaults + metadata = SyncMetadataStorage(userDefaults: syncSuite.defaults) + // The account the scripted transport reports, recorded as already adopted so a run does not + // start the server side over as it would for a new account. metadata.lastAccountId = ScriptedSyncTransport.accountId tracker = SyncChangeTracker(metadataStorage: metadata) recordCache = SyncRecordCache( diff --git a/TableProTests/Models/Connection/PortableConnectionMappingTests.swift b/TableProTests/Models/Connection/PortableConnectionMappingTests.swift index a68fa64459..70b8e1728d 100644 --- a/TableProTests/Models/Connection/PortableConnectionMappingTests.swift +++ b/TableProTests/Models/Connection/PortableConnectionMappingTests.swift @@ -83,9 +83,11 @@ struct PortableConnectionMappingTests { // MARK: - Safe Mode - @Test("An iOS Confirm Writes connection imports as Alert") - func iOSConfirmWritesImportsAsAlert() { - #expect(imported(settings(safeModeLevel: "confirmWrites")).preferredSafeModeLevel == .alert) + @Test("An iOS Confirm Writes connection and an unrecognized level import as Alert, not Silent", arguments: [ + "confirmWrites", "someFutureLevel", + ]) + func importsAsAlert(_ level: String) { + #expect(imported(settings(safeModeLevel: level)).preferredSafeModeLevel == .alert) } @Test("An iOS Off connection and a file with no level import as Silent") @@ -99,11 +101,6 @@ struct PortableConnectionMappingTests { #expect(imported(settings(safeModeLevel: level.rawValue)).preferredSafeModeLevel == level) } - @Test("An unrecognized level imports as Alert instead of Silent") - func unrecognizedLevelImportsAsAlert() { - #expect(imported(settings(safeModeLevel: "someFutureLevel")).preferredSafeModeLevel == .alert) - } - // MARK: - Links to local records @Test("A credential profile links only when the import created it") diff --git a/TableProTests/Models/ConnectionSessionTests.swift b/TableProTests/Models/ConnectionSessionTests.swift index 082b91523e..347b2bdbef 100644 --- a/TableProTests/Models/ConnectionSessionTests.swift +++ b/TableProTests/Models/ConnectionSessionTests.swift @@ -1,10 +1,3 @@ -// -// ConnectionSessionTests.swift -// TableProTests -// -// Tests for ConnectionSession.isContentViewEquivalent — verifies which -// field changes trigger SwiftUI re-renders and which are ignored. -// import Foundation import TableProPluginKit @@ -202,11 +195,13 @@ struct ConnectionSessionStateTests { } @Test("markActive updates lastActiveAt") - func markActiveUpdatesLastActiveAt() async throws { + func markActiveUpdatesLastActiveAt() { var session = makeSession() - try await Task.sleep(for: .milliseconds(10)) + session.lastActiveAt = .distantPast + let before = Date() session.markActive() - #expect(session.lastActiveAt > session.connectedAt) + #expect(session.lastActiveAt >= before) + #expect(session.lastActiveAt >= session.connectedAt) } @Test("id matches connection.id") diff --git a/TableProTests/Models/DatabaseScopeTests.swift b/TableProTests/Models/DatabaseScopeTests.swift index 539481a2cd..b63f4a7d51 100644 --- a/TableProTests/Models/DatabaseScopeTests.swift +++ b/TableProTests/Models/DatabaseScopeTests.swift @@ -61,7 +61,6 @@ struct DatabaseScopeTests { } } -@Suite("DatabaseManager scope resolution", .serialized) @MainActor struct DatabaseManagerScopeResolutionTests { private static func makeSession( diff --git a/TableProTests/Models/ERDiagram/ERDiagramDragTests.swift b/TableProTests/Models/ERDiagram/ERDiagramDragTests.swift index c1b2acf802..6378b58860 100644 --- a/TableProTests/Models/ERDiagram/ERDiagramDragTests.swift +++ b/TableProTests/Models/ERDiagram/ERDiagramDragTests.swift @@ -1,21 +1,25 @@ -// -// ERDiagramDragTests.swift -// TableProTests -// -// AppKit owns pan and zoom, so every point the view hands the view model is already in -// document space. These pin the hit testing, the node drag and the persisted coordinates. -// +// AppKit owns pan and zoom, so every point the view hands the view model is already in +// document space. These pin the hit testing, the node drag and the persisted coordinates. import AppKit import CoreGraphics import Foundation @testable import TablePro +import TableProTestSupport import Testing @MainActor struct ERDiagramDragTests { - private func makeViewModel() -> ERDiagramViewModel { - ERDiagramViewModel(connectionId: UUID(), databaseName: "app", schemaKey: "app.default") + private func makeViewModel(autoPanClock: any Clock = ContinuousClock()) -> ERDiagramViewModel { + ERDiagramViewModel(connectionId: UUID(), databaseName: "app", schemaKey: "app.default", autoPanClock: autoPanClock) + } + + /// The loop ticks, then sleeps on the clock until it is advanced, so each advance is one more tick. + private func runAutoPan(_ clock: ManualClock, ticks: Int) async throws { + for tick in 0 ... ticks { + if tick > 0 { clock.advance(by: .milliseconds(16)) } + try #require(await BoundedCall.result { await clock.waitUntilSleeping() }, "The auto-pan loop stopped") + } } /// Registers the node in the graph as well as in the rect cache, because hit testing resolves @@ -232,7 +236,8 @@ struct ERDiagramDragTests { .enabled(if: !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion) ) func autoPanFollowsTheAppliedScroll() async throws { - let viewModel = makeViewModel() + let clock = ManualClock() + let viewModel = makeViewModel(autoPanClock: clock) let nodeId = placeNode(in: viewModel, at: CGPoint(x: 150, y: 330)) let scrollView = attachViewport(to: viewModel) #expect(scrollView.contentView.bounds.origin == .zero) @@ -241,7 +246,7 @@ struct ERDiagramDragTests { viewModel.updateDrag(translation: CGSize(width: -15, height: 50), currentPoint: CGPoint(x: 30, y: 380)) #expect(viewModel.position(for: nodeId) == CGPoint(x: 135, y: 380)) - try await Task.sleep(for: .milliseconds(150)) + try await runAutoPan(clock, ticks: 9) let scrolled = scrollView.contentView.bounds.origin #expect(scrolled.x == 0) @@ -258,7 +263,8 @@ struct ERDiagramDragTests { .enabled(if: !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion) ) func autoPanGrowsTheCanvasAtLowZoom() async throws { - let viewModel = makeViewModel() + let clock = ManualClock() + let viewModel = makeViewModel(autoPanClock: clock) let nodeId = placeNode(in: viewModel, at: CGPoint(x: 2_300, y: 300)) let scrollView = attachViewport(to: viewModel, magnification: 0.25) viewModel.viewport.scrollBy(CGSize(width: 10_000, height: 0)) @@ -269,7 +275,7 @@ struct ERDiagramDragTests { viewModel.updateDrag(translation: CGSize(width: -30, height: 0), currentPoint: CGPoint(x: 2_370, y: 300)) let held = viewModel.position(for: nodeId) - try await Task.sleep(for: .milliseconds(150)) + try await runAutoPan(clock, ticks: 9) let scrolled = scrollView.contentView.bounds.origin.x - edge #expect(scrolled > 0) diff --git a/TableProTests/Models/JSON/JSONRowFilterTests.swift b/TableProTests/Models/JSON/JSONRowFilterTests.swift index 01df83be17..6b5d67e0d1 100644 --- a/TableProTests/Models/JSON/JSONRowFilterTests.swift +++ b/TableProTests/Models/JSON/JSONRowFilterTests.swift @@ -44,13 +44,13 @@ struct JSONRowFilterTests { Issue.record("Whitespace should read as no filter") } - @Test("A key match keeps the key") - func matchesKeys() throws { + @Test("A key match keeps the key, and slashes make the query a regular expression", arguments: ["length", "/^len/"]) + func matchesKeys(_ query: String) throws { let root = makeRoot() let visible = JSONRowFilter.visiblePaths( root: root, fetchedForeignKeys: [:], - matcher: try matcher("length") + matcher: try matcher(query) ) #expect(visible.contains(try path(of: "length", in: root))) #expect(visible.contains(try path(of: "title", in: root)) == false) @@ -80,18 +80,6 @@ struct JSONRowFilterTests { #expect(visible.contains(try path(of: "title", in: root)) == false) } - @Test("Slashes make the query a regular expression") - func readsRegex() throws { - let root = makeRoot() - let visible = JSONRowFilter.visiblePaths( - root: root, - fetchedForeignKeys: [:], - matcher: try matcher("/^len/") - ) - #expect(visible.contains(try path(of: "length", in: root))) - #expect(visible.contains(try path(of: "title", in: root)) == false) - } - @Test("A broken regular expression is reported, not treated as text") func reportsInvalidRegex() { if case .invalidRegex = JSONRowMatcher.make(query: "/[/") { return } diff --git a/TableProTests/Models/LoadingRevealPolicyTests.swift b/TableProTests/Models/LoadingRevealPolicyTests.swift index 099d030e89..5d147279e2 100644 --- a/TableProTests/Models/LoadingRevealPolicyTests.swift +++ b/TableProTests/Models/LoadingRevealPolicyTests.swift @@ -1,89 +1,84 @@ +// The dwell half of the policy, which is the half that gets left out. // -// LoadingRevealPolicyTests.swift -// TableProTests -// -// The dwell half of the policy, which is the half that gets left out. -// -// A grace on its own trades one flicker for another: work that ends just past -// the grace shows a spinner for the handful of milliseconds between the two, -// which is worse than either showing it throughout or never showing it. -// +// A grace on its own trades one flicker for another: work that ends just past +// the grace shows a spinner for the handful of milliseconds between the two, +// which is worse than either showing it throughout or never showing it. @testable import TablePro -import XCTest +import Testing -final class LoadingRevealPolicyTests: XCTestCase { - func testAnIndicatorRevealedAMomentAgoHasToStay() { +struct LoadingRevealPolicyTests { + @Test func anIndicatorRevealedAMomentAgoHasToStay() { let revealedAt = ContinuousClock.now let remaining = LoadingRevealPolicy.remainingDwell( revealedAt: revealedAt, now: revealedAt.advanced(by: .milliseconds(10)) ) - XCTAssertEqual(remaining, .milliseconds(490), "10ms of spinner is a flash, not a report") + #expect(remaining == .milliseconds(490), "10ms of spinner is a flash, not a report") } - func testAnIndicatorThatHasServedItsDwellMayGoAtOnce() { + @Test func anIndicatorThatHasServedItsDwellMayGoAtOnce() { let revealedAt = ContinuousClock.now let remaining = LoadingRevealPolicy.remainingDwell( revealedAt: revealedAt, now: revealedAt.advanced(by: .seconds(4)) ) - XCTAssertEqual(remaining, .zero, "anything slow enough to show one has already earned it") + #expect(remaining == .zero, "anything slow enough to show one has already earned it") } - func testTheDwellEndsExactlyAtItsBoundary() { + @Test func theDwellEndsExactlyAtItsBoundary() { let revealedAt = ContinuousClock.now let remaining = LoadingRevealPolicy.remainingDwell( revealedAt: revealedAt, now: revealedAt.advanced(by: LoadingRevealPolicy.minimumDwell) ) - XCTAssertEqual(remaining, .zero) + #expect(remaining == .zero) } /// A clock that has not moved is what a same-run-loop-turn reveal and hide look like, and the /// answer has to be the whole dwell rather than zero. - func testAnIndicatorRevealedAndFinishedInTheSameInstantStaysForTheWholeDwell() { + @Test func anIndicatorRevealedAndFinishedInTheSameInstantStaysForTheWholeDwell() { let revealedAt = ContinuousClock.now let remaining = LoadingRevealPolicy.remainingDwell(revealedAt: revealedAt, now: revealedAt) - XCTAssertEqual(remaining, LoadingRevealPolicy.minimumDwell) + #expect(remaining == LoadingRevealPolicy.minimumDwell) } - func testTheGraceStaysUnderTheSecondAtWhichAWaitStopsFeelingLikeOneGesture() { - XCTAssertLessThan(LoadingRevealPolicy.grace, .seconds(1)) + @Test func theGraceStaysUnderTheSecondAtWhichAWaitStopsFeelingLikeOneGesture() { + #expect(LoadingRevealPolicy.grace < .seconds(1)) } - func testWorkNobodySawStartWaitsTheWholeGrace() { - XCTAssertEqual(LoadingRevealPolicy.remainingGrace(activeSince: nil, now: .now), LoadingRevealPolicy.grace) + @Test func workNobodySawStartWaitsTheWholeGrace() { + #expect(LoadingRevealPolicy.remainingGrace(activeSince: nil, now: .now) == LoadingRevealPolicy.grace) } - func testTheGraceCountsFromWhenTheWorkStarted() { + @Test func theGraceCountsFromWhenTheWorkStarted() { let started = ContinuousClock.now let remaining = LoadingRevealPolicy.remainingGrace( activeSince: started, now: started.advanced(by: .milliseconds(200)) ) - XCTAssertEqual(remaining, .milliseconds(300)) + #expect(remaining == .milliseconds(300)) } - func testWorkAlreadyPastTheGraceRevealsAtOnce() { + @Test func workAlreadyPastTheGraceRevealsAtOnce() { let started = ContinuousClock.now let remaining = LoadingRevealPolicy.remainingGrace( activeSince: started, now: started.advanced(by: .seconds(2)) ) - XCTAssertEqual(remaining, .zero) + #expect(remaining == .zero) } - func testAStartAfterNowWaitsTheWholeGrace() { + @Test func aStartAfterNowWaitsTheWholeGrace() { let now = ContinuousClock.now let remaining = LoadingRevealPolicy.remainingGrace(activeSince: now.advanced(by: .seconds(1)), now: now) - XCTAssertEqual(remaining, LoadingRevealPolicy.grace) + #expect(remaining == LoadingRevealPolicy.grace) } } diff --git a/TableProTests/Models/Query/QueryTabManagerRecencyTests.swift b/TableProTests/Models/Query/QueryTabManagerRecencyTests.swift index 92b2a06802..4b77dbd950 100644 --- a/TableProTests/Models/Query/QueryTabManagerRecencyTests.swift +++ b/TableProTests/Models/Query/QueryTabManagerRecencyTests.swift @@ -1,17 +1,15 @@ -// -// QueryTabManagerRecencyTests.swift -// TableProTests -// - import Foundation @testable import TablePro import Testing @MainActor struct QueryTabManagerRecencyTests { - /// A selection is recorded once the main queue turn it happened in has finished. + /// A selection is recorded by a block queued on the main queue, and the main queue runs blocks + /// in the order they were queued, so a block queued now runs after the record. private func settle() async { - try? await Task.sleep(for: .milliseconds(20)) + await withCheckedContinuation { continuation in + DispatchQueue.main.async { continuation.resume() } + } } private func makeManager(tabCount: Int) async -> QueryTabManager { diff --git a/TableProTests/Models/Query/StatementAnchorTests.swift b/TableProTests/Models/Query/StatementAnchorTests.swift index 8dff15582d..5cb22c377e 100644 --- a/TableProTests/Models/Query/StatementAnchorTests.swift +++ b/TableProTests/Models/Query/StatementAnchorTests.swift @@ -92,21 +92,12 @@ struct StatementAnchorTests { } /// A reader who wrote a comment above a statement has already named it better than its first clause could. - @Test("A leading comment is the statement's name") - func labelPrefersALeadingComment() { - let subject = StatementAnchor( - range: NSRange(location: 0, length: 8), - preview: "-- monthly totals\nSELECT count(*) FROM orders" - ) - #expect(subject.label == "monthly totals") - } - - @Test("Several comment lines above a statement all become its name") - func labelJoinsConsecutiveComments() { - let subject = StatementAnchor( - range: NSRange(location: 0, length: 8), - preview: "-- monthly\n-- totals\nSELECT count(*)" - ) + @Test("A leading comment is the statement's name, joined across several comment lines", arguments: [ + "-- monthly totals\nSELECT count(*) FROM orders", + "-- monthly\n-- totals\nSELECT count(*)", + ]) + func labelPrefersALeadingComment(_ preview: String) { + let subject = StatementAnchor(range: NSRange(location: 0, length: 8), preview: preview) #expect(subject.label == "monthly totals") } diff --git a/TableProTests/Models/Query/TabScopeIsWindowIndependentTests.swift b/TableProTests/Models/Query/TabScopeIsWindowIndependentTests.swift index e767d355f8..ee0df03a29 100644 --- a/TableProTests/Models/Query/TabScopeIsWindowIndependentTests.swift +++ b/TableProTests/Models/Query/TabScopeIsWindowIndependentTests.swift @@ -1,34 +1,19 @@ -// -// TabScopeIsWindowIndependentTests.swift -// TableProTests -// -// A macOS window tab is a full NSWindow the user can drag out at any time, so -// "Move Tab to New Window" has to be semantically a no-op. That holds only while a -// tab's target is a pure function of the tab and its connection. #2026 was what -// happened when it was not: the tab read the window's sidebar selection instead. -// +// A macOS window tab is a full NSWindow the user can drag out at any time, so +// "Move Tab to New Window" has to be semantically a no-op. That holds only while a +// tab's target is a pure function of the tab and its connection. When it was not, +// the tab read the window's sidebar selection instead. import Foundation @testable import TablePro import TableProPluginKit import Testing -@Suite("A tab's scope is independent of the window showing it", .serialized) @MainActor struct TabScopeIsWindowIndependentTests { - private static func makeCoordinator( - connection: DatabaseConnection, - tab: QueryTab - ) -> MainContentCoordinator { - let tabManager = QueryTabManager() - tabManager.tabs.append(tab) - tabManager.selectedTabId = tab.id - return MainContentCoordinator( - connection: connection, - tabManager: tabManager, - changeManager: DataChangeManager(), - toolbarState: ConnectionToolbarState() - ) + private static func makeWindow(connection: DatabaseConnection, tab: QueryTab) -> CoordinatorFixture { + let window = CoordinatorFixture(connection: connection) + window.addTab(tab) + return window } private static func makeTab(database: String, schema: String?) -> QueryTab { @@ -48,14 +33,14 @@ struct TabScopeIsWindowIndependentTests { var browsingOrders = ConnectionSession(connection: connection) browsingOrders.browseDatabase = "orders" DatabaseManager.shared.injectSession(browsingOrders, for: connection.id) - let windowOnOrders = Self.makeCoordinator(connection: connection, tab: tab) - let scopeFromOrders = try #require(windowOnOrders.scope(for: tab)) + let windowOnOrders = Self.makeWindow(connection: connection, tab: tab) + let scopeFromOrders = try #require(windowOnOrders.coordinator.scope(for: tab)) var browsingInventory = ConnectionSession(connection: connection) browsingInventory.browseDatabase = "inventory" DatabaseManager.shared.injectSession(browsingInventory, for: connection.id) - let windowOnInventory = Self.makeCoordinator(connection: connection, tab: tab) - let scopeFromInventory = try #require(windowOnInventory.scope(for: tab)) + let windowOnInventory = Self.makeWindow(connection: connection, tab: tab) + let scopeFromInventory = try #require(windowOnInventory.coordinator.scope(for: tab)) #expect(scopeFromOrders == scopeFromInventory) #expect(scopeFromOrders.database == "orders") @@ -73,8 +58,8 @@ struct TabScopeIsWindowIndependentTests { session.browseSchema = "dbo" DatabaseManager.shared.injectSession(session, for: connection.id) - let coordinator = Self.makeCoordinator(connection: connection, tab: tab) - let scope = try #require(coordinator.scope(for: tab)) + let window = Self.makeWindow(connection: connection, tab: tab) + let scope = try #require(window.coordinator.scope(for: tab)) #expect(scope.database == "orders") #expect(scope.schema == "sales") @@ -91,8 +76,8 @@ struct TabScopeIsWindowIndependentTests { session.browseDatabase = "inventory" DatabaseManager.shared.injectSession(session, for: connection.id) - let coordinator = Self.makeCoordinator(connection: connection, tab: tab) - let seeded = try #require(coordinator.scope(for: tab)) + let window = Self.makeWindow(connection: connection, tab: tab) + let seeded = try #require(window.coordinator.scope(for: tab)) #expect(seeded.database == "inventory") } diff --git a/TableProTests/Models/Query/TableRowsTests.swift b/TableProTests/Models/Query/TableRowsTests.swift index f7c1c06f7c..601fb699b8 100644 --- a/TableProTests/Models/Query/TableRowsTests.swift +++ b/TableProTests/Models/Query/TableRowsTests.swift @@ -378,27 +378,16 @@ struct TableRowsInsertTests { #expect(table.rows[1].values == ["a", "b", "c"]) } - @Test("insertInsertedRow with negative index returns Delta.none and does not mutate") - func insertInsertedRowNegativeIndexIsNoOp() { - var table = TableRows.from( - queryRows: [["a"]], - columns: ["c1"], - columnTypes: [.text(rawType: nil)] - ) - let delta = table.insertInsertedRow(at: -1, values: ["z"]) - #expect(delta == .none) - #expect(table.count == 1) - #expect(table.rows[0].values == ["a"]) - } - - @Test("insertInsertedRow past the end returns Delta.none and does not mutate") - func insertInsertedRowPastEndIsNoOp() { + @Test("insertInsertedRow at a negative index or past the end returns Delta.none and does not mutate", arguments: [ + -1, 2, + ]) + func insertInsertedRowOutOfRangeIsNoOp(_ index: Int) { var table = TableRows.from( queryRows: [["a"]], columns: ["c1"], columnTypes: [.text(rawType: nil)] ) - let delta = table.insertInsertedRow(at: 2, values: ["z"]) + let delta = table.insertInsertedRow(at: index, values: ["z"]) #expect(delta == .none) #expect(table.count == 1) #expect(table.rows[0].values == ["a"]) diff --git a/TableProTests/Models/QuickSwitcherKeyCommandTests.swift b/TableProTests/Models/QuickSwitcherKeyCommandTests.swift index 9177a5249f..d5c8372ff8 100644 --- a/TableProTests/Models/QuickSwitcherKeyCommandTests.swift +++ b/TableProTests/Models/QuickSwitcherKeyCommandTests.swift @@ -45,15 +45,12 @@ struct QuickSwitcherKeyCommandTests { // MARK: - Command-Return - @Test("Command-Return resolves to a new-tab commit") - func commandReturnCommitsToNewTab() { - #expect(resolve("\r", .command) == .commit(.openInNewWindowTab)) - } - /// The numeric keypad's Enter reports U+0003, not a carriage return. - @Test("Command-Enter on the numeric keypad resolves to a new-tab commit") - func commandEnterCommitsToNewTab() { - #expect(resolve("\u{3}", .command) == .commit(.openInNewWindowTab)) + @Test("Command-Return, and Command-Enter on the numeric keypad, resolve to a new-tab commit", arguments: [ + "\r", "\u{3}", + ]) + func commandReturnCommitsToNewTab(_ key: String) { + #expect(resolve(key, .command) == .commit(.openInNewWindowTab)) } /// Plain Return reaches the field editor, which owns it. Claiming it here would take the diff --git a/TableProTests/Models/RestoredHiddenColumnsTests.swift b/TableProTests/Models/RestoredHiddenColumnsTests.swift index 6826f58121..e54d8087e6 100644 --- a/TableProTests/Models/RestoredHiddenColumnsTests.swift +++ b/TableProTests/Models/RestoredHiddenColumnsTests.swift @@ -1,37 +1,19 @@ -// -// RestoredHiddenColumnsTests.swift -// TableProTests -// -// Hidden columns live in the per-table layout store, not in the tab record, and only the tab -// selected at launch used to read them. Every other restored tab came back showing columns the -// user had hidden, and its first hide wrote that near-empty set over the table's stored one. -// +// Hidden columns live in the per-table layout store, not in the tab record, and only the tab +// selected at launch used to read them. Every other restored tab came back showing columns the +// user had hidden, and its first hide wrote that near-empty set over the table's stored one. import Foundation @testable import TablePro import Testing -@MainActor -private final class StubColumnLayoutPersister: ColumnLayoutPersisting { - var hidden: [String: Set] = [:] - - func load(for key: ColumnLayoutTableKey) -> ColumnLayoutState? { nil } - func save(_ layout: ColumnLayoutState, for key: ColumnLayoutTableKey) {} - func clear(for key: ColumnLayoutTableKey) {} - - func loadHiddenColumns(for key: ColumnLayoutTableKey) -> Set { - hidden[key.tableName] ?? [] - } - - func saveHiddenColumns(_ hidden: Set, for key: ColumnLayoutTableKey) { - self.hidden[key.tableName] = hidden - } -} - @MainActor struct RestoredHiddenColumnsTests { private let connectionId = UUID() + private func key(_ tableName: String) -> ColumnLayoutTableKey { + ColumnLayoutTableKey(connectionId: connectionId, databaseName: "", schemaName: nil, tableName: tableName) + } + private func tableTab(_ tableName: String) -> QueryTab { QueryTab( title: tableName, @@ -43,8 +25,8 @@ struct RestoredHiddenColumnsTests { @Test("A restored table tab picks up the columns hidden for its table") func hydratesATableTab() { - let persister = StubColumnLayoutPersister() - persister.hidden["users"] = ["email"] + let persister = InMemoryColumnLayoutStore() + persister.hidden[key("users")] = ["email"] var tabs = [tableTab("users")] RestoredHiddenColumns.hydrate(&tabs, connectionId: connectionId, persister: persister) @@ -55,10 +37,10 @@ struct RestoredHiddenColumnsTests { /// The reported failure: three tabs, only the one in front came back correct. @Test("Every restored tab is hydrated, not just the first") func hydratesEveryTab() { - let persister = StubColumnLayoutPersister() - persister.hidden["users"] = ["email"] - persister.hidden["orders"] = ["total"] - persister.hidden["products"] = ["sku"] + let persister = InMemoryColumnLayoutStore() + persister.hidden[key("users")] = ["email"] + persister.hidden[key("orders")] = ["total"] + persister.hidden[key("products")] = ["sku"] var tabs = [tableTab("users"), tableTab("orders"), tableTab("products")] RestoredHiddenColumns.hydrate(&tabs, connectionId: connectionId, persister: persister) @@ -70,8 +52,8 @@ struct RestoredHiddenColumnsTests { @Test("A tab that already carries hidden columns is left alone") func leavesAPopulatedTabAlone() { - let persister = StubColumnLayoutPersister() - persister.hidden["users"] = ["email"] + let persister = InMemoryColumnLayoutStore() + persister.hidden[key("users")] = ["email"] var tabs = [tableTab("users")] tabs[0].columnLayout.hiddenColumns = ["notes"] @@ -87,7 +69,7 @@ struct RestoredHiddenColumnsTests { RestoredHiddenColumns.hydrate( &tabs, connectionId: connectionId, - persister: StubColumnLayoutPersister() + persister: InMemoryColumnLayoutStore() ) #expect(tabs[0].columnLayout.hiddenColumns.isEmpty) @@ -95,8 +77,8 @@ struct RestoredHiddenColumnsTests { @Test("A query tab is not touched") func skipsQueryTabs() { - let persister = StubColumnLayoutPersister() - persister.hidden["users"] = ["email"] + let persister = InMemoryColumnLayoutStore() + persister.hidden[key("users")] = ["email"] var tabs = [QueryTab(title: "Query 1", query: "SELECT 1", tabType: .query)] RestoredHiddenColumns.hydrate(&tabs, connectionId: connectionId, persister: persister) @@ -106,8 +88,8 @@ struct RestoredHiddenColumnsTests { @Test("A table tab with no table name is skipped") func skipsATabWithoutATableName() { - let persister = StubColumnLayoutPersister() - persister.hidden[""] = ["email"] + let persister = InMemoryColumnLayoutStore() + persister.hidden[key("")] = ["email"] var tabs = [QueryTab(title: "untitled", query: "", tabType: .table)] RestoredHiddenColumns.hydrate(&tabs, connectionId: connectionId, persister: persister) diff --git a/TableProTests/Models/Settings/LegacyThemeFontsTests.swift b/TableProTests/Models/Settings/LegacyThemeFontsTests.swift index 4322d7701f..f15612b21d 100644 --- a/TableProTests/Models/Settings/LegacyThemeFontsTests.swift +++ b/TableProTests/Models/Settings/LegacyThemeFontsTests.swift @@ -110,7 +110,8 @@ struct LegacyThemeFontsTests { let file = try writeTheme(id: "user.dark", editorSize: 16, in: directory) let original = try Data(contentsOf: file) - let defaults = try #require(UserDefaults(suiteName: "legacy-theme-fonts-\(UUID().uuidString)")) + let suite = try EphemeralDefaults() + let defaults = suite.defaults let storage = AppSettingsStorage(userDefaults: defaults) storage.saveAppearance(appearance(.dark)) diff --git a/TableProTests/Models/Settings/SettingsFieldDecodingTests.swift b/TableProTests/Models/Settings/SettingsFieldDecodingTests.swift index ed98d31cd2..7ef8c3d6c7 100644 --- a/TableProTests/Models/Settings/SettingsFieldDecodingTests.swift +++ b/TableProTests/Models/Settings/SettingsFieldDecodingTests.swift @@ -181,7 +181,8 @@ struct AISettingsProviderDecodingTests { @Test("Storage loads the AI category around an unknown provider row") func storageLoadsAroundUnknownRow() throws { - let defaults = try #require(UserDefaults(suiteName: "settings-decode-\(UUID().uuidString)")) + let suite = try EphemeralDefaults() + let defaults = suite.defaults let storage = AppSettingsStorage(userDefaults: defaults) var stored = AISettings.default stored.enabled = false diff --git a/TableProTests/Models/SystemContainerNamesTests.swift b/TableProTests/Models/SystemContainerNamesTests.swift index de67ad79e9..cba03ee9f8 100644 --- a/TableProTests/Models/SystemContainerNamesTests.swift +++ b/TableProTests/Models/SystemContainerNamesTests.swift @@ -1,8 +1,3 @@ -// -// SystemContainerNamesTests.swift -// TableProTests -// - import Foundation @testable import TablePro import Testing @@ -60,3 +55,29 @@ struct SystemContainerNamesTests { #expect(teradata.withSwitchRouting(from: teradata).schema.systemDatabaseNamesIgnoreCase) } } + +extension GlobalStateSuites { + @MainActor + struct SystemContainerViewOptionsTests { + /// The sidebar's View Options entry and the General pane's toggle are two routes to one setting. + @Test( + "System Databases and Schemas in View Options flips the setting the General pane shows", + .appSettings { $0.general.showSystemContainers = false } + ) + func viewOptionsEntryWritesTheGeneralSetting() throws { + let before = AppSettingsManager.shared.general.showSystemContainers + let items = SidebarViewOptionsMenu.currentSections().flatMap(\.items) + let found = items.lazy.compactMap { item -> SidebarMenuEntry? in + guard case .command(let entry) = item, entry.command == .toggleSystemContainers else { return nil } + return entry + }.first + let entry = try #require(found) + #expect(entry.title == String(localized: "System Databases and Schemas")) + #expect(entry.isOn == before) + + #expect(SidebarViewOptionsMenu.apply(entry.command)) + + #expect(AppSettingsManager.shared.general.showSystemContainers == !before) + } + } +} diff --git a/TableProTests/Models/TrailingPaneHeaderModelTests.swift b/TableProTests/Models/TrailingPaneHeaderModelTests.swift index 785196a36e..28a106a2dd 100644 --- a/TableProTests/Models/TrailingPaneHeaderModelTests.swift +++ b/TableProTests/Models/TrailingPaneHeaderModelTests.swift @@ -1,11 +1,6 @@ -// -// TrailingPaneHeaderModelTests.swift -// TableProTests -// -// The pane's three surfaces drew three different headers: a title over a subtitle beside a picker, -// a headline beside two buttons, and an icon-only picker as the whole top edge. They draw one now, -// from one value, and these pin what that value says for each surface in each mode. -// +// The pane's three surfaces drew three different headers: a title over a subtitle beside a picker, +// a headline beside two buttons, and an icon-only picker as the whole top edge. They draw one now, +// from one value, and these pin what that value says for each surface in each mode. import AppKit import Foundation @@ -13,138 +8,138 @@ import SwiftUI @testable import TablePro import Testing -@MainActor -struct TrailingPaneHeaderModelTests { - // MARK: - Segments and title - - @Test("Browsing with AI on offers a picker between the inspector and the assistant") - func browsingOffersBothSurfaces() { - for surface in [TrailingPaneSurface.inspector, .assistant] { - let model = TrailingPaneHeaderModel(surface: surface, contentMode: .browse, isAIEnabled: true) - #expect(model.segments == [.inspector, .assistant], "\(surface)") - #expect(model.showsPicker, "\(surface)") +extension GlobalStateSuites { + @MainActor + struct TrailingPaneHeaderModelTests { + // MARK: - Segments and title + + @Test("Browsing with AI on offers a picker between the inspector and the assistant") + func browsingOffersBothSurfaces() { + for surface in [TrailingPaneSurface.inspector, .assistant] { + let model = TrailingPaneHeaderModel(surface: surface, contentMode: .browse, isAIEnabled: true) + #expect(model.segments == [.inspector, .assistant], "\(surface)") + #expect(model.showsPicker, "\(surface)") + } } - } - /// A single segment is a control with nothing to choose, which is what the AI setting being off - /// leaves, so the header names the surface instead. - @Test("With AI off the header names the inspector rather than drawing a one-segment picker") - func aiOffDrawsATitle() { - let model = TrailingPaneHeaderModel(surface: .inspector, contentMode: .browse, isAIEnabled: false) - #expect(model.segments == [.inspector]) - #expect(!model.showsPicker) - #expect(model.title == TrailingPaneSurface.inspector.localizedTitle) - } - - @Test("Agent mode names the result, with nothing to choose") - func agentModeDrawsTheResultTitle() { - let model = TrailingPaneHeaderModel(surface: .agentResult, contentMode: .agent, isAIEnabled: true) - #expect(model.segments.isEmpty) - #expect(!model.showsPicker) - #expect(model.title == String(localized: "Result")) - } + /// A single segment is a control with nothing to choose, which is what the AI setting being off + /// leaves, so the header names the surface instead. + @Test("With AI off the header names the inspector rather than drawing a one-segment picker") + func aiOffDrawsATitle() { + let model = TrailingPaneHeaderModel(surface: .inspector, contentMode: .browse, isAIEnabled: false) + #expect(model.segments == [.inspector]) + #expect(!model.showsPicker) + #expect(model.title == TrailingPaneSurface.inspector.localizedTitle) + } - /// The picker draws the surface it sits over as selected, so a surface outside the segments would - /// be a picker with nothing selected. - @Test("A surface the picker does not offer never draws the picker") - func unofferedSurfaceDrawsATitle() { - let model = TrailingPaneHeaderModel(surface: .agentResult, contentMode: .browse, isAIEnabled: true) - #expect(!model.showsPicker) - } + @Test("Agent mode names the result, with nothing to choose") + func agentModeDrawsTheResultTitle() { + let model = TrailingPaneHeaderModel(surface: .agentResult, contentMode: .agent, isAIEnabled: true) + #expect(model.segments.isEmpty) + #expect(!model.showsPicker) + #expect(model.title == String(localized: "Result")) + } - // MARK: - The menu - - @Test("The inspector's menu offers its renderings, and the JSON commands only over JSON") - func inspectorMenu() { - let fields = TrailingPaneHeaderModel( - surface: .inspector, - contentMode: .browse, - isAIEnabled: true, - inspectorRendering: .fields - ) - #expect(fields.menuSections == [.inspectorRendering]) - - let json = TrailingPaneHeaderModel( - surface: .inspector, - contentMode: .browse, - isAIEnabled: true, - inspectorRendering: .json - ) - #expect(json.menuSections == [.inspectorRendering, .jsonReading]) - } + /// The picker draws the surface it sits over as selected, so a surface outside the segments would + /// be a picker with nothing selected. + @Test("A surface the picker does not offer never draws the picker") + func unofferedSurfaceDrawsATitle() { + let model = TrailingPaneHeaderModel(surface: .agentResult, contentMode: .browse, isAIEnabled: true) + #expect(!model.showsPicker) + } - /// A schema grid's column definition has no JSON form, and a pane with no row draws table info or - /// nothing. A dimmed choice there still checked the stored rendering, which after JSON was picked - /// elsewhere on the connection read JSON over a pane drawing fields. - @Test("A selection with one rendering offers no choice between two") - func singleRenderingOffersNoChoice() { - let model = TrailingPaneHeaderModel( - surface: .inspector, - contentMode: .browse, - isAIEnabled: true, - inspectorRendering: nil - ) - #expect(!model.menuSections.contains(.inspectorRendering)) - #expect(!model.menuSections.contains(.jsonReading)) - } + // MARK: - The menu - /// Clear Recents is its own section because it deletes, and its confirmation stays with it. - @Test("The assistant's menu offers its conversations, with Clear Recents apart") - func assistantMenu() { - let model = TrailingPaneHeaderModel(surface: .assistant, contentMode: .browse, isAIEnabled: true) - #expect(model.menuSections == [.conversations, .clearRecents]) - } + @Test("The inspector's menu offers its renderings, and the JSON commands only over JSON") + func inspectorMenu() { + let fields = TrailingPaneHeaderModel( + surface: .inspector, + contentMode: .browse, + isAIEnabled: true, + inspectorRendering: .fields + ) + #expect(fields.menuSections == [.inspectorRendering]) - @Test("The result's menu offers its views") - func resultMenu() { - let model = TrailingPaneHeaderModel(surface: .agentResult, contentMode: .agent, isAIEnabled: true) - #expect(model.menuSections == [.resultView]) - } + let json = TrailingPaneHeaderModel( + surface: .inspector, + contentMode: .browse, + isAIEnabled: true, + inspectorRendering: .json + ) + #expect(json.menuSections == [.inspectorRendering, .jsonReading]) + } - /// Every command in the menu acts on a row, a conversation or a session a window without a live - /// connection does not have. - @Test("A pane with nothing behind it offers no menu") - func noContentNoMenu() { - for surface in TrailingPaneSurface.allCases { + /// A schema grid's column definition has no JSON form, and a pane with no row draws table info or + /// nothing. A dimmed choice there still checked the stored rendering, which after JSON was picked + /// elsewhere on the connection read JSON over a pane drawing fields. + @Test("A selection with one rendering offers no choice between two") + func singleRenderingOffersNoChoice() { let model = TrailingPaneHeaderModel( - surface: surface, + surface: .inspector, contentMode: .browse, isAIEnabled: true, - hasContent: false + inspectorRendering: nil ) - #expect(model.menuSections.isEmpty, "\(surface)") + #expect(!model.menuSections.contains(.inspectorRendering)) + #expect(!model.menuSections.contains(.jsonReading)) } - } - /// The ellipsis carries no text, so its label is the only name VoiceOver and the tooltip have. - @Test("Each surface's menu has a name of its own") - func menuLabelsAreDistinct() { - let labels = TrailingPaneSurface.allCases.map { - TrailingPaneHeaderModel(surface: $0, contentMode: .browse, isAIEnabled: true).menuLabel + /// Clear Recents is its own section because it deletes, and its confirmation stays with it. + @Test("The assistant's menu offers its conversations, with Clear Recents apart") + func assistantMenu() { + let model = TrailingPaneHeaderModel(surface: .assistant, contentMode: .browse, isAIEnabled: true) + #expect(model.menuSections == [.conversations, .clearRecents]) } - #expect(Set(labels).count == labels.count) - #expect(!labels.contains { $0.isEmpty }) - } - /// Two unrelated histories in one window stopped sharing `clock`; the surfaces cannot share a - /// glyph either, or the picker's segments are told apart by their tooltips alone. - @Test("Each surface has a glyph of its own, none of them the pane's") - func surfaceGlyphsAreDistinct() { - let symbols = TrailingPaneSurface.allCases.map(\.symbolName) - #expect(Set(symbols).count == symbols.count) - #expect(!symbols.contains("sidebar.right")) - } + @Test("The result's menu offers its views") + func resultMenu() { + let model = TrailingPaneHeaderModel(surface: .agentResult, contentMode: .agent, isAIEnabled: true) + #expect(model.menuSections == [.resultView]) + } - // MARK: - The view + /// Every command in the menu acts on a row, a conversation or a session a window without a live + /// connection does not have. + @Test("A pane with nothing behind it offers no menu") + func noContentNoMenu() { + for surface in TrailingPaneSurface.allCases { + let model = TrailingPaneHeaderModel( + surface: surface, + contentMode: .browse, + isAIEnabled: true, + hasContent: false + ) + #expect(model.menuSections.isEmpty, "\(surface)") + } + } + + /// The ellipsis carries no text, so its label is the only name VoiceOver and the tooltip have. + @Test("Each surface's menu has a name of its own") + func menuLabelsAreDistinct() { + let labels = TrailingPaneSurface.allCases.map { + TrailingPaneHeaderModel(surface: $0, contentMode: .browse, isAIEnabled: true).menuLabel + } + #expect(Set(labels).count == labels.count) + #expect(!labels.contains { $0.isEmpty }) + } + + /// Two unrelated histories in one window stopped sharing `clock`; the surfaces cannot share a + /// glyph either, or the picker's segments are told apart by their tooltips alone. + @Test("Each surface has a glyph of its own, none of them the pane's") + func surfaceGlyphsAreDistinct() { + let symbols = TrailingPaneSurface.allCases.map(\.symbolName) + #expect(Set(symbols).count == symbols.count) + #expect(!symbols.contains("sidebar.right")) + } + + // MARK: - The view - /// The complaint the shared header answers: switching surface changed the height of the pane's - /// top edge, so everything under it jumped. A picker, a plain title, and no menu at all must all - /// come out the same height. - @Test("The header is the same height on every surface") - func headerHeightIsConstant() { - let paneState = TrailingPaneState() - let heights = AIFeatureScope.enabled { - [ + /// The complaint the shared header answers: switching surface changed the height of the pane's + /// top edge, so everything under it jumped. A picker, a plain title, and no menu at all must all + /// come out the same height. + @Test("The header is the same height on every surface", .appSettings { $0.ai.enabled = true }) + func headerHeightIsConstant() { + let paneState = TrailingPaneState() + let heights = [ measuredHeight(TrailingPaneHeaderView(surface: .inspector, contentMode: .browse, paneState: paneState) { _ in EmptyView() }), @@ -163,16 +158,16 @@ struct TrailingPaneHeaderModelTests { EmptyView() }), ] + #expect(Set(heights).count == 1, "heights: \(heights)") + #expect(heights.allSatisfy { $0 >= TrailingPaneHeaderMetrics.height }) } - #expect(Set(heights).count == 1, "heights: \(heights)") - #expect(heights.allSatisfy { $0 >= TrailingPaneHeaderMetrics.height }) - } - private func measuredHeight(_ header: some View) -> CGFloat { - let host = NSHostingView(rootView: header.frame(width: 270)) - host.frame = NSRect(x: 0, y: 0, width: 270, height: 200) - host.layoutSubtreeIfNeeded() - return host.fittingSize.height + private func measuredHeight(_ header: some View) -> CGFloat { + let host = NSHostingView(rootView: header.frame(width: 270)) + host.frame = NSRect(x: 0, y: 0, width: 270, height: 200) + host.layoutSubtreeIfNeeded() + return host.fittingSize.height + } } } diff --git a/TableProTests/Models/TrailingPaneStateTests.swift b/TableProTests/Models/TrailingPaneStateTests.swift index 461e227dbe..50dcc0a9c6 100644 --- a/TableProTests/Models/TrailingPaneStateTests.swift +++ b/TableProTests/Models/TrailingPaneStateTests.swift @@ -1,23 +1,10 @@ -// -// TrailingPaneStateTests.swift -// TableProTests -// - import Combine import Foundation @testable import TablePro import TableProPluginKit import Testing -@Suite("Trailing pane state", .serialized) struct TrailingPaneStateTests { - private func makeDefaults() throws -> UserDefaults { - let suite = "TrailingPaneStateTests.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) - return defaults - } - @Test("teardown is idempotent") @MainActor func teardownIdempotent() { @@ -147,7 +134,8 @@ struct TrailingPaneStateTests { @Test("The surface defaults to the inspector when nothing is stored") @MainActor func surfaceDefaults() throws { - let defaults = try makeDefaults() + let suite = try EphemeralDefaults() + let defaults = suite.defaults let state = TrailingPaneState(connectionId: UUID(), defaults: defaults) #expect(state.surface == .inspector) #expect(state.inspector.viewMode == .fields) @@ -156,7 +144,8 @@ struct TrailingPaneStateTests { @Test("The surface round-trips per connection") @MainActor func surfaceRoundTrip() throws { - let defaults = try makeDefaults() + let suite = try EphemeralDefaults() + let defaults = suite.defaults let connectionId = UUID() TrailingPaneState(connectionId: connectionId, defaults: defaults).surface = .assistant let restored = TrailingPaneState(connectionId: connectionId, defaults: defaults) @@ -166,7 +155,8 @@ struct TrailingPaneStateTests { @Test("The view mode round-trips per connection") @MainActor func viewModeRoundTrip() throws { - let defaults = try makeDefaults() + let suite = try EphemeralDefaults() + let defaults = suite.defaults let connectionId = UUID() TrailingPaneState(connectionId: connectionId, defaults: defaults).inspector.viewMode = .json let restored = TrailingPaneState(connectionId: connectionId, defaults: defaults) @@ -176,7 +166,8 @@ struct TrailingPaneStateTests { @Test("The surface is isolated per connection") @MainActor func surfaceIsolation() throws { - let defaults = try makeDefaults() + let suite = try EphemeralDefaults() + let defaults = suite.defaults let first = UUID() let second = UUID() TrailingPaneState(connectionId: first, defaults: defaults).surface = .assistant @@ -187,7 +178,8 @@ struct TrailingPaneStateTests { @Test("Nothing is persisted without a connection id") @MainActor func noConnectionMeansNoPersistence() throws { - let defaults = try makeDefaults() + let suite = try EphemeralDefaults() + let defaults = suite.defaults let state = TrailingPaneState(connectionId: nil, defaults: defaults) state.surface = .assistant state.inspector.viewMode = .json @@ -204,7 +196,8 @@ struct TrailingPaneStateTests { @Test("A connection last left on AI Chat comes back on the assistant") @MainActor func migratesAIChatToTheAssistant() throws { - let defaults = try makeDefaults() + let suite = try EphemeralDefaults() + let defaults = suite.defaults let connectionId = UUID() defaults.set("AI Chat", forKey: TrailingPaneState.legacyActiveTabKeyPrefix + connectionId.uuidString) @@ -216,7 +209,8 @@ struct TrailingPaneStateTests { @Test("A connection last left on JSON comes back on the inspector showing JSON") @MainActor func migratesJSONToTheInspectorsJSONMode() throws { - let defaults = try makeDefaults() + let suite = try EphemeralDefaults() + let defaults = suite.defaults let connectionId = UUID() defaults.set("JSON", forKey: TrailingPaneState.legacyActiveTabKeyPrefix + connectionId.uuidString) @@ -229,7 +223,8 @@ struct TrailingPaneStateTests { @Test("A connection last left on Details comes back on the inspector's fields") @MainActor func migratesDetailsToTheInspector() throws { - let defaults = try makeDefaults() + let suite = try EphemeralDefaults() + let defaults = suite.defaults let connectionId = UUID() defaults.set("Details", forKey: TrailingPaneState.legacyActiveTabKeyPrefix + connectionId.uuidString) @@ -242,7 +237,8 @@ struct TrailingPaneStateTests { @Test("The legacy key is consumed so it cannot override a later choice") @MainActor func migrationConsumesTheLegacyKey() throws { - let defaults = try makeDefaults() + let suite = try EphemeralDefaults() + let defaults = suite.defaults let connectionId = UUID() let legacyKey = TrailingPaneState.legacyActiveTabKeyPrefix + connectionId.uuidString defaults.set("AI Chat", forKey: legacyKey) diff --git a/TableProTests/Models/UI/FilterPresetStorageTests.swift b/TableProTests/Models/UI/FilterPresetStorageTests.swift index 660a5136a5..fb2de168e6 100644 --- a/TableProTests/Models/UI/FilterPresetStorageTests.swift +++ b/TableProTests/Models/UI/FilterPresetStorageTests.swift @@ -9,7 +9,6 @@ import TableProPluginKit import Testing @MainActor -@Suite("FilterPresetStorage - savePreset", .serialized) struct FilterPresetStorageTests { private let storage = FilterPresetStorage.shared diff --git a/TableProTests/Models/UI/WindowSidebarStateSeedingTests.swift b/TableProTests/Models/UI/WindowSidebarStateSeedingTests.swift index 5b6873482f..30f5392dcb 100644 --- a/TableProTests/Models/UI/WindowSidebarStateSeedingTests.swift +++ b/TableProTests/Models/UI/WindowSidebarStateSeedingTests.swift @@ -1,8 +1,3 @@ -// -// WindowSidebarStateSeedingTests.swift -// TableProTests -// - import Foundation @testable import TablePro import Testing @@ -11,12 +6,14 @@ import Testing /// opened", so the seed has to record that it ran rather than infer it from emptiness. @MainActor struct WindowSidebarStateSeedingTests { + private let suite: EphemeralDefaults + + init() throws { + suite = try EphemeralDefaults() + } + private func makeDefaults() -> UserDefaults { - let suiteName = "com.TablePro.tests.sidebarSeeding.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - fatalError("Could not create a test UserDefaults suite") - } - return defaults + suite.defaults } @Test("Seeding opens the browsed database and schema") diff --git a/TableProTests/SOCKS/SOCKSProxyManagerTests.swift b/TableProTests/SOCKS/SOCKSProxyManagerTests.swift index e1ac442edf..bf43f05ef8 100644 --- a/TableProTests/SOCKS/SOCKSProxyManagerTests.swift +++ b/TableProTests/SOCKS/SOCKSProxyManagerTests.swift @@ -1,8 +1,3 @@ -// -// SOCKSProxyManagerTests.swift -// TableProTests -// - import Foundation import Network import os @@ -11,30 +6,13 @@ import Testing @testable import TablePro import TableProTestSupport -@Suite("SOCKS proxy manager", .serialized) struct SOCKSProxyManagerTests { private func config(port: Int, username: String = "") -> SOCKSProxyConfiguration { SOCKSProxyConfiguration(host: "127.0.0.1", port: port, username: username) } - private func waitUntil( - timeout: Duration = .seconds(2), - _ condition: @escaping @Sendable () -> Bool - ) async -> Bool { - let deadline = ContinuousClock.now.advanced(by: timeout) - while ContinuousClock.now < deadline { - if condition() { return true } - try? await Task.sleep(for: .milliseconds(5)) - } - return condition() - } - - private func expectPortEventuallyFree(_ port: Int, host: String = "127.0.0.1") async { - for _ in 0..<100 { - if await !LoopbackPort.isReachable(host: host, port: port) { return } - try? await Task.sleep(for: .milliseconds(50)) - } - #expect(await !LoopbackPort.isReachable(host: host, port: port)) + private func expectPortEventuallyFree(_ port: Int, host: String = "127.0.0.1") async throws { + try await waitUntil { await !LoopbackPort.isReachable(host: host, port: port) } } @Test("invalid configuration is rejected without touching the network") @@ -207,7 +185,7 @@ struct SOCKSProxyManagerTests { ) } defer { creation.cancel() } - try #require(await waitUntil { server.acceptedConnectionCount > 0 }) + try await waitUntil { server.acceptedConnectionCount > 0 } let result = try #require(await BoundedCall.result(within: .seconds(2)) { await creation.result @@ -240,7 +218,7 @@ struct SOCKSProxyManagerTests { ) } defer { creation.cancel() } - try await Task.sleep(nanoseconds: 200_000_000) + try await waitUntil { server.acceptedConnectionCount > 0 } let started = Date() creation.cancel() let result = try #require(await BoundedCall.result(within: .seconds(2)) { @@ -261,11 +239,12 @@ struct SOCKSProxyManagerTests { try await server.start() defer { server.stop() } - let manager = SOCKSProxyManager(connectTimeout: 30) + let skew = OSAllocatedUnfairLock(initialState: Duration.zero) + let manager = SOCKSProxyManager(connectTimeout: 30, now: { .now.advanced(by: skew.withLock { $0 }) }) let connectionId = UUID() let deadline = ConnectionDeadline( configuredSeconds: 30, - instant: ContinuousClock.now.advanced(by: .seconds(1)) + instant: ContinuousClock.now.advanced(by: .seconds(10)) ) let localPort = try await manager.createTunnel( connectionId: connectionId, @@ -277,21 +256,19 @@ struct SOCKSProxyManagerTests { ) defer { Task { try await manager.closeTunnel(connectionId: connectionId) } } - try await Task.sleep(for: .milliseconds(1_050)) + skew.withLock { $0 = .seconds(11) } let client = try await TestTCPClient.connect(port: localPort) defer { client.cancel() } - for _ in 0..<100 { - if let failure = await manager.consumeLastConnectionFailure(connectionId: connectionId) { - #expect(failure == ConnectionTimeoutError( - endpoint: .proxy("127.0.0.1:\(server.port)"), - configuredSeconds: 30 - )) - return - } - try await Task.sleep(for: .milliseconds(10)) + var failure: ConnectionTimeoutError? + try await waitUntil { + failure = await manager.consumeLastConnectionFailure(connectionId: connectionId) + return failure != nil } - Issue.record("The first relay did not report the original connection deadline") + #expect(failure == ConnectionTimeoutError( + endpoint: .proxy("127.0.0.1:\(server.port)"), + configuredSeconds: 30 + )) } @Test("two concurrent clients relay independently through one tunnel") @@ -351,7 +328,7 @@ struct SOCKSProxyManagerTests { try await manager.closeTunnel(connectionId: connectionId) #expect(await !manager.hasTunnel(connectionId: connectionId)) #expect(await manager.getLocalPort(connectionId: connectionId) == nil) - await expectPortEventuallyFree(localPort) + try await expectPortEventuallyFree(localPort) } @Test("recreating a tunnel for the same connection replaces the old one") @@ -379,7 +356,7 @@ struct SOCKSProxyManagerTests { defer { Task { try await manager.closeTunnel(connectionId: connectionId) } } #expect(await manager.getLocalPort(connectionId: connectionId) == secondPort) - await expectPortEventuallyFree(firstPort) + try await expectPortEventuallyFree(firstPort) } } diff --git a/TableProTests/Services/ContentWindowBackdropTests.swift b/TableProTests/Services/ContentWindowBackdropTests.swift index a1d7258b0f..eaf93ab695 100644 --- a/TableProTests/Services/ContentWindowBackdropTests.swift +++ b/TableProTests/Services/ContentWindowBackdropTests.swift @@ -9,7 +9,6 @@ import Foundation import Testing /// The real controllers, built offscreen: never ordered front and never closed. -@Suite("Content windows carry the theme's window backdrop", .serialized) @MainActor struct ContentWindowBackdropTests { private var drawsBackdrop: Bool { diff --git a/TableProTests/Services/EditorWindowInitialFrameTests.swift b/TableProTests/Services/EditorWindowInitialFrameTests.swift index a27ffe0461..13188c3dfa 100644 --- a/TableProTests/Services/EditorWindowInitialFrameTests.swift +++ b/TableProTests/Services/EditorWindowInitialFrameTests.swift @@ -13,7 +13,6 @@ import Testing /// sidebar's top row under the titlebar and its toolbar's trailing items in the overflow menu, and /// stayed that way. The pin resized the window from a `WindowAccessor` callback, which runs inside /// the window's layout pass. -@Suite("Editor window initial frame", .serialized) @MainActor struct EditorWindowInitialFrameTests { private let pinnedSize = CGSize(width: 1_000, height: 700) diff --git a/TableProTests/Services/MacAnalyticsProviderTests.swift b/TableProTests/Services/MacAnalyticsProviderTests.swift index 21bbe93d4a..1c733da737 100644 --- a/TableProTests/Services/MacAnalyticsProviderTests.swift +++ b/TableProTests/Services/MacAnalyticsProviderTests.swift @@ -1,8 +1,3 @@ -// -// MacAnalyticsProviderTests.swift -// TableProTests -// - import Foundation import TableProPluginKit import Testing @@ -11,79 +6,63 @@ import Testing @MainActor struct MacAnalyticsProviderTests { - private static let suiteCounter = SuiteCounter() - - private final class SuiteCounter: @unchecked Sendable { - private var value: Int = 0 - private let lock = NSLock() - func next() -> Int { - lock.lock() - defer { lock.unlock() } - value += 1 - return value - } + private let storage: EphemeralDefaults + private let provider: MacAnalyticsProvider + + init() throws { + storage = try EphemeralDefaults() + provider = MacAnalyticsProvider(defaults: storage.defaults) } - private func makeProvider(test: String = #function) throws -> (MacAnalyticsProvider, UserDefaults) { - let id = "test.MacAnalyticsProviderTests.\(test).\(Self.suiteCounter.next()).\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: id)) - defaults.removePersistentDomain(forName: id) - return (MacAnalyticsProvider(defaults: defaults), defaults) + /// A stamp no second mark could write, so a timestamp that survives the mark was not rewritten. + private func seed(_ key: String) -> Date { + let recorded = Date(timeIntervalSince1970: 1) + storage.defaults.set(recorded, forKey: key) + return recorded } @Test("Getter returns nil when no timestamp recorded") - func gettersAreNilByDefault() throws { - let (provider, _) = try makeProvider() + func gettersAreNilByDefault() { #expect(provider.connectionAttemptedAt == nil) #expect(provider.connectionSucceededAt == nil) #expect(provider.firstQueryExecutedAt == nil) } @Test("markConnectionAttempted writes once and never overwrites") - func attemptedIsWriteOnce() throws { - let (provider, _) = try makeProvider() + func attemptedIsWriteOnce() { provider.markConnectionAttempted() - let first = provider.connectionAttemptedAt - #expect(first != nil) + #expect(provider.connectionAttemptedAt != nil) - Thread.sleep(forTimeInterval: 0.01) + let first = seed(MacAnalyticsProvider.Keys.connectionAttemptedAt) provider.markConnectionAttempted() - let second = provider.connectionAttemptedAt - #expect(first == second, "Second mark must not overwrite the first timestamp") + #expect(provider.connectionAttemptedAt == first, "Second mark must not overwrite the first timestamp") } @Test("markConnectionSucceeded writes once and never overwrites") - func succeededIsWriteOnce() throws { - let (provider, _) = try makeProvider() + func succeededIsWriteOnce() { provider.markConnectionSucceeded() - let first = provider.connectionSucceededAt - #expect(first != nil) + #expect(provider.connectionSucceededAt != nil) - Thread.sleep(forTimeInterval: 0.01) + let first = seed(MacAnalyticsProvider.Keys.connectionSucceededAt) provider.markConnectionSucceeded() - let second = provider.connectionSucceededAt - #expect(first == second) + #expect(provider.connectionSucceededAt == first) } @Test("markFirstQueryExecuted writes once and never overwrites") - func firstQueryIsWriteOnce() throws { - let (provider, _) = try makeProvider() + func firstQueryIsWriteOnce() { provider.markFirstQueryExecuted() - let first = provider.firstQueryExecutedAt - #expect(first != nil) + #expect(provider.firstQueryExecutedAt != nil) - Thread.sleep(forTimeInterval: 0.01) + let first = seed(MacAnalyticsProvider.Keys.firstQueryExecutedAt) provider.markFirstQueryExecuted() - let second = provider.firstQueryExecutedAt - #expect(first == second) + #expect(provider.firstQueryExecutedAt == first) } @Test("markConnectionAttempted does not affect connectionSucceededAt") - func attemptedDoesNotAffectSucceeded() throws { - let (provider, _) = try makeProvider() + func attemptedDoesNotAffectSucceeded() { provider.markConnectionAttempted() #expect(provider.connectionAttemptedAt != nil) @@ -92,13 +71,11 @@ struct MacAnalyticsProviderTests { } @Test("Repeated successful connections keep the timestamp write-once") - func succeededTimestampStaysWriteOnce() throws { - let (provider, _) = try makeProvider() - + func succeededTimestampStaysWriteOnce() { provider.markConnectionSucceeded() - let firstSucceededAt = provider.connectionSucceededAt - #expect(firstSucceededAt != nil) + #expect(provider.connectionSucceededAt != nil) + let firstSucceededAt = seed(MacAnalyticsProvider.Keys.connectionSucceededAt) provider.markConnectionSucceeded() provider.markConnectionSucceeded() diff --git a/TableProTests/Services/MainWindowToolbarLayoutTests.swift b/TableProTests/Services/MainWindowToolbarLayoutTests.swift index 95a3d54392..06059e592e 100644 --- a/TableProTests/Services/MainWindowToolbarLayoutTests.swift +++ b/TableProTests/Services/MainWindowToolbarLayoutTests.swift @@ -73,20 +73,12 @@ struct MainWindowToolbarInspectorPlacementTests { /// once was the shape this revamp took the Tables and Favorites control out of the titlebar /// for, and offering it in Customize Toolbar kept it reachable. View > Show Assistant and /// ⌥⌘A are the command, and `TrailingPaneCommandTitleTests` is what pins their behaviour. - @Test("No toolbar item opens the assistant") - func assistantHasNoToolbarItem() { + @Test("No toolbar item opens the assistant or the Server Dashboard", arguments: [".assistant", ".dashboard"]) + func surfaceHasNoToolbarItem(_ suffix: String) { let identifiers = Set(MainWindowToolbar.allowedItemIdentifiers).union( MainWindowToolbar.defaultItemIdentifiers ) - #expect(!identifiers.contains { $0.rawValue.hasSuffix(".assistant") }) - } - - @Test("No toolbar item opens the Server Dashboard") - func dashboardHasNoToolbarItem() { - let identifiers = Set(MainWindowToolbar.allowedItemIdentifiers).union( - MainWindowToolbar.defaultItemIdentifiers - ) - #expect(!identifiers.contains { $0.rawValue.hasSuffix(".dashboard") }) + #expect(!identifiers.contains { $0.rawValue.hasSuffix(suffix) }) } /// Ahead of the separator the toggle lands in the content section, which measured wrong in both diff --git a/TableProTests/Services/MainWindowToolbarShortcutHintTests.swift b/TableProTests/Services/MainWindowToolbarShortcutHintTests.swift index 986847e6b3..0d14502087 100644 --- a/TableProTests/Services/MainWindowToolbarShortcutHintTests.swift +++ b/TableProTests/Services/MainWindowToolbarShortcutHintTests.swift @@ -1,157 +1,128 @@ -// -// MainWindowToolbarShortcutHintTests.swift -// TableProTests -// - import AppKit import Foundation @testable import TablePro +import TableProTestSupport import Testing -/// A toolbar item's tooltip and its overflow-menu key equivalent are written once, when the -/// delegate vends the item, and AppKit never revisits either. Both used to be spelled out as -/// literals beside the action, so rebinding a shortcut in Settings reached the menu bar and -/// stopped there, leaving the toolbar advertising a key that no longer ran the command. -@MainActor -struct MainWindowToolbarShortcutHintTests { - private func vend(_ identifier: NSToolbarItem.Identifier, from owner: MainWindowToolbar) -> NSToolbarItem? { - owner.toolbar( - owner.managedToolbar, - itemForItemIdentifier: identifier, - willBeInsertedIntoToolbar: true - ) - } - - /// Back and Forward ride a group, so the delegate vends no standalone item for either. Resolving - /// through the group is what the tests have to do, and it is also what the overflow menu and - /// the customization palette do. - private func vendSubitem( - _ identifier: NSToolbarItem.Identifier, - of groupIdentifier: NSToolbarItem.Identifier, - from owner: MainWindowToolbar - ) -> NSToolbarItem? { - let group = vend(groupIdentifier, from: owner) as? NSToolbarItemGroup - return group?.subitems.first { $0.itemIdentifier == identifier } - } - - private func withKeyboard( - _ key: BoundKey, - for action: ShortcutAction, - _ body: (MainWindowToolbar) throws -> Void - ) rethrows { - let previous = AppSettingsManager.shared.keyboard - defer { AppSettingsManager.shared.keyboard = previous } - - var keyboard = previous - keyboard.setShortcut(key, for: action) - AppSettingsManager.shared.keyboard = keyboard +extension GlobalStateSuites { + /// A toolbar item's tooltip and its overflow-menu key equivalent are written once, when the + /// delegate vends the item, and AppKit never revisits either. Both used to be spelled out as + /// literals beside the action, so rebinding a shortcut in Settings reached the menu bar and + /// stopped there, leaving the toolbar advertising a key that no longer ran the command. + @MainActor + struct MainWindowToolbarShortcutHintTests { + private func vend(_ identifier: NSToolbarItem.Identifier, from owner: MainWindowToolbar) -> NSToolbarItem? { + owner.toolbar( + owner.managedToolbar, + itemForItemIdentifier: identifier, + willBeInsertedIntoToolbar: true + ) + } - try body(MainWindowToolbar()) - } + /// Back and Forward ride a group, so the delegate vends no standalone item for either. Resolving + /// through the group is what the tests have to do, and it is also what the overflow menu and + /// the customization palette do. + private func vendSubitem( + _ identifier: NSToolbarItem.Identifier, + of groupIdentifier: NSToolbarItem.Identifier, + from owner: MainWindowToolbar + ) -> NSToolbarItem? { + let group = vend(groupIdentifier, from: owner) as? NSToolbarItemGroup + return group?.subitems.first { $0.itemIdentifier == identifier } + } - @Test("A vended item takes its key equivalent from the user's binding, not a literal") - func vendedItemFollowsCustomBinding() { - withKeyboard(.character("j", command: true, control: true), for: .quickSwitcher) { owner in - let item = vend(MainWindowToolbar.quickSwitcher, from: owner) + @Test( + "A vended item takes its key equivalent from the user's binding, not a literal", + .appSettings { $0.keyboard.setShortcut(.character("j", command: true, control: true), for: .quickSwitcher) } + ) + func vendedItemFollowsCustomBinding() { + let item = vend(MainWindowToolbar.quickSwitcher, from: MainWindowToolbar()) let menuItem = item?.menuFormRepresentation #expect(menuItem?.keyEquivalent == "j") #expect(menuItem?.keyEquivalentModifierMask == [.command, .control]) } - } - @Test("A vended item's tooltip names the user's binding") - func vendedItemTooltipNamesCustomBinding() { - withKeyboard(.character("j", command: true, control: true), for: .quickSwitcher) { owner in - let item = vend(MainWindowToolbar.quickSwitcher, from: owner) + @Test( + "A vended item's tooltip names the user's binding", + .appSettings { $0.keyboard.setShortcut(.character("j", command: true, control: true), for: .quickSwitcher) } + ) + func vendedItemTooltipNamesCustomBinding() { + let item = vend(MainWindowToolbar.quickSwitcher, from: MainWindowToolbar()) #expect(item?.toolTip?.contains("⌃⌘J") == true) } - } - - /// The regression this suite exists for: an item vended before the rebind has to follow it. - @Test("An already-vended item follows a later rebind") - func vendedItemFollowsLaterRebind() { - let previous = AppSettingsManager.shared.keyboard - defer { AppSettingsManager.shared.keyboard = previous } - AppSettingsManager.shared.keyboard = { - var keyboard = previous - keyboard.resetToDefault(for: .quickSwitcher) - return keyboard - }() - - /// A private identifier with autosave off. Inserting into a toolbar named like the app's own - /// writes an arrangement into the test host's defaults, which are the app's. - let identifier = NSToolbar.Identifier("com.TablePro.tests.\(UUID().uuidString)") - let owner = MainWindowToolbar(managedToolbar: NSToolbar(identifier: identifier)) - owner.managedToolbar.autosavesConfiguration = false - guard let item = vend(MainWindowToolbar.quickSwitcher, from: owner) else { - Issue.record("Toolbar did not vend the Open Quickly item") - return - } - owner.managedToolbar.insertItem(withItemIdentifier: MainWindowToolbar.quickSwitcher, at: 0) - - var keyboard = AppSettingsManager.shared.keyboard - keyboard.setShortcut(.character("j", command: true, control: true), for: .quickSwitcher) - AppSettingsManager.shared.keyboard = keyboard - - let vendedItem = owner.managedToolbar.items.first { - $0.itemIdentifier == MainWindowToolbar.quickSwitcher + /// The regression this suite exists for: an item vended before the rebind has to follow it. + @Test( + "An already-vended item follows a later rebind", + .appSettings { $0.keyboard.resetToDefault(for: .quickSwitcher) } + ) + func vendedItemFollowsLaterRebind() async throws { + /// A private identifier with autosave off. Inserting into a toolbar named like the app's own + /// writes an arrangement into the test host's defaults, which are the app's. + let identifier = NSToolbar.Identifier("com.TablePro.tests.\(UUID().uuidString)") + let owner = MainWindowToolbar(managedToolbar: NSToolbar(identifier: identifier)) + owner.managedToolbar.autosavesConfiguration = false + guard let item = vend(MainWindowToolbar.quickSwitcher, from: owner) else { + Issue.record("Toolbar did not vend the Open Quickly item") + return + } + owner.managedToolbar.insertItem(withItemIdentifier: MainWindowToolbar.quickSwitcher, at: 0) + + var keyboard = AppSettingsManager.shared.keyboard + keyboard.setShortcut(.character("j", command: true, control: true), for: .quickSwitcher) + AppSettingsManager.shared.keyboard = keyboard + + let vendedItem = owner.managedToolbar.items.first { + $0.itemIdentifier == MainWindowToolbar.quickSwitcher + } + /// The toolbar refreshes off the settings write rather than inside it, so the hop through + /// the main run loop has to complete before the item can be read. + try await waitUntil { vendedItem?.menuFormRepresentation?.keyEquivalent == "j" } + #expect(vendedItem?.menuFormRepresentation?.keyEquivalentModifierMask == [.command, .control]) + #expect(vendedItem?.toolTip?.contains("⌃⌘J") == true) + _ = item } - /// The toolbar refreshes off the settings write rather than inside it, so the hop through - /// the main run loop has to complete before the item can be read. - #expect(spinRunLoopUntil { vendedItem?.menuFormRepresentation?.keyEquivalent == "j" }) - #expect(vendedItem?.menuFormRepresentation?.keyEquivalentModifierMask == [.command, .control]) - #expect(vendedItem?.toolTip?.contains("⌃⌘J") == true) - _ = item - } - private func spinRunLoopUntil(timeout: TimeInterval = 2, _ condition: () -> Bool) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if condition() { return true } - RunLoop.main.run(mode: .default, before: Date().addingTimeInterval(0.01)) + @Test("An item with no shortcut keeps a plain tooltip and no key equivalent") + func itemWithoutShortcutHasNoKeyEquivalent() { + let owner = MainWindowToolbar() + let item = vend(MainWindowToolbar.restorePreviousValues, from: owner) + #expect(item?.menuFormRepresentation?.keyEquivalent == "") + #expect(item?.toolTip?.isEmpty == false) } - return condition() - } - - @Test("An item with no shortcut keeps a plain tooltip and no key equivalent") - func itemWithoutShortcutHasNoKeyEquivalent() { - let owner = MainWindowToolbar() - let item = vend(MainWindowToolbar.restorePreviousValues, from: owner) - #expect(item?.menuFormRepresentation?.keyEquivalent == "") - #expect(item?.toolTip?.isEmpty == false) - } - /// Back is reachable from `allItems()` only while it is installed in its group, and `allItems()` - /// is the only channel that re-applies a rebind to a toolbar item. A commit that hid the pair by - /// emptying the group took both arrows out of that walk, so a rebind reached the menu bar and - /// stopped, leaving the toolbar advertising a key that no longer ran the command. `try #require` - /// rather than optional chaining on purpose: the house style here passes vacuously when the - /// subitem is absent, which is exactly the state this guards against. - @Test("A rebind reaches the navigation arrows") - func navigationFollowsLaterRebind() throws { - try withKeyboard(.character("j", command: true, control: true), for: .navigateBack) { owner in + /// Back is reachable from `allItems()` only while it is installed in its group, and `allItems()` + /// is the only channel that re-applies a rebind to a toolbar item. A commit that hid the pair by + /// emptying the group took both arrows out of that walk, so a rebind reached the menu bar and + /// stopped, leaving the toolbar advertising a key that no longer ran the command. `try #require` + /// rather than optional chaining on purpose: the house style here passes vacuously when the + /// subitem is absent, which is exactly the state this guards against. + @Test( + "A rebind reaches the navigation arrows", + .appSettings { $0.keyboard.setShortcut(.character("j", command: true, control: true), for: .navigateBack) } + ) + func navigationFollowsLaterRebind() throws { let item = try #require( vendSubitem( MainWindowToolbar.navigateBack, of: MainWindowToolbar.backForwardGroup, - from: owner + from: MainWindowToolbar() ), "Back must be reachable through its group, or a rebind cannot find it" ) #expect(item.toolTip?.contains("⌃⌘J") == true) #expect(item.menuFormRepresentation?.keyEquivalent == "j") } - } - /// The Import item opens a submenu, so its menu form carries the submenu rather than a key. - /// Writing a key equivalent onto it would put a shortcut on a row that only opens a menu. - @Test("The Import item keeps its submenu and takes no key equivalent") - func submenuItemTakesNoKeyEquivalent() { - let owner = MainWindowToolbar() - let item = vend(MainWindowToolbar.importTables, from: owner) - #expect(item?.menuFormRepresentation?.submenu != nil) - #expect(item?.menuFormRepresentation?.keyEquivalent == "") - #expect(item?.toolTip?.isEmpty == false) + /// The Import item opens a submenu, so its menu form carries the submenu rather than a key. + /// Writing a key equivalent onto it would put a shortcut on a row that only opens a menu. + @Test("The Import item keeps its submenu and takes no key equivalent") + func submenuItemTakesNoKeyEquivalent() { + let owner = MainWindowToolbar() + let item = vend(MainWindowToolbar.importTables, from: owner) + #expect(item?.menuFormRepresentation?.submenu != nil) + #expect(item?.menuFormRepresentation?.keyEquivalent == "") + #expect(item?.toolTip?.isEmpty == false) + } } } diff --git a/TableProTests/Services/MainWindowToolbarValidationTests.swift b/TableProTests/Services/MainWindowToolbarValidationTests.swift index a62eb12e72..7f9f2a0a8f 100644 --- a/TableProTests/Services/MainWindowToolbarValidationTests.swift +++ b/TableProTests/Services/MainWindowToolbarValidationTests.swift @@ -1,12 +1,8 @@ -// -// MainWindowToolbarValidationTests.swift -// TableProTests -// - import AppKit import Foundation @testable import TablePro import TableProPluginKit +import TableProTestSupport import Testing @MainActor @@ -79,16 +75,11 @@ struct MainWindowToolbarValidationTests { return (owner, toolbar) } - private func waitForValidation(_ toolbar: RecordingToolbar, after baseline: Int) async { - for _ in 0..<10 { - guard toolbar.validationCount <= baseline else { return } - await Task.yield() - } - } - - private func drainMainActor() async { - for _ in 0..<10 { - await Task.yield() + /// `onMainActorChange` delivers through `RunLoop.main.perform`, which runs blocks in the order + /// they were queued, so this returns after every callback a change queued before it. + private func runLoopTurn() async { + await withCheckedContinuation { continuation in + RunLoop.main.perform { continuation.resume() } } } @@ -325,8 +316,8 @@ struct MainWindowToolbarValidationTests { @Test("Item validation reads the live session, not the connected state alone") func validateToolbarItemFollowsLiveSession() { - let coordinator = makeCoordinator() - defer { coordinator.teardown() } + let coordinatorFixture = makeFixture() + let coordinator = coordinatorFixture.coordinator let owner = MainWindowToolbar() owner.repoint(to: coordinator) let refresh = NSToolbarItem(itemIdentifier: MainWindowToolbar.refresh) @@ -344,12 +335,10 @@ struct MainWindowToolbarValidationTests { @Test("Pending changes revalidate native items with the final state") func pendingChangesRevalidateNativeItems() async throws { - let coordinator = makeCoordinator() + let coordinatorFixture = makeFixture() + let coordinator = coordinatorFixture.coordinator let (owner, toolbar) = makeRecordingOwner() - defer { - owner.invalidate() - coordinator.teardown() - } + defer { owner.invalidate() } coordinator.toolbarState.connectionState = .connected toolbar.pendingSnapshotProvider = { ( @@ -362,7 +351,7 @@ struct MainWindowToolbarValidationTests { let dirtyBaseline = toolbar.validationCount coordinator.toolbarState.hasDataPendingChanges = true coordinator.toolbarState.hasPendingChanges = true - await waitForValidation(toolbar, after: dirtyBaseline) + try await waitUntil { toolbar.validationCount > dirtyBaseline } #expect(toolbar.validationCount > dirtyBaseline) let dirtySnapshot = try #require(toolbar.pendingSnapshots.last) @@ -372,7 +361,7 @@ struct MainWindowToolbarValidationTests { let cleanBaseline = toolbar.validationCount coordinator.toolbarState.hasDataPendingChanges = false coordinator.toolbarState.hasPendingChanges = false - await waitForValidation(toolbar, after: cleanBaseline) + try await waitUntil { toolbar.validationCount > cleanBaseline } #expect(toolbar.validationCount > cleanBaseline) let cleanSnapshot = try #require(toolbar.pendingSnapshots.last) @@ -385,12 +374,10 @@ struct MainWindowToolbarValidationTests { /// beside `hasPendingChanges`, and it is the one the commit control reads. @Test("Overflow Save and Preview use the pending-change predicates") func overflowPendingActionsValidateAgainstCurrentState() throws { - let coordinator = makeCoordinator() + let coordinatorFixture = makeFixture() + let coordinator = coordinatorFixture.coordinator let owner = MainWindowToolbar() - defer { - owner.invalidate() - coordinator.teardown() - } + defer { owner.invalidate() } coordinator.toolbarState.connectionState = .connected owner.repoint(to: coordinator) @@ -423,54 +410,52 @@ struct MainWindowToolbarValidationTests { } @Test("A queued validation cannot cross a toolbar repoint") - func queuedValidationCannotCrossRepoint() async { - let first = makeCoordinator() - let second = makeCoordinator() + func queuedValidationCannotCrossRepoint() async throws { + let firstFixture = makeFixture() + let secondFixture = makeFixture() + let first = firstFixture.coordinator + let second = secondFixture.coordinator let (owner, toolbar) = makeRecordingOwner() - defer { - owner.invalidate() - first.teardown() - second.teardown() - } + defer { owner.invalidate() } owner.repoint(to: first) first.toolbarState.hasPendingChanges = true owner.repoint(to: second) let repointBaseline = toolbar.validationCount - await drainMainActor() + await runLoopTurn() #expect(toolbar.validationCount == repointBaseline) first.toolbarState.hasPendingChanges = false - await drainMainActor() + await runLoopTurn() #expect(toolbar.validationCount == repointBaseline) second.toolbarState.hasPendingChanges = true - await waitForValidation(toolbar, after: repointBaseline) + try await waitUntil { toolbar.validationCount > repointBaseline } #expect(toolbar.validationCount > repointBaseline) } @Test("Invalidation drops queued and future pending-state callbacks") func invalidationDropsPendingStateCallbacks() async { - let coordinator = makeCoordinator() + let coordinatorFixture = makeFixture() + let coordinator = coordinatorFixture.coordinator let (owner, toolbar) = makeRecordingOwner() - defer { coordinator.teardown() } owner.repoint(to: coordinator) coordinator.toolbarState.hasPendingChanges = true owner.invalidate() let invalidationBaseline = toolbar.validationCount - await drainMainActor() + await runLoopTurn() #expect(toolbar.validationCount == invalidationBaseline) coordinator.toolbarState.hasPendingChanges = false - await drainMainActor() + await runLoopTurn() #expect(toolbar.validationCount == invalidationBaseline) } @Test("Toolbar is configured for user customization and autosave") func toolbarConfigurationEnablesAutosave() { - let coordinator = makeCoordinator() - defer { coordinator.teardown() } + let coordinatorFixture = makeFixture() + let coordinator = coordinatorFixture.coordinator let owner = MainWindowToolbar() owner.repoint(to: coordinator) #expect(owner.managedToolbar.identifier == MainWindowToolbar.toolbarIdentifier) @@ -480,8 +465,8 @@ struct MainWindowToolbarValidationTests { @Test("Allowed item identifiers are a superset of defaults so restored items survive autosave") func allowedItemIdentifiersAreSupersetOfDefaults() { - let coordinator = makeCoordinator() - defer { coordinator.teardown() } + let coordinatorFixture = makeFixture() + let coordinator = coordinatorFixture.coordinator let owner = MainWindowToolbar() owner.repoint(to: coordinator) let toolbar = owner.managedToolbar @@ -490,37 +475,25 @@ struct MainWindowToolbarValidationTests { #expect(defaults.isSubset(of: allowed)) } - private func makeCoordinator() -> MainContentCoordinator { - MainContentCoordinator( - connection: TestFixtures.makeConnection(database: "db_a"), - tabManager: QueryTabManager(), - changeManager: DataChangeManager(), - toolbarState: ConnectionToolbarState() - ) + private func makeFixture() -> CoordinatorFixture { + CoordinatorFixture(connection: TestFixtures.makeConnection(database: "db_a")) } } @MainActor struct MainWindowToolbarRepointTests { - private func makeCoordinator() -> MainContentCoordinator { - MainContentCoordinator( - connection: TestFixtures.makeConnection(database: "db_a"), - tabManager: QueryTabManager(), - changeManager: DataChangeManager(), - toolbarState: ConnectionToolbarState() - ) + private func makeFixture() -> CoordinatorFixture { + CoordinatorFixture(connection: TestFixtures.makeConnection(database: "db_a")) } /// The window keeps one toolbar and points it at whichever connection it is showing, so a /// switch has to change what the items are about without rebuilding them. @Test("Repointing changes the subject the items read") func repointChangesTheSubject() { - let first = makeCoordinator() - let second = makeCoordinator() - defer { - first.teardown() - second.teardown() - } + let firstFixture = makeFixture() + let secondFixture = makeFixture() + let first = firstFixture.coordinator + let second = secondFixture.coordinator let owner = MainWindowToolbar() owner.repoint(to: first) @@ -538,8 +511,8 @@ struct MainWindowToolbarRepointTests { /// forward. @Test("Repointing to the same coordinator is a no-op") func repointToSameCoordinatorIsIgnored() { - let coordinator = makeCoordinator() - defer { coordinator.teardown() } + let coordinatorFixture = makeFixture() + let coordinator = coordinatorFixture.coordinator let owner = MainWindowToolbar() owner.repoint(to: coordinator) @@ -553,12 +526,10 @@ struct MainWindowToolbarRepointTests { /// pinned it to the collapsed glyph for good once that coordinator went away. @Test("The Results glyph follows the repointed connection") func resultsSymbolFollowsTheRepointedConnection() throws { - let collapsed = makeCoordinator() - let expanded = makeCoordinator() - defer { - collapsed.teardown() - expanded.teardown() - } + let collapsedFixture = makeFixture() + let expandedFixture = makeFixture() + let collapsed = collapsedFixture.coordinator + let expanded = expandedFixture.coordinator collapsed.toolbarState.isResultsCollapsed = true expanded.toolbarState.isResultsCollapsed = false diff --git a/TableProTests/Services/SchemaRefreshCommitCostTests.swift b/TableProTests/Services/SchemaRefreshCommitCostTests.swift index 5dedf213ac..2656bf7d11 100644 --- a/TableProTests/Services/SchemaRefreshCommitCostTests.swift +++ b/TableProTests/Services/SchemaRefreshCommitCostTests.swift @@ -1,8 +1,3 @@ -// -// SchemaRefreshCommitCostTests.swift -// TableProTests -// - import Foundation @testable import TablePro import TableProPluginKit @@ -361,21 +356,23 @@ struct SchemaGroupedRefreshCostTests { metadataDriverProvider: provider, databaseManager: nil ) - let coordinator = MainContentCoordinator( + let fixture = CoordinatorFixture( connection: connection, - tabManager: QueryTabManager(), - changeManager: DataChangeManager(), - toolbarState: ConnectionToolbarState(), services: Self.services(schemaService: schemaService, schemaRefreshService: refresh) ) var truncates = Set() var deletes = Set() - coordinator.handleDiscard(pendingTruncates: &truncates, pendingDeletes: &deletes) - try await Task.sleep(for: .milliseconds(200)) + fixture.coordinator.handleDiscard(pendingTruncates: &truncates, pendingDeletes: &deletes) + // A refresh the discard started is a main-actor task queued ahead of this block, and it + // registers with the service before it first suspends. + await withCheckedContinuation { continuation in + DispatchQueue.main.async { continuation.resume() } + } + await refresh.waitForRefresh(connectionId: connection.id) #expect(driver.reads.isEmpty) - await coordinator.refreshTables() + await fixture.coordinator.refreshTables() #expect(driver.reads.contains("tables")) } diff --git a/TableProTests/Services/SchemaServiceRefreshTests.swift b/TableProTests/Services/SchemaServiceRefreshTests.swift index df62e8022c..baae278511 100644 --- a/TableProTests/Services/SchemaServiceRefreshTests.swift +++ b/TableProTests/Services/SchemaServiceRefreshTests.swift @@ -1,13 +1,9 @@ -// -// SchemaServiceRefreshTests.swift -// TableProTests -// - import Foundation import TableProPluginKit import Testing @testable import TablePro +import TableProTestSupport private final class RefreshMockDriver: DatabaseDriver, @unchecked Sendable { let connection: DatabaseConnection @@ -150,7 +146,7 @@ struct SchemaServiceRefreshTests { } @Test("A load for a new scope does not adopt the in-flight load for the old scope") - func loadsForDifferentScopesDoNotJoin() async { + func loadsForDifferentScopesDoNotJoin() async throws { let connectionId = UUID() let connection = TestFixtures.makeConnection(id: connectionId, type: .postgresql) let driver = RefreshMockDriver(connection: connection) @@ -184,7 +180,7 @@ struct SchemaServiceRefreshTests { scope: analytics ) } - await Task.yield() + try await waitUntil { driver.tablesCallCount == 2 } driver.resumeFetchTables() await first.value await second.value @@ -236,7 +232,11 @@ struct SchemaServiceRefreshTests { await service.waitForRefresh(connectionId: connectionId) waiterFinished = true } - await Task.yield() + // The waiter is a main-actor task queued ahead of this block, and a refresh that let it + // through would let it finish without suspending. + await withCheckedContinuation { continuation in + DispatchQueue.main.async { continuation.resume() } + } #expect(!waiterFinished) driver.resumeFetchTables() diff --git a/TableProTests/Services/SchemaServiceRoutinesTests.swift b/TableProTests/Services/SchemaServiceRoutinesTests.swift index 9df07ad6f4..37c294b60d 100644 --- a/TableProTests/Services/SchemaServiceRoutinesTests.swift +++ b/TableProTests/Services/SchemaServiceRoutinesTests.swift @@ -1,13 +1,9 @@ -// -// SchemaServiceRoutinesTests.swift -// TableProTests -// - import Foundation import TableProPluginKit import Testing @testable import TablePro +import TableProTestSupport private final class RoutineMockDriver: DatabaseDriver, @unchecked Sendable { let connection: DatabaseConnection @@ -153,31 +149,6 @@ private final class FailingRoutineDriver: DatabaseDriver, @unchecked Sendable { } } -private actor AsyncGate { - private var waiters: [CheckedContinuation] = [] - private var isOpen = false - - func wait() async { - if isOpen { - return - } - - await withCheckedContinuation { continuation in - waiters.append(continuation) - } - } - - func open() { - guard !isOpen else { return } - isOpen = true - let currentWaiters = waiters - waiters.removeAll() - for continuation in currentWaiters { - continuation.resume() - } - } -} - private final class BlockingAuxiliaryDriver: DatabaseDriver, @unchecked Sendable { let connection: DatabaseConnection var status: ConnectionStatus = .connected @@ -376,7 +347,7 @@ struct SchemaServiceRoutinesTests { } @Test("table state becomes loaded before auxiliary metadata finishes") - func tableStateLoadsBeforeAuxiliaryMetadata() async { + func tableStateLoadsBeforeAuxiliaryMetadata() async throws { let service = SchemaService() let connectionId = UUID() let connection = TestFixtures.makeConnection(id: connectionId, type: .postgresql) @@ -395,7 +366,7 @@ struct SchemaServiceRoutinesTests { } await driver.tablesGate.open() - await waitForLoadedState(service, connectionId: connectionId) + try await waitForLoadedState(service, connectionId: connectionId) #expect(service.tables(for: connectionId).map(\.name) == ["users"]) #expect(service.procedures(for: connectionId).isEmpty) @@ -415,7 +386,7 @@ struct SchemaServiceRoutinesTests { /// for the database being entered joined the in-flight routine fetch of the one being left /// and committed that database's routines under the new scope. @Test("A load for another scope never joins the routine fetch of the scope being left") - func scopeChangeDoesNotJoinInFlightRoutineFetch() async { + func scopeChangeDoesNotJoinInFlightRoutineFetch() async throws { let service = SchemaService() let connectionId = UUID() let connection = TestFixtures.makeConnection(id: connectionId, type: .postgresql) @@ -430,13 +401,13 @@ struct SchemaServiceRoutinesTests { await service.load(connectionId: connectionId, driver: driver, connection: connection, scope: primary) } await driver.tablesGate.open() - await waitForLoadedState(service, connectionId: connectionId) - await waitUntil { driver.routinesCallCount == 1 } + try await waitForLoadedState(service, connectionId: connectionId) + try await waitUntil { driver.routinesCallCount == 1 } let analyticsLoad = Task { await service.load(connectionId: connectionId, driver: driver, connection: connection, scope: analytics) } - await waitUntil { driver.routinesCallCount == 2 } + try await waitUntil { driver.routinesCallCount == 2 } #expect(driver.routinesCallCount == 2) await driver.routinesGate.open() @@ -448,18 +419,10 @@ struct SchemaServiceRoutinesTests { #expect(service.procedures(for: connectionId).map(\.name) == ["add_user"]) } - private func waitForLoadedState(_ service: SchemaService, connectionId: UUID) async { - while true { - if case .loaded = service.state(for: connectionId) { - return - } - await Task.yield() - } - } - - private func waitUntil(_ condition: () -> Bool) async { - for _ in 0..<10_000 where !condition() { - await Task.yield() + private func waitForLoadedState(_ service: SchemaService, connectionId: UUID) async throws { + try await waitUntil { + if case .loaded = service.state(for: connectionId) { return true } + return false } } diff --git a/TableProTests/Services/SparkleUpdatePreferencesTests.swift b/TableProTests/Services/SparkleUpdatePreferencesTests.swift index 71edefbda6..0f4ce0685e 100644 --- a/TableProTests/Services/SparkleUpdatePreferencesTests.swift +++ b/TableProTests/Services/SparkleUpdatePreferencesTests.swift @@ -55,9 +55,8 @@ struct SparkleUpdatePreferencesTests { @Test("Reset removes every user-settable key and nothing else") func resetRemovesOnlyTheKeysSparkleOwns() throws { - let suite = "com.TablePro.tests.sparkle-reset" - let defaults = try #require(UserDefaults(suiteName: suite)) - defer { defaults.removePersistentDomain(forName: suite) } + let suite = try EphemeralDefaults() + let defaults = suite.defaults for key in SparkleUpdatePreferences.userSettableKeys { defaults.set(true, forKey: key) @@ -88,9 +87,8 @@ struct SparkleUpdatePreferencesTests { @Test("Reset on a domain that never held the keys is a no-op") func resetIsIdempotent() throws { - let suite = "com.TablePro.tests.sparkle-reset-empty" - let defaults = try #require(UserDefaults(suiteName: suite)) - defer { defaults.removePersistentDomain(forName: suite) } + let suite = try EphemeralDefaults() + let defaults = suite.defaults SparkleUpdatePreferences.reset(in: defaults) SparkleUpdatePreferences.reset(in: defaults) diff --git a/TableProTests/Services/ToolbarHiddenSetTests.swift b/TableProTests/Services/ToolbarHiddenSetTests.swift index 749df572f0..abe4bb6915 100644 --- a/TableProTests/Services/ToolbarHiddenSetTests.swift +++ b/TableProTests/Services/ToolbarHiddenSetTests.swift @@ -1,11 +1,7 @@ -// -// ToolbarHiddenSetTests.swift -// TableProTests -// - import AppKit import Foundation @testable import TablePro +import TableProTestSupport import Testing @MainActor @@ -50,35 +46,20 @@ struct ToolbarHiddenSetTests { } /// Lets what a case's setup asked for arrive before the case starts counting: the pass the - /// toolbar's own inserts asked for, and the observer callbacks a repoint queued. - /// - /// Wall-clock time, not main-actor hops. Measured in the test host, an insert's announcement - /// reaches the delegate only once the run loop turns, and five main-actor hops after building - /// the toolbar had not yet run the pass its six inserts asked for. - private func settle() async { - try? await Task.sleep(for: .milliseconds(250)) + /// toolbar's own inserts asked for, and the observer callbacks a repoint queued. A toolbar with + /// no window announces an insert only once its items are next read, and an announcement reaches + /// the delegate only once the run loop turns, so the wait reads the items and turns it. + private func settle(_ toolbar: CountingToolbar) async throws { + try await waitUntil { toolbar.items.count >= Self.placed.count && toolbar.validationCount > 0 } + await runLoopTurn() } - /// Waits until the condition holds, bounded at two seconds. A sleep hands the main thread back - /// to the run loop, which is what delivers both an insert's announcement and every observer - /// that receives on `RunLoop.main`. A chain of main-actor hops can drain inside one pass of the - /// main queue without the run loop ever turning, and a spun run loop runs no main-actor job at - /// all, because the test is itself one. - private func waitForRunLoop(until condition: () -> Bool) async -> Bool { - for _ in 0..<200 { - if condition() { return true } - try? await Task.sleep(for: .milliseconds(10)) + /// `RunLoop.main.perform` runs blocks in the order they were queued, so this returns after every + /// observer callback queued before it, and the case resumes behind the pass each one queued. + private func runLoopTurn() async { + await withCheckedContinuation { continuation in + RunLoop.main.perform { continuation.resume() } } - return condition() - } - - private func makeCoordinator() -> MainContentCoordinator { - MainContentCoordinator( - connection: TestFixtures.makeConnection(database: "db_a"), - tabManager: QueryTabManager(), - changeManager: DataChangeManager(), - toolbarState: ConnectionToolbarState() - ) } @available(macOS 15.0, *) @@ -165,7 +146,7 @@ struct ToolbarHiddenSetTests { @Test("An item put back from the palette takes the context on the next turn") func droppedItemTakesTheContextNextTurn() async throws { let (owner, toolbar) = makeOwner() - await settle() + try await settle(toolbar) owner.apply(ToolbarVisibility(hidden: [MainWindowToolbar.refresh])) let index = try #require(toolbar.items.firstIndex { $0.itemIdentifier == MainWindowToolbar.refresh }) toolbar.removeItem(at: index) @@ -174,15 +155,15 @@ struct ToolbarHiddenSetTests { let dropped = try #require(item(MainWindowToolbar.refresh, in: toolbar)) #expect(dropped.isHidden == false, "A fresh item arrives visible, and the pass is not run in the notification") - #expect(await waitForRunLoop { dropped.isHidden }) + try await waitUntil { dropped.isHidden } } /// A Customize Toolbar drop of several items announces each one, and each announcement asks for /// a pass. They coalesce into one, because every pass validates every visible item. /// /// The pass the toolbar's own inserts asked for is let through first, and the count is read - /// again after a quiet interval rather than the moment it first moves, so a second pass that - /// arrived late would still be counted. + /// again after the run loop has turned past the first pass rather than the moment it moves, so + /// a second pass queued behind it would still be counted. /// /// Every wait reads `NSToolbar.items`. Measured in the test host, a toolbar with no window /// announces an insert to its delegate only once its items are next read: a wait on the pass @@ -193,17 +174,14 @@ struct ToolbarHiddenSetTests { @Test("Two items put back in one turn cost exactly one pass") func aBurstOfDropsIsOnePass() async throws { let (owner, toolbar) = makeOwner() - #expect( - await waitForRunLoop { toolbar.items.count == Self.placed.count && toolbar.validationCount > 0 }, - "Building the toolbar asked for no pass" - ) - await settle() + try await settle(toolbar) owner.apply(ToolbarVisibility(hidden: [MainWindowToolbar.refresh, MainWindowToolbar.saveChanges])) for identifier in [MainWindowToolbar.saveChanges, MainWindowToolbar.refresh] { let index = try #require(toolbar.items.firstIndex { $0.itemIdentifier == identifier }) toolbar.removeItem(at: index) } - await settle() + _ = toolbar.items + await runLoopTurn() let beforeDrops = toolbar.validationCount toolbar.insertItem(withItemIdentifier: MainWindowToolbar.refresh, at: 2) @@ -211,11 +189,9 @@ struct ToolbarHiddenSetTests { let afterDrops = toolbar.validationCount #expect(afterDrops == beforeDrops, "The pass must not run inside the announcement") - #expect( - await waitForRunLoop { toolbar.items.count == Self.placed.count && toolbar.validationCount > afterDrops }, - "No pass ran for the drops at all" - ) - await settle() + try await waitUntil { toolbar.items.count == Self.placed.count && toolbar.validationCount > afterDrops } + _ = toolbar.items + await runLoopTurn() #expect(toolbar.validationCount == afterDrops + 1) #expect(item(MainWindowToolbar.refresh, in: toolbar)?.isHidden == true) #expect(item(MainWindowToolbar.saveChanges, in: toolbar)?.isHidden == true) @@ -230,19 +206,19 @@ struct ToolbarHiddenSetTests { @Test("Staging a change under a live commit control leaves its label alone") func stagingLeavesTheLabelAlone() async throws { let (owner, toolbar) = makeOwner() - let coordinator = makeCoordinator() - defer { coordinator.teardown() } - coordinator.tabManager.addTab() - owner.repoint(to: coordinator) - await settle() + let fixture = CoordinatorFixture(connection: TestFixtures.makeConnection(database: "db_a")) + fixture.tabManager.addTab() + owner.repoint(to: fixture.coordinator) + try await settle(toolbar) let commit = try #require(item(MainWindowToolbar.saveChanges, in: toolbar)) #expect(commit.label == String(localized: "Save Changes")) for staged in [PendingChangeKind.createTable, .principals, nil] { let before = toolbar.validationCount - coordinator.toolbarState.pendingChange = staged + fixture.coordinator.toolbarState.pendingChange = staged + await runLoopTurn() #expect( - await waitForRunLoop { toolbar.validationCount > before }, + toolbar.validationCount > before, "The toolbar-state observer never ran, so the label check below proves nothing" ) #expect(commit.label == String(localized: "Save Changes"), "\(String(describing: staged))") @@ -255,18 +231,17 @@ struct ToolbarHiddenSetTests { @Test("Switching to another kind of tab relabels the live commit control") func tabSwitchRelabelsTheCommitControl() async throws { let (owner, toolbar) = makeOwner() - let coordinator = makeCoordinator() - defer { coordinator.teardown() } - coordinator.tabManager.addTab() - owner.repoint(to: coordinator) + let fixture = CoordinatorFixture(connection: TestFixtures.makeConnection(database: "db_a")) + fixture.tabManager.addTab() + owner.repoint(to: fixture.coordinator) let commit = try #require(item(MainWindowToolbar.saveChanges, in: toolbar)) #expect(commit.label == String(localized: "Save Changes")) - coordinator.tabManager.addCreateTableTab() - #expect(await waitForRunLoop { commit.label == String(localized: "Create Table") }) + fixture.tabManager.addCreateTableTab() + try await waitUntil { commit.label == String(localized: "Create Table") } #expect(commit.menuFormRepresentation?.title == String(localized: "Create Table")) - coordinator.tabManager.selectTab(at: 0) - #expect(await waitForRunLoop { commit.label == String(localized: "Save Changes") }) + fixture.tabManager.selectTab(at: 0) + try await waitUntil { commit.label == String(localized: "Save Changes") } } } diff --git a/TableProTests/Storage/CustomSlashCommandStorageSyncTests.swift b/TableProTests/Storage/CustomSlashCommandStorageSyncTests.swift index f3f0ff907f..aa2ffed144 100644 --- a/TableProTests/Storage/CustomSlashCommandStorageSyncTests.swift +++ b/TableProTests/Storage/CustomSlashCommandStorageSyncTests.swift @@ -1,8 +1,3 @@ -// -// CustomSlashCommandStorageSyncTests.swift -// TableProTests -// - import Foundation @testable import TablePro import Testing @@ -10,24 +5,30 @@ import TableProSyncTransport @MainActor struct CustomSlashCommandStorageSyncTests { - private func makeStorage() throws -> (CustomSlashCommandStorage, SyncChangeTracker) { - let defaults = try #require(UserDefaults(suiteName: "slashcmd-\(UUID().uuidString)")) - let metaDefaults = try #require(UserDefaults(suiteName: "slashcmd-meta-\(UUID().uuidString)")) - let tracker = SyncChangeTracker(metadataStorage: SyncMetadataStorage(userDefaults: metaDefaults)) - let storage = CustomSlashCommandStorage(defaults: defaults, syncTracker: tracker) + private let commands: EphemeralDefaults + private let metadata: EphemeralDefaults + + init() throws { + commands = try EphemeralDefaults() + metadata = try EphemeralDefaults() + } + + private func makeStorage() -> (CustomSlashCommandStorage, SyncChangeTracker) { + let tracker = SyncChangeTracker(metadataStorage: SyncMetadataStorage(userDefaults: metadata.defaults)) + let storage = CustomSlashCommandStorage(defaults: commands.defaults, syncTracker: tracker) return (storage, tracker) } @Test("Adding a command marks it dirty for sync") func addMarksDirty() throws { - let (storage, tracker) = try makeStorage() + let (storage, tracker) = makeStorage() try storage.add(CustomSlashCommand(name: "explain", promptTemplate: "Explain {{query}}")) #expect(tracker.dirtyRecords(for: .settings).contains(CustomSlashCommandStorage.syncCategory)) } @Test("Deleting a command marks it dirty for sync") func deleteMarksDirty() throws { - let (storage, tracker) = try makeStorage() + let (storage, tracker) = makeStorage() let command = CustomSlashCommand(name: "explain", promptTemplate: "Explain {{query}}") try storage.add(command) tracker.clearAllDirty(.settings) @@ -37,7 +38,7 @@ struct CustomSlashCommandStorageSyncTests { @Test("Applying remote commands replaces the list without marking dirty") func applyRemoteDoesNotMarkDirty() throws { - let (storage, tracker) = try makeStorage() + let (storage, tracker) = makeStorage() storage.applyRemote([CustomSlashCommand(name: "remote", promptTemplate: "{{query}}")]) #expect(storage.commands.map(\.name) == ["remote"]) #expect(tracker.dirtyRecords(for: .settings).contains(CustomSlashCommandStorage.syncCategory) == false) diff --git a/TableProTests/Storage/FileColumnLayoutPersisterTests.swift b/TableProTests/Storage/FileColumnLayoutPersisterTests.swift index 17a68ca3e2..f1d7682aa9 100644 --- a/TableProTests/Storage/FileColumnLayoutPersisterTests.swift +++ b/TableProTests/Storage/FileColumnLayoutPersisterTests.swift @@ -501,7 +501,8 @@ struct FileColumnLayoutPersisterTests { let directory = FileManager.default.temporaryDirectory .appendingPathComponent("TableProTests-\(UUID().uuidString)", isDirectory: true) defer { cleanup(directory) } - let defaults = try #require(UserDefaults(suiteName: "colvis-\(UUID().uuidString)")) + let suite = try EphemeralDefaults() + let defaults = suite.defaults let tableKey = key("users", UUID()) let legacyKey = "com.TablePro.columns.hiddenColumns." + tableKey.storageKey defaults.set(["email", "phone"], forKey: legacyKey) diff --git a/TableProTests/Storage/ForeignKeyLabelColumnStoreTests.swift b/TableProTests/Storage/ForeignKeyLabelColumnStoreTests.swift index 0d713e7712..c63f495f82 100644 --- a/TableProTests/Storage/ForeignKeyLabelColumnStoreTests.swift +++ b/TableProTests/Storage/ForeignKeyLabelColumnStoreTests.swift @@ -5,9 +5,14 @@ import Testing @MainActor struct ForeignKeyLabelColumnStoreTests { - private func makeStore() throws -> ForeignKeyLabelColumnStore { - let defaults = try #require(UserDefaults(suiteName: "ForeignKeyLabelColumnTests.\(UUID().uuidString)")) - return ForeignKeyLabelColumnStore(defaults: defaults) + private let suite: EphemeralDefaults + + init() throws { + suite = try EphemeralDefaults() + } + + private func makeStore() -> ForeignKeyLabelColumnStore { + ForeignKeyLabelColumnStore(defaults: suite.defaults) } private func scope( @@ -21,7 +26,7 @@ struct ForeignKeyLabelColumnStoreTests { @Test("Dropping a table forgets its label column and leaves its siblings alone") func dropTableForgetsOnlyThatTable() throws { - let store = try makeStore() + let store = makeStore() let connectionId = UUID() let dropped = scope(connectionId: connectionId, table: "Artist") let kept = scope(connectionId: connectionId, table: "Album") @@ -36,7 +41,7 @@ struct ForeignKeyLabelColumnStoreTests { @Test("Dropping a database forgets every table under it and nothing outside it") func dropContainerForgetsTheWholeDatabase() throws { - let store = try makeStore() + let store = makeStore() let connectionId = UUID() let inside = scope(connectionId: connectionId, table: "Artist") let alsoInside = scope(connectionId: connectionId, table: "Album") @@ -54,13 +59,13 @@ struct ForeignKeyLabelColumnStoreTests { @Test("A table with no stored choice answers nil") func unsetScopeAnswersNil() throws { - let store = try makeStore() + let store = makeStore() #expect(store.labelChoice(for: scope(connectionId: UUID())) == .unset) } @Test("A stored choice comes back") func storedChoiceRoundTrips() throws { - let store = try makeStore() + let store = makeStore() let target = scope(connectionId: UUID()) store.setLabelChoice(.columns(["Name"]), for: target) #expect(store.labelChoice(for: target) == .columns(["Name"])) @@ -68,7 +73,7 @@ struct ForeignKeyLabelColumnStoreTests { @Test("Unset clears the stored choice") func unsetClearsTheChoice() throws { - let store = try makeStore() + let store = makeStore() let target = scope(connectionId: UUID()) store.setLabelChoice(.columns(["Name"]), for: target) store.setLabelChoice(.unset, for: target) @@ -79,7 +84,7 @@ struct ForeignKeyLabelColumnStoreTests { /// answer at all, so the heuristic picked a label again the moment the picker was reopened. @Test("Choosing no label outlives a reopen") func explicitNoneOutlivesAReopen() throws { - let store = try makeStore() + let store = makeStore() let target = scope(connectionId: UUID()) store.setLabelChoice(.columns(["Name"]), for: target) store.setLabelChoice(.noLabel, for: target) @@ -88,7 +93,7 @@ struct ForeignKeyLabelColumnStoreTests { @Test("The three states are distinct") func theThreeStatesAreDistinct() throws { - let store = try makeStore() + let store = makeStore() let unset = scope(connectionId: UUID()) let none = scope(connectionId: UUID()) let named = scope(connectionId: UUID()) @@ -102,7 +107,7 @@ struct ForeignKeyLabelColumnStoreTests { @Test("Several chosen columns come back in order") func severalChosenColumnsRoundTrip() throws { - let store = try makeStore() + let store = makeStore() let target = scope(connectionId: UUID()) store.setLabelChoice(.columns(["descrizione", "marchio"]), for: target) #expect(store.labelChoice(for: target) == .columns(["descrizione", "marchio"])) @@ -112,7 +117,7 @@ struct ForeignKeyLabelColumnStoreTests { /// really pick. It must not read back as either of the other two states. @Test("An empty column name is a choice of its own") func anEmptyColumnNameIsAChoiceOfItsOwn() throws { - let store = try makeStore() + let store = makeStore() let target = scope(connectionId: UUID()) store.setLabelChoice(.columns([""]), for: target) #expect(store.labelChoice(for: target) == .columns([""])) @@ -120,7 +125,7 @@ struct ForeignKeyLabelColumnStoreTests { @Test("Choosing no label survives a rename") func explicitNoneSurvivesARename() throws { - let store = try makeStore() + let store = makeStore() let old = scope(connectionId: UUID()) let new = scope(connectionId: old.connectionId, table: "Performer") store.setLabelChoice(.noLabel, for: old) @@ -134,7 +139,7 @@ struct ForeignKeyLabelColumnStoreTests { /// The seam the defect lived in: the store said nothing, so the resolver guessed. @Test("Choosing no label stops the heuristic") func explicitNoneStopsTheHeuristic() throws { - let store = try makeStore() + let store = makeStore() let target = scope(connectionId: UUID()) store.setLabelChoice(.noLabel, for: target) @@ -152,7 +157,7 @@ struct ForeignKeyLabelColumnStoreTests { /// different connections, databases or schemas keep their own. @Test("Each table keeps its own choice") func choiceIsScopedToTheTable() throws { - let store = try makeStore() + let store = makeStore() let connection = UUID() store.setLabelChoice(.columns(["Name"]), for: scope(connectionId: connection)) store.setLabelChoice(.columns(["Title"]), for: scope(connectionId: connection, table: "Album")) @@ -166,7 +171,7 @@ struct ForeignKeyLabelColumnStoreTests { @Test("A name with a dot or a quote survives the key encoding") func awkwardNamesSurvive() throws { - let store = try makeStore() + let store = makeStore() let target = scope(connectionId: UUID(), schema: "public.v2", table: "user\"s") store.setLabelChoice(.columns(["full name"]), for: target) #expect(store.labelChoice(for: target) == .columns(["full name"])) @@ -175,7 +180,7 @@ struct ForeignKeyLabelColumnStoreTests { @Test("A table rename moves its choice and leaves a longer name alone") func renameTableMovesOnlyThatTable() throws { - let store = try makeStore() + let store = makeStore() let connection = UUID() let other = UUID() store.setLabelChoice(.columns(["Name"]), for: scope(connectionId: connection)) @@ -195,7 +200,7 @@ struct ForeignKeyLabelColumnStoreTests { @Test("A schema rename moves every table in it and nothing outside it") func renameContainerMovesTheSchema() throws { - let store = try makeStore() + let store = makeStore() let connection = UUID() let other = UUID() store.setLabelChoice(.columns(["Name"]), for: scope(connectionId: connection, schema: "music")) @@ -217,7 +222,7 @@ struct ForeignKeyLabelColumnStoreTests { @Test("A database rename moves its tables and leaves a longer database name alone") func renameDatabaseMovesItsTables() throws { - let store = try makeStore() + let store = makeStore() let connection = UUID() store.setLabelChoice(.columns(["Name"]), for: scope(connectionId: connection)) store.setLabelChoice(.columns(["Title"]), for: scope(connectionId: connection, database: "chinook_backup")) @@ -234,7 +239,7 @@ struct ForeignKeyLabelColumnStoreTests { @Test("Deleting a connection removes its choices and keeps every other connection's") func purgeConnectionsRemovesOnlyThatConnection() throws { - let store = try makeStore() + let store = makeStore() let connection = UUID() let other = UUID() store.setLabelChoice(.columns(["Name"]), for: scope(connectionId: connection)) diff --git a/TableProTests/Storage/ImportColumnMappingStoreTests.swift b/TableProTests/Storage/ImportColumnMappingStoreTests.swift index 4fd23d874c..f314c5d8fa 100644 --- a/TableProTests/Storage/ImportColumnMappingStoreTests.swift +++ b/TableProTests/Storage/ImportColumnMappingStoreTests.swift @@ -1,8 +1,3 @@ -// -// ImportColumnMappingStoreTests.swift -// TableProTests -// - import Foundation import Testing @@ -10,9 +5,14 @@ import Testing @MainActor struct ImportColumnMappingStoreTests { - private func makeStore() throws -> ImportColumnMappingStore { - let defaults = try #require(UserDefaults(suiteName: "ImportColumnMappingStoreTests.\(UUID().uuidString)")) - return ImportColumnMappingStore(defaults: defaults) + private let suite: EphemeralDefaults + + init() throws { + suite = try EphemeralDefaults() + } + + private func makeStore() -> ImportColumnMappingStore { + ImportColumnMappingStore(defaults: suite.defaults) } private func scope( @@ -26,7 +26,7 @@ struct ImportColumnMappingStoreTests { @Test("Overrides remembered for a table come back for that table only") func rememberedOverridesRoundTrip() throws { - let store = try makeStore() + let store = makeStore() let connectionId = UUID() let people = scope(connectionId: connectionId) store.remember(["Name": .column("full_name"), "id": .skip], forFields: ["id", "Name"], in: people) @@ -39,7 +39,7 @@ struct ImportColumnMappingStoreTests { @Test("A later import that falls back to the name match for every field forgets the table") func importMatchingByNameClearsTheEntry() throws { - let store = try makeStore() + let store = makeStore() let people = scope(connectionId: UUID()) store.remember(["Name": .column("full_name")], forFields: ["Name"], in: people) @@ -50,7 +50,7 @@ struct ImportColumnMappingStoreTests { @Test("A second file layout into the same table keeps the first layout's choices") func secondLayoutKeepsTheFirst() throws { - let store = try makeStore() + let store = makeStore() let people = scope(connectionId: UUID()) store.remember(["Name": .column("full_name")], forFields: ["Name"], in: people) @@ -61,7 +61,7 @@ struct ImportColumnMappingStoreTests { @Test("Renaming a table carries its choices to the new name") func renameTableMovesTheEntry() throws { - let store = try makeStore() + let store = makeStore() let connectionId = UUID() let old = scope(connectionId: connectionId, table: "people") let new = scope(connectionId: connectionId, table: "customers") @@ -75,7 +75,7 @@ struct ImportColumnMappingStoreTests { @Test("Renaming a database carries every table's choices") func renameContainerMovesEveryTable() throws { - let store = try makeStore() + let store = makeStore() let connectionId = UUID() let people = scope(connectionId: connectionId, database: "shop", table: "people") store.remember(["Name": .column("full_name")], forFields: ["Name"], in: people) @@ -90,7 +90,7 @@ struct ImportColumnMappingStoreTests { @Test("Dropping a table forgets its choices and leaves its siblings alone") func dropTableForgetsOnlyThatTable() throws { - let store = try makeStore() + let store = makeStore() let connectionId = UUID() let dropped = scope(connectionId: connectionId, table: "people") let kept = scope(connectionId: connectionId, table: "orders") @@ -105,7 +105,7 @@ struct ImportColumnMappingStoreTests { @Test("Dropping a database forgets every table under it and nothing outside it") func dropContainerForgetsTheWholeDatabase() throws { - let store = try makeStore() + let store = makeStore() let connectionId = UUID() let inside = scope(connectionId: connectionId, database: "shop") let outside = scope(connectionId: connectionId, database: "archive") @@ -120,7 +120,7 @@ struct ImportColumnMappingStoreTests { @Test("Deleting a connection forgets its choices and nobody else's") func purgeForgetsOnlyThatConnection() throws { - let store = try makeStore() + let store = makeStore() let deleted = scope(connectionId: UUID()) let kept = scope(connectionId: UUID()) store.remember(["Name": .column("full_name")], forFields: ["Name"], in: deleted) diff --git a/TableProTests/Storage/RecentTablesStoreTests.swift b/TableProTests/Storage/RecentTablesStoreTests.swift index 5af2472584..41e8d4728d 100644 --- a/TableProTests/Storage/RecentTablesStoreTests.swift +++ b/TableProTests/Storage/RecentTablesStoreTests.swift @@ -5,14 +5,19 @@ import Testing @MainActor struct RecentTablesStoreTests { - private func makeStore() throws -> RecentTablesStore { - let defaults = try #require(UserDefaults(suiteName: "RecentTablesTests.\(UUID().uuidString)")) - return RecentTablesStore(defaults: defaults) + private let suite: EphemeralDefaults + + init() throws { + suite = try EphemeralDefaults() + } + + private func makeStore() -> RecentTablesStore { + RecentTablesStore(defaults: suite.defaults) } @Test("Record inserts entry at the front") func recordInsertsAtFront() throws { - let store = try makeStore() + let store = makeStore() let conn = UUID() store.record(connectionId: conn, database: "db", schema: nil, name: "a", isView: false, objectType: nil) store.record(connectionId: conn, database: "db", schema: nil, name: "b", isView: false, objectType: nil) @@ -21,7 +26,7 @@ struct RecentTablesStoreTests { @Test("Record dedupes by identity and bumps to front") func recordDedupes() throws { - let store = try makeStore() + let store = makeStore() let conn = UUID() store.record(connectionId: conn, database: "db", schema: nil, name: "a", isView: false, objectType: nil) store.record(connectionId: conn, database: "db", schema: nil, name: "b", isView: false, objectType: nil) @@ -31,7 +36,7 @@ struct RecentTablesStoreTests { @Test("Record preserves the view flag") func recordPreservesViewFlag() throws { - let store = try makeStore() + let store = makeStore() let conn = UUID() store.record( connectionId: conn, database: "db", schema: nil, name: "orders_view", isView: true, objectType: .view @@ -41,7 +46,7 @@ struct RecentTablesStoreTests { @Test("Per-database list caps at 10 entries") func capsPerDatabase() throws { - let store = try makeStore() + let store = makeStore() let conn = UUID() for index in 0..<15 { store.record( @@ -56,7 +61,7 @@ struct RecentTablesStoreTests { @Test("Cap applies per database, not per connection") func capIsPerDatabase() throws { - let store = try makeStore() + let store = makeStore() let conn = UUID() for index in 0..<10 { store.record( @@ -74,7 +79,7 @@ struct RecentTablesStoreTests { @Test("Entries isolated per connection") func isolatedPerConnection() throws { - let store = try makeStore() + let store = makeStore() let connA = UUID() let connB = UUID() store.record(connectionId: connA, database: "db", schema: nil, name: "alpha", isView: false, objectType: nil) @@ -85,7 +90,7 @@ struct RecentTablesStoreTests { @Test("Schema-qualified table is distinct from same-name unqualified") func schemaDistinct() throws { - let store = try makeStore() + let store = makeStore() let conn = UUID() store.record( connectionId: conn, database: "db", schema: "public", name: "users", isView: false, objectType: nil @@ -96,7 +101,7 @@ struct RecentTablesStoreTests { @Test("A qualified open replaces the same table recorded without a schema") func qualifiedOpenReplacesUnqualified() throws { - let store = try makeStore() + let store = makeStore() let conn = UUID() store.record(connectionId: conn, database: "db", schema: nil, name: "users", isView: false, objectType: nil) store.record( @@ -109,7 +114,7 @@ struct RecentTablesStoreTests { @Test("Resolving a schema rewrites the entry in place and keeps its open time") func resolveSchemaRewritesInPlace() throws { - let store = try makeStore() + let store = makeStore() let conn = UUID() let firstOpened = Date(timeIntervalSince1970: 3_000) store.record( @@ -136,7 +141,7 @@ struct RecentTablesStoreTests { @Test("Same name in different databases stays distinct") func databaseDistinct() throws { - let store = try makeStore() + let store = makeStore() let conn = UUID() store.record(connectionId: conn, database: "a", schema: nil, name: "orders", isView: false, objectType: nil) store.record(connectionId: conn, database: "b", schema: nil, name: "orders", isView: false, objectType: nil) @@ -145,7 +150,7 @@ struct RecentTablesStoreTests { @Test("Dotted identifiers do not collide") func dottedIdentifiersDistinct() throws { - let store = try makeStore() + let store = makeStore() let conn = UUID() store.record(connectionId: conn, database: "db", schema: "a", name: "b.c", isView: false, objectType: nil) store.record(connectionId: conn, database: "db", schema: "a.b", name: "c", isView: false, objectType: nil) @@ -154,7 +159,7 @@ struct RecentTablesStoreTests { @Test("Remove drops the matching entry") func removeDrops() throws { - let store = try makeStore() + let store = makeStore() let conn = UUID() store.record(connectionId: conn, database: "db", schema: nil, name: "a", isView: false, objectType: nil) let remaining = store.record( @@ -167,7 +172,7 @@ struct RecentTablesStoreTests { @Test("Clear removes only the given database") func clearScopesToDatabase() throws { - let store = try makeStore() + let store = makeStore() let conn = UUID() store.record(connectionId: conn, database: "db", schema: nil, name: "a", isView: false, objectType: nil) store.record(connectionId: conn, database: "other", schema: nil, name: "b", isView: false, objectType: nil) @@ -177,7 +182,7 @@ struct RecentTablesStoreTests { @Test("Entries persist across store instances on the same defaults") func persistsAcrossInstances() throws { - let defaults = try #require(UserDefaults(suiteName: "RecentTablesTests.\(UUID().uuidString)")) + let defaults = suite.defaults let conn = UUID() RecentTablesStore(defaults: defaults).record( connectionId: conn, database: "db", schema: nil, name: "a", isView: false, objectType: nil diff --git a/TableProTests/Storage/SplitViewAutosaveSweepTests.swift b/TableProTests/Storage/SplitViewAutosaveSweepTests.swift index b526bf8fa1..2d2a9e63ee 100644 --- a/TableProTests/Storage/SplitViewAutosaveSweepTests.swift +++ b/TableProTests/Storage/SplitViewAutosaveSweepTests.swift @@ -3,62 +3,21 @@ // TableProTests // -import AppKit import Foundation -import Testing - @testable import TablePro +import TableProUtilities +import Testing @MainActor struct SplitViewAutosaveSweepTests { private func key(_ name: String) -> String { - SplitViewAutosaveRecord.keyPrefix + name + SplitViewAutosaveKey.key(forAutosaveName: name) } private func retiredName(connectionId: UUID = UUID(), tabId: UUID = UUID()) -> String { "\(SplitViewAutosaveName.querySplitPrefix)\(connectionId.uuidString)-\(tabId.uuidString)" } - // MARK: - The undocumented key spelling - - /// Apple documents `autosaveName` and nothing else: no key format, and no way to forget a - /// record, which is why the sweep reaches into the domain by hand. The spelling is therefore a - /// transcription, and this derives it from a real `NSSplitView` instead of trusting it. - @Test("The key prefix is the one AppKit actually writes") - func keyPrefixMatchesAppKit() { - let name = "SplitViewAutosaveSweepTests-\(UUID().uuidString)" - let defaults = UserDefaults.standard - let expected = key(name) - defaults.removeObject(forKey: expected) - - let controller = NSSplitViewController() - controller.splitView.isVertical = true - for _ in 0 ..< 2 { - let pane = NSViewController() - pane.view = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 200)) - controller.addSplitViewItem(NSSplitViewItem(viewController: pane)) - } - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 400, height: 200), - styleMask: [.titled, .resizable], - backing: .buffered, - defer: false - ) - window.contentViewController = controller - controller.splitView.autosaveName = NSSplitView.AutosaveName(name) - window.orderFront(nil) - RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.4)) - - defer { - window.orderOut(nil) - defaults.removeObject(forKey: expected) - } - #expect( - defaults.object(forKey: expected) != nil, - "AppKit no longer writes its record under \(expected), so the sweep matches a stale spelling" - ) - } - // MARK: - Parsing /// The retired name joined two UUIDs with the character that also separates their own groups, so diff --git a/TableProTests/TeamLibrary/TeamLibrarySyncCoordinatorTests.swift b/TableProTests/TeamLibrary/TeamLibrarySyncCoordinatorTests.swift index cf810c0046..69a0c49169 100644 --- a/TableProTests/TeamLibrary/TeamLibrarySyncCoordinatorTests.swift +++ b/TableProTests/TeamLibrary/TeamLibrarySyncCoordinatorTests.swift @@ -1,14 +1,7 @@ -// -// TeamLibrarySyncCoordinatorTests.swift -// TablePro -// -// Tests the team library coordinator against a mock client: gating, pull caching, the 7-day pull -// cadence, and that publishing sends the mapped content and refreshes. -// - import Combine import Foundation @testable import TablePro +import TableProTestSupport import Testing @MainActor @@ -100,7 +93,12 @@ struct TeamLibrarySyncCoordinatorTests { licenseChanges.send() licenseChanges.send() try await waitUntil { coordinator.library.queries.count == 1 } - try await Task.sleep(for: .milliseconds(100)) + // Each license change that starts a pull queues it on the main actor while `send()` runs, + // and the mock client answers before the pull first suspends. Every pull those sends + // started has called the client once this block runs. + await withCheckedContinuation { continuation in + DispatchQueue.main.async { continuation.resume() } + } #expect(coordinator.library.queries.map(\.name) == ["Q"]) #expect(mock.pullCallCount == 1) @@ -130,13 +128,6 @@ struct TeamLibrarySyncCoordinatorTests { #expect(mock.publishedRequests[0].queries[0].name == "Recent") #expect(mock.pullCallCount == 1) } - - private func waitUntil(_ condition: @MainActor () -> Bool) async throws { - let deadline = ContinuousClock.now + .seconds(2) - while !condition(), ContinuousClock.now < deadline { - try await Task.sleep(for: .milliseconds(10)) - } - } } @MainActor diff --git a/TableProTests/Theme/ThemeTableChromeTests.swift b/TableProTests/Theme/ThemeTableChromeTests.swift index a4af5d8eb7..7e56a16837 100644 --- a/TableProTests/Theme/ThemeTableChromeTests.swift +++ b/TableProTests/Theme/ThemeTableChromeTests.swift @@ -83,7 +83,6 @@ private final class FillView: NSView { /// Each test adopts a pair on the shared `ThemeEngine` and restores it in a `defer`. The bodies are /// synchronous and `@MainActor`, so nothing else on the main actor runs between the two. -@Suite("Content tables follow the theme", .serialized) @MainActor struct ThemeTableChromeTests { private struct Table { diff --git a/TableProTests/Theme/ThemeTextViewStyleTests.swift b/TableProTests/Theme/ThemeTextViewStyleTests.swift index 77928451d5..17ee323067 100644 --- a/TableProTests/Theme/ThemeTextViewStyleTests.swift +++ b/TableProTests/Theme/ThemeTextViewStyleTests.swift @@ -13,7 +13,6 @@ import Testing /// Each test adopts a pair on the shared `ThemeEngine` and restores it in a `defer`. The bodies are /// synchronous and `@MainActor`, so nothing else on the main actor runs between the two. -@Suite("Text views follow the editor theme", .serialized) @MainActor struct ThemeTextViewStyleTests { private func makeTextView() throws -> NSTextView { diff --git a/TableProTests/Theme/ThemeWindowBackdropTests.swift b/TableProTests/Theme/ThemeWindowBackdropTests.swift index 78381bcc65..44bfde5738 100644 --- a/TableProTests/Theme/ThemeWindowBackdropTests.swift +++ b/TableProTests/Theme/ThemeWindowBackdropTests.swift @@ -13,7 +13,6 @@ import Testing /// Each test adopts a pair on the shared `ThemeEngine` and restores it in a `defer`. The bodies are /// synchronous and `@MainActor`, so nothing else on the main actor runs between the two. -@Suite("The window backdrop follows the theme", .serialized) @MainActor struct ThemeWindowBackdropTests { private static let windowHex = "#282A36" diff --git a/TableProTests/TunnelCommand/TunnelCommandManagerTests.swift b/TableProTests/TunnelCommand/TunnelCommandManagerTests.swift index dc609a94ff..c43107d8cd 100644 --- a/TableProTests/TunnelCommand/TunnelCommandManagerTests.swift +++ b/TableProTests/TunnelCommand/TunnelCommandManagerTests.swift @@ -1,8 +1,3 @@ -// -// TunnelCommandManagerTests.swift -// TableProTests -// - import Darwin import Foundation import Testing @@ -148,18 +143,6 @@ struct TunnelCommandManagerTests { TunnelCommandConfiguration(method: .custom, command: command) } - private func waitUntil( - timeout: Duration = .seconds(2), - _ condition: @escaping @Sendable () -> Bool - ) async -> Bool { - let deadline = ContinuousClock.now.advanced(by: timeout) - while ContinuousClock.now < deadline { - if condition() { return true } - try? await Task.sleep(for: .milliseconds(5)) - } - return condition() - } - @Test("createTunnel returns the port the command was told to listen on") func readinessSucceeds() async throws { let fake = FakeTunnelCommandRunner(behavior: .ready) @@ -273,7 +256,7 @@ struct TunnelCommandManagerTests { fake.stop() } - try #require(await waitUntil { fake.startCallCount > 0 }) + try await waitUntil { fake.startCallCount > 0 } task.cancel() let result = try #require(await BoundedCall.result(within: .seconds(2)) { await task.result @@ -310,7 +293,7 @@ struct TunnelCommandManagerTests { fake.stop() } - try #require(await waitUntil { fake.startCallCount > 0 }) + try await waitUntil { fake.startCallCount > 0 } let result = try #require(await BoundedCall.result(within: .seconds(2)) { await creation.result }) diff --git a/TableProTests/ViewModels/AIChatViewModelStreamingCadenceTests.swift b/TableProTests/ViewModels/AIChatViewModelStreamingCadenceTests.swift index 5e605c2d3e..bdf7d36118 100644 --- a/TableProTests/ViewModels/AIChatViewModelStreamingCadenceTests.swift +++ b/TableProTests/ViewModels/AIChatViewModelStreamingCadenceTests.swift @@ -8,7 +8,6 @@ import TableProPluginKit @testable import TablePro import Testing -@Suite("AIChatViewModel streaming cadence", .serialized) @MainActor struct AIChatViewModelStreamingCadenceTests { private final class NeverTickingClock: StreamFlushClock, @unchecked Sendable { diff --git a/TableProTests/ViewModels/AIChatViewModelToolLoopTests.swift b/TableProTests/ViewModels/AIChatViewModelToolLoopTests.swift index 800747edcb..0a7cd2519d 100644 --- a/TableProTests/ViewModels/AIChatViewModelToolLoopTests.swift +++ b/TableProTests/ViewModels/AIChatViewModelToolLoopTests.swift @@ -1,15 +1,9 @@ -// -// AIChatViewModelToolLoopTests.swift -// TableProTests -// - import Foundation import TableProMCP import TableProPluginKit @testable import TablePro import Testing -@Suite("AIChatViewModel tool roundtrip loop", .serialized) @MainActor struct AIChatViewModelToolLoopTests { private static let testLimit = 5 @@ -240,8 +234,8 @@ struct AIChatViewModelToolLoopTests { let viewModel = AIChatViewModel() viewModel.messages.append(ChatTurn(role: .user, blocks: [.text("typed first")])) - for _ in 0..<10 { - await Task.yield() + await withCheckedContinuation { continuation in + DispatchQueue.main.async { continuation.resume() } } #expect(viewModel.messages.count == 1) diff --git a/TableProTests/ViewModels/ERDiagramViewModelLoadTests.swift b/TableProTests/ViewModels/ERDiagramViewModelLoadTests.swift index 9d2fa0b355..9460034dce 100644 --- a/TableProTests/ViewModels/ERDiagramViewModelLoadTests.swift +++ b/TableProTests/ViewModels/ERDiagramViewModelLoadTests.swift @@ -1,8 +1,3 @@ -// -// ERDiagramViewModelLoadTests.swift -// TableProTests -// - import AppKit @testable import TablePro import Testing @@ -37,7 +32,10 @@ struct ERDiagramViewModelLoadTests { let openingLoad = Task { await viewModel.loadDiagram() } await opening.reached.wait() let returningLoad = Task { await viewModel.loadDiagram() } - await Task.yield() + // The load is a main-actor task, so a block queued after it runs once it has joined the first. + await withCheckedContinuation { continuation in + DispatchQueue.main.async { continuation.resume() } + } await opening.release.open() await openingLoad.value diff --git a/TableProTests/ViewModels/FavoritesExpansionStateTests.swift b/TableProTests/ViewModels/FavoritesExpansionStateTests.swift index 9fc73c3577..005f04338d 100644 --- a/TableProTests/ViewModels/FavoritesExpansionStateTests.swift +++ b/TableProTests/ViewModels/FavoritesExpansionStateTests.swift @@ -1,8 +1,3 @@ -// -// FavoritesExpansionStateTests.swift -// TableProTests -// - import Foundation import Testing @@ -10,16 +5,19 @@ import Testing @MainActor struct FavoritesExpansionStateTests { - private func makeState() throws -> (FavoritesExpansionState, UserDefaults, String) { - let suite = "FavoritesExpansionStateTests.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) - return (FavoritesExpansionState(defaults: defaults), defaults, suite) + private let suite: EphemeralDefaults + + init() throws { + suite = try EphemeralDefaults() + } + + private func makeState() throws -> (FavoritesExpansionState, UserDefaults) { + (FavoritesExpansionState(defaults: suite.defaults), suite.defaults) } @Test("A group is expanded until it is collapsed") func collapsingAGroup() throws { - let (state, _, _) = try makeState() + let (state, _) = try makeState() let connectionId = UUID() #expect(state.isDatabaseEnvironmentExpanded(.production, for: connectionId)) @@ -31,7 +29,7 @@ struct FavoritesExpansionStateTests { @Test("Collapsed groups survive a reload") func collapsedGroupsPersist() throws { - let (state, defaults, _) = try makeState() + let (state, defaults) = try makeState() let connectionId = UUID() state.setDatabaseEnvironmentExpanded(.development, expanded: false, for: connectionId) @@ -78,7 +76,7 @@ struct FavoritesExpansionStateTests { @Test("Deleting a connection forgets its groups and leaves the others alone") func removeConnection() throws { - let (state, _, _) = try makeState() + let (state, _) = try makeState() let deleted = UUID() let kept = UUID() state.setDatabaseEnvironmentExpanded(.production, expanded: false, for: deleted) diff --git a/TableProTests/ViewModels/FavoritesSidebarViewModelTests.swift b/TableProTests/ViewModels/FavoritesSidebarViewModelTests.swift index e04569a47c..b8283cbb5e 100644 --- a/TableProTests/ViewModels/FavoritesSidebarViewModelTests.swift +++ b/TableProTests/ViewModels/FavoritesSidebarViewModelTests.swift @@ -1,11 +1,7 @@ -// -// FavoritesSidebarViewModelTests.swift -// TableProTests -// - import Combine import Foundation import TableProPluginKit +import TableProTestSupport import Testing @testable import TablePro @@ -292,15 +288,13 @@ struct FavoriteNodeTests { } } -/// Issue #3016. The view model publishes the Favorites tab's whole Queries tree, but that tree +/// The view model publishes the Favorites tab's whole Queries tree, but that tree /// lives in a cache of its own, and SwiftUI hears only the object a property wrapper names. @MainActor struct FavoritesSidebarCacheObservationTests { - private func waitForEmission(from counter: EmissionCounter, timeout: TimeInterval = 5) async { - let deadline = Date().addingTimeInterval(timeout) - while counter.emissions.isEmpty, Date() < deadline { - try? await Task.sleep(nanoseconds: 5_000_000) - } + /// Returns on a timeout too, so the case's own expectation reports the missing signal. + private func waitForEmission(from counter: EmissionCounter) async { + _ = try? await waitUntil(timeout: .seconds(5)) { !counter.emissions.isEmpty } } @MainActor @@ -357,7 +351,7 @@ struct FavoritesSidebarCacheObservationTests { #expect(viewModel.nodes.isEmpty) } - /// Issue #3045. A connection fetches its own folders plus every global one, so a folder + /// A connection fetches its own folders plus every global one, so a folder /// belonging to another connection never arrives. The global query inside it does, and used to /// be drawn at no level at all: present in storage, in the Quick Switcher and in AI chat, and /// missing from the sidebar. diff --git a/TableProTests/ViewModels/HistoryPanelViewModelTests.swift b/TableProTests/ViewModels/HistoryPanelViewModelTests.swift index 758ffa105e..0b52ab2f16 100644 --- a/TableProTests/ViewModels/HistoryPanelViewModelTests.swift +++ b/TableProTests/ViewModels/HistoryPanelViewModelTests.swift @@ -1,11 +1,7 @@ -// -// HistoryPanelViewModelTests.swift -// TableProTests -// - import Combine import Foundation @testable import TablePro +import TableProTestSupport import Testing private actor RecordingHistoryReader: QueryHistoryReading { @@ -118,7 +114,8 @@ struct HistoryPanelViewModelTests { private func makeViewModel( connectionId: UUID, entries: [QueryHistoryEntry], - pageSize: Int = 10 + pageSize: Int = 10, + clock: any Clock = ContinuousClock() ) -> (HistoryPanelViewModel, RecordingHistoryReader) { HistoryPanelState.removeConnection(connectionId) HistoryPanelPreferencesStorage.remove(for: connectionId) @@ -127,7 +124,8 @@ struct HistoryPanelViewModelTests { connectionId: connectionId, history: reader, connectionDirectory: .none, - pageSize: pageSize + pageSize: pageSize, + clock: clock ) return (viewModel, reader) } @@ -155,9 +153,11 @@ struct HistoryPanelViewModelTests { @Test("a deactivated panel stops refetching when history changes") func deactivatedPanelStopsRefetching() async throws { let connectionId = UUID() + let clock = ManualClock() let (viewModel, reader) = makeViewModel( connectionId: connectionId, - entries: [Self.makeEntry(connectionId: connectionId, query: "SELECT 1")] + entries: [Self.makeEntry(connectionId: connectionId, query: "SELECT 1")], + clock: clock ) await viewModel.activate() @@ -165,30 +165,31 @@ struct HistoryPanelViewModelTests { let afterActivate = await reader.fetchCount() AppEvents.shared.queryHistoryDidUpdate.send(connectionId) - let refetched = await Self.waitForFetchCount(above: afterActivate, in: reader) - #expect(refetched, "A visible panel refreshes when a query is recorded") + let scheduled = (try? await waitUntil { clock.pendingSleeperCount > 0 }) != nil + clock.advance(by: .milliseconds(200)) + let refetched = (try? await waitUntil { await reader.fetchCount() > afterActivate }) != nil + #expect(scheduled && refetched, "A visible panel refreshes when a query is recorded") let whileVisible = await reader.fetchCount() viewModel.deactivate() #expect(!viewModel.isObserving) AppEvents.shared.queryHistoryDidUpdate.send(connectionId) - try await Task.sleep(for: .milliseconds(600)) + await Self.drainMainRunLoop() + #expect(clock.pendingSleeperCount == 0, "No refresh is scheduled for a panel nobody can see") + clock.advance(by: .milliseconds(200)) + await Self.drainMainRunLoop() #expect( await reader.fetchCount() == whileVisible, "A panel nobody can see must not go back to the store" ) } - private static func waitForFetchCount( - above baseline: Int, - in reader: RecordingHistoryReader - ) async -> Bool { - for _ in 0..<40 { - if await reader.fetchCount() > baseline { return true } - try? await Task.sleep(for: .milliseconds(50)) + // Runs after the RunLoop.main delivery of an earlier send, and resumes behind any main-actor task it started. + private static func drainMainRunLoop() async { + await withCheckedContinuation { continuation in + RunLoop.main.perform { continuation.resume() } } - return false } @Test("the panel scopes to its own connection by default") diff --git a/TableProTests/ViewModels/JSONRowInspectorViewModelTests.swift b/TableProTests/ViewModels/JSONRowInspectorViewModelTests.swift index 4a060dbe14..c0a8c3f522 100644 --- a/TableProTests/ViewModels/JSONRowInspectorViewModelTests.swift +++ b/TableProTests/ViewModels/JSONRowInspectorViewModelTests.swift @@ -1,11 +1,6 @@ -// -// JSONRowInspectorViewModelTests.swift -// TableProTests -// -// What the JSON inspector does with a row that changes under it while a foreign key is still -// being fetched. A rerun keeps a row's identity while its values move, so a fetched row held -// against a node path is the wrong row the moment the values do. -// +// What the JSON inspector does with a row that changes under it while a foreign key is still +// being fetched. A rerun keeps a row's identity while its values move, so a fetched row held +// against a node path is the wrong row the moment the values do. import AppKit import Foundation @@ -14,19 +9,6 @@ import Testing @testable import TablePro -private final class JSONInspectorClipboard: ClipboardProvider { - var texts: [String] = [] - - func readText() -> String? { texts.last } - func readGridRows() -> GridRowsClipboardPayload? { nil } - func writeText(_ text: String) { texts.append(text) } - func writeCsv(_ csv: String) {} - func writeImage(_ image: NSImage) {} - func writeRows(tsv: String, html: String?, gridRows: GridRowsClipboardPayload) {} - var hasText: Bool { !texts.isEmpty } - var hasGridRows: Bool { false } -} - @MainActor struct JSONRowInspectorViewModelTests { private static let connectionId = UUID(uuidString: "00000000-0000-0000-0000-0000000000AA") ?? UUID() @@ -65,9 +47,14 @@ struct JSONRowInspectorViewModelTests { } /// Lets the model's fetch task run up to its first suspension point, which is where it hands - /// the gate its continuation. Nothing can be released before that has happened. + /// the gate its continuation. Nothing can be released before that has happened. Each pass waits + /// for one main-queue turn, which runs every main-actor job queued before it. private func settle() async { - for _ in 0..<8 { await Task.yield() } + for _ in 0..<8 { + await withCheckedContinuation { continuation in + DispatchQueue.main.async { continuation.resume() } + } + } } private static func snapshot( @@ -96,7 +83,7 @@ struct JSONRowInspectorViewModelTests { } /// A row with one nested document, so there is a container to close besides the root. - private static func documentSnapshot(rowIdentity: String = "tab\u{001F}existing(0)") -> JSONRowSnapshot { + fileprivate static func documentSnapshot(rowIdentity: String = "tab\u{001F}existing(0)") -> JSONRowSnapshot { JSONRowSnapshot( rowIdentity: rowIdentity, columns: ["id", "meta"], @@ -420,22 +407,22 @@ struct JSONRowInspectorViewModelTests { #expect(keys(in: model) == ["ArtistId", "ArtistId", "Name"]) } +} - // MARK: - Clipboard - - @Test("Copy Visible writes the lines on screen through the clipboard service") - func copyVisibleWritesTheFilteredLines() throws { - let original = ClipboardService.shared - defer { ClipboardService.shared = original } - let clipboard = JSONInspectorClipboard() - ClipboardService.shared = clipboard - - let model = makeModel(gate: FetchGate()) - model.update(snapshot: Self.documentSnapshot()) - model.filterText = "teal" - model.toggle(row: try metaRow(in: model)) - model.copyVisible() - - #expect(clipboard.texts == ["{\n \"meta\": {…}\n}"]) +extension GlobalStateSuites { + @Suite(.recordingClipboard) + @MainActor + struct JSONRowInspectorCopyTests { + @Test("Copy Visible writes the lines on screen through the clipboard service") + func copyVisibleWritesTheFilteredLines() throws { + let clipboard = try #require(RecordingClipboard.current) + let model = JSONRowInspectorViewModel { _, _, _, _ in nil } + model.update(snapshot: JSONRowInspectorViewModelTests.documentSnapshot()) + model.filterText = "teal" + model.toggle(row: try #require(model.displayRows.first { $0.key == .name("meta") && $0.token != .closeObject })) + model.copyVisible() + + #expect(clipboard.writes == ["{\n \"meta\": {…}\n}"]) + } } } diff --git a/TableProTests/ViewModels/QuickSwitcherCrossSchemaTests.swift b/TableProTests/ViewModels/QuickSwitcherCrossSchemaTests.swift index 1520b99351..b980802ee1 100644 --- a/TableProTests/ViewModels/QuickSwitcherCrossSchemaTests.swift +++ b/TableProTests/ViewModels/QuickSwitcherCrossSchemaTests.swift @@ -1,8 +1,3 @@ -// -// QuickSwitcherCrossSchemaTests.swift -// TableProTests -// - import Foundation @testable import TablePro import TableProPluginKit @@ -11,6 +6,12 @@ import Testing @MainActor struct QuickSwitcherCrossSchemaTests { + private let suite: EphemeralDefaults + + init() throws { + suite = try EphemeralDefaults() + } + private func table(_ name: String, _ schema: String?, type: TableInfo.TableType = .table) -> TableInfo { TableInfo(name: name, type: type, rowCount: nil, schema: schema) } @@ -30,10 +31,7 @@ struct QuickSwitcherCrossSchemaTests { } private func search(_ query: String, in catalog: [QuickSwitcherItem]) async -> [QuickSwitcherItem] { - guard let defaults = UserDefaults(suiteName: "QuickSwitcherCrossSchemaTests.\(UUID().uuidString)") else { - return [] - } - let viewModel = QuickSwitcherViewModel(connectionId: UUID(), services: .live, defaults: defaults) + let viewModel = QuickSwitcherViewModel(connectionId: UUID(), services: .live, defaults: suite.defaults) viewModel.allItems = catalog viewModel.searchText = query await viewModel.flushPendingFilter() @@ -317,11 +315,7 @@ struct QuickSwitcherCrossSchemaTests { target: target, connectionSwitchesDatabases: true ) - guard let defaults = UserDefaults(suiteName: "QuickSwitcherCrossSchemaTests.\(UUID().uuidString)") else { - Issue.record("no defaults suite") - return - } - let viewModel = QuickSwitcherViewModel(connectionId: UUID(), services: .live, defaults: defaults) + let viewModel = QuickSwitcherViewModel(connectionId: UUID(), services: .live, defaults: suite.defaults) viewModel.crossConnectionItems = remote viewModel.scope = .connections viewModel.searchText = "attendance.timesheet" diff --git a/TableProTests/ViewModels/QuickSwitcherListingDemandTests.swift b/TableProTests/ViewModels/QuickSwitcherListingDemandTests.swift index 1a0ebda518..7075f1f15e 100644 --- a/TableProTests/ViewModels/QuickSwitcherListingDemandTests.swift +++ b/TableProTests/ViewModels/QuickSwitcherListingDemandTests.swift @@ -1,15 +1,17 @@ -// -// QuickSwitcherListingDemandTests.swift -// TableProTests -// - import Foundation @testable import TablePro import TableProPluginKit +import TableProTestSupport import Testing @MainActor struct QuickSwitcherListingDemandTests { + private let suite: EphemeralDefaults + + init() throws { + suite = try EphemeralDefaults() + } + // MARK: - Pure rules @Test("All asks for the listing on a query or for Recent, Tables always, the rest never") @@ -99,34 +101,28 @@ struct QuickSwitcherListingDemandTests { } private func makeSwitcher(_ connection: DatabaseConnection) -> QuickSwitcherViewModel { - let defaults = UserDefaults(suiteName: "QuickSwitcherListingDemandTests.\(UUID().uuidString)") ?? .standard - return QuickSwitcherViewModel( + QuickSwitcherViewModel( connectionId: connection.id, services: .live, - defaults: defaults, + defaults: suite.defaults, catalogStore: QuickSwitcherCatalogStore() ) } - private func waitUntil(_ condition: () -> Bool) async { - for _ in 0..<300 where !condition() { - try? await Task.sleep(nanoseconds: 10_000_000) - } - } - @Test("Opening with nothing typed reads no other schema, and the first keystroke reads them once") - func listingWaitsForTheFirstKeystroke() async { + func listingWaitsForTheFirstKeystroke() async throws { let (connection, driver) = connect() let switcher = makeSwitcher(connection) await switcher.loadItems(databaseType: .pglite, browseSchema: "public") - try? await Task.sleep(nanoseconds: 100_000_000) + // A listing sets isLoadingTables before its task runs, so after the barrier one is either still flagged or counted. + await switcher.flushPendingFilter() #expect(driver.fetchTablesInAllSchemasCallCount == 0) #expect(!switcher.isLoadingTables) switcher.searchText = "time" #expect(switcher.isLoadingTables) - await waitUntil { driver.fetchTablesInAllSchemasCallCount == 1 && !switcher.isLoadingTables } + try await waitUntil { driver.fetchTablesInAllSchemasCallCount == 1 && !switcher.isLoadingTables } switcher.searchText = "times" #expect(driver.fetchTablesInAllSchemasCallCount == 1) @@ -135,13 +131,13 @@ struct QuickSwitcherListingDemandTests { } @Test("The Tables scope lists every schema's tables, so it reads them on open") - func tablesScopeReadsOnOpen() async { + func tablesScopeReadsOnOpen() async throws { let (connection, driver) = connect() let switcher = makeSwitcher(connection) switcher.scope = .tables await switcher.loadItems(databaseType: .pglite, browseSchema: "public") - await waitUntil { driver.fetchTablesInAllSchemasCallCount == 1 && !switcher.isLoadingTables } + try await waitUntil { driver.fetchTablesInAllSchemasCallCount == 1 && !switcher.isLoadingTables } #expect(driver.fetchTablesInAllSchemasCallCount == 1) await disconnect(connection) diff --git a/TableProTests/ViewModels/QuickSwitcherRecentIdentityTests.swift b/TableProTests/ViewModels/QuickSwitcherRecentIdentityTests.swift index f1705afeff..e53b78af0b 100644 --- a/TableProTests/ViewModels/QuickSwitcherRecentIdentityTests.swift +++ b/TableProTests/ViewModels/QuickSwitcherRecentIdentityTests.swift @@ -1,8 +1,3 @@ -// -// QuickSwitcherRecentIdentityTests.swift -// TableProTests -// - import Foundation @testable import TablePro @testable import TableProUtilities @@ -10,11 +5,17 @@ import Testing @MainActor struct QuickSwitcherRecentIdentityTests { + private let suite: EphemeralDefaults + + init() throws { + suite = try EphemeralDefaults() + } + private let connectionId = UUID() private let start = Date() private func makeDefaults() -> UserDefaults? { - UserDefaults(suiteName: "QuickSwitcherRecentIdentityTests.\(UUID().uuidString)") + suite.defaults } private func table(_ name: String) -> TableInfo { diff --git a/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift b/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift index 43a823c092..6e1c0096e5 100644 --- a/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift +++ b/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift @@ -1,21 +1,20 @@ -// -// QuickSwitcherViewModelTests.swift -// TableProTests -// - import Foundation @testable import TablePro import TableProPluginKit +import TableProTestSupport @testable import TableProUtilities import Testing @MainActor struct QuickSwitcherViewModelTests { + private let suite: EphemeralDefaults + + init() throws { + suite = try EphemeralDefaults() + } + private func makeDefaults() -> UserDefaults { - guard let suite = UserDefaults(suiteName: "QuickSwitcherTests.\(UUID().uuidString)") else { - return .standard - } - return suite + suite.defaults } private func makeViewModel( @@ -429,7 +428,7 @@ struct QuickSwitcherViewModelTests { vm.crossConnectionItems = [remote] vm.scope = .connections vm.searchText = "analytics" - try await Task.sleep(nanoseconds: 200_000_000) + await vm.flushPendingFilter() #expect(vm.flatItems.first?.frecencyKey == "remote") #expect(vm.flatItems.first?.target == target) @@ -457,7 +456,7 @@ struct QuickSwitcherViewModelTests { vm.selectedItemId = items[0].id vm.searchText = "track" - try await Task.sleep(nanoseconds: 200_000_000) + await vm.flushPendingFilter() #expect(vm.flatItems.first?.name == "Track") #expect(vm.selectedItem()?.name == "Track") @@ -490,7 +489,7 @@ struct QuickSwitcherViewModelTests { vm.selectedItemId = stale.id vm.searchText = "invoice" - try await Task.sleep(nanoseconds: 200_000_000) + await vm.flushPendingFilter() #expect(vm.flatItems.contains { $0.id == stale.id }) #expect(vm.flatItems.first?.name == "Invoice") @@ -518,14 +517,14 @@ struct QuickSwitcherViewModelTests { ) vm.scope = .connections vm.searchText = "invoice" - try await Task.sleep(nanoseconds: 200_000_000) + await vm.flushPendingFilter() let chosen = try #require(vm.flatItems.first { $0.name == "InvoiceLine" }) vm.selectedItemId = chosen.id vm.crossConnectionItems = QuickSwitcherViewModel.makeCrossConnectionItems( tables: tables, target: target, connectionSwitchesDatabases: true ) - try await Task.sleep(nanoseconds: 200_000_000) + await vm.flushPendingFilter() #expect(vm.selectedItem()?.name == "InvoiceLine") } @@ -628,7 +627,7 @@ struct QuickSwitcherViewModelTests { ]) vm.searchText = "users'; DROP TABLE audit; --" - try await Task.sleep(nanoseconds: 200_000_000) + await vm.flushPendingFilter() #expect(vm.flatItems.isEmpty) #expect(vm.allItems.map(\.name) == ["users"]) @@ -671,12 +670,25 @@ struct QuickSwitcherViewModelTests { func filteredGroupHasNoHeader() async throws { let vm = makeViewModel(items: sampleItems()) vm.searchText = "users" - try await Task.sleep(nanoseconds: 200_000_000) + await vm.flushPendingFilter() #expect(vm.groups.count == 1) #expect(vm.groups.first?.header == nil) #expect(vm.flatItems.allSatisfy { $0.name.localizedCaseInsensitiveContains("u") }) } + // Every other case flushes, which cancels the debounced task; this one waits it out. + @Test("Typing alone filters once the debounce has passed") + func typingFiltersWithoutAFlush() async throws { + let vm = makeViewModel(items: sampleItems()) + vm.searchText = "users" + try #require(vm.isFiltering) + + try await waitUntil { !vm.isFiltering } + + #expect(vm.flatItems.first?.name == "users") + #expect(!vm.flatItems.contains { $0.name == "orders" }) + } + @Test("Saved query is found by its keyword") func savedQueryFoundByKeyword() async throws { var items = sampleItems() @@ -689,7 +701,7 @@ struct QuickSwitcherViewModelTests { )) let vm = makeViewModel(items: items) vm.searchText = "rpt" - try await Task.sleep(nanoseconds: 200_000_000) + await vm.flushPendingFilter() #expect(vm.flatItems.contains { $0.id == "favorite_1" }, "Typing the keyword must surface the saved query") } @@ -796,7 +808,7 @@ struct QuickSwitcherViewModelTests { func filteredResultsCarryMatchedIndices() async throws { let vm = makeViewModel(items: sampleItems()) vm.searchText = "usr" - try await Task.sleep(nanoseconds: 200_000_000) + await vm.flushPendingFilter() let users = vm.flatItems.first { $0.id == "t1" } #expect(users?.matchedIndices == [0, 1, 3]) } @@ -811,14 +823,14 @@ struct QuickSwitcherViewModelTests { ] let vm = makeViewModel(items: items, connectionId: connectionId, defaults: suite) vm.searchText = "users" - try await Task.sleep(nanoseconds: 200_000_000) + await vm.flushPendingFilter() #expect(vm.flatItems.first?.id == "ta") vm.recordSelection(items[1]) let vm2 = QuickSwitcherViewModel(connectionId: connectionId, services: .live, defaults: suite) vm2.allItems = items vm2.searchText = "users" - try await Task.sleep(nanoseconds: 200_000_000) + await vm2.flushPendingFilter() #expect(vm2.flatItems.first?.id == "tb") } @@ -851,7 +863,7 @@ struct QuickSwitcherViewModelTests { )] let vm = makeViewModel(items: items) vm.searchText = "revenue" - try await Task.sleep(nanoseconds: 200_000_000) + await vm.flushPendingFilter() #expect(vm.flatItems.first?.payload == "SELECT SUM(total) FROM orders GROUP BY month;") } @@ -871,7 +883,7 @@ struct QuickSwitcherViewModelTests { let vm = makeViewModel(items: sampleItems()) vm.scope = .containers vm.searchText = "r" - try await Task.sleep(nanoseconds: 200_000_000) + await vm.flushPendingFilter() #expect(vm.flatItems.allSatisfy { [.database, .schema].contains($0.kind) }) #expect(vm.flatItems.contains { $0.id == "d1" }) } @@ -884,7 +896,7 @@ struct QuickSwitcherViewModelTests { ] let vm = makeViewModel(items: items) vm.searchText = "users" - try await Task.sleep(nanoseconds: 200_000_000) + await vm.flushPendingFilter() #expect(vm.flatItems.first?.id == "tb") } @@ -892,7 +904,7 @@ struct QuickSwitcherViewModelTests { func subtitleMatchSurfacesItem() async throws { let vm = makeViewModel(items: sampleItems()) vm.searchText = "mydb" - try await Task.sleep(nanoseconds: 200_000_000) + await vm.flushPendingFilter() #expect(vm.flatItems.contains { $0.id == "h1" }) #expect(vm.flatItems.first { $0.id == "h1" }?.matchedIndices.isEmpty == true) } @@ -902,7 +914,7 @@ struct QuickSwitcherViewModelTests { let vm = makeViewModel(items: sampleItems()) vm.selectedItemId = "t1" vm.searchText = "users" - try await Task.sleep(nanoseconds: 200_000_000) + await vm.flushPendingFilter() #expect(vm.flatItems.contains(where: { $0.id == "t1" })) #expect(vm.selectedItemId == "t1") } @@ -912,7 +924,7 @@ struct QuickSwitcherViewModelTests { let vm = makeViewModel(items: sampleItems()) vm.selectedItemId = "d1" vm.searchText = "users" - try await Task.sleep(nanoseconds: 200_000_000) + await vm.flushPendingFilter() #expect(vm.flatItems.contains(where: { $0.id == "d1" }) == false) #expect(vm.selectedItemId == vm.flatItems.first?.id) } @@ -928,7 +940,7 @@ struct QuickSwitcherViewModelTests { func listHeightSingleFilteredRow() async throws { let vm = makeViewModel(items: [QuickSwitcherItem(frecencyKey: "t1", name: "users", kind: .table, subtitle: "")]) vm.searchText = "users" - try await Task.sleep(nanoseconds: 200_000_000) + await vm.flushPendingFilter() #expect(vm.groups.first?.header == nil) #expect(vm.listHeight(rowHeight: 30, headerHeight: 28, maxVisibleRows: 9) == 30) } @@ -941,7 +953,7 @@ struct QuickSwitcherViewModelTests { } let vm = makeViewModel(items: items) vm.searchText = "tbl" - try await Task.sleep(nanoseconds: 200_000_000) + await vm.flushPendingFilter() #expect(vm.flatItems.count == 9) #expect(vm.listHeight(rowHeight: 30, headerHeight: 28, maxVisibleRows: 9) == 270) } @@ -954,7 +966,7 @@ struct QuickSwitcherViewModelTests { } let vm = makeViewModel(items: items) vm.searchText = "tbl" - try await Task.sleep(nanoseconds: 200_000_000) + await vm.flushPendingFilter() #expect(vm.flatItems.count == 20) #expect(vm.listHeight(rowHeight: 30, headerHeight: 28, maxVisibleRows: 9) == 270) } diff --git a/TableProTests/ViewModels/RedisKeyTreeViewModelLoadTests.swift b/TableProTests/ViewModels/RedisKeyTreeViewModelLoadTests.swift index 0eba3a0d6d..397920ffba 100644 --- a/TableProTests/ViewModels/RedisKeyTreeViewModelLoadTests.swift +++ b/TableProTests/ViewModels/RedisKeyTreeViewModelLoadTests.swift @@ -1,38 +1,14 @@ -// -// RedisKeyTreeViewModelLoadTests.swift -// TableProTests -// -// The key tree used to catch every load failure, log it and clear itself, so a refused `SCAN` or a -// `MULTI` block left open on the session read as a database with no keys. It also ran each load as -// an unowned task, so a load for a database the user had already left could land over the one they -// had moved to. -// +// The key tree used to catch every load failure, log it and clear itself, so a refused `SCAN` or a +// `MULTI` block left open on the session read as a database with no keys. It also ran each load as +// an unowned task, so a load for a database the user had already left could land over the one they +// had moved to. import Foundation @testable import TablePro import TableProPluginKit +import TableProTestSupport import Testing -private actor KeyTreeLatch { - private var waiters: [CheckedContinuation] = [] - private var isOpen = false - - func open() { - guard !isOpen else { return } - isOpen = true - let pending = waiters - waiters = [] - for waiter in pending { - waiter.resume() - } - } - - func wait() async { - guard !isOpen else { return } - await withCheckedContinuation { waiters.append($0) } - } -} - private enum KeyTreeReply: Sendable { case keys([String]) case failure(any Error) @@ -65,8 +41,8 @@ private final class ExecutedQueryLog: @unchecked Sendable { @MainActor private final class ScriptedKeyTreeProvider: ScopedMetadataProviding { private struct Hold { - let reached: KeyTreeLatch - let release: KeyTreeLatch + let reached: AsyncGate + let release: AsyncGate } private let connection: DatabaseConnection @@ -88,8 +64,8 @@ private final class ScriptedKeyTreeProvider: ScopedMetadataProviding { } /// The next read of `database` waits for `release`, after opening `reached`. - func hold(_ database: String) -> (reached: KeyTreeLatch, release: KeyTreeLatch) { - let hold = Hold(reached: KeyTreeLatch(), release: KeyTreeLatch()) + func hold(_ database: String) -> (reached: AsyncGate, release: AsyncGate) { + let hold = Hold(reached: AsyncGate(), release: AsyncGate()) holds[database] = hold return (hold.reached, hold.release) } @@ -120,7 +96,6 @@ private final class ScriptedKeyTreeProvider: ScopedMetadataProviding { } } -@Suite("Redis key tree load state", .serialized) @MainActor struct RedisKeyTreeViewModelLoadTests { private let connection = TestFixtures.makeConnection(database: "0", type: .redis) @@ -320,7 +295,7 @@ struct RedisKeyTreeViewModelLoadTests { } } -private final class KeyTreePluginDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class KeyTreePluginDriver: PluginDriverStub, @unchecked Sendable { private let keys: [String] private let log: ExecutedQueryLog @@ -340,20 +315,5 @@ private final class KeyTreePluginDriver: PluginDatabaseDriver, @unchecked Sendab ) } - func connect() async throws {} - func disconnect() {} func ping() async throws {} - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } diff --git a/TableProTests/ViewModels/SidebarViewModelTests.swift b/TableProTests/ViewModels/SidebarViewModelTests.swift index 1337919c36..d6689d4181 100644 --- a/TableProTests/ViewModels/SidebarViewModelTests.swift +++ b/TableProTests/ViewModels/SidebarViewModelTests.swift @@ -1,8 +1,3 @@ -// -// SidebarViewModelTests.swift -// TableProTests -// - import AppKit import Combine import Foundation @@ -11,27 +6,6 @@ import TableProTestSupport import Testing @testable import TablePro -private final class SidebarMockClipboard: ClipboardProvider { - var lastWrittenText: String? - - func readText() -> String? { lastWrittenText } - func readGridRows() -> GridRowsClipboardPayload? { nil } - func writeText(_ text: String) { lastWrittenText = text } - func writeCsv(_ csv: String) { lastWrittenText = csv } - - var copiedImages: [NSImage] = [] - - func writeImage(_ image: NSImage) { - copiedImages.append(image) - } - func writeRows(tsv: String, html: String?, gridRows: GridRowsClipboardPayload) { lastWrittenText = tsv } - var hasText: Bool { lastWrittenText != nil } - var hasGridRows: Bool { false } -} - -// MARK: - Helper - -/// Creates a SidebarViewModel with controllable state bindings for testing @MainActor private func makeSUT( tables: [TableInfo] = [], @@ -234,38 +208,33 @@ struct SidebarViewModelTests { #expect(vm.pendingOperationTables.isEmpty) } - // MARK: - Copy Table Names +} - @Test("copySelectedTableNames copies sorted comma-separated names") +extension GlobalStateSuites { + @Suite(.recordingClipboard) @MainActor - func copyTableNames() { - let original = ClipboardService.shared - defer { ClipboardService.shared = original } - let clipboard = SidebarMockClipboard() - ClipboardService.shared = clipboard - - let t1 = makeRef("zebra") - let t2 = makeRef("alpha") - let (vm, _, _, _, _, _) = makeSUT(selectedTables: [t1, t2]) + struct SidebarViewModelCopyTests { + @Test("copySelectedTableNames copies sorted comma-separated names") + func copyTableNames() throws { + let clipboard = try #require(RecordingClipboard.current) + let t1 = makeRef("zebra") + let t2 = makeRef("alpha") + let (vm, _, _, _, _, _) = makeSUT(selectedTables: [t1, t2]) - vm.copySelectedTableNames() + vm.copySelectedTableNames() - #expect(clipboard.lastWrittenText == "alpha,zebra") - } - - @Test("copySelectedTableNames does nothing when no selection") - @MainActor - func copyTableNamesNoSelection() { - let original = ClipboardService.shared - defer { ClipboardService.shared = original } - let clipboard = SidebarMockClipboard() - ClipboardService.shared = clipboard + #expect(clipboard.text == "alpha,zebra") + } - let (vm, _, _, _, _, _) = makeSUT() + @Test("copySelectedTableNames does nothing when no selection") + func copyTableNamesNoSelection() throws { + let clipboard = try #require(RecordingClipboard.current) + let (vm, _, _, _, _, _) = makeSUT() - vm.copySelectedTableNames() + vm.copySelectedTableNames() - #expect(clipboard.lastWrittenText == nil) + #expect(clipboard.text == nil) + } } } @@ -274,7 +243,9 @@ struct SidebarViewModelTests { @MainActor private func makeViewModel( connectionId: UUID = UUID(), - databaseType: DatabaseType = .postgresql + databaseType: DatabaseType = .postgresql, + defaults: UserDefaults, + clock: any Clock = ContinuousClock() ) -> SidebarViewModel { var selectedState: Set = [] var truncates: Set = [] @@ -290,15 +261,23 @@ private func makeViewModel( pendingDeletes: deletesBinding, tableOperationOptions: optionsBinding, databaseType: databaseType, - connectionId: connectionId + connectionId: connectionId, + defaults: defaults, + clock: clock ) } +@MainActor struct SidebarViewModelMultiSectionTests { + private let suite: EphemeralDefaults + + init() throws { + suite = try EphemeralDefaults() + } + @Test("tables of kind splits by TableType raw value") - @MainActor func splitsTablesByKind() { - let vm = makeViewModel() + let vm = makeViewModel(defaults: suite.defaults) let userTable = TestFixtures.makeTableInfo(name: "users", type: .table) let profileView = TestFixtures.makeTableInfo(name: "profiles", type: .view) let mixed = [userTable, profileView] @@ -313,9 +292,8 @@ struct SidebarViewModelMultiSectionTests { } @Test("system tables fall into the table bucket") - @MainActor func systemTablesBucketAsTables() { - let vm = makeViewModel() + let vm = makeViewModel(defaults: suite.defaults) let sysTable = TestFixtures.makeTableInfo(name: "pg_stat_user_tables", type: .systemTable) let tablesOnly = vm.tables(of: .table, from: [sysTable]) @@ -326,9 +304,8 @@ struct SidebarViewModelMultiSectionTests { } @Test("routine kinds return empty when querying tables(of:)") - @MainActor func routinesEmptyInTablesOf() { - let vm = makeViewModel() + let vm = makeViewModel(defaults: suite.defaults) let table = TestFixtures.makeTableInfo(name: "users", type: .table) #expect(vm.tables(of: .procedure, from: [table]).isEmpty) @@ -336,9 +313,8 @@ struct SidebarViewModelMultiSectionTests { } @Test("filteredTables(of:) honors search across kinds") - @MainActor func filteredTablesAcrossKinds() { - let vm = makeViewModel() + let vm = makeViewModel(defaults: suite.defaults) let users = TestFixtures.makeTableInfo(name: "users", type: .table) let userView = TestFixtures.makeTableInfo(name: "user_profile_view", type: .view) let orders = TestFixtures.makeTableInfo(name: "orders", type: .table) @@ -353,9 +329,8 @@ struct SidebarViewModelMultiSectionTests { } @Test("filteredRoutines filters by name across kinds") - @MainActor func filteredRoutinesByKind() { - let vm = makeViewModel() + let vm = makeViewModel(defaults: suite.defaults) let getUser = RoutineInfo(name: "get_user_by_id", kind: .procedure, schema: "public") let calcAge = RoutineInfo(name: "calculate_age", kind: .function, schema: "public") let mixed = [getUser, calcAge] @@ -368,9 +343,8 @@ struct SidebarViewModelMultiSectionTests { } @Test("Sidebar filter uses substring matching, not fuzzy abbreviations") - @MainActor func sidebarFilterUsesSubstringNotAbbreviation() { - let vm = makeViewModel() + let vm = makeViewModel(defaults: suite.defaults) let userProfileView = TestFixtures.makeTableInfo(name: "user_profile_view", type: .view) let orders = TestFixtures.makeTableInfo(name: "orders", type: .view) vm.searchText = "upv" @@ -381,9 +355,8 @@ struct SidebarViewModelMultiSectionTests { } @Test("Sidebar filter ranks prefix matches above interior-substring matches") - @MainActor func sidebarFilterRanksPrefixFirst() { - let vm = makeViewModel() + let vm = makeViewModel(defaults: suite.defaults) let auditUser = TestFixtures.makeTableInfo(name: "audit_user", type: .table) let users = TestFixtures.makeTableInfo(name: "users", type: .table) let userLog = TestFixtures.makeTableInfo(name: "user_log", type: .table) @@ -395,9 +368,8 @@ struct SidebarViewModelMultiSectionTests { } @Test("filteredRoutines search matches name case insensitively") - @MainActor func filteredRoutinesSearch() { - let vm = makeViewModel() + let vm = makeViewModel(defaults: suite.defaults) let getUser = RoutineInfo(name: "GET_USER_BY_ID", kind: .procedure) let other = RoutineInfo(name: "log_event", kind: .procedure) vm.searchText = "user" @@ -408,9 +380,8 @@ struct SidebarViewModelMultiSectionTests { } @Test("effectiveExpanded honors stored state when search empty") - @MainActor func effectiveExpandedRespectsStored() { - let vm = makeViewModel() + let vm = makeViewModel(defaults: suite.defaults) vm.expanded[.view] = false #expect(vm.effectiveExpanded(kind: .view, hasMatches: false) == false) @@ -418,9 +389,8 @@ struct SidebarViewModelMultiSectionTests { } @Test("effectiveExpanded auto-expands when search has matches") - @MainActor func effectiveExpandedAutoExpandsOnSearch() { - let vm = makeViewModel() + let vm = makeViewModel(defaults: suite.defaults) vm.expanded[.procedure] = false vm.searchText = "user" @@ -430,9 +400,8 @@ struct SidebarViewModelMultiSectionTests { } @Test("effectiveExpanded stays collapsed when search has no matches") - @MainActor func effectiveExpandedStaysCollapsedWithoutMatches() { - let vm = makeViewModel() + let vm = makeViewModel(defaults: suite.defaults) vm.expanded[.function] = false vm.searchText = "nonexistent" @@ -442,9 +411,8 @@ struct SidebarViewModelMultiSectionTests { } @Test("default expansion is true for tables only") - @MainActor func defaultExpansionTablesOnly() { - let vm = makeViewModel() + let vm = makeViewModel(defaults: suite.defaults) #expect(vm.expanded[.table] == true) #expect(vm.expanded[.view] == false) @@ -455,79 +423,60 @@ struct SidebarViewModelMultiSectionTests { } @Test("expansion writes persist to per-kind UserDefaults keys") - @MainActor func expansionWritesPersist() { - let defaults = AppStorageEnvironment.shared.defaults + let defaults = suite.defaults let connectionId = UUID() - let vm = makeViewModel(connectionId: connectionId) + let vm = makeViewModel(connectionId: connectionId, defaults: defaults) let key = SidebarPersistenceKey.expanded(connectionId: connectionId, kind: .procedure) - defaults.removeObject(forKey: key) vm.expanded[.procedure] = true #expect(defaults.bool(forKey: key) == true) - defaults.removeObject(forKey: key) } @Test("recents expansion defaults to expanded and persists across init") - @MainActor func recentsExpansionPersists() { - let defaults = AppStorageEnvironment.shared.defaults + let defaults = suite.defaults let connectionId = UUID() let key = SidebarPersistenceKey.recentsExpanded(connectionId: connectionId) - defaults.removeObject(forKey: key) - let first = makeViewModel(connectionId: connectionId) + let first = makeViewModel(connectionId: connectionId, defaults: defaults) #expect(first.isRecentsExpanded == true) first.isRecentsExpanded = false #expect(defaults.object(forKey: key) as? Bool == false) - let second = makeViewModel(connectionId: connectionId) + let second = makeViewModel(connectionId: connectionId, defaults: defaults) #expect(second.isRecentsExpanded == false) - - defaults.removeObject(forKey: key) } @Test("expansion seeds .table from legacy per-connection key on first init") - @MainActor func legacyMigrationFromPerConnectionKey() { - let defaults = AppStorageEnvironment.shared.defaults + let defaults = suite.defaults let connectionId = UUID() let perKindKey = SidebarPersistenceKey.expanded(connectionId: connectionId, kind: .table) let legacyKey = SidebarPersistenceKey.tablesExpanded(connectionId: connectionId) - defaults.removeObject(forKey: perKindKey) defaults.set(false, forKey: legacyKey) - let vm = makeViewModel(connectionId: connectionId) + let vm = makeViewModel(connectionId: connectionId, defaults: defaults) #expect(vm.expanded[.table] == false) - defaults.removeObject(forKey: perKindKey) - defaults.removeObject(forKey: legacyKey) + #expect(defaults.object(forKey: perKindKey) as? Bool == false) } @Test("expansion seeds .table from global legacy key when no per-connection key set") - @MainActor func legacyMigrationFromGlobalKey() { - let defaults = AppStorageEnvironment.shared.defaults + let defaults = suite.defaults let connectionId = UUID() - let perKindKey = SidebarPersistenceKey.expanded(connectionId: connectionId, kind: .table) - let perConnLegacy = SidebarPersistenceKey.tablesExpanded(connectionId: connectionId) - defaults.removeObject(forKey: perKindKey) - defaults.removeObject(forKey: perConnLegacy) defaults.set(false, forKey: SidebarPersistenceKey.legacyTablesExpanded) - let vm = makeViewModel(connectionId: connectionId) + let vm = makeViewModel(connectionId: connectionId, defaults: defaults) #expect(vm.expanded[.table] == false) - defaults.removeObject(forKey: perKindKey) - defaults.removeObject(forKey: SidebarPersistenceKey.legacyTablesExpanded) } } -/// The debounce runs on real time, so a test waits for the value it publishes and not for a -/// sleep of its own to outlast it. `$filterQuery` replays its current value to a new subscriber, -/// which `dropFirst` discards. +/// `$filterQuery` replays its current value to a new subscriber, which `dropFirst` discards. @MainActor private final class DebouncedFilterQueries { private(set) var published: [String] = [] @@ -553,11 +502,26 @@ private final class DebouncedFilterQueries { } } +@MainActor struct SidebarViewModelSearchDebounceTests { + private let suite: EphemeralDefaults + private let clock = ManualClock() + + init() throws { + suite = try EphemeralDefaults() + } + + private func makeDebouncingViewModel() -> SidebarViewModel { + makeViewModel(defaults: suite.defaults, clock: clock) + } + + private func debounceStarted() async throws { + try await waitUntil { clock.pendingSleeperCount > 0 } + } + @Test("filterQuery updates immediately on first non-empty input") - @MainActor func filterQueryUpdatesImmediatelyOnFirstInput() { - let vm = makeViewModel() + let vm = makeDebouncingViewModel() vm.searchText = "user" @@ -565,12 +529,11 @@ struct SidebarViewModelSearchDebounceTests { } @Test("filterQuery clears immediately when search becomes empty") - @MainActor - func filterQueryClearsImmediatelyOnEmpty() async { - let vm = makeViewModel() + func filterQueryClearsImmediatelyOnEmpty() async throws { + let vm = makeDebouncingViewModel() vm.searchText = "user" vm.searchText = "users" - await Task.yield() + try await debounceStarted() vm.searchText = "" @@ -578,35 +541,34 @@ struct SidebarViewModelSearchDebounceTests { } @Test("filterQuery stays at previous value during debounce window") - @MainActor - func filterQueryHoldsPreviousValueDuringDebounce() async { - let vm = makeViewModel() + func filterQueryHoldsPreviousValueDuringDebounce() async throws { + let vm = makeDebouncingViewModel() vm.searchText = "user" #expect(vm.filterQuery == "user") vm.searchText = "users" - await Task.yield() + try await debounceStarted() #expect(vm.filterQuery == "user") } @Test("filterQuery catches up after debounce window elapses") - @MainActor - func filterQueryCatchesUpAfterDebounce() async { - let vm = makeViewModel() + func filterQueryCatchesUpAfterDebounce() async throws { + let vm = makeDebouncingViewModel() vm.searchText = "user" let debounced = DebouncedFilterQueries(of: vm) vm.searchText = "users" + try await debounceStarted() + clock.advance(by: .milliseconds(150)) #expect(await debounced.first() == "users") #expect(vm.filterQuery == "users") } @Test("rapid consecutive keystrokes collapse to the final value") - @MainActor - func rapidKeystrokesCollapseToFinalValue() async { - let vm = makeViewModel() + func rapidKeystrokesCollapseToFinalValue() async throws { + let vm = makeDebouncingViewModel() vm.searchText = "u" let debounced = DebouncedFilterQueries(of: vm) @@ -614,9 +576,10 @@ struct SidebarViewModelSearchDebounceTests { vm.searchText = "us" vm.searchText = "use" vm.searchText = "user" - await Task.yield() + try await debounceStarted() #expect(vm.filterQuery == "u") + clock.advance(by: .milliseconds(150)) #expect(await debounced.first() == "user") #expect(debounced.published == ["user"]) @@ -624,9 +587,8 @@ struct SidebarViewModelSearchDebounceTests { } @Test("filter caches still serve filteredTables using filterQuery, not searchText") - @MainActor - func filteredTablesHonorsFilterQueryNotSearchText() async { - let vm = makeViewModel() + func filteredTablesHonorsFilterQueryNotSearchText() async throws { + let vm = makeDebouncingViewModel() let users = TestFixtures.makeTableInfo(name: "users", type: .table) let userLog = TestFixtures.makeTableInfo(name: "user_log", type: .table) let orders = TestFixtures.makeTableInfo(name: "orders", type: .table) @@ -634,7 +596,7 @@ struct SidebarViewModelSearchDebounceTests { vm.searchText = "user" vm.searchText = "users" - await Task.yield() + try await debounceStarted() let matches = vm.filteredTables(of: .table, from: mixed) diff --git a/TableProTests/ViewModels/UsersRolesViewModelChangeForwardingTests.swift b/TableProTests/ViewModels/UsersRolesViewModelChangeForwardingTests.swift index a5621beb43..e8e934cb3b 100644 --- a/TableProTests/ViewModels/UsersRolesViewModelChangeForwardingTests.swift +++ b/TableProTests/ViewModels/UsersRolesViewModelChangeForwardingTests.swift @@ -14,25 +14,10 @@ import Testing @testable import TablePro /// Lists one schema under any database, so a lazy expand has something to load. -private final class ScopeTreeDriverStub: PluginDatabaseDriver, PluginPrincipalManagement, @unchecked Sendable { - func connect() async throws {} - func disconnect() {} +private final class ScopeTreeDriverStub: PluginDriverStub, PluginPrincipalManagement, @unchecked Sendable { func execute(query: String) async throws -> PluginQueryResult { PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) } - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } func fetchPrincipals() async throws -> [PluginPrincipalInfo] { [] } func fetchPrivilegeCatalog() async throws -> PluginPrivilegeCatalog { PluginPrivilegeCatalog() } diff --git a/TableProTests/ViewModels/UsersRolesViewModelReappearanceTests.swift b/TableProTests/ViewModels/UsersRolesViewModelReappearanceTests.swift index 474bed0ff7..720de27e42 100644 --- a/TableProTests/ViewModels/UsersRolesViewModelReappearanceTests.swift +++ b/TableProTests/ViewModels/UsersRolesViewModelReappearanceTests.swift @@ -1,46 +1,22 @@ -// -// UsersRolesViewModelReappearanceTests.swift -// TableProTests -// -// The Users & Roles view loads again each time it appears, which is every editor tab switch and -// every connection switch. A load that reset the change manager there threw away staged creates, -// passwords and grants, and the undo stack with them. -// +// The Users & Roles view loads again each time it appears, which is every editor tab switch and +// every connection switch. A load that reset the change manager there threw away staged creates, +// passwords and grants, and the undo stack with them. import Foundation import TableProPluginKit +import TableProTestSupport import Testing @testable import TablePro -private actor PrincipalFetchLatch { - private var waiters: [CheckedContinuation] = [] - private var isOpen = false - - func open() { - guard !isOpen else { return } - isOpen = true - let pending = waiters - waiters = [] - for waiter in pending { - waiter.resume() - } - } - - func wait() async { - guard !isOpen else { return } - await withCheckedContinuation { waiters.append($0) } - } -} - private struct PrincipalFetchRefused: Error {} /// A server whose principal list the test can change between loads. The loader calls it off the /// main actor, so its state is locked rather than isolated. -private final class PrincipalServerStub: PluginDatabaseDriver, PluginPrincipalManagement, @unchecked Sendable { +private final class PrincipalServerStub: PluginDriverStub, PluginPrincipalManagement, @unchecked Sendable { private struct Hold { - let reached: PrincipalFetchLatch - let release: PrincipalFetchLatch + let reached: AsyncGate + let release: AsyncGate } private let lock = NSLock() @@ -66,30 +42,16 @@ private final class PrincipalServerStub: PluginDatabaseDriver, PluginPrincipalMa } /// The next `currentPrincipalRef()` opens `reached`, then waits for `release`. - func holdNextCurrentPrincipal() -> (reached: PrincipalFetchLatch, release: PrincipalFetchLatch) { - let hold = Hold(reached: PrincipalFetchLatch(), release: PrincipalFetchLatch()) + func holdNextCurrentPrincipal() -> (reached: AsyncGate, release: AsyncGate) { + let hold = Hold(reached: AsyncGate(), release: AsyncGate()) lock.withLock { currentPrincipalHold = hold } return (hold.reached, hold.release) } - func connect() async throws {} - func disconnect() {} func execute(query: String) async throws -> PluginQueryResult { PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) } - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } func fetchDatabases() async throws -> [String] { ["app"] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } func fetchPrincipals() async throws -> [PluginPrincipalInfo] { let reply: [PluginPrincipalInfo]? = lock.withLock { diff --git a/TableProTests/ViewModels/WelcomeViewModelTests.swift b/TableProTests/ViewModels/WelcomeViewModelTests.swift index 3b37081b07..92e06adc31 100644 --- a/TableProTests/ViewModels/WelcomeViewModelTests.swift +++ b/TableProTests/ViewModels/WelcomeViewModelTests.swift @@ -1,20 +1,17 @@ -// -// WelcomeViewModelTests.swift -// TableProTests -// - +import Foundation @testable import TablePro import TableProConnectionLibrary import TableProImport import TableProPluginKit import TableProSyncTransport -import XCTest +import TableProTestSupport +import Testing @MainActor -final class WelcomeViewModelTests: XCTestCase { - private var suiteName: String! +final class WelcomeViewModelTests { + private var suite: EphemeralDefaults! + private var syncSuite: EphemeralDefaults! private var defaults: UserDefaults! - private var syncSuiteName: String! private var syncDefaults: UserDefaults! private var connectionFileURL: URL! private var groupStorage: GroupStorage! @@ -24,18 +21,12 @@ final class WelcomeViewModelTests: XCTestCase { private var preferences: ConnectionListPreferences! private var viewModel: WelcomeViewModel! - override func setUp() async throws { - try await super.setUp() + init() throws { let unique = UUID().uuidString - suiteName = "com.TablePro.tests.WelcomeViewModel.\(unique)" - syncSuiteName = "com.TablePro.tests.WelcomeViewModel.sync.\(unique)" - guard let defaults = UserDefaults(suiteName: suiteName), - let syncDefaults = UserDefaults(suiteName: syncSuiteName) else { - XCTFail("Could not create isolated UserDefaults suites") - return - } - self.defaults = defaults - self.syncDefaults = syncDefaults + suite = try EphemeralDefaults() + syncSuite = try EphemeralDefaults() + defaults = suite.defaults + syncDefaults = syncSuite.defaults let tracker = SyncChangeTracker(metadataStorage: SyncMetadataStorage(userDefaults: syncDefaults)) connectionFileURL = FileManager.default.temporaryDirectory .appendingPathComponent("tablepro-tests") @@ -75,22 +66,8 @@ final class WelcomeViewModelTests: XCTestCase { ) } - override func tearDown() { - defaults.removePersistentDomain(forName: suiteName) - syncDefaults.removePersistentDomain(forName: syncSuiteName) + isolated deinit { try? FileManager.default.removeItem(at: connectionFileURL.deletingLastPathComponent()) - viewModel = nil - preferences = nil - recents = nil - welcomeRouter = nil - groupStorage = nil - connectionStorage = nil - defaults = nil - syncDefaults = nil - suiteName = nil - syncSuiteName = nil - connectionFileURL = nil - super.tearDown() } private func makeServices() -> AppServices { @@ -132,7 +109,7 @@ final class WelcomeViewModelTests: XCTestCase { } private func save(_ connections: [DatabaseConnection]) { - XCTAssertTrue(connectionStorage.saveConnections(connections)) + #expect(connectionStorage.saveConnections(connections)) } private func groupIds(in outline: LibraryOutline) -> [UUID] { @@ -151,57 +128,57 @@ final class WelcomeViewModelTests: XCTestCase { // MARK: - Groups - func testCreateGroupShowsImmediatelyInTheOutline() throws { - XCTAssertTrue(groupIds(in: viewModel.outline).isEmpty) + @Test func testCreateGroupShowsImmediatelyInTheOutline() throws { + #expect(groupIds(in: viewModel.outline).isEmpty) try viewModel.createGroup(name: "Production", color: .red, iconName: nil, parentId: nil, moving: []) - let created = try XCTUnwrap(groupStorage.loadGroups().first { $0.name == "Production" }) - XCTAssertTrue(groupIds(in: viewModel.outline).contains(created.id)) - XCTAssertTrue(viewModel.expandedGroupIds.contains(created.id)) + let created = try #require(groupStorage.loadGroups().first { $0.name == "Production" }) + #expect(groupIds(in: viewModel.outline).contains(created.id)) + #expect(viewModel.expandedGroupIds.contains(created.id)) } - func testCreateSubgroupExpandsParentAndChild() throws { + @Test func testCreateSubgroupExpandsParentAndChild() throws { try viewModel.createGroup(name: "Parent", color: .none, iconName: nil, parentId: nil, moving: []) - let parentId = try XCTUnwrap(groupStorage.loadGroups().first { $0.name == "Parent" }?.id) + let parentId = try #require(groupStorage.loadGroups().first { $0.name == "Parent" }?.id) try viewModel.createGroup(name: "Child", color: .none, iconName: nil, parentId: parentId, moving: []) - let childId = try XCTUnwrap(groupStorage.loadGroups().first { $0.name == "Child" }?.id) + let childId = try #require(groupStorage.loadGroups().first { $0.name == "Child" }?.id) - XCTAssertTrue(groupIds(in: viewModel.outline).contains(parentId)) - XCTAssertTrue(groupIds(in: viewModel.outline).contains(childId)) - XCTAssertTrue(viewModel.expandedGroupIds.contains(parentId)) - XCTAssertTrue(viewModel.expandedGroupIds.contains(childId)) + #expect(groupIds(in: viewModel.outline).contains(parentId)) + #expect(groupIds(in: viewModel.outline).contains(childId)) + #expect(viewModel.expandedGroupIds.contains(parentId)) + #expect(viewModel.expandedGroupIds.contains(childId)) } - func testCreateDuplicateNameReportsWhyAndAddsNoSecondNode() throws { + @Test func testCreateDuplicateNameReportsWhyAndAddsNoSecondNode() throws { try viewModel.createGroup(name: "Staging", color: .orange, iconName: nil, parentId: nil, moving: []) - XCTAssertThrowsError( + let error = #expect(throws: (any Error).self) { try viewModel.createGroup(name: "staging", color: .blue, iconName: nil, parentId: nil, moving: []) - ) { error in - XCTAssertEqual(error as? GroupStorageError, .duplicateName("staging")) } + #expect(error as? GroupStorageError == .duplicateName("staging")) - XCTAssertEqual(groupIds(in: viewModel.outline).count, 1) + #expect(groupIds(in: viewModel.outline).count == 1) } - func testANewGroupTakesTheConnectionsItWasRequestedFor() throws { + @Test func testANewGroupTakesTheConnectionsItWasRequestedFor() throws { let prod = DatabaseConnection(name: "Prod", type: .mysql) save([prod]) viewModel.loadConnections() viewModel.requestNewGroup(parentId: nil, movingConnectionIds: [prod.id]) guard case .newGroup(let request) = viewModel.activeSheet else { - return XCTFail("Move to Group > New Group must open the new group sheet") + Issue.record("Move to Group > New Group must open the new group sheet") + return } try viewModel.createGroup(name: "Acme", color: .none, iconName: nil, parentId: nil, moving: request.movingConnectionIds) - let group = try XCTUnwrap(groupStorage.loadGroups().first) - XCTAssertEqual(connectionStorage.loadConnection(id: prod.id)?.groupId, group.id) + let group = try #require(groupStorage.loadGroups().first) + #expect(connectionStorage.loadConnection(id: prod.id)?.groupId == group.id) } - func testACancelledMoveToNewGroupIsNotReplayedByALaterSubgroup() throws { + @Test func testACancelledMoveToNewGroupIsNotReplayedByALaterSubgroup() throws { let archive = ConnectionGroup(name: "Archive") try groupStorage.addGroup(archive) let prod = DatabaseConnection(name: "Prod", type: .mysql) @@ -212,7 +189,8 @@ final class WelcomeViewModelTests: XCTestCase { viewModel.activeSheet = nil viewModel.perform(.newSubgroup(archive.id)) guard case .newGroup(let request) = viewModel.activeSheet else { - return XCTFail("New Subgroup must open the new group sheet") + Issue.record("New Subgroup must open the new group sheet") + return } try viewModel.createGroup( name: "2024", @@ -222,27 +200,27 @@ final class WelcomeViewModelTests: XCTestCase { moving: request.movingConnectionIds ) - XCTAssertNil(connectionStorage.loadConnection(id: prod.id)?.groupId) + #expect(connectionStorage.loadConnection(id: prod.id)?.groupId == nil) } - func testRenamingAGroupToASiblingsNameReportsWhy() throws { + @Test func testRenamingAGroupToASiblingsNameReportsWhy() throws { try viewModel.createGroup(name: "Production", color: .none, iconName: nil, parentId: nil, moving: []) try viewModel.createGroup(name: "Staging", color: .none, iconName: nil, parentId: nil, moving: []) - let staging = try XCTUnwrap(groupStorage.loadGroups().first { $0.name == "Staging" }) + let staging = try #require(groupStorage.loadGroups().first { $0.name == "Staging" }) viewModel.commitRename(.group(staging.id), to: "production") - XCTAssertNotNil(viewModel.libraryErrorMessage) - XCTAssertEqual(groupStorage.group(for: staging.id)?.name, "Staging") + #expect(viewModel.libraryErrorMessage != nil) + #expect(groupStorage.group(for: staging.id)?.name == "Staging") } - func testANewGroupKeepsTheIconItWasCreatedWith() throws { + @Test func testANewGroupKeepsTheIconItWasCreatedWith() throws { try viewModel.createGroup(name: "Production", color: .red, iconName: "server.rack", parentId: nil, moving: []) - XCTAssertEqual(groupStorage.loadGroups().first?.iconName, "server.rack") + #expect(groupStorage.loadGroups().first?.iconName == "server.rack") } - func testEditGroupOpensTheEditSheetForThatGroup() throws { + @Test func testEditGroupOpensTheEditSheetForThatGroup() throws { let group = ConnectionGroup(name: "Production", iconName: "server.rack") try groupStorage.addGroup(group) viewModel.loadConnections() @@ -250,21 +228,22 @@ final class WelcomeViewModelTests: XCTestCase { viewModel.perform(.editGroup(group.id)) guard case .editGroup(let editing) = viewModel.activeSheet else { - return XCTFail("Edit Group… must open the group editor") + Issue.record("Edit Group… must open the group editor") + return } - XCTAssertEqual(editing.id, group.id) - XCTAssertEqual(editing.iconName, "server.rack") + #expect(editing.id == group.id) + #expect(editing.iconName == "server.rack") } - func testEditGroupForAGroupThatIsGoneOpensNothing() { + @Test func testEditGroupForAGroupThatIsGoneOpensNothing() { viewModel.loadConnections() viewModel.requestEditGroup(UUID()) - XCTAssertNil(viewModel.activeSheet) + #expect(viewModel.activeSheet == nil) } - func testSavingAGroupEditMovesItAndShowsItUnderItsNewParent() throws { + @Test func testSavingAGroupEditMovesItAndShowsItUnderItsNewParent() throws { let clients = ConnectionGroup(name: "Clients") let acme = ConnectionGroup(name: "Acme") try groupStorage.addGroup(clients) @@ -278,53 +257,52 @@ final class WelcomeViewModelTests: XCTestCase { to: ConnectionGroupFields(name: "Acme Corp", color: .blue, iconName: "building.2", parentId: clients.id) ) - let saved = try XCTUnwrap(groupStorage.group(for: acme.id)) - XCTAssertEqual(saved.name, "Acme Corp") - XCTAssertEqual(saved.color, .blue) - XCTAssertEqual(saved.iconName, "building.2") - XCTAssertEqual(saved.parentId, clients.id) - XCTAssertTrue(viewModel.expandedGroupIds.contains(clients.id)) - XCTAssertEqual(viewModel.groupsById[acme.id]?.name, "Acme Corp") + let saved = try #require(groupStorage.group(for: acme.id)) + #expect(saved.name == "Acme Corp") + #expect(saved.color == .blue) + #expect(saved.iconName == "building.2") + #expect(saved.parentId == clients.id) + #expect(viewModel.expandedGroupIds.contains(clients.id)) + #expect(viewModel.groupsById[acme.id]?.name == "Acme Corp") } - func testSavingAGroupEditUnderItsOwnSubgroupReportsWhyAndChangesNothing() throws { + @Test func testSavingAGroupEditUnderItsOwnSubgroupReportsWhyAndChangesNothing() throws { let parent = ConnectionGroup(name: "Parent") let child = ConnectionGroup(name: "Child", parentId: parent.id) try groupStorage.addGroup(parent) try groupStorage.addGroup(child) viewModel.loadConnections() - XCTAssertThrowsError( + let error = #expect(throws: (any Error).self) { try viewModel.saveGroup( id: parent.id, from: ConnectionGroupFields(parent), to: ConnectionGroupFields(name: "Renamed", parentId: child.id) ) - ) { error in - XCTAssertEqual(error as? GroupStorageError, .wouldCreateCycle) } - XCTAssertEqual(groupStorage.group(for: parent.id)?.name, "Parent") - XCTAssertNil(groupStorage.group(for: parent.id)?.parentId) + #expect(error as? GroupStorageError == .wouldCreateCycle) + #expect(groupStorage.group(for: parent.id)?.name == "Parent") + #expect(groupStorage.group(for: parent.id)?.parentId == nil) } // MARK: - List State - func testAnEmptyStoreOpensOnTheFirstRunState() { + @Test func testAnEmptyStoreOpensOnTheFirstRunState() { viewModel.loadConnections() - XCTAssertEqual(viewModel.listState, .firstRun) - XCTAssertFalse(viewModel.isSearchAvailable) + #expect(viewModel.listState == .firstRun) + #expect(!viewModel.isSearchAvailable) } - func testASavedConnectionReplacesTheFirstRunState() { + @Test func testASavedConnectionReplacesTheFirstRunState() { save([DatabaseConnection(name: "Local", type: .postgresql, sortOrder: 0)]) viewModel.loadConnections() - XCTAssertEqual(viewModel.listState, .content) - XCTAssertTrue(viewModel.isSearchAvailable) + #expect(viewModel.listState == .content) + #expect(viewModel.isSearchAvailable) } - func testASearchMissIsReportedWhenAFavoriteIsStored() { + @Test func testASearchMissIsReportedWhenAFavoriteIsStored() { var favorite = DatabaseConnection(name: "Alpha", type: .mysql, sortOrder: 0) favorite.isFavorite = true save([favorite]) @@ -332,10 +310,10 @@ final class WelcomeViewModelTests: XCTestCase { viewModel.searchText = "zzz" - XCTAssertEqual(viewModel.listState, .noSearchMatch("zzz")) + #expect(viewModel.listState == .noSearchMatch("zzz")) } - func testATagFilterThatHidesEveryConnectionIsAFilterMiss() { + @Test func testATagFilterThatHidesEveryConnectionIsAFilterMiss() { let first = UUID() let second = UUID() var alpha = DatabaseConnection(name: "Alpha", type: .mysql, sortOrder: 0) @@ -348,10 +326,10 @@ final class WelcomeViewModelTests: XCTestCase { viewModel.tagMatch = .all viewModel.searchTokens = [token(first), token(second)] - XCTAssertEqual(viewModel.listState, .noFilterMatch) + #expect(viewModel.listState == .noFilterMatch) } - func testDeletingTheLastConnectionClearsTheSearchItCanNoLongerRun() { + @Test func testDeletingTheLastConnectionClearsTheSearchItCanNoLongerRun() { let only = DatabaseConnection(name: "Only", type: .mysql, sortOrder: 0) save([only]) viewModel.loadConnections() @@ -360,84 +338,84 @@ final class WelcomeViewModelTests: XCTestCase { viewModel.connectionsToDelete = [only] viewModel.deleteSelectedConnections() - XCTAssertEqual(viewModel.searchText, "") - XCTAssertEqual(viewModel.listState, .firstRun) + #expect(viewModel.searchText == "") + #expect(viewModel.listState == .firstRun) } - func testTheImportOfferFollowsTheInstalledAppDetector() { + @Test func testTheImportOfferFollowsTheInstalledAppDetector() { let offering = makeViewModel(importableAppDetector: { true }) offering.setUp() - XCTAssertTrue(offering.hasImportableApp) - XCTAssertFalse(viewModel.hasImportableApp) + #expect(offering.hasImportableApp) + #expect(!viewModel.hasImportableApp) } - func testRefreshingTheImportOfferPicksUpANewlyInstalledApp() { + @Test func testRefreshingTheImportOfferPicksUpANewlyInstalledApp() { let installed = InstalledAppFlag() let offering = makeViewModel(importableAppDetector: { installed.value }) offering.refreshImportableApp() - XCTAssertFalse(offering.hasImportableApp) + #expect(!offering.hasImportableApp) installed.value = true offering.refreshImportableApp() - XCTAssertTrue(offering.hasImportableApp) + #expect(offering.hasImportableApp) } // MARK: - Welcome Sheet - func testAFirstLaunchPresentsTheWelcomeSheetOnce() { + @Test func testAFirstLaunchPresentsTheWelcomeSheetOnce() { viewModel.setUp() - XCTAssertTrue(viewModel.presentsWelcomeSheet) + #expect(viewModel.presentsWelcomeSheet) viewModel.presentsWelcomeSheet = false viewModel.welcomeSheetDidDismiss() let relaunched = makeViewModel() relaunched.setUp() - XCTAssertFalse(relaunched.presentsWelcomeSheet, "The sheet must not come back on its own") + #expect(!relaunched.presentsWelcomeSheet, "The sheet must not come back on its own") } - func testAUITestSandboxSkipsTheFirstLaunchSheet() { + @Test func testAUITestSandboxSkipsTheFirstLaunchSheet() { let sandboxed = makeViewModel(isUITestSandbox: true) sandboxed.setUp() - XCTAssertFalse(sandboxed.presentsWelcomeSheet) + #expect(!sandboxed.presentsWelcomeSheet) } - func testARoutedSheetWinsOverTheWelcomeSheet() { + @Test func testARoutedSheetWinsOverTheWelcomeSheet() { welcomeRouter.route(.importFromApp) viewModel.setUp() - XCTAssertFalse(viewModel.presentsWelcomeSheet) - XCTAssertNotNil(viewModel.activeSheet) + #expect(!viewModel.presentsWelcomeSheet) + #expect(viewModel.activeSheet != nil) } - func testTheHelpMenuRequestShowsTheSheetAgain() { + @Test func testTheHelpMenuRequestShowsTheSheetAgain() { AppSettingsStorage(userDefaults: defaults).markWelcomeSheetSeen() viewModel.setUp() - XCTAssertFalse(viewModel.presentsWelcomeSheet) + #expect(!viewModel.presentsWelcomeSheet) viewModel.handle(.showWelcomeSheet) - XCTAssertTrue(viewModel.presentsWelcomeSheet) + #expect(viewModel.presentsWelcomeSheet) } - func testTheHelpMenuRequestLeavesAnOpenSheetAlone() { + @Test func testTheHelpMenuRequestLeavesAnOpenSheetAlone() { AppSettingsStorage(userDefaults: defaults).markWelcomeSheetSeen() viewModel.setUp() viewModel.activeSheet = .activation viewModel.handle(.showWelcomeSheet) - XCTAssertFalse(viewModel.presentsWelcomeSheet, "A second sheet can never stack, so the request must not wait stuck") + #expect(!viewModel.presentsWelcomeSheet, "A second sheet can never stack, so the request must not wait stuck") } // MARK: - Favorites, Recent, Tags and Groups - func testAFavoriteAppearsInFavoritesAndStaysInItsGroup() throws { + @Test func testAFavoriteAppearsInFavoritesAndStaysInItsGroup() throws { let group = ConnectionGroup(name: "Acme") try groupStorage.addGroup(group) var favorite = DatabaseConnection(name: "Prod", type: .mysql, sortOrder: 0) @@ -449,11 +427,11 @@ final class WelcomeViewModelTests: XCTestCase { viewModel.loadConnections() - XCTAssertEqual(viewModel.outline.connectionIds(in: .favorites), [favorite.id]) - XCTAssertEqual(viewModel.outline.connectionIds(in: .connections), [favorite.id, other.id]) + #expect(viewModel.outline.connectionIds(in: .favorites) == [favorite.id]) + #expect(viewModel.outline.connectionIds(in: .connections) == [favorite.id, other.id]) } - func testASearchHidesFavoritesAndOpensTheCollapsedGroupHoldingTheMatch() throws { + @Test func testASearchHidesFavoritesAndOpensTheCollapsedGroupHoldingTheMatch() throws { let group = ConnectionGroup(name: "Acme") try groupStorage.addGroup(group) var favorite = DatabaseConnection(name: "Orders", type: .mysql) @@ -465,13 +443,13 @@ final class WelcomeViewModelTests: XCTestCase { viewModel.searchText = "Orders" - XCTAssertNil(viewModel.outline.section(.favorites)) - XCTAssertTrue(viewModel.isGroupExpanded(group.id)) - XCTAssertTrue(viewModel.visibleRowIds().contains(.connection(favorite.id, section: .connections))) - XCTAssertTrue(viewModel.expandedGroupIds.isEmpty, "A search must not rewrite the groups the user collapsed") + #expect(viewModel.outline.section(.favorites) == nil) + #expect(viewModel.isGroupExpanded(group.id)) + #expect(viewModel.visibleRowIds().contains(.connection(favorite.id, section: .connections))) + #expect(viewModel.expandedGroupIds.isEmpty, "A search must not rewrite the groups the user collapsed") } - func testCollapsingAGroupDropsTheSelectionInsideIt() throws { + @Test func testCollapsingAGroupDropsTheSelectionInsideIt() throws { let group = ConnectionGroup(name: "Acme") try groupStorage.addGroup(group) var member = DatabaseConnection(name: "Prod", type: .mysql) @@ -484,10 +462,10 @@ final class WelcomeViewModelTests: XCTestCase { viewModel.expandedGroupIds = [] viewModel.rebuildOutline() - XCTAssertTrue(viewModel.selection.isEmpty, "A row the list no longer shows must not stay selected") + #expect(viewModel.selection.isEmpty, "A row the list no longer shows must not stay selected") } - func testDeletingTheLastTaggedConnectionDropsItsTokenFromTheSearch() { + @Test func testDeletingTheLastTaggedConnectionDropsItsTokenFromTheSearch() { let tagId = UUID() var tagged = DatabaseConnection(name: "Prod", type: .mysql, sortOrder: 0) tagged.tagIds = [tagId] @@ -499,25 +477,25 @@ final class WelcomeViewModelTests: XCTestCase { viewModel.connectionsToDelete = [tagged] viewModel.deleteSelectedConnections() - XCTAssertTrue(viewModel.searchTokens.isEmpty, "A token for a tag nothing carries hides every connection") - XCTAssertEqual(viewModel.outline.connectionIds(in: .connections), [plain.id]) - XCTAssertEqual(viewModel.listState, .content) + #expect(viewModel.searchTokens.isEmpty, "A token for a tag nothing carries hides every connection") + #expect(viewModel.outline.connectionIds(in: .connections) == [plain.id]) + #expect(viewModel.listState == .content) } - func testCollapsingEveryGroupSurvivesAReopen() throws { + @Test func testCollapsingEveryGroupSurvivesAReopen() throws { let group = ConnectionGroup(name: "Acme") try groupStorage.addGroup(group) viewModel.setUp() - XCTAssertEqual(viewModel.expandedGroupIds, [group.id], "A first open expands every group") + #expect(viewModel.expandedGroupIds == [group.id], "A first open expands every group") viewModel.expandedGroupIds.remove(group.id) let reopened = makeViewModel() reopened.setUp() - XCTAssertTrue(reopened.expandedGroupIds.isEmpty, "Collapsing every group must be remembered") + #expect(reopened.expandedGroupIds.isEmpty, "Collapsing every group must be remembered") } - func testRecentConnectionsFillTheRecentSection() { + @Test func testRecentConnectionsFillTheRecentSection() { let first = DatabaseConnection(name: "First", type: .mysql, sortOrder: 0) let second = DatabaseConnection(name: "Second", type: .mysql, sortOrder: 1) save([first, second]) @@ -526,29 +504,29 @@ final class WelcomeViewModelTests: XCTestCase { viewModel.loadConnections() - XCTAssertEqual(viewModel.outline.connectionIds(in: .recent), [second.id, first.id]) + #expect(viewModel.outline.connectionIds(in: .recent) == [second.id, first.id]) } - func testHidingRecentTakesEffectInTheOpenWindowAndKeepsTheHistory() { + @Test func testHidingRecentTakesEffectInTheOpenWindowAndKeepsTheHistory() { let first = DatabaseConnection(name: "First", type: .mysql, sortOrder: 0) save([first]) recents.record(first.id, at: Date(timeIntervalSince1970: 1)) viewModel.setUp() viewModel.loadConnections() - XCTAssertEqual(viewModel.outline.connectionIds(in: .recent), [first.id]) + #expect(viewModel.outline.connectionIds(in: .recent) == [first.id]) preferences.setShowsRecent(false) - XCTAssertNil(viewModel.outline.section(.recent), "Hiding Recent must rebuild the open list") - XCTAssertEqual(viewModel.outline.connectionIds(in: .connections), [first.id]) - XCTAssertFalse(recents.ledger.isEmpty, "Hiding Recent must not clear the history") + #expect(viewModel.outline.section(.recent) == nil, "Hiding Recent must rebuild the open list") + #expect(viewModel.outline.connectionIds(in: .connections) == [first.id]) + #expect(!recents.ledger.isEmpty, "Hiding Recent must not clear the history") preferences.setShowsRecent(true) - XCTAssertEqual(viewModel.outline.connectionIds(in: .recent), [first.id]) + #expect(viewModel.outline.connectionIds(in: .recent) == [first.id]) } - func testAListOpenedWhileRecentIsHiddenStartsWithoutIt() { + @Test func testAListOpenedWhileRecentIsHiddenStartsWithoutIt() { let first = DatabaseConnection(name: "First", type: .mysql, sortOrder: 0) save([first]) recents.record(first.id, at: Date(timeIntervalSince1970: 1)) @@ -557,51 +535,51 @@ final class WelcomeViewModelTests: XCTestCase { let reopened = makeViewModel() reopened.loadConnections() - XCTAssertNil(reopened.outline.section(.recent)) + #expect(reopened.outline.section(.recent) == nil) } - func testChangingTheSortModeReordersTheTree() { + @Test func testChangingTheSortModeReordersTheTree() { let zulu = DatabaseConnection(name: "Zulu", type: .mysql, sortOrder: 0) let alpha = DatabaseConnection(name: "Alpha", type: .mysql, sortOrder: 1) save([zulu, alpha]) viewModel.loadConnections() - XCTAssertEqual(viewModel.outline.connectionIds(in: .connections), [zulu.id, alpha.id]) + #expect(viewModel.outline.connectionIds(in: .connections) == [zulu.id, alpha.id]) viewModel.setSortMode(.name) - XCTAssertEqual(viewModel.outline.connectionIds(in: .connections), [alpha.id, zulu.id]) - XCTAssertEqual(preferences.sortMode, .name) + #expect(viewModel.outline.connectionIds(in: .connections) == [alpha.id, zulu.id]) + #expect(preferences.sortMode == .name) } // MARK: - Mutations - func testAddingAFavoriteKeepsASafeModeLevelSetElsewhere() throws { + @Test func testAddingAFavoriteKeepsASafeModeLevelSetElsewhere() throws { let prod = DatabaseConnection(name: "Prod", type: .postgresql) save([prod]) viewModel.loadConnections() - XCTAssertTrue(connectionStorage.updateSafeModeLevel(.readOnly, for: prod.id)) + #expect(connectionStorage.updateSafeModeLevel(.readOnly, for: prod.id)) viewModel.setFavorite([prod.id], true, undoManager: nil) - let stored = try XCTUnwrap(connectionStorage.loadConnection(id: prod.id)) - XCTAssertTrue(stored.isFavorite) - XCTAssertEqual(stored.preferredSafeModeLevel, .readOnly) + let stored = try #require(connectionStorage.loadConnection(id: prod.id)) + #expect(stored.isFavorite) + #expect(stored.preferredSafeModeLevel == .readOnly) } - func testRenamingAConnectionKeepsASafeModeLevelSetElsewhere() throws { + @Test func testRenamingAConnectionKeepsASafeModeLevelSetElsewhere() throws { let prod = DatabaseConnection(name: "Prod", type: .postgresql) save([prod]) viewModel.loadConnections() - XCTAssertTrue(connectionStorage.updateSafeModeLevel(.readOnly, for: prod.id)) + #expect(connectionStorage.updateSafeModeLevel(.readOnly, for: prod.id)) viewModel.commitRename(.connection(prod.id, section: .connections), to: " Production ") - let stored = try XCTUnwrap(connectionStorage.loadConnection(id: prod.id)) - XCTAssertEqual(stored.name, "Production") - XCTAssertEqual(stored.preferredSafeModeLevel, .readOnly) + let stored = try #require(connectionStorage.loadConnection(id: prod.id)) + #expect(stored.name == "Production") + #expect(stored.preferredSafeModeLevel == .readOnly) } - func testMovingAConnectionIntoAGroupPlacesItAfterTheOnesAlreadyThere() throws { + @Test func testMovingAConnectionIntoAGroupPlacesItAfterTheOnesAlreadyThere() throws { let group = ConnectionGroup(name: "Acme") try groupStorage.addGroup(group) var first = DatabaseConnection(name: "Zeta", type: .mysql, sortOrder: 4) @@ -612,10 +590,10 @@ final class WelcomeViewModelTests: XCTestCase { viewModel.moveConnections([moving.id], toGroup: group.id, before: nil, undoManager: nil) - XCTAssertEqual(viewModel.outline.connectionIds(in: .connections), [first.id, moving.id]) + #expect(viewModel.outline.connectionIds(in: .connections) == [first.id, moving.id]) } - func testAManualDropPlacesTheConnectionBeforeTheTarget() { + @Test func testAManualDropPlacesTheConnectionBeforeTheTarget() { let a = DatabaseConnection(name: "A", type: .mysql, sortOrder: 0) let b = DatabaseConnection(name: "B", type: .mysql, sortOrder: 1) let c = DatabaseConnection(name: "C", type: .mysql, sortOrder: 2) @@ -624,10 +602,10 @@ final class WelcomeViewModelTests: XCTestCase { viewModel.applyDrop(.moveConnections([c.id], toGroup: nil, before: a.id), undoManager: nil) - XCTAssertEqual(viewModel.outline.connectionIds(in: .connections), [c.id, a.id, b.id]) + #expect(viewModel.outline.connectionIds(in: .connections) == [c.id, a.id, b.id]) } - func testUndoPutsAMovedConnectionBack() throws { + @Test func testUndoPutsAMovedConnectionBack() throws { let group = ConnectionGroup(name: "Acme") try groupStorage.addGroup(group) let moving = DatabaseConnection(name: "Prod", type: .mysql) @@ -639,14 +617,14 @@ final class WelcomeViewModelTests: XCTestCase { undoManager.beginUndoGrouping() viewModel.moveConnections([moving.id], toGroup: group.id, before: nil, undoManager: undoManager) undoManager.endUndoGrouping() - XCTAssertEqual(connectionStorage.loadConnection(id: moving.id)?.groupId, group.id) + #expect(connectionStorage.loadConnection(id: moving.id)?.groupId == group.id) undoManager.undo() - XCTAssertNil(connectionStorage.loadConnection(id: moving.id)?.groupId) + #expect(connectionStorage.loadConnection(id: moving.id)?.groupId == nil) } - func testDeleteMeansWhatTheSectionHolds() throws { + @Test func testDeleteMeansWhatTheSectionHolds() throws { let group = ConnectionGroup(name: "Acme") try groupStorage.addGroup(group) var favorite = DatabaseConnection(name: "Prod", type: .mysql) @@ -655,26 +633,17 @@ final class WelcomeViewModelTests: XCTestCase { save([favorite, recent]) viewModel.loadConnections() - XCTAssertEqual( - viewModel.deleteIntent(for: [.connection(favorite.id, section: .favorites)]), - .removeFavorites([favorite.id]) - ) - XCTAssertEqual( - viewModel.deleteIntent(for: [.connection(recent.id, section: .recent)]), - .removeRecent([recent.id]) - ) - XCTAssertEqual( - viewModel.deleteIntent(for: [.connection(favorite.id, section: .connections)]), - .connections([favorite.id]) - ) - XCTAssertEqual(viewModel.deleteIntent(for: [.group(group.id)]), .group(group.id)) - XCTAssertNil(viewModel.deleteIntent(for: [ + #expect(viewModel.deleteIntent(for: [.connection(favorite.id, section: .favorites)]) == .removeFavorites([favorite.id])) + #expect(viewModel.deleteIntent(for: [.connection(recent.id, section: .recent)]) == .removeRecent([recent.id])) + #expect(viewModel.deleteIntent(for: [.connection(favorite.id, section: .connections)]) == .connections([favorite.id])) + #expect(viewModel.deleteIntent(for: [.group(group.id)]) == .group(group.id)) + #expect(viewModel.deleteIntent(for: [ .connection(favorite.id, section: .favorites), .connection(recent.id, section: .connections), - ])) + ]) == nil) } - func testRemovingAFavoriteRowKeepsTheConnection() { + @Test func testRemovingAFavoriteRowKeepsTheConnection() { var favorite = DatabaseConnection(name: "Prod", type: .mysql) favorite.isFavorite = true save([favorite]) @@ -682,109 +651,96 @@ final class WelcomeViewModelTests: XCTestCase { viewModel.performDelete(rows: [.connection(favorite.id, section: .favorites)]) - XCTAssertEqual(connectionStorage.loadConnection(id: favorite.id)?.isFavorite, false) - XCTAssertFalse(viewModel.showDeleteConfirmation) + #expect(connectionStorage.loadConnection(id: favorite.id)?.isFavorite == false) + #expect(!viewModel.showDeleteConfirmation) } // MARK: - Welcome Router Requests - private func waitForChooser(timeout: TimeInterval = 2) async -> DatabaseTypeChooserPayload? { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if let chooser = viewModel.databaseTypeChooser { - return chooser - } - try? await Task.sleep(nanoseconds: 5_000_000) - } - return viewModel.databaseTypeChooser - } - - func testChooserRoutedWhileWelcomeWindowIsClosedSurvivesUntilItMounts() { + @Test func testChooserRoutedWhileWelcomeWindowIsClosedSurvivesUntilItMounts() { let payload = DatabaseTypeChooserPayload(initialType: .postgresql) { _ in } welcomeRouter.route(.chooseDatabaseType(payload)) viewModel.setUp() - XCTAssertEqual(viewModel.databaseTypeChooser?.id, payload.id) - XCTAssertNil(welcomeRouter.pendingRequest) + #expect(viewModel.databaseTypeChooser?.id == payload.id) + #expect(welcomeRouter.pendingRequest == nil) } - func testChooserRoutedWhileWelcomeWindowIsOpenIsDelivered() async { + @Test func testChooserRoutedWhileWelcomeWindowIsOpenIsDelivered() async { viewModel.setUp() - XCTAssertNil(viewModel.databaseTypeChooser) + #expect(viewModel.databaseTypeChooser == nil) let payload = DatabaseTypeChooserPayload(initialType: .mysql) { _ in } welcomeRouter.route(.chooseDatabaseType(payload)) - let delivered = await waitForChooser() - XCTAssertEqual(delivered?.id, payload.id) + _ = try? await waitUntil { self.viewModel.databaseTypeChooser != nil } + #expect(viewModel.databaseTypeChooser?.id == payload.id) } - func testImportFromURLRequestPresentsTheURLSheet() { + @Test func testImportFromURLRequestPresentsTheURLSheet() { welcomeRouter.route(.importFromURL) viewModel.setUp() - XCTAssertTrue(viewModel.urlImportPresented) + #expect(viewModel.urlImportPresented) } - func testImportFromAppRequestPresentsTheImportSheet() { + @Test func testImportFromAppRequestPresentsTheImportSheet() { welcomeRouter.route(.importFromApp) viewModel.setUp() guard case .importFromApp = viewModel.activeSheet else { - return XCTFail("Expected the Import from Other App sheet") + Issue.record("Expected the Import from Other App sheet") + return } } - func testExportConnectionsRequestIsIgnoredWhenThereAreNoConnections() { + @Test func testExportConnectionsRequestIsIgnoredWhenThereAreNoConnections() { welcomeRouter.route(.exportConnections) viewModel.setUp() - XCTAssertNil(viewModel.activeSheet) + #expect(viewModel.activeSheet == nil) } - func testRequestIsDrainedAheadOfABackgroundPluginInstall() { + @Test func testRequestIsDrainedAheadOfABackgroundPluginInstall() { let connection = DatabaseConnection(name: "Pending", type: .mysql) welcomeRouter.routePluginInstall(connection) welcomeRouter.route(.importFromURL) viewModel.setUp() - XCTAssertTrue(viewModel.urlImportPresented) - XCTAssertNil(viewModel.pluginInstallConnection) - XCTAssertEqual(welcomeRouter.pendingPluginInstall?.id, connection.id) + #expect(viewModel.urlImportPresented) + #expect(viewModel.pluginInstallConnection == nil) + #expect(welcomeRouter.pendingPluginInstall?.id == connection.id) } // MARK: - Delete - func testDeleteConfirmationIsNotPresentedBeforeTheFavoritesCheckFinishes() async throws { + @Test func testDeleteConfirmationIsNotPresentedBeforeTheFavoritesCheckFinishes() async throws { let connection = DatabaseConnection(name: "Prod", type: .mysql) save([connection]) viewModel.loadConnections() viewModel.requestDeleteConnections([connection.id]) - XCTAssertFalse( - viewModel.showDeleteConfirmation, - "The alert must wait for the favorites lookup, or it renders the wrong message" - ) - XCTAssertEqual(viewModel.connectionsToDelete.map(\.id), [connection.id]) + #expect(!viewModel.showDeleteConfirmation, "The alert must wait for the favorites lookup, or it renders the wrong message") + #expect(viewModel.connectionsToDelete.map(\.id) == [connection.id]) try await waitUntil { self.viewModel.showDeleteConfirmation } - XCTAssertFalse(viewModel.pendingDeleteHasFavorites) + #expect(!viewModel.pendingDeleteHasFavorites) } - func testDeleteRequestWithNoTargetsPresentsNothing() async throws { + @Test func testDeleteRequestWithNoTargetsPresentsNothing() async throws { viewModel.requestDeleteConnections([]) - XCTAssertFalse(viewModel.showDeleteConfirmation) - XCTAssertTrue(viewModel.connectionsToDelete.isEmpty) + #expect(!viewModel.showDeleteConfirmation) + #expect(viewModel.connectionsToDelete.isEmpty) } - func testASecondDeleteRequestSupersedesTheFirst() async throws { + @Test func testASecondDeleteRequestSupersedesTheFirst() async throws { let first = DatabaseConnection(name: "First", type: .mysql) let second = DatabaseConnection(name: "Second", type: .mysql) save([first, second]) @@ -794,18 +750,14 @@ final class WelcomeViewModelTests: XCTestCase { viewModel.requestDeleteConnections([second.id]) try await waitUntil { self.viewModel.showDeleteConfirmation } - XCTAssertEqual( - viewModel.connectionsToDelete.map(\.id), - [second.id], - "The superseded request must not present an alert for its own targets" - ) + #expect(viewModel.connectionsToDelete.map(\.id) == [second.id], "The superseded request must not present an alert for its own targets") } - func testDeleteKeepsTheConnectionAndReportsWhenPersistenceFails() throws { + @Test func testDeleteKeepsTheConnectionAndReportsWhenPersistenceFails() throws { let connection = DatabaseConnection(name: "Prod", type: .mysql) - XCTAssertTrue(connectionStorage.saveConnections([connection])) + #expect(connectionStorage.saveConnections([connection])) viewModel.loadConnections() - XCTAssertEqual(viewModel.connections.map(\.id), [connection.id]) + #expect(viewModel.connections.map(\.id) == [connection.id]) let directory = connectionFileURL.deletingLastPathComponent() try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: directory.path) @@ -817,34 +769,14 @@ final class WelcomeViewModelTests: XCTestCase { viewModel.connectionsToDelete = [connection] viewModel.deleteSelectedConnections() - XCTAssertEqual( - viewModel.connections.map(\.id), - [connection.id], - "A connection that could not be persisted as deleted must not disappear from the list" - ) - XCTAssertTrue(viewModel.connectionsToDelete.isEmpty) - XCTAssertNotNil(viewModel.libraryErrorMessage, "A refused delete must say so") - } - - private func waitUntil( - timeout: TimeInterval = 5, - _ condition: @MainActor () -> Bool, - file: StaticString = #filePath, - line: UInt = #line - ) async throws { - let deadline = Date().addingTimeInterval(timeout) - while !condition() { - if Date() >= deadline { - XCTFail("Condition never became true within \(timeout)s", file: file, line: line) - return - } - try await Task.sleep(nanoseconds: 5_000_000) - } + #expect(viewModel.connections.map(\.id) == [connection.id], "A connection that could not be persisted as deleted must not disappear from the list") + #expect(viewModel.connectionsToDelete.isEmpty) + #expect(viewModel.libraryErrorMessage != nil, "A refused delete must say so") } // MARK: - Shared Connections - func testSharedRowIdsAreDerivedFromThePayloadAndAreStable() { + @Test func testSharedRowIdsAreDerivedFromThePayloadAndAreStable() { let folderId = UUID() let payload = makeExportable(name: "Shared") @@ -856,23 +788,20 @@ final class WelcomeViewModelTests: XCTestCase { connection: makeExportable(name: "Different") ) - XCTAssertEqual(first, second, "A shared row must keep its identity across launches") - XCTAssertNotEqual(first, otherFolder) - XCTAssertNotEqual(first, otherPayload) + #expect(first == second, "A shared row must keep its identity across launches") + #expect(first != otherFolder) + #expect(first != otherPayload) } - func testSharedConnectionsThatDifferOnlyByDatabaseAreDifferentRows() { + @Test func testSharedConnectionsThatDifferOnlyByDatabaseAreDifferentRows() { let folderId = UUID() let production = makeExportable(name: "Analytics", database: "prod") let staging = makeExportable(name: "Analytics", database: "staging") - XCTAssertNotEqual( - LinkedFolderWatcher.stableId(folderId: folderId, connection: production), - LinkedFolderWatcher.stableId(folderId: folderId, connection: staging) - ) + #expect(LinkedFolderWatcher.stableId(folderId: folderId, connection: production) != LinkedFolderWatcher.stableId(folderId: folderId, connection: staging)) } - func testASharedConnectionOpensWithoutItsTunnelCommandOrStartupSQL() { + @Test func testASharedConnectionOpensWithoutItsTunnelCommandOrStartupSQL() { let base = makeExportable(name: "Shared") let hostile = ExportableConnection( name: base.name, @@ -909,14 +838,10 @@ final class WelcomeViewModelTests: XCTestCase { resolvesSSHProfile: { _ in false } ) - XCTAssertEqual(opened.tunnelCommandMode, .disabled, "A shared file must never start a process on connect") - XCTAssertNil(opened.startupCommands, "A shared file must never run SQL on connect") - XCTAssertEqual(opened.host, base.host) - XCTAssertEqual( - linked.id, - LinkedFolderWatcher.stableId(folderId: linked.folderId, connection: hostile), - "Dropping the command must not change the row's identity" - ) + #expect(opened.tunnelCommandMode == .disabled, "A shared file must never start a process on connect") + #expect(opened.startupCommands == nil, "A shared file must never run SQL on connect") + #expect(opened.host == base.host) + #expect(linked.id == LinkedFolderWatcher.stableId(folderId: linked.folderId, connection: hostile), "Dropping the command must not change the row's identity") } @MainActor diff --git a/TableProTests/ViewModels/WindowSidebarStateTests.swift b/TableProTests/ViewModels/WindowSidebarStateTests.swift index e6815e167e..834364ad0b 100644 --- a/TableProTests/ViewModels/WindowSidebarStateTests.swift +++ b/TableProTests/ViewModels/WindowSidebarStateTests.swift @@ -1,13 +1,8 @@ -// -// WindowSidebarStateTests.swift -// TableProTests -// -// Pins per-window scoping of table selection. Regression guard for #1313 where -// selectedTables was shared across windows of the same connection, causing -// Cmd+T to jump focus back to a sibling window. Sidebar filter text is -// connection-scoped and lives in SharedSidebarState; see SharedSidebarStateTests. -// Also pins per-connection persistence of database-tree expansion. -// +// Pins per-window scoping of table selection. Regression guard for #1313 where +// selectedTables was shared across windows of the same connection, causing +// Cmd+T to jump focus back to a sibling window. Sidebar filter text is +// connection-scoped and lives in SharedSidebarState; see SharedSidebarStateTests. +// Also pins per-connection persistence of database-tree expansion. import Foundation @testable import TablePro @@ -16,6 +11,12 @@ import Testing @MainActor struct WindowSidebarStateTests { + private let suite: EphemeralDefaults + + init() throws { + suite = try EphemeralDefaults() + } + @Test func twoInstancesHoldIndependentSelection() { let windowA = WindowSidebarState() @@ -64,7 +65,7 @@ struct WindowSidebarStateTests { } private func makeDefaults() throws -> UserDefaults { - try #require(UserDefaults(suiteName: "sidebar-tree-\(UUID().uuidString)")) + suite.defaults } @Test("Tree expansion persists and restores across instances for a connection") diff --git a/TableProTests/Views/Compare/CompareRowGridModelTests.swift b/TableProTests/Views/Compare/CompareRowGridModelTests.swift index 75a91c2230..806a0e552b 100644 --- a/TableProTests/Views/Compare/CompareRowGridModelTests.swift +++ b/TableProTests/Views/Compare/CompareRowGridModelTests.swift @@ -1,130 +1,119 @@ -// -// CompareRowGridModelTests.swift -// TableProTests -// - import AppKit import TableProPluginKit -import XCTest +import Testing @testable import TablePro @MainActor -final class CompareRowGridModelTests: XCTestCase { - private let suiteName = "CompareRowGridModelTests" +struct CompareRowGridModelTests { + private let suite: EphemeralDefaults private let columns = ["id", "email", "name"] - override func setUp() { - super.setUp() - UserDefaults(suiteName: suiteName)?.removePersistentDomain(forName: suiteName) - } - - override func tearDown() { - UserDefaults(suiteName: suiteName)?.removePersistentDomain(forName: suiteName) - super.tearDown() + init() throws { + suite = try EphemeralDefaults() } - func testInsertAndIdenticalGiveOneSourceLineThatOpensTheEntry() { + @Test func testInsertAndIdenticalGiveOneSourceLineThatOpensTheEntry() { for kind in [RowDiffKind.insert, .identical] { let diff = entry(kind, key: "k1") let lines = CompareRowGridModel.lines(for: diff) - XCTAssertEqual(lines.count, 1, "\(kind)") - XCTAssertEqual(lines.first?.side, .source, "\(kind)") - XCTAssertEqual(lines.first?.opensEntry, true, "\(kind)") - XCTAssertEqual(lines.first?.entry.id, diff.id, "\(kind)") + #expect(lines.count == 1, "\(kind)") + #expect(lines.first?.side == .source, "\(kind)") + #expect(lines.first?.opensEntry == true, "\(kind)") + #expect(lines.first?.entry.id == diff.id, "\(kind)") } } - func testDeleteGivesOneTargetLineThatOpensTheEntry() { + @Test func testDeleteGivesOneTargetLineThatOpensTheEntry() { let diff = entry(.delete, key: "k1") let lines = CompareRowGridModel.lines(for: diff) - XCTAssertEqual(lines.count, 1) - XCTAssertEqual(lines.first?.side, .target) - XCTAssertEqual(lines.first?.opensEntry, true) - XCTAssertEqual(lines.first?.entry.id, diff.id) + #expect(lines.count == 1) + #expect(lines.first?.side == .target) + #expect(lines.first?.opensEntry == true) + #expect(lines.first?.entry.id == diff.id) } - func testUpdateAndConflictGiveSourceThenTargetWithOnlyTheFirstOpeningTheEntry() { + @Test func testUpdateAndConflictGiveSourceThenTargetWithOnlyTheFirstOpeningTheEntry() { for kind in [RowDiffKind.update, .conflict] { let diff = entry(kind, key: "k1", differingIn: ["email"]) let lines = CompareRowGridModel.lines(for: diff) - XCTAssertEqual(lines.map(\.side), [ComparisonSide.source, .target], "\(kind)") - XCTAssertEqual(lines.map(\.opensEntry), [true, false], "\(kind)") - XCTAssertEqual(lines.map(\.entry.id), [diff.id, diff.id], "\(kind)") + #expect(lines.map(\.side) == [ComparisonSide.source, .target], "\(kind)") + #expect(lines.map(\.opensEntry) == [true, false], "\(kind)") + #expect(lines.map(\.entry.id) == [diff.id, diff.id], "\(kind)") } } - func testAnInsertLineIsMarkedInsertedInComparisonWords() throws { - let line = try XCTUnwrap(CompareRowGridModel.lines(for: entry(.insert, key: "k1")).first) + @Test func testAnInsertLineIsMarkedInsertedInComparisonWords() throws { + let line = try #require(CompareRowGridModel.lines(for: entry(.insert, key: "k1")).first) let state = CompareRowGridModel.visualState(for: line, columns: columns) - XCTAssertTrue(state.isInserted) - XCTAssertFalse(state.isDeleted) - XCTAssertTrue(state.modifiedColumns.isEmpty) - XCTAssertTrue(state.struckColumns.isEmpty) - XCTAssertEqual(state.vocabulary, .comparison) + #expect(state.isInserted) + #expect(!state.isDeleted) + #expect(state.modifiedColumns.isEmpty) + #expect(state.struckColumns.isEmpty) + #expect(state.vocabulary == .comparison) } - func testADeleteLineIsMarkedDeletedInComparisonWords() throws { - let line = try XCTUnwrap(CompareRowGridModel.lines(for: entry(.delete, key: "k1")).first) + @Test func testADeleteLineIsMarkedDeletedInComparisonWords() throws { + let line = try #require(CompareRowGridModel.lines(for: entry(.delete, key: "k1")).first) let state = CompareRowGridModel.visualState(for: line, columns: columns) - XCTAssertTrue(state.isDeleted) - XCTAssertFalse(state.isInserted) - XCTAssertTrue(state.modifiedColumns.isEmpty) - XCTAssertTrue(state.struckColumns.isEmpty) - XCTAssertEqual(state.vocabulary, .comparison) + #expect(state.isDeleted) + #expect(!state.isInserted) + #expect(state.modifiedColumns.isEmpty) + #expect(state.struckColumns.isEmpty) + #expect(state.vocabulary == .comparison) } /// The grid puts Include, Change and Side ahead of the plan's columns, so `email` at plan index 1 /// is grid column 4. - func testAnUpdateMarksTheSourceModifiedAndTheTargetStruckAtTheDifferingGridColumn() { + @Test func testAnUpdateMarksTheSourceModifiedAndTheTargetStruckAtTheDifferingGridColumn() { let lines = CompareRowGridModel.lines(for: entry(.update, key: "k1", differingIn: ["email"])) let expectedColumn = 1 + CompareRowGridModel.leadingColumnCount let source = CompareRowGridModel.visualState(for: lines[0], columns: columns) let target = CompareRowGridModel.visualState(for: lines[1], columns: columns) - XCTAssertEqual(source.modifiedColumns, [expectedColumn]) - XCTAssertTrue(source.struckColumns.isEmpty) - XCTAssertEqual(target.struckColumns, [expectedColumn]) - XCTAssertTrue(target.modifiedColumns.isEmpty) + #expect(source.modifiedColumns == [expectedColumn]) + #expect(source.struckColumns.isEmpty) + #expect(target.struckColumns == [expectedColumn]) + #expect(target.modifiedColumns.isEmpty) for state in [source, target] { - XCTAssertFalse(state.isInserted) - XCTAssertFalse(state.isDeleted) - XCTAssertEqual(state.vocabulary, .comparison) + #expect(!state.isInserted) + #expect(!state.isDeleted) + #expect(state.vocabulary == .comparison) } } - func testADifferingColumnIsFoundWhateverItsCase() { + @Test func testADifferingColumnIsFoundWhateverItsCase() { let lines = CompareRowGridModel.lines(for: entry(.update, key: "k1", differingIn: ["EMAIL", "Name"])) let source = CompareRowGridModel.visualState(for: lines[0], columns: columns) - XCTAssertEqual(source.modifiedColumns, [4, 5]) + #expect(source.modifiedColumns == [4, 5]) } - func testAnIdenticalLineCarriesNoMark() throws { - let line = try XCTUnwrap(CompareRowGridModel.lines(for: entry(.identical, key: "k1")).first) + @Test func testAnIdenticalLineCarriesNoMark() throws { + let line = try #require(CompareRowGridModel.lines(for: entry(.identical, key: "k1")).first) let state = CompareRowGridModel.visualState(for: line, columns: columns) - XCTAssertFalse(state.isInserted) - XCTAssertFalse(state.isDeleted) - XCTAssertTrue(state.modifiedColumns.isEmpty) - XCTAssertTrue(state.struckColumns.isEmpty) - XCTAssertEqual(state.vocabulary, .comparison) + #expect(!state.isInserted) + #expect(!state.isDeleted) + #expect(state.modifiedColumns.isEmpty) + #expect(state.struckColumns.isEmpty) + #expect(state.vocabulary == .comparison) } - func testEnsureLoadedLeadsWithIncludeChangeAndSideThenThePlanColumns() throws { + @Test func testEnsureLoadedLeadsWithIncludeChangeAndSideThenThePlanColumns() throws { let session = try makeSession() let model = CompareRowGridModel() let entries = [ @@ -135,13 +124,13 @@ final class CompareRowGridModelTests: XCTestCase { let rows = model.ensureLoaded(key: key(entryCount: 3), plan: plan(), entries: entries, session: session) - XCTAssertEqual(rows.columns, ["Include", "Change", "Side", "id", "email", "name"]) - XCTAssertEqual(rows.columnTypes.count, 6) - XCTAssertEqual(rows.count, 4) - XCTAssertEqual(rows.count, model.lines.count) + #expect(rows.columns == ["Include", "Change", "Side", "id", "email", "name"]) + #expect(rows.columnTypes.count == 6) + #expect(rows.count == 4) + #expect(rows.count == model.lines.count) } - func testEnsureLoadedFillsEachLineFromItsOwnSide() throws { + @Test func testEnsureLoadedFillsEachLineFromItsOwnSide() throws { let session = try makeSession() let model = CompareRowGridModel() let entries = [ @@ -152,21 +141,21 @@ final class CompareRowGridModelTests: XCTestCase { let rows = model.ensureLoaded(key: key(entryCount: 3), plan: plan(), entries: entries, session: session) - XCTAssertEqual(rows.rows.map(\.id), [.existing(0), .existing(1), .existing(2), .existing(3)]) - XCTAssertEqual(rows.rows[0][0], .null) - XCTAssertEqual(rows.rows[0][1], .text("Insert")) - XCTAssertEqual(rows.rows[0][2], .text("Source")) - XCTAssertEqual(rows.rows[0][4], .text("k1@source")) - XCTAssertEqual(rows.rows[1][1], .text("Update")) - XCTAssertEqual(rows.rows[1][4], .text("k2@source")) - XCTAssertEqual(rows.rows[2][2], .text("Target")) - XCTAssertEqual(rows.rows[2][4], .text("k2@target")) - XCTAssertEqual(rows.rows[3][1], .text("Delete")) - XCTAssertEqual(rows.rows[3][2], .text("Target")) - XCTAssertEqual(rows.rows[3][4], .text("k3@target")) - } - - func testEnsureLoadedDoesNotRebuildForTheSameKey() throws { + #expect(rows.rows.map(\.id) == [.existing(0), .existing(1), .existing(2), .existing(3)]) + #expect(rows.rows[0][0] == .null) + #expect(rows.rows[0][1] == .text("Insert")) + #expect(rows.rows[0][2] == .text("Source")) + #expect(rows.rows[0][4] == .text("k1@source")) + #expect(rows.rows[1][1] == .text("Update")) + #expect(rows.rows[1][4] == .text("k2@source")) + #expect(rows.rows[2][2] == .text("Target")) + #expect(rows.rows[2][4] == .text("k2@target")) + #expect(rows.rows[3][1] == .text("Delete")) + #expect(rows.rows[3][2] == .text("Target")) + #expect(rows.rows[3][4] == .text("k3@target")) + } + + @Test func testEnsureLoadedDoesNotRebuildForTheSameKey() throws { let session = try makeSession() let model = CompareRowGridModel() let sameKey = key(entryCount: 1) @@ -179,11 +168,11 @@ final class CompareRowGridModelTests: XCTestCase { session: session ) - XCTAssertEqual(again.count, 1) - XCTAssertEqual(model.lines.count, 1) + #expect(again.count == 1) + #expect(model.lines.count == 1) } - func testEnsureLoadedRebuildsForADifferentKey() throws { + @Test func testEnsureLoadedRebuildsForADifferentKey() throws { let session = try makeSession() let model = CompareRowGridModel() _ = model.ensureLoaded( @@ -197,55 +186,55 @@ final class CompareRowGridModelTests: XCTestCase { session: session ) - XCTAssertEqual(rebuilt.count, 3) - XCTAssertEqual(model.lines.count, 3) + #expect(rebuilt.count == 3) + #expect(model.lines.count == 3) } - func testTheDelegateHandsBackEachLinesVisualState() throws { + @Test func testTheDelegateHandsBackEachLinesVisualState() throws { let session = try makeSession() let model = CompareRowGridModel() let entries = [entry(.insert, key: "k1"), entry(.update, key: "k2", differingIn: ["email"])] _ = model.ensureLoaded(key: key(entryCount: 2), plan: plan(), entries: entries, session: session) - XCTAssertEqual(model.dataGridVisualState(forRow: 0)?.isInserted, true) - XCTAssertEqual(model.dataGridVisualState(forRow: 1)?.modifiedColumns, Set([4])) - XCTAssertEqual(model.dataGridVisualState(forRow: 2)?.struckColumns, Set([4])) - XCTAssertNil(model.dataGridVisualState(forRow: 3)) - XCTAssertNil(model.dataGridVisualState(forRow: -1)) + #expect(model.dataGridVisualState(forRow: 0)?.isInserted == true) + #expect(model.dataGridVisualState(forRow: 1)?.modifiedColumns == Set([4])) + #expect(model.dataGridVisualState(forRow: 2)?.struckColumns == Set([4])) + #expect(model.dataGridVisualState(forRow: 3) == nil) + #expect(model.dataGridVisualState(forRow: -1) == nil) } - func testTheIncludeCheckboxShowsOnlyOnTheOpeningLineOfADifference() throws { + @Test func testTheIncludeCheckboxShowsOnlyOnTheOpeningLineOfADifference() throws { let session = try makeSession() let model = loadedModel(session: session) - XCTAssertEqual(includeState(model, row: 0), true) - XCTAssertEqual(includeState(model, row: 1), true) - XCTAssertNil(includeState(model, row: 2), "the target line of an update") - XCTAssertNil(includeState(model, row: 3), "an identical row") - XCTAssertNil(includeState(model, row: 4), "a conflict row") - XCTAssertNil(includeState(model, row: 5), "the target line of a conflict") + #expect(includeState(model, row: 0) == true) + #expect(includeState(model, row: 1) == true) + #expect(includeState(model, row: 2) == nil, "the target line of an update") + #expect(includeState(model, row: 3) == nil, "an identical row") + #expect(includeState(model, row: 4) == nil, "a conflict row") + #expect(includeState(model, row: 5) == nil, "the target line of a conflict") } - func testTheIncludeCheckboxLivesInTheIncludeColumnAlone() throws { + @Test func testTheIncludeCheckboxLivesInTheIncludeColumnAlone() throws { let session = try makeSession() let model = loadedModel(session: session) - XCTAssertNil(model.dataGridCheckboxState(row: 0, column: 1)) - XCTAssertNil(model.dataGridCheckboxState(row: 0, column: 4)) - XCTAssertNil(includeState(model, row: 99)) + #expect(model.dataGridCheckboxState(row: 0, column: 1) == nil) + #expect(model.dataGridCheckboxState(row: 0, column: 4) == nil) + #expect(includeState(model, row: 99) == nil) } - func testTheIncludeCheckboxReflectsARowExcludedInTheSession() throws { + @Test func testTheIncludeCheckboxReflectsARowExcludedInTheSession() throws { let session = try makeSession() let model = loadedModel(session: session) session.setRowsIncluded(false, entries: [model.lines[0].entry], planId: "orders") - XCTAssertEqual(includeState(model, row: 0), false) - XCTAssertEqual(includeState(model, row: 1), true) + #expect(includeState(model, row: 0) == false) + #expect(includeState(model, row: 1) == true) } - func testTheIncludeCheckboxHasNoStateOnceTheSessionNoLongerHoldsThePlan() throws { + @Test func testTheIncludeCheckboxHasNoStateOnceTheSessionNoLongerHoldsThePlan() throws { let session = try makeSession() let model = loadedModel(session: session) @@ -259,130 +248,127 @@ final class CompareRowGridModelTests: XCTestCase { ) ]) - XCTAssertNil(includeState(model, row: 0)) + #expect(includeState(model, row: 0) == nil) } - func testClearingTheCheckboxExcludesTheEntriesAndTickingItIncludesThemAgain() throws { + @Test func testClearingTheCheckboxExcludesTheEntriesAndTickingItIncludesThemAgain() throws { let session = try makeSession() let model = loadedModel(session: session) model.dataGridSetCheckbox(false, rows: IndexSet([0, 1]), column: CompareRowGridModel.includeColumn) - XCTAssertEqual(session.dataPlans.first?.excludedRowKeys, Set(["k1", "k2"])) - XCTAssertEqual(includeState(model, row: 0), false) - XCTAssertEqual(includeState(model, row: 1), false) + #expect(session.dataPlans.first?.excludedRowKeys == Set(["k1", "k2"])) + #expect(includeState(model, row: 0) == false) + #expect(includeState(model, row: 1) == false) model.dataGridSetCheckbox(true, rows: IndexSet(integer: 0), column: CompareRowGridModel.includeColumn) - XCTAssertEqual(session.dataPlans.first?.excludedRowKeys, Set(["k2"])) - XCTAssertEqual(includeState(model, row: 0), true) + #expect(session.dataPlans.first?.excludedRowKeys == Set(["k2"])) + #expect(includeState(model, row: 0) == true) } - func testSettingACheckboxOutsideTheIncludeColumnChangesNothing() throws { + @Test func testSettingACheckboxOutsideTheIncludeColumnChangesNothing() throws { let session = try makeSession() let model = loadedModel(session: session) model.dataGridSetCheckbox(false, rows: IndexSet([0, 1]), column: 1) - XCTAssertEqual(session.dataPlans.first?.excludedRowKeys, Set()) + #expect(session.dataPlans.first?.excludedRowKeys == Set()) } - func testSettingTheCheckboxOnRowsThatAreNotDifferencesExcludesNothing() throws { + @Test func testSettingTheCheckboxOnRowsThatAreNotDifferencesExcludesNothing() throws { let session = try makeSession() let model = loadedModel(session: session) model.dataGridSetCheckbox(false, rows: IndexSet([3, 4, 5]), column: CompareRowGridModel.includeColumn) - XCTAssertEqual(session.dataPlans.first?.excludedRowKeys, Set()) + #expect(session.dataPlans.first?.excludedRowKeys == Set()) } - func testSameListsTheRetainedIdenticalRows() { + @Test func testSameListsTheRetainedIdenticalRows() { let fixture = summaryFixture() let listed = RowDiffFilter.same.entries(in: fixture.summary) - XCTAssertEqual(listed.map(\.id), [fixture.identical.id]) + #expect(listed.map(\.id) == [fixture.identical.id]) } - func testAllListsTheDifferencesFollowedByTheIdenticalRows() { + @Test func testAllListsTheDifferencesFollowedByTheIdenticalRows() { let fixture = summaryFixture() let listed = RowDiffFilter.all.entries(in: fixture.summary) - XCTAssertEqual( - listed.map(\.id), - [fixture.insert.id, fixture.update.id, fixture.delete.id, fixture.conflict.id, fixture.identical.id] - ) + #expect(listed.map(\.id) == [fixture.insert.id, fixture.update.id, fixture.delete.id, fixture.conflict.id, fixture.identical.id]) } - func testOutsideFilterListsOnlyTheConflicts() { + @Test func testOutsideFilterListsOnlyTheConflicts() { let fixture = summaryFixture() let listed = RowDiffFilter.conflict.entries(in: fixture.summary) - XCTAssertEqual(listed.map(\.id), [fixture.conflict.id]) + #expect(listed.map(\.id) == [fixture.conflict.id]) } - func testDifferenceListsInsertsUpdatesAndDeletesButNoConflict() { + @Test func testDifferenceListsInsertsUpdatesAndDeletesButNoConflict() { let fixture = summaryFixture() let listed = RowDiffFilter.difference.entries(in: fixture.summary) - XCTAssertEqual(listed.map(\.id), [fixture.insert.id, fixture.update.id, fixture.delete.id]) + #expect(listed.map(\.id) == [fixture.insert.id, fixture.update.id, fixture.delete.id]) } - func testAStruckColumnIsStruckThroughAndAModifiedColumnIsUnderlined() { + @Test func testAStruckColumnIsStruckThroughAndAModifiedColumnIsUnderlined() { let struck = comparisonState(struckColumns: [4]) let modified = comparisonState(modifiedColumns: [4]) - XCTAssertEqual(DataGridCellTextMark.resolve(state: struck, columnIndex: 4), .struckThrough) - XCTAssertNil(DataGridCellTextMark.resolve(state: struck, columnIndex: 5)) - XCTAssertEqual(DataGridCellTextMark.resolve(state: modified, columnIndex: 4), .underlined) - XCTAssertNil(DataGridCellTextMark.resolve(state: modified, columnIndex: 5)) + #expect(DataGridCellTextMark.resolve(state: struck, columnIndex: 4) == .struckThrough) + #expect(DataGridCellTextMark.resolve(state: struck, columnIndex: 5) == nil) + #expect(DataGridCellTextMark.resolve(state: modified, columnIndex: 4) == .underlined) + #expect(DataGridCellTextMark.resolve(state: modified, columnIndex: 5) == nil) } - func testAWholeRowOnlyOnOneSideMarksEveryColumn() { + @Test func testAWholeRowOnlyOnOneSideMarksEveryColumn() { let inserted = comparisonState(isInserted: true) let deleted = comparisonState(isDeleted: true) - XCTAssertEqual(DataGridCellTextMark.resolve(state: inserted, columnIndex: 5), .underlined) - XCTAssertEqual(DataGridCellTextMark.resolve(state: deleted, columnIndex: 5), .struckThrough) + #expect(DataGridCellTextMark.resolve(state: inserted, columnIndex: 5) == .underlined) + #expect(DataGridCellTextMark.resolve(state: deleted, columnIndex: 5) == .struckThrough) } - func testAComparisonReadsInComparisonWords() { + @Test func testAComparisonReadsInComparisonWords() { let source = comparisonState(modifiedColumns: [4]) let target = comparisonState(struckColumns: [4]) - XCTAssertEqual(spoken(comparisonState(isInserted: true), at: 3), "only in the source") - XCTAssertEqual(spoken(comparisonState(isDeleted: true), at: 3), "only in the target") - XCTAssertEqual(spoken(source, at: 4), "source value, differs") - XCTAssertEqual(spoken(target, at: 4), "target value, differs") - XCTAssertNil(spoken(source, at: 5)) - XCTAssertNil(spoken(target, at: 5)) + #expect(spoken(comparisonState(isInserted: true), at: 3) == "only in the source") + #expect(spoken(comparisonState(isDeleted: true), at: 3) == "only in the target") + #expect(spoken(source, at: 4) == "source value, differs") + #expect(spoken(target, at: 4) == "target value, differs") + #expect(spoken(source, at: 5) == nil) + #expect(spoken(target, at: 5) == nil) } - func testAPendingEditReadsInEditingWords() { + @Test func testAPendingEditReadsInEditingWords() { let inserted = RowVisualState(isDeleted: false, isInserted: true, modifiedColumns: []) let deleted = RowVisualState(isDeleted: true, isInserted: false, modifiedColumns: []) let edited = RowVisualState(isDeleted: false, isInserted: false, modifiedColumns: [4]) - XCTAssertEqual(spoken(inserted, at: 3), "new row") - XCTAssertEqual(spoken(deleted, at: 3), "marked for deletion") - XCTAssertEqual(spoken(edited, at: 4), "edited") - XCTAssertNil(spoken(edited, at: 5)) + #expect(spoken(inserted, at: 3) == "new row") + #expect(spoken(deleted, at: 3) == "marked for deletion") + #expect(spoken(edited, at: 4) == "edited") + #expect(spoken(edited, at: 5) == nil) } - func testTheGridLinesOfAnUpdateReadAsSourceAndTargetValuesAtTheDifferingColumn() { + @Test func testTheGridLinesOfAnUpdateReadAsSourceAndTargetValuesAtTheDifferingColumn() { let lines = CompareRowGridModel.lines(for: entry(.update, key: "k1", differingIn: ["email"])) let source = CompareRowGridModel.visualState(for: lines[0], columns: columns) let target = CompareRowGridModel.visualState(for: lines[1], columns: columns) - XCTAssertEqual(spoken(source, at: 4), "source value, differs") - XCTAssertEqual(spoken(target, at: 4), "target value, differs") - XCTAssertNil(spoken(source, at: 3)) + #expect(spoken(source, at: 4) == "source value, differs") + #expect(spoken(target, at: 4) == "target value, differs") + #expect(spoken(source, at: 3) == nil) } - func testACheckboxColumnPresentsAsACheckboxWithNoChevron() { + @Test func testACheckboxColumnPresentsAsACheckboxWithNoChevron() { let presentation = DataGridColumnPresentation.resolve( columnType: .boolean(rawType: "BOOLEAN"), isForeignKey: true, @@ -393,46 +379,45 @@ final class CompareRowGridModelTests: XCTestCase { isCheckbox: true ) - XCTAssertEqual(presentation.kind, .checkbox) - XCTAssertEqual(presentation.accessory, .none) + #expect(presentation.kind == .checkbox) + #expect(presentation.accessory == .none) } - func testTheCheckboxMarkIsCentredInTheCell() { + @Test func testTheCheckboxMarkIsCentredInTheCell() { let cell = NSRect(x: 100, y: 0, width: 60, height: 24) let frame = DataGridCheckboxMark.frame(in: cell) - XCTAssertEqual(frame, NSRect(x: 122, y: 4, width: 16, height: 16)) - XCTAssertEqual(frame.midX, cell.midX) - XCTAssertEqual(frame.midY, cell.midY) + #expect(frame == NSRect(x: 122, y: 4, width: 16, height: 16)) + #expect(frame.midX == cell.midX) + #expect(frame.midY == cell.midY) } - func testTheCheckboxMarkIsEmptyInACellTooSmallForIt() { - XCTAssertTrue(DataGridCheckboxMark.frame(in: NSRect(x: 0, y: 0, width: 12, height: 24)).isEmpty) - XCTAssertTrue(DataGridCheckboxMark.frame(in: NSRect(x: 0, y: 0, width: 60, height: 10)).isEmpty) + @Test func testTheCheckboxMarkIsEmptyInACellTooSmallForIt() { + #expect(DataGridCheckboxMark.frame(in: NSRect(x: 0, y: 0, width: 12, height: 24)).isEmpty) + #expect(DataGridCheckboxMark.frame(in: NSRect(x: 0, y: 0, width: 60, height: 10)).isEmpty) } - func testACheckboxCellCarriesItsMarkAndNoText() { + @Test func testACheckboxCellCarriesItsMarkAndNoText() { let checked = appearance(kind: .checkbox, text: "", placeholder: .null, checkboxMark: .checked) let unchecked = appearance(kind: .checkbox, text: "", placeholder: .null, checkboxMark: .unchecked) - XCTAssertEqual(checked.checkboxMark, .checked) - XCTAssertEqual(checked.text, "") - XCTAssertEqual(unchecked.checkboxMark, .unchecked) - XCTAssertEqual(unchecked.text, "") + #expect(checked.checkboxMark == .checked) + #expect(checked.text == "") + #expect(unchecked.checkboxMark == .unchecked) + #expect(unchecked.text == "") } - func testATextCellCarriesNoCheckboxMarkEvenWhenHandedOne() { + @Test func testATextCellCarriesNoCheckboxMarkEvenWhenHandedOne() { let text = appearance(kind: .text, text: "value", placeholder: nil, checkboxMark: .checked) - XCTAssertNil(text.checkboxMark) - XCTAssertEqual(text.text, "value") + #expect(text.checkboxMark == nil) + #expect(text.text == "value") } private func makeSession() throws -> CompareSyncSession { - let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) let session = CompareSyncSession( - profileStorage: CompareSyncProfileStorage(defaults: defaults), + profileStorage: CompareSyncProfileStorage(defaults: suite.defaults), connectionsProvider: { [] } ) session.adoptDataPlans([plan()]) @@ -469,7 +454,7 @@ final class CompareRowGridModelTests: XCTestCase { entry(.conflict, key: "k4") ] let rows = model.ensureLoaded(key: key(entryCount: 4), plan: plan(), entries: entries, session: session) - XCTAssertEqual(rows.count, 6) + #expect(rows.count == 6) return model } diff --git a/TableProTests/Views/Components/CollapsingSplitViewControllerTests.swift b/TableProTests/Views/Components/CollapsingSplitViewControllerTests.swift index 8b15c7242c..c0f4aa48f3 100644 --- a/TableProTests/Views/Components/CollapsingSplitViewControllerTests.swift +++ b/TableProTests/Views/Components/CollapsingSplitViewControllerTests.swift @@ -1,13 +1,9 @@ -// -// CollapsingSplitViewControllerTests.swift -// TableProTests -// -// The Users & Roles principal list collapses while the tab is too narrow for it and for nothing -// else. A list collapsed any other way has no divider left to drag it back by. -// +// The Users & Roles principal list collapses while the tab is too narrow for it and for nothing +// else. A list collapsed any other way has no divider left to drag it back by. import AppKit import SwiftUI +import TableProTestSupport import Testing @testable import TablePro @@ -25,9 +21,12 @@ struct CollapsingSplitViewControllerTests { let primary = try #require(controller.splitViewItems.first) #expect(await settle(window) { primary.viewController.view.frame.width > 0 }) + // A drag collapses inside setPosition, and the controller's next layout reopens a list that fits. controller.splitView.setPosition(0, ofDividerAt: 0) + #expect(!primary.isCollapsed) - #expect(!(await settle(window) { primary.isCollapsed })) + window.contentView?.layoutSubtreeIfNeeded() + #expect(!primary.isCollapsed) } @Test("A list restored collapsed opens again once there is room") @@ -69,12 +68,11 @@ struct CollapsingSplitViewControllerTests { /// SwiftUI lays the representable out on its own pass, so the split controller's /// `viewDidLayout` runs after the window's synchronous layout returns. private func settle(_ window: NSWindow, until condition: () -> Bool) async -> Bool { - for _ in 0 ..< 50 { + let met: Void? = try? await waitUntil { window.contentView?.layoutSubtreeIfNeeded() - if condition() { return true } - try? await Task.sleep(for: .milliseconds(20)) + return condition() } - return condition() + return met != nil } private func splitController(in view: NSView?) -> CollapsingSplitViewController? { diff --git a/TableProTests/Views/ConnectionForm/ClearedSecretSaveTests.swift b/TableProTests/Views/ConnectionForm/ClearedSecretSaveTests.swift index 13e1a55b7b..568edea8f4 100644 --- a/TableProTests/Views/ConnectionForm/ClearedSecretSaveTests.swift +++ b/TableProTests/Views/ConnectionForm/ClearedSecretSaveTests.swift @@ -1,8 +1,3 @@ -// -// ClearedSecretSaveTests.swift -// TableProTests -// - import Foundation import Testing @@ -13,6 +8,12 @@ import Testing /// it, and a duplicate copies it. @MainActor struct ClearedSecretSaveTests { + private let suite: EphemeralDefaults + + init() throws { + suite = try EphemeralDefaults() + } + private final class ScriptedKeychain: KeychainStoring, @unchecked Sendable { private let lock = NSLock() private var result: KeychainStringResult @@ -45,7 +46,7 @@ struct ClearedSecretSaveTests { ) return ConnectionStorage( fileURL: fileURL, - userDefaults: UserDefaults(suiteName: "com.TablePro.tests.ClearedSecret.\(unique)")!, + userDefaults: suite.defaults, keychain: ScriptedKeychain(result) ) } diff --git a/TableProTests/Views/DatabaseSwitcher/CreateDatabaseFormRulesTests.swift b/TableProTests/Views/DatabaseSwitcher/CreateDatabaseFormRulesTests.swift index 46d1401af1..fdc0c31d09 100644 --- a/TableProTests/Views/DatabaseSwitcher/CreateDatabaseFormRulesTests.swift +++ b/TableProTests/Views/DatabaseSwitcher/CreateDatabaseFormRulesTests.swift @@ -1,16 +1,11 @@ -// -// CreateDatabaseFormRulesTests.swift -// TableProTests -// -// The create-database form's own rules, now that two callers share them: the -// New Database sheet and Duplicate Database. -// +// The create-database form's own rules, now that two callers share them: the +// New Database sheet and Duplicate Database. @testable import TablePro import TableProPluginKit -import XCTest +import Testing -final class CreateDatabaseFormRulesTests: XCTestCase { +struct CreateDatabaseFormRulesTests { private func option( _ value: String, subtitle: String? = nil, @@ -58,41 +53,41 @@ final class CreateDatabaseFormRulesTests: XCTestCase { ]) } - func testTheDriversPreferredAnswerIsChosenFirst() { + @Test func testTheDriversPreferredAnswerIsChosenFirst() { let values = CreateDatabaseFormRules.initialValues(for: charsetSpec()) - XCTAssertEqual(values["charset"], "utf8mb4") - XCTAssertEqual(values["collation"], "utf8mb4_general_ci") + #expect(values["charset"] == "utf8mb4") + #expect(values["collation"] == "utf8mb4_general_ci") } - func testAFieldWithNoPreferredAnswerTakesItsFirstOption() { + @Test func testAFieldWithNoPreferredAnswerTakesItsFirstOption() { let subject = spec(fields: [field("owner", options: [option("postgres")], defaultValue: nil)]) - XCTAssertEqual(CreateDatabaseFormRules.initialValues(for: subject)["owner"], "postgres") + #expect(CreateDatabaseFormRules.initialValues(for: subject)["owner"] == "postgres") } - func testACollationIsOnlyOfferedForTheChosenCharacterSet() { + @Test func testACollationIsOnlyOfferedForTheChosenCharacterSet() { let offered = CreateDatabaseFormRules.filteredOptions( for: charsetSpec().fields[1], values: ["charset": "latin1"] ) - XCTAssertEqual(offered.map(\.value), ["latin1_swedish_ci"]) + #expect(offered.map(\.value) == ["latin1_swedish_ci"]) } /// Leaving a collation from the character set the user just moved away from would submit a /// pair the server refuses. - func testChangingTheCharacterSetResetsTheCollation() { + @Test func testChangingTheCharacterSetResetsTheCollation() { let updated = CreateDatabaseFormRules.resettingGroupedFields( after: "charset", in: charsetSpec(), values: ["charset": "latin1", "collation": "utf8mb4_general_ci"] ) - XCTAssertEqual(updated["collation"], "latin1_swedish_ci") + #expect(updated["collation"] == "latin1_swedish_ci") } - func testGroupSourcesAreTheFieldsOthersAreGroupedBy() { - XCTAssertEqual(CreateDatabaseFormRules.groupSourceFieldIds(in: charsetSpec()), ["charset"]) + @Test func testGroupSourcesAreTheFieldsOthersAreGroupedBy() { + #expect(CreateDatabaseFormRules.groupSourceFieldIds(in: charsetSpec()) == ["charset"]) } // MARK: - Visibility @@ -109,34 +104,34 @@ final class CreateDatabaseFormRulesTests: XCTestCase { ]) } - func testAHiddenFieldIsNotOffered() { + @Test func testAHiddenFieldIsNotOffered() { let visible = CreateDatabaseFormRules.visibleFields( in: conditionalSpec(), values: ["kind": "standard"] ) - XCTAssertEqual(visible.map(\.id), ["kind"]) + #expect(visible.map(\.id) == ["kind"]) } /// A hidden field holds a stale answer rather than a chosen one. - func testAHiddenFieldIsNotSubmitted() { + @Test func testAHiddenFieldIsNotSubmitted() { let submitted = CreateDatabaseFormRules.submissionValues( from: ["kind": "standard", "source": "app"], spec: conditionalSpec() ) - XCTAssertEqual(submitted, ["kind": "standard"]) + #expect(submitted == ["kind": "standard"]) } - func testAVisibleFieldIsSubmitted() { + @Test func testAVisibleFieldIsSubmitted() { let submitted = CreateDatabaseFormRules.submissionValues( from: ["kind": "clone", "source": "app"], spec: conditionalSpec() ) - XCTAssertEqual(submitted, ["kind": "clone", "source": "app"]) + #expect(submitted == ["kind": "clone", "source": "app"]) } // MARK: - Required text - func testAnEmptyRequiredInputBlocksSubmission() { + @Test func testAnEmptyRequiredInputBlocksSubmission() { let subject = spec( fields: [], textInputs: [CreateDatabaseFormSpec.TextInput( @@ -144,11 +139,11 @@ final class CreateDatabaseFormRulesTests: XCTestCase { )] ) - XCTAssertTrue(CreateDatabaseFormRules.missingRequiredInput(in: subject, values: ["owner": " "])) - XCTAssertFalse(CreateDatabaseFormRules.missingRequiredInput(in: subject, values: ["owner": "admin"])) + #expect(CreateDatabaseFormRules.missingRequiredInput(in: subject, values: ["owner": " "])) + #expect(!CreateDatabaseFormRules.missingRequiredInput(in: subject, values: ["owner": "admin"])) } - func testAnOptionalInputDoesNotBlockSubmission() { + @Test func testAnOptionalInputDoesNotBlockSubmission() { let subject = spec( fields: [], textInputs: [CreateDatabaseFormSpec.TextInput( @@ -156,17 +151,14 @@ final class CreateDatabaseFormRulesTests: XCTestCase { )] ) - XCTAssertFalse(CreateDatabaseFormRules.missingRequiredInput(in: subject, values: [:])) + #expect(!CreateDatabaseFormRules.missingRequiredInput(in: subject, values: [:])) } - func testASubtitleIsAppendedToTheOptionLabel() { - XCTAssertEqual( - CreateDatabaseFormRules.displayLabel(for: option("utf8mb4", subtitle: "(default)")), - "utf8mb4 (default)" - ) + @Test func testASubtitleIsAppendedToTheOptionLabel() { + #expect(CreateDatabaseFormRules.displayLabel(for: option("utf8mb4", subtitle: "(default)")) == "utf8mb4 (default)") } - func testAnOptionWithNoSubtitleKeepsItsLabel() { - XCTAssertEqual(CreateDatabaseFormRules.displayLabel(for: option("utf8mb4")), "utf8mb4") + @Test func testAnOptionWithNoSubtitleKeepsItsLabel() { + #expect(CreateDatabaseFormRules.displayLabel(for: option("utf8mb4")) == "utf8mb4") } } diff --git a/TableProTests/Views/DetailSurfaceThemeTests.swift b/TableProTests/Views/DetailSurfaceThemeTests.swift index c6c35e1356..0fcd31dce2 100644 --- a/TableProTests/Views/DetailSurfaceThemeTests.swift +++ b/TableProTests/Views/DetailSurfaceThemeTests.swift @@ -12,7 +12,6 @@ import SwiftUI @testable import TablePro import Testing -@Suite("Detail surfaces keep their system color until the theme sets their slot", .serialized) @MainActor struct DetailSurfaceThemeTests { private static let size = CGSize(width: 320, height: 240) diff --git a/TableProTests/Views/ERDiagram/ERDiagramInitialFitTests.swift b/TableProTests/Views/ERDiagram/ERDiagramInitialFitTests.swift index 23f1483909..c42df0621a 100644 --- a/TableProTests/Views/ERDiagram/ERDiagramInitialFitTests.swift +++ b/TableProTests/Views/ERDiagram/ERDiagramInitialFitTests.swift @@ -1,8 +1,3 @@ -// -// ERDiagramInitialFitTests.swift -// TableProTests -// - import AppKit import SwiftUI @testable import TablePro @@ -14,7 +9,7 @@ private final class HostedDiagram { private let window: NSWindow private let host: NSHostingView - init(viewModel: ERDiagramViewModel) { + init(viewModel: ERDiagramViewModel) async { self.viewModel = viewModel let frame = CGRect(x: 0, y: 0, width: 900, height: 700) window = NSWindow(contentRect: frame, styleMask: [.borderless], backing: .buffered, defer: false) @@ -22,31 +17,35 @@ private final class HostedDiagram { host = NSHostingView(rootView: AnyView(ERDiagramView(viewModel: viewModel))) host.sizingOptions = [] window.contentView = host - settle() + await settle() } var scrollView: DiagramScrollView? { host.firstDescendant(of: DiagramScrollView.self) } - func leaveTheTab() { + func leaveTheTab() async { host.rootView = AnyView(Color.clear) - settle() + await settle() } - func returnToTheTab() { + func returnToTheTab() async { host.rootView = AnyView(ERDiagramView(viewModel: viewModel)) - settle() + await settle() } func close() { window.orderOut(nil) } - private func settle() { + /// Each pass lays out, then waits for one main-queue turn: SwiftUI mounts the canvas on a later + /// run-loop turn, and the fit it triggers runs as main-actor work queued before the turn returns. + private func settle() async { for _ in 0 ..< 20 { host.layoutSubtreeIfNeeded() - RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.01)) + await withCheckedContinuation { continuation in + DispatchQueue.main.async { continuation.resume() } + } } } } @@ -59,7 +58,7 @@ struct ERDiagramInitialFitTests { defer { fixture.tearDown() } await fixture.viewModel.loadDiagram() - let diagram = HostedDiagram(viewModel: fixture.viewModel) + let diagram = await HostedDiagram(viewModel: fixture.viewModel) defer { diagram.close() } let scrollView = try #require(diagram.scrollView) @@ -75,15 +74,15 @@ struct ERDiagramInitialFitTests { defer { fixture.tearDown() } await fixture.viewModel.loadDiagram() - let diagram = HostedDiagram(viewModel: fixture.viewModel) + let diagram = await HostedDiagram(viewModel: fixture.viewModel) defer { diagram.close() } diagram.viewModel.viewport.resetZoom() diagram.viewModel.viewport.zoomOut() #expect(diagram.viewModel.viewport.magnification == 0.75) - diagram.leaveTheTab() + await diagram.leaveTheTab() #expect(diagram.scrollView == nil) - diagram.returnToTheTab() + await diagram.returnToTheTab() let scrollView = try #require(diagram.scrollView) #expect(scrollView.magnification == 0.75) diff --git a/TableProTests/Views/ERDiagram/ERDiagramPointerInputTests.swift b/TableProTests/Views/ERDiagram/ERDiagramPointerInputTests.swift index 4f99786364..6ba5e5528f 100644 --- a/TableProTests/Views/ERDiagram/ERDiagramPointerInputTests.swift +++ b/TableProTests/Views/ERDiagram/ERDiagramPointerInputTests.swift @@ -1,11 +1,6 @@ -// -// ERDiagramPointerInputTests.swift -// TableProTests -// -// Drives the ER diagram's document view with mouse events at the zoom levels the app reaches. The -// SwiftUI gestures it replaced resolved every point at the pointer's position times the zoom, so -// these pin the one thing that went wrong: the point handed on is the one drawn under the pointer. -// +// Drives the ER diagram's document view with mouse events at the zoom levels the app reaches. The +// SwiftUI gestures it replaced resolved every point at the pointer's position times the zoom, so +// these pin the one thing that went wrong: the point handed on is the one drawn under the pointer. import AppKit @testable import TablePro @@ -232,9 +227,9 @@ struct ERDiagramPointerInputTests { /// A tab switch mounts the canvas while the outgoing tab's view still holds focus and removes that /// view in the same update, which leaves the window holding focus. The claim runs on a later /// main-queue turn, which a nested run loop inside this main-actor test never reaches, so the test - /// suspends instead. + /// suspends behind it instead. @Test("The canvas takes focus once the view that held it on arrival leaves") - func canvasClaimsFocusAfterTheOutgoingViewLeaves() async throws { + func canvasClaimsFocusAfterTheOutgoingViewLeaves() async { let frame = CGRect(x: 0, y: 0, width: 800, height: 600) let window = NSWindow(contentRect: frame, styleMask: [.borderless], backing: .buffered, defer: false) let container = NSView(frame: frame) @@ -248,13 +243,13 @@ struct ERDiagramPointerInputTests { #expect(window.firstResponder === outgoing) outgoing.removeFromSuperview() - try await Task.sleep(for: .milliseconds(100)) + await afterQueuedMainWork() #expect(window.firstResponder === sceneView) } @Test("The canvas leaves focus alone when another view still holds it") - func canvasLeavesHeldFocusAlone() async throws { + func canvasLeavesHeldFocusAlone() async { let frame = CGRect(x: 0, y: 0, width: 800, height: 600) let window = NSWindow(contentRect: frame, styleMask: [.borderless], backing: .buffered, defer: false) let container = NSView(frame: frame) @@ -264,11 +259,17 @@ struct ERDiagramPointerInputTests { window.makeFirstResponder(sidebar) container.addSubview(ERDiagramSceneView(frame: frame)) - try await Task.sleep(for: .milliseconds(100)) + await afterQueuedMainWork() #expect(window.firstResponder === sidebar) } + private func afterQueuedMainWork() async { + await withCheckedContinuation { continuation in + DispatchQueue.main.async { continuation.resume() } + } + } + @Test("A click on the canvas gives it focus back") func clickTakesFocus() throws { let fixture = makeFixture(magnification: 0.5) diff --git a/TableProTests/Views/ERDiagram/ERDiagramThemeTests.swift b/TableProTests/Views/ERDiagram/ERDiagramThemeTests.swift index f7b385067d..5c1a395f06 100644 --- a/TableProTests/Views/ERDiagram/ERDiagramThemeTests.swift +++ b/TableProTests/Views/ERDiagram/ERDiagramThemeTests.swift @@ -31,7 +31,6 @@ private final class ThemeFillView: NSView { } } -@Suite("The ER diagram follows the theme", .serialized) @MainActor struct ERDiagramThemeTests { private typealias Fixture = ThemeTestFixture diff --git a/TableProTests/Views/Editor/EditorLifecycleTeardownTests.swift b/TableProTests/Views/Editor/EditorLifecycleTeardownTests.swift index 7d8c761aca..ec7312ee2a 100644 --- a/TableProTests/Views/Editor/EditorLifecycleTeardownTests.swift +++ b/TableProTests/Views/Editor/EditorLifecycleTeardownTests.swift @@ -1,17 +1,12 @@ -// -// EditorLifecycleTeardownTests.swift -// TableProTests -// -// Regression tests for #2236. A connection switch unparents the outgoing connection's pane -// and re-parents it on the way back, which SwiftUI reports as onDisappear then onAppear on a -// live editor. Teardown wired to that pair blanked the SQL editor, cleared its undo stack, and -// left its highlighter and key bindings dead, because only loadView() rebuilds them. -// +// A connection switch unparents the outgoing connection's pane and re-parents it on the way +// back, which SwiftUI reports as onDisappear then onAppear on a live editor. Teardown wired to that pair blanked the SQL editor, cleared its undo stack, and +// left its highlighter and key bindings dead, because only loadView() rebuilds them. import AppKit import SwiftUI @testable import TablePro import TableProEditorKit +import TableProTestSupport import TableProTextEngine import Testing @@ -78,10 +73,10 @@ struct EditorLifecycleTeardownTests { /// controller. Nothing lays a detached view out, so without the explicit layout pass SwiftUI /// never reconciles the cleared `rootView` and the tree stays mounted. @Test("WorkspacePanes.teardown dismantles a detached pane's content") - func teardownDismantlesDetachedPaneContent() throws { + func teardownDismantlesDetachedPaneContent() async throws { let recorder = DismantleRecorder() let panes = WorkspacePanes() - let window = try mount(panes.detail, showing: DismantleProbe(recorder: recorder)) + let window = try await mount(panes.detail, showing: DismantleProbe(recorder: recorder)) defer { window.orderOut(nil) } panes.detail.view.removeFromSuperview() @@ -94,10 +89,10 @@ struct EditorLifecycleTeardownTests { } @Test("WorkspacePanes.teardown dismantles a pane that is still on screen") - func teardownDismantlesAttachedPaneContent() throws { + func teardownDismantlesAttachedPaneContent() async throws { let recorder = DismantleRecorder() let panes = WorkspacePanes() - let window = try mount(panes.detail, showing: DismantleProbe(recorder: recorder)) + let window = try await mount(panes.detail, showing: DismantleProbe(recorder: recorder)) defer { window.orderOut(nil) } panes.teardown() @@ -111,7 +106,7 @@ struct EditorLifecycleTeardownTests { /// defaults `isReleasedWhenClosed` to true, so closing one that ARC also owns over-releases it /// and takes the whole test host down with it; the window is ordered out instead. A probe that /// never builds fails the test rather than letting it pass having asserted nothing. - private func mount(_ pane: NSHostingController, showing probe: DismantleProbe) throws -> NSWindow { + private func mount(_ pane: NSHostingController, showing probe: DismantleProbe) async throws -> NSWindow { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), styleMask: [.titled], @@ -126,12 +121,11 @@ struct EditorLifecycleTeardownTests { pane.view.frame = container.bounds container.addSubview(pane.view) - let deadline = Date(timeIntervalSinceNow: 10) - while probe.recorder.makeCount == 0, Date() < deadline { + let mounted: Void? = try? await waitUntil { pane.view.layoutSubtreeIfNeeded() - RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.02)) + return probe.recorder.makeCount > 0 } - try #require(probe.recorder.makeCount == 1, "the probe never mounted, so the test proves nothing") + try #require(mounted != nil && probe.recorder.makeCount == 1, "the probe never mounted, so the test proves nothing") return window } } diff --git a/TableProTests/Views/Editor/FoldCommandBehaviourTests.swift b/TableProTests/Views/Editor/FoldCommandBehaviourTests.swift index 5cb48300f3..b467fc25c5 100644 --- a/TableProTests/Views/Editor/FoldCommandBehaviourTests.swift +++ b/TableProTests/Views/Editor/FoldCommandBehaviourTests.swift @@ -1,8 +1,3 @@ -// -// FoldCommandBehaviourTests.swift -// TableProTests -// - import AppKit import Foundation @testable import TablePro @@ -10,6 +5,7 @@ import TableProEditorKit import TableProGrammars import TableProPluginKit import TableProSQLGrammar +import TableProTestSupport import TableProTextEngine import Testing @@ -48,11 +44,9 @@ struct FoldCommandBehaviourTests { return controller } + /// Returns on a timeout too, so the case's own expectation names what never arrived. private func waitForFolds(_ controller: TextViewController) async throws { - for _ in 0..<40 { - if !controller.foldRanges.isEmpty { return } - try await Task.sleep(for: .milliseconds(50)) - } + _ = try? await waitUntil { !controller.foldRanges.isEmpty } } /// Hiding the line numbers hides the gutter with them, and the chevrons go with it, but the reader turned off diff --git a/TableProTests/Views/Editor/FoldPreviewHitTestTests.swift b/TableProTests/Views/Editor/FoldPreviewHitTestTests.swift index d32508077e..7d0fa9d8cd 100644 --- a/TableProTests/Views/Editor/FoldPreviewHitTestTests.swift +++ b/TableProTests/Views/Editor/FoldPreviewHitTestTests.swift @@ -1,8 +1,3 @@ -// -// FoldPreviewHitTestTests.swift -// TableProTests -// - import AppKit import Foundation @testable import TablePro @@ -10,6 +5,7 @@ import TableProEditorKit import TableProGrammars import TableProPluginKit import TableProSQLGrammar +import TableProTestSupport import TableProTextEngine import Testing @@ -46,10 +42,7 @@ struct FoldPreviewHitTestTests { } private func foldAndWait(_ controller: TextViewController) async throws { - for _ in 0..<40 { - if !controller.foldRanges.isEmpty { break } - try await Task.sleep(for: .milliseconds(50)) - } + _ = try? await waitUntil { !controller.foldRanges.isEmpty } controller.foldAll() controller.textView.layoutManager.layoutLines() } diff --git a/TableProTests/Views/Editor/QueryCompletionAdapterLifecycleTests.swift b/TableProTests/Views/Editor/QueryCompletionAdapterLifecycleTests.swift index aeba2c1a38..eaf2abe904 100644 --- a/TableProTests/Views/Editor/QueryCompletionAdapterLifecycleTests.swift +++ b/TableProTests/Views/Editor/QueryCompletionAdapterLifecycleTests.swift @@ -1,14 +1,9 @@ -// -// QueryCompletionAdapterLifecycleTests.swift -// TableProTests -// -// Guards the invariants behind the autocomplete delegate-lifetime fix (#1731): -// the completion engine produces keyword suggestions before any schema is -// attached, and reconfiguring the adapter across nil/non-nil schema transitions -// keeps it functional. The SwiftUI onDisappear/onAppear delegate attachment that -// caused the original dropout is an AppKit lifecycle behaviour and is not -// deterministically unit-testable; these tests cover the logic the fix relies on. -// +// Guards the invariants behind the autocomplete delegate-lifetime fix: +// the completion engine produces keyword suggestions before any schema is +// attached, and reconfiguring the adapter across nil/non-nil schema transitions +// keeps it functional. The SwiftUI onDisappear/onAppear delegate attachment that +// caused the original dropout is an AppKit lifecycle behaviour and is not +// deterministically unit-testable; these tests cover the logic the fix relies on. import Foundation @testable import TablePro @@ -18,331 +13,315 @@ import TableProPluginKit import TableProTextEngine import Testing -struct QueryCompletionAdapterLifecycleTests { - @Test("engine returns keyword completions with no schema provider") - func keywordsAvailableWithoutSchema() async { - let engine = CompletionEngine(schemaProvider: nil, databaseType: .postgresql) - let context = await engine.getCompletions(text: "SEL", cursorPosition: 3) - #expect(context?.items.contains { $0.label == "SELECT" } == true) - } +extension GlobalStateSuites { + struct QueryCompletionAdapterLifecycleTests { + @Test("engine returns keyword completions with no schema provider") + func keywordsAvailableWithoutSchema() async { + let engine = CompletionEngine(schemaProvider: nil, databaseType: .postgresql) + let context = await engine.getCompletions(text: "SEL", cursorPosition: 3) + #expect(context?.items.contains { $0.label == "SELECT" } == true) + } - @Test("engine returns keyword completions with no schema and no database type") - func keywordsAvailableWithoutSchemaOrDatabaseType() async { - let engine = CompletionEngine(schemaProvider: nil, databaseType: nil) - let context = await engine.getCompletions(text: "SEL", cursorPosition: 3) - #expect(context?.items.contains { $0.label == "SELECT" } == true) - } + @Test("engine returns keyword completions with no schema and no database type") + func keywordsAvailableWithoutSchemaOrDatabaseType() async { + let engine = CompletionEngine(schemaProvider: nil, databaseType: nil) + let context = await engine.getCompletions(text: "SEL", cursorPosition: 3) + #expect(context?.items.contains { $0.label == "SELECT" } == true) + } - @MainActor - @Test("configure across nil and non-nil schema keeps the adapter functional") - func configureAcrossSchemaTransitionsKeepsAdapterUsable() { - let adapter = QueryCompletionAdapter(schemaProvider: nil, databaseType: nil) - #expect(!adapter.completionTriggerCharacters().isEmpty) + @MainActor + @Test("configure across nil and non-nil schema keeps the adapter functional") + func configureAcrossSchemaTransitionsKeepsAdapterUsable() { + let adapter = QueryCompletionAdapter(schemaProvider: nil, databaseType: nil) + #expect(!adapter.completionTriggerCharacters().isEmpty) - adapter.configure(schemaProvider: nil, databaseType: .postgresql) - adapter.configure(schemaProvider: SQLSchemaProvider(), databaseType: .postgresql) - adapter.configure(schemaProvider: nil, databaseType: .mysql) + adapter.configure(schemaProvider: nil, databaseType: .postgresql) + adapter.configure(schemaProvider: SQLSchemaProvider(), databaseType: .postgresql) + adapter.configure(schemaProvider: nil, databaseType: .mysql) - #expect(!adapter.completionTriggerCharacters().isEmpty) - } - - /// Rebuilding the service drops the open completion session. A refresh in any window of the - /// connection bumps the profile revision the editor's task keys on, so an unconditional - /// rebuild closed the popup under whoever was typing. - @MainActor - @Test("configuring with unchanged inputs does not rebuild the service") - func configureWithUnchangedInputsKeepsTheService() { - let provider = SQLSchemaProvider() - let adapter = QueryCompletionAdapter(schemaProvider: provider, databaseType: .postgresql) - let profile = QueryCompletionProfile( - resolvedDialect: nil, - statementCompletions: [], - revision: "rev-1" - ) - adapter.configure(schemaProvider: provider, databaseType: .postgresql, profile: profile) - let first = adapter.serviceIdentityForTesting + #expect(!adapter.completionTriggerCharacters().isEmpty) + } - adapter.configure(schemaProvider: provider, databaseType: .postgresql, profile: profile) + /// Rebuilding the service drops the open completion session. A refresh in any window of the + /// connection bumps the profile revision the editor's task keys on, so an unconditional + /// rebuild closed the popup under whoever was typing. + @MainActor + @Test("configuring with unchanged inputs does not rebuild the service") + func configureWithUnchangedInputsKeepsTheService() { + let provider = SQLSchemaProvider() + let adapter = QueryCompletionAdapter(schemaProvider: provider, databaseType: .postgresql) + let profile = QueryCompletionProfile( + resolvedDialect: nil, + statementCompletions: [], + revision: "rev-1" + ) + adapter.configure(schemaProvider: provider, databaseType: .postgresql, profile: profile) + let first = adapter.serviceIdentityForTesting - #expect(adapter.serviceIdentityForTesting == first) - } + adapter.configure(schemaProvider: provider, databaseType: .postgresql, profile: profile) - @MainActor - @Test("a changed profile revision rebuilds the service") - func changedProfileRevisionRebuildsTheService() { - let provider = SQLSchemaProvider() - let adapter = QueryCompletionAdapter(schemaProvider: provider, databaseType: .postgresql) - adapter.configure( - schemaProvider: provider, - databaseType: .postgresql, - profile: QueryCompletionProfile(resolvedDialect: nil, statementCompletions: [], revision: "rev-1") - ) - let first = adapter.serviceIdentityForTesting + #expect(adapter.serviceIdentityForTesting == first) + } - adapter.configure( - schemaProvider: provider, - databaseType: .postgresql, - profile: QueryCompletionProfile(resolvedDialect: nil, statementCompletions: [], revision: "rev-2") - ) + @MainActor + @Test("a changed profile revision rebuilds the service") + func changedProfileRevisionRebuildsTheService() { + let provider = SQLSchemaProvider() + let adapter = QueryCompletionAdapter(schemaProvider: provider, databaseType: .postgresql) + adapter.configure( + schemaProvider: provider, + databaseType: .postgresql, + profile: QueryCompletionProfile(resolvedDialect: nil, statementCompletions: [], revision: "rev-1") + ) + let first = adapter.serviceIdentityForTesting - #expect(adapter.serviceIdentityForTesting != first) - } + adapter.configure( + schemaProvider: provider, + databaseType: .postgresql, + profile: QueryCompletionProfile(resolvedDialect: nil, statementCompletions: [], revision: "rev-2") + ) - @MainActor - @Test("a changed schema provider rebuilds the service") - func changedSchemaProviderRebuildsTheService() { - let adapter = QueryCompletionAdapter(schemaProvider: SQLSchemaProvider(), databaseType: .postgresql) - adapter.configure(schemaProvider: SQLSchemaProvider(), databaseType: .postgresql) - let first = adapter.serviceIdentityForTesting + #expect(adapter.serviceIdentityForTesting != first) + } - adapter.configure(schemaProvider: SQLSchemaProvider(), databaseType: .postgresql) + @MainActor + @Test("a changed schema provider rebuilds the service") + func changedSchemaProviderRebuildsTheService() { + let adapter = QueryCompletionAdapter(schemaProvider: SQLSchemaProvider(), databaseType: .postgresql) + adapter.configure(schemaProvider: SQLSchemaProvider(), databaseType: .postgresql) + let first = adapter.serviceIdentityForTesting - #expect(adapter.serviceIdentityForTesting != first) - } + adapter.configure(schemaProvider: SQLSchemaProvider(), databaseType: .postgresql) - // MARK: - Incremental ranking (#2444) - - /// The popup preselects its first row on every incremental update, so a list still ordered for - /// the prefix it opened with is what Return commits. Every pair here opened with the function - /// correctly ahead of the keyword, and has to swap once the typed token completes the keyword. - @MainActor - @Test( - "typing to an exact keyword puts it first", - arguments: [ - (opening: "t", typed: "true", exact: "TRUE", longer: "TRUNCATE"), - (opening: "n", typed: "null", exact: "NULL", longer: "NULLIF"), - (opening: "i", typed: "in", exact: "IN", longer: "INSTR"), - (opening: "i", typed: "is", exact: "IS", longer: "ISNULL"), - (opening: "r", typed: "regexp", exact: "REGEXP", longer: "REGEXP_REPLACE") - ] - ) - func incrementalUpdatePutsTheExactKeywordFirst( - opening: String, - typed: String, - exact: String, - longer: String - ) async { - let keywordCase = PinnedKeywordCase(.upper) - defer { keywordCase.restore() } - let labels = await incrementalLabels(opening: opening, typed: typed) - - #expect(labels.first == exact) - if let exactIndex = labels.firstIndex(of: exact), let longerIndex = labels.firstIndex(of: longer) { - #expect(exactIndex < longerIndex) + #expect(adapter.serviceIdentityForTesting != first) } - } - /// The reporter's own A/B: dismissing the popup and reopening it on the complete token gave the - /// right order, so an incremental update has to arrive at the same answer. - @MainActor - @Test("typing into an open popup lands where reopening it would") - func incrementalUpdateMatchesAFreshRequest() async { - let keywordCase = PinnedKeywordCase(.upper) - defer { keywordCase.restore() } - let opened = "SELECT * FROM gt_user WHERE t" - let completed = "SELECT * FROM gt_user WHERE true" - - let controller = EditorControllerFixture.make(string: opened) - let adapter = QueryCompletionAdapter(schemaProvider: nil, databaseType: .mysql) - _ = await adapter.completionSuggestionsRequested( - textView: controller, - cursorPosition: cursor(atEndOf: opened), - isManualTrigger: false + // MARK: - Incremental ranking (#2444) + + /// The popup preselects its first row on every incremental update, so a list still ordered for + /// the prefix it opened with is what Return commits. Every pair here opened with the function + /// correctly ahead of the keyword, and has to swap once the typed token completes the keyword. + @MainActor + @Test( + "typing to an exact keyword puts it first", + .upperCaseKeywords, + arguments: [ + (opening: "t", typed: "true", exact: "TRUE", longer: "TRUNCATE"), + (opening: "n", typed: "null", exact: "NULL", longer: "NULLIF"), + (opening: "i", typed: "in", exact: "IN", longer: "INSTR"), + (opening: "i", typed: "is", exact: "IS", longer: "ISNULL"), + (opening: "r", typed: "regexp", exact: "REGEXP", longer: "REGEXP_REPLACE") + ] ) + func incrementalUpdatePutsTheExactKeywordFirst( + opening: String, + typed: String, + exact: String, + longer: String + ) async { + let labels = await incrementalLabels(opening: opening, typed: typed) + + #expect(labels.first == exact) + if let exactIndex = labels.firstIndex(of: exact), let longerIndex = labels.firstIndex(of: longer) { + #expect(exactIndex < longerIndex) + } + } - controller.textView.setText(completed) - for length in (opened.utf16.count + 1)...completed.utf16.count { - _ = adapter.completionOnCursorMove( + /// The reporter's own A/B: dismissing the popup and reopening it on the complete token gave the + /// right order, so an incremental update has to arrive at the same answer. + @MainActor + @Test("typing into an open popup lands where reopening it would", .upperCaseKeywords) + func incrementalUpdateMatchesAFreshRequest() async { + let opened = "SELECT * FROM gt_user WHERE t" + let completed = "SELECT * FROM gt_user WHERE true" + + let controller = EditorControllerFixture.make(string: opened) + let adapter = QueryCompletionAdapter(schemaProvider: nil, databaseType: .mysql) + _ = await adapter.completionSuggestionsRequested( textView: controller, - cursorPosition: CursorPosition(range: NSRange(location: length, length: 0)) + cursorPosition: cursor(atEndOf: opened), + isManualTrigger: false ) + + controller.textView.setText(completed) + for length in (opened.utf16.count + 1)...completed.utf16.count { + _ = adapter.completionOnCursorMove( + textView: controller, + cursorPosition: CursorPosition(range: NSRange(location: length, length: 0)) + ) + } + let incremental = adapter.completionOnCursorMove( + textView: controller, + cursorPosition: cursor(atEndOf: completed) + )?.map(\.label) + + let freshController = EditorControllerFixture.make(string: completed) + let freshAdapter = QueryCompletionAdapter(schemaProvider: nil, databaseType: .mysql) + let fresh = await freshAdapter.completionSuggestionsRequested( + textView: freshController, + cursorPosition: cursor(atEndOf: completed), + isManualTrigger: false + )?.items.map(\.label) + + #expect(incremental?.first == "TRUE") + #expect(incremental?.first == fresh?.first) } - let incremental = adapter.completionOnCursorMove( - textView: controller, - cursorPosition: cursor(atEndOf: completed) - )?.map(\.label) - - let freshController = EditorControllerFixture.make(string: completed) - let freshAdapter = QueryCompletionAdapter(schemaProvider: nil, databaseType: .mysql) - let fresh = await freshAdapter.completionSuggestionsRequested( - textView: freshController, - cursorPosition: cursor(atEndOf: completed), - isManualTrigger: false - )?.items.map(\.label) - - #expect(incremental?.first == "TRUE") - #expect(incremental?.first == fresh?.first) - } - /// Deleting a character has to widen the list again, which it only does when each update is - /// resolved from the session's own candidates rather than from the previous keystroke's. - @MainActor - @Test("deleting a character widens the list again") - func deletingACharacterWidensTheList() async { - let keywordCase = PinnedKeywordCase(.upper) - defer { keywordCase.restore() } - let opened = "SELECT * FROM gt_user WHERE t" - let controller = EditorControllerFixture.make(string: opened) - let adapter = QueryCompletionAdapter(schemaProvider: nil, databaseType: .mysql) - _ = await adapter.completionSuggestionsRequested( - textView: controller, - cursorPosition: cursor(atEndOf: opened), - isManualTrigger: false - ) + /// Deleting a character has to widen the list again, which it only does when each update is + /// resolved from the session's own candidates rather than from the previous keystroke's. + @MainActor + @Test("deleting a character widens the list again", .upperCaseKeywords) + func deletingACharacterWidensTheList() async { + let opened = "SELECT * FROM gt_user WHERE t" + let controller = EditorControllerFixture.make(string: opened) + let adapter = QueryCompletionAdapter(schemaProvider: nil, databaseType: .mysql) + _ = await adapter.completionSuggestionsRequested( + textView: controller, + cursorPosition: cursor(atEndOf: opened), + isManualTrigger: false + ) - let narrow = "SELECT * FROM gt_user WHERE trun" - controller.textView.setText(narrow) - let narrowed = adapter.completionOnCursorMove( - textView: controller, - cursorPosition: cursor(atEndOf: narrow) - )?.map(\.label) ?? [] - - let wide = "SELECT * FROM gt_user WHERE tr" - controller.textView.setText(wide) - let widened = adapter.completionOnCursorMove( - textView: controller, - cursorPosition: cursor(atEndOf: wide) - )?.map(\.label) ?? [] - - #expect(!narrowed.isEmpty) - #expect(widened.count > narrowed.count) - #expect(widened.contains("TRUE")) - } + let narrow = "SELECT * FROM gt_user WHERE trun" + controller.textView.setText(narrow) + let narrowed = adapter.completionOnCursorMove( + textView: controller, + cursorPosition: cursor(atEndOf: narrow) + )?.map(\.label) ?? [] - // MARK: - The session pool - - /// An open popup re-ranks against what the session kept rather than asking again, so the - /// session keeps more candidates than the popup shows. It still keeps a bounded number: ranking - /// walks every one of them on every keystroke. - @MainActor - @Test("an open session re-ranks a bounded pool wider than the visible list") - func openSessionReranksABoundedPool() async { - let items = (0..<5_000).map { SQLCompletionItem.keyword(String(format: "ab%04d", $0)) } - let service = RankingInputRecordingCompletionService(items: items, poolLimit: 400) - let adapter = QueryCompletionAdapter(serviceForTesting: service) - let controller = EditorControllerFixture.make(string: "a") - - _ = await adapter.completionSuggestionsRequested( - textView: controller, - cursorPosition: CursorPosition(range: NSRange(location: 1, length: 0)), - isManualTrigger: false - ) + let wide = "SELECT * FROM gt_user WHERE tr" + controller.textView.setText(wide) + let widened = adapter.completionOnCursorMove( + textView: controller, + cursorPosition: cursor(atEndOf: wide) + )?.map(\.label) ?? [] - controller.textView.setText("ab") - _ = adapter.completionOnCursorMove( - textView: controller, - cursorPosition: CursorPosition(range: NSRange(location: 2, length: 0)) - ) - controller.textView.setText("ab0") - _ = adapter.completionOnCursorMove( - textView: controller, - cursorPosition: CursorPosition(range: NSRange(location: 3, length: 0)) - ) + #expect(!narrowed.isEmpty) + #expect(widened.count > narrowed.count) + #expect(widened.contains("TRUE")) + } - #expect(service.rankingInputCounts == [400, 400]) - } + // MARK: - The session pool - // MARK: - The session's token + /// An open popup re-ranks against what the session kept rather than asking again, so the + /// session keeps more candidates than the popup shows. It still keeps a bounded number: ranking + /// walks every one of them on every keystroke. + @MainActor + @Test("an open session re-ranks a bounded pool wider than the visible list") + func openSessionReranksABoundedPool() async { + let items = (0..<5_000).map { SQLCompletionItem.keyword(String(format: "ab%04d", $0)) } + let service = RankingInputRecordingCompletionService(items: items, poolLimit: 400) + let adapter = QueryCompletionAdapter(serviceForTesting: service) + let controller = EditorControllerFixture.make(string: "a") - @MainActor - @Test("a session re-ranks its own token and declines the next one") - func sessionDeclinesACursorOnAnotherToken() async { - let service = TokenBoundCompletionService(labels: ["select", "set", "update", "users"]) - let adapter = QueryCompletionAdapter(serviceForTesting: service) - let controller = EditorControllerFixture.make(string: "se") + _ = await adapter.completionSuggestionsRequested( + textView: controller, + cursorPosition: CursorPosition(range: NSRange(location: 1, length: 0)), + isManualTrigger: false + ) - _ = await adapter.completionSuggestionsRequested( - textView: controller, - cursorPosition: cursor(atEndOf: "se"), - isManualTrigger: false - ) + controller.textView.setText("ab") + _ = adapter.completionOnCursorMove( + textView: controller, + cursorPosition: CursorPosition(range: NSRange(location: 2, length: 0)) + ) + controller.textView.setText("ab0") + _ = adapter.completionOnCursorMove( + textView: controller, + cursorPosition: CursorPosition(range: NSRange(location: 3, length: 0)) + ) - controller.textView.setText("sel") - let sameToken = adapter.completionOnCursorMove( - textView: controller, - cursorPosition: cursor(atEndOf: "sel") - )?.map(\.label) + #expect(service.rankingInputCounts == [400, 400]) + } - controller.textView.setText("se u") - let nextToken = adapter.completionOnCursorMove( - textView: controller, - cursorPosition: cursor(atEndOf: "se u") - )?.map(\.label) + // MARK: - The session's token - #expect(sameToken == ["select"]) - #expect(nextToken == nil) - } + @MainActor + @Test("a session re-ranks its own token and declines the next one") + func sessionDeclinesACursorOnAnotherToken() async { + let service = TokenBoundCompletionService(labels: ["select", "set", "update", "users"]) + let adapter = QueryCompletionAdapter(serviceForTesting: service) + let controller = EditorControllerFixture.make(string: "se") - @MainActor - @Test("a response names the prefix it was ranked for, not the text the editor holds when it lands") - func responseNamesThePrefixItWasRankedFor() async { - let controller = EditorControllerFixture.make(string: "sl") - let service = TokenBoundCompletionService(labels: ["sleep", "select", "set"]) - service.whileAnswering = { controller.textView.setText("se") } - let adapter = QueryCompletionAdapter(serviceForTesting: service) - - let response = await adapter.completionSuggestionsRequested( - textView: controller, - cursorPosition: cursor(atEndOf: "sl"), - isManualTrigger: false - ) + _ = await adapter.completionSuggestionsRequested( + textView: controller, + cursorPosition: cursor(atEndOf: "se"), + isManualTrigger: false + ) - #expect(controller.textView.string == "se") - #expect(response?.prefix == CodeSuggestionPrefix(range: NSRange(location: 0, length: 2), text: "sl")) - #expect(response?.windowPosition.range == NSRange(location: 2, length: 0)) - } + controller.textView.setText("sel") + let sameToken = adapter.completionOnCursorMove( + textView: controller, + cursorPosition: cursor(atEndOf: "sel") + )?.map(\.label) - // MARK: - Helpers + controller.textView.setText("se u") + let nextToken = adapter.completionOnCursorMove( + textView: controller, + cursorPosition: cursor(atEndOf: "se u") + )?.map(\.label) - @MainActor - private func cursor(atEndOf text: String) -> CursorPosition { - CursorPosition(range: NSRange(location: text.utf16.count, length: 0)) - } + #expect(sameToken == ["select"]) + #expect(nextToken == nil) + } + @MainActor + @Test("a response names the prefix it was ranked for, not the text the editor holds when it lands") + func responseNamesThePrefixItWasRankedFor() async { + let controller = EditorControllerFixture.make(string: "sl") + let service = TokenBoundCompletionService(labels: ["sleep", "select", "set"]) + service.whileAnswering = { controller.textView.setText("se") } + let adapter = QueryCompletionAdapter(serviceForTesting: service) - @MainActor - private func incrementalLabels(opening: String, typed: String) async -> [String] { - let queryPrefix = "SELECT * FROM gt_user WHERE " - let openingQuery = queryPrefix + opening - let controller = EditorControllerFixture.make(string: openingQuery) - let adapter = QueryCompletionAdapter(schemaProvider: nil, databaseType: .mysql) + let response = await adapter.completionSuggestionsRequested( + textView: controller, + cursorPosition: cursor(atEndOf: "sl"), + isManualTrigger: false + ) - _ = await adapter.completionSuggestionsRequested( - textView: controller, - cursorPosition: cursor(atEndOf: openingQuery), - isManualTrigger: false - ) + #expect(controller.textView.string == "se") + #expect(response?.prefix == CodeSuggestionPrefix(range: NSRange(location: 0, length: 2), text: "sl")) + #expect(response?.windowPosition.range == NSRange(location: 2, length: 0)) + } - let typedQuery = queryPrefix + typed - controller.textView.setText(typedQuery) + // MARK: - Helpers - return adapter.completionOnCursorMove( - textView: controller, - cursorPosition: cursor(atEndOf: typedQuery) - )?.map(\.label) ?? [] - } -} + @MainActor + private func cursor(atEndOf text: String) -> CursorPosition { + CursorPosition(range: NSRange(location: text.utf16.count, length: 0)) + } -/// Holds `SQLKeywordCase` at one value for the length of a test, and puts the user's own back. -/// -/// A completion's label follows that setting, so every ranking assertion written against a -/// spelling would otherwise answer to whatever the machine running it has chosen: the same four -/// tests pass on a developer set to UPPERCASE and fail on CI, which takes the shipped default and -/// follows the lowercase prefix they type. -@MainActor -private struct PinnedKeywordCase { - private let previous: SQLKeywordCase - init(_ value: SQLKeywordCase) { - previous = AppSettingsManager.shared.editor.keywordCase - AppSettingsManager.shared.editor.keywordCase = value - } + @MainActor + private func incrementalLabels(opening: String, typed: String) async -> [String] { + let queryPrefix = "SELECT * FROM gt_user WHERE " + let openingQuery = queryPrefix + opening + let controller = EditorControllerFixture.make(string: openingQuery) + let adapter = QueryCompletionAdapter(schemaProvider: nil, databaseType: .mysql) - func restore() { - AppSettingsManager.shared.editor.keywordCase = previous + _ = await adapter.completionSuggestionsRequested( + textView: controller, + cursorPosition: cursor(atEndOf: openingQuery), + isManualTrigger: false + ) + + let typedQuery = queryPrefix + typed + controller.textView.setText(typedQuery) + + return adapter.completionOnCursorMove( + textView: controller, + cursorPosition: cursor(atEndOf: typedQuery) + )?.map(\.label) ?? [] + } } } +/// A completion's label follows `SQLKeywordCase`, so every ranking assertion written against a +/// spelling would otherwise answer to whatever the machine running it has chosen: the same tests +/// pass on a developer set to UPPERCASE and fail on CI, which follows the lowercase prefix typed. +private extension Trait where Self == AppSettingsTrait { + static var upperCaseKeywords: Self { .appSettings { $0.editor.keywordCase = .upper } } +} + @MainActor private final class TokenBoundCompletionService: QueryCompletionService { private let items: [SQLCompletionItem] diff --git a/TableProTests/Views/Editor/QueryCompletionProfileRegistryTests.swift b/TableProTests/Views/Editor/QueryCompletionProfileRegistryTests.swift index 9319a58f4d..2c75c6745a 100644 --- a/TableProTests/Views/Editor/QueryCompletionProfileRegistryTests.swift +++ b/TableProTests/Views/Editor/QueryCompletionProfileRegistryTests.swift @@ -1,6 +1,7 @@ import Foundation @testable import TablePro import TableProPluginKit +import TableProTestSupport import Testing @MainActor @@ -36,26 +37,6 @@ struct QueryCompletionProfileRegistryTests { } } - /// Lets one resolution announce that the registry has already recorded it as in flight, - /// so the joining call is made against a known state instead of racing `async let` ordering. - actor Signal { - private var isRaised = false - private var waiters: [CheckedContinuation] = [] - - func raise() { - isRaised = true - for waiter in waiters { - waiter.resume() - } - waiters = [] - } - - func wait() async { - guard !isRaised else { return } - await withCheckedContinuation { waiters.append($0) } - } - } - nonisolated private static func base(revision: String = "base") -> QueryCompletionProfile { QueryCompletionProfile( resolvedDialect: nil, @@ -169,12 +150,12 @@ struct QueryCompletionProfileRegistryTests { let scope = DatabaseScope(connectionId: UUID(), database: "shop", schema: nil) let resolutions = Counter() - let didStart = Signal() - let mayFinish = Signal() + let didStart = AsyncGate() + let mayFinish = AsyncGate() async let first = registry.resolve(scope: scope, databaseType: .mysql, base: Self.base()) { await resolutions.increment() - await didStart.raise() + await didStart.open() await mayFinish.wait() return Self.base(revision: "resolved") } @@ -183,7 +164,7 @@ struct QueryCompletionProfileRegistryTests { await resolutions.increment() return Self.base(revision: "duplicate") } - await mayFinish.raise() + await mayFinish.open() let revisions = await [first.revision, second.revision] #expect(await resolutions.value == 1) @@ -194,20 +175,27 @@ struct QueryCompletionProfileRegistryTests { /// early, measured, so a superseded resolution always runs its tail to the commit. Refusing to /// cache is only half the guard: the stale value must not reach the caller either, because the /// caller configures the editor with whatever it is handed. - @Test("invalidation prevents an old resolution from replacing or being returned by the next generation") + @Test( + "invalidation prevents an old resolution from replacing or being returned by the next generation", + .timeLimit(.minutes(1)) + ) func invalidationFencesOldResolution() async { let registry = QueryCompletionProfileRegistry() let scope = DatabaseScope(connectionId: UUID(), database: "shop", schema: nil) + let didStart = AsyncGate() + let mayFinish = AsyncGate() async let old = registry.resolve(scope: scope, databaseType: .mysql, base: Self.base()) { - try? await Task.sleep(nanoseconds: 10_000_000) + await didStart.open() + await mayFinish.wait() return Self.base(revision: "old") } - await Task.yield() + await didStart.wait() registry.invalidate(scope: scope) let current = await registry.resolve(scope: scope, databaseType: .mysql, base: Self.base()) { Self.base(revision: "current") } + await mayFinish.open() let fenced = await old let cached = await registry.resolve(scope: scope, databaseType: .mysql, base: Self.base()) { Self.base(revision: "unexpected") @@ -261,17 +249,17 @@ struct QueryCompletionProfileRegistryTests { let connectionId = UUID() let scope = DatabaseScope(connectionId: connectionId, database: "shop", schema: nil) - let didStart = Signal() - let mayFinish = Signal() + let didStart = AsyncGate() + let mayFinish = AsyncGate() async let inFlight = registry.resolve(scope: scope, databaseType: .mysql, base: Self.base()) { - await didStart.raise() + await didStart.open() await mayFinish.wait() return Self.base(revision: "from-the-closed-session") } await didStart.wait() registry.clear(connectionId: connectionId) - await mayFinish.raise() + await mayFinish.open() _ = await inFlight let afterReconnect = await registry.resolve(scope: scope, databaseType: .mysql, base: Self.base()) { diff --git a/TableProTests/Views/Editor/QueryDiagnosticsRefreshTests.swift b/TableProTests/Views/Editor/QueryDiagnosticsRefreshTests.swift index 6edc41aaaf..953eea4ee9 100644 --- a/TableProTests/Views/Editor/QueryDiagnosticsRefreshTests.swift +++ b/TableProTests/Views/Editor/QueryDiagnosticsRefreshTests.swift @@ -1,13 +1,9 @@ -// -// QueryDiagnosticsRefreshTests.swift -// TableProTests -// - import AppKit import Foundation import SwiftUI @testable import TablePro import TableProEditorKit +import TableProTestSupport import TableProTextEngine import Testing @@ -24,8 +20,10 @@ private final class DocumentReplacementRecorder: TextViewCoordinator { @MainActor struct QueryDiagnosticsRefreshTests { + private let clock = ManualClock() + private func makeEditor(_ text: String = "") -> (SQLEditorCoordinator, TextViewController) { - let coordinator = SQLEditorCoordinator() + let coordinator = SQLEditorCoordinator(diagnosticsClock: clock) coordinator.databaseType = .mysql let controller = EditorControllerFixture.make(string: text, coordinators: [coordinator]) return (coordinator, controller) @@ -37,19 +35,24 @@ struct QueryDiagnosticsRefreshTests { .map(\.range) ?? [] } - private func waitForUnderlines( - in controller: TextViewController, - timeout: Duration = .seconds(5) - ) async -> [NSRange] { - let deadline = ContinuousClock.now.advanced(by: timeout) - while ContinuousClock.now < deadline { - let ranges = underlines(in: controller) - if !ranges.isEmpty { return ranges } - try? await Task.sleep(for: .milliseconds(20)) - } + private func passDebounce() async throws { + try await waitUntil { clock.pendingSleeperCount > 0 } + clock.advance(by: .milliseconds(500)) + } + + private func waitForUnderlines(in controller: TextViewController) async throws -> [NSRange] { + try await passDebounce() + try await waitUntil { !underlines(in: controller).isEmpty } return underlines(in: controller) } + // The woken refresh is already queued on the main executor, so a block queued after it runs later. + private func afterQueuedMainActorWork() async { + await withCheckedContinuation { continuation in + DispatchQueue.main.async { continuation.resume() } + } + } + @Test("Replacing the document tells every coordinator, and an edit does not") func setTextNotifiesCoordinators() { let recorder = DocumentReplacementRecorder() @@ -63,13 +66,13 @@ struct QueryDiagnosticsRefreshTests { } @Test("A replaced document is checked without a keystroke") - func replacedDocumentIsChecked() async { + func replacedDocumentIsChecked() async throws { let (coordinator, controller) = makeEditor("SELECT 1") defer { coordinator.destroy() } controller.setText("SELECT 2))") - #expect(await waitForUnderlines(in: controller) == [NSRange(location: 8, length: 1)]) + #expect(try await waitForUnderlines(in: controller) == [NSRange(location: 8, length: 1)]) } @Test("The previous document's underline goes the moment the document is replaced") @@ -77,12 +80,13 @@ struct QueryDiagnosticsRefreshTests { let (coordinator, controller) = makeEditor("SELECT 1") defer { coordinator.destroy() } controller.textView.replaceCharacters(in: NSRange(location: 8, length: 0), with: ")") - #expect(await waitForUnderlines(in: controller) == [NSRange(location: 8, length: 1)]) + #expect(try await waitForUnderlines(in: controller) == [NSRange(location: 8, length: 1)]) controller.setText("SELECT name FROM t") #expect(underlines(in: controller).isEmpty) - try await Task.sleep(for: .milliseconds(800)) + try await passDebounce() + await afterQueuedMainActorWork() #expect(underlines(in: controller).isEmpty) } @@ -90,16 +94,16 @@ struct QueryDiagnosticsRefreshTests { /// The binding plumbing itself is the editor's, and `SourceEditorBindingSyncTests` covers it; what belongs /// here is that a second replacement re-checks rather than leaving the first document's answer behind. @Test("A second replacement re-checks the incoming document") - func secondReplacementIsChecked() async { + func secondReplacementIsChecked() async throws { let (coordinator, controller) = makeEditor() defer { coordinator.destroy() } controller.setText("SELECT 1)") - #expect(await waitForUnderlines(in: controller) == [NSRange(location: 8, length: 1)]) + #expect(try await waitForUnderlines(in: controller) == [NSRange(location: 8, length: 1)]) controller.setText("SELECT name FROM t /* open") #expect(underlines(in: controller).isEmpty) - #expect(await waitForUnderlines(in: controller) == [NSRange(location: 19, length: 2)]) + #expect(try await waitForUnderlines(in: controller) == [NSRange(location: 19, length: 2)]) } @Test("An underline below the lines on screen is drawn once its line lays out") @@ -108,7 +112,7 @@ struct QueryDiagnosticsRefreshTests { defer { coordinator.destroy() } let statements = (1...200).map { "SELECT \($0);" } + ["SELECT 201)"] controller.setText(statements.joined(separator: "\n")) - let range = try #require(await waitForUnderlines(in: controller).first) + let range = try #require(try await waitForUnderlines(in: controller).first) let textView = try #require(controller.textView) textView.layoutManager.layoutLines(in: CGRect(origin: .zero, size: CGSize(width: 1_000, height: 20_000))) diff --git a/TableProTests/Views/Editor/SQLCompletionAdapterFuzzyTests.swift b/TableProTests/Views/Editor/SQLCompletionAdapterFuzzyTests.swift index 0852fc2e41..3be8747b31 100644 --- a/TableProTests/Views/Editor/SQLCompletionAdapterFuzzyTests.swift +++ b/TableProTests/Views/Editor/SQLCompletionAdapterFuzzyTests.swift @@ -1,9 +1,4 @@ -// -// SQLCompletionAdapterFuzzyTests.swift -// TableProTests -// -// Regression tests for fuzzy matching used by autocomplete. -// +// Regression tests for fuzzy matching used by autocomplete. import TableProPluginKit @testable import TablePro @@ -59,14 +54,13 @@ struct SQLCompletionAdapterFuzzyTests { // MARK: - No Match - @Test("No matching characters returns false") - func noMatch() { - #expect(fuzzyMatch(pattern: "xyz", target: "select") == false) - } - - @Test("Characters present but in wrong order returns false") - func wrongOrderReturnsFalse() { - #expect(fuzzyMatch(pattern: "tces", target: "select") == false) + /// No matching characters, the right characters in the wrong order, a pattern longer than the + /// target, a case that differs, and one character the target lacks. + @Test("A pattern that is not an in-order subsequence of the target does not match", arguments: [ + "xyz", "tces", "selectfromwhere", "SELECT", "z", + ]) + func patternOutsideTheTargetDoesNotMatch(pattern: String) { + #expect(fuzzyMatch(pattern: pattern, target: "select") == false) } // MARK: - Empty Pattern @@ -81,19 +75,6 @@ struct SQLCompletionAdapterFuzzyTests { #expect(fuzzyMatch(pattern: "", target: "") == true) } - // MARK: - Pattern Longer Than Target - - @Test("Pattern longer than target returns false") - func patternLongerThanTarget() { - #expect(fuzzyMatch(pattern: "selectfromwhere", target: "select") == false) - } - - // MARK: - Case Sensitivity - - @Test("Matching is case-sensitive by default") - func caseSensitive() { - #expect(fuzzyMatch(pattern: "SELECT", target: "select") == false) - } // MARK: - Unicode @@ -133,9 +114,4 @@ struct SQLCompletionAdapterFuzzyTests { func singleCharPresent() { #expect(fuzzyMatch(pattern: "s", target: "select") == true) } - - @Test("Single character absent returns false") - func singleCharAbsent() { - #expect(fuzzyMatch(pattern: "z", target: "select") == false) - } } diff --git a/TableProTests/Views/Editor/SQLEditorCursorPropagationTests.swift b/TableProTests/Views/Editor/SQLEditorCursorPropagationTests.swift index 606cc11341..f4f0473be3 100644 --- a/TableProTests/Views/Editor/SQLEditorCursorPropagationTests.swift +++ b/TableProTests/Views/Editor/SQLEditorCursorPropagationTests.swift @@ -1,15 +1,11 @@ -// -// SQLEditorCursorPropagationTests.swift -// TableProTests -// -// Run, Explain and the AI actions read the caret the editor reports through its binding, so a caret moved while -// focus is elsewhere (find navigation, a jump to a result's statement) still has to reach it. -// +// Run, Explain and the AI actions read the caret the editor reports through its binding, so a caret moved while +// focus is elsewhere (find navigation, a jump to a result's statement) still has to reach it. import AppKit import Combine import SwiftUI import TableProEditorKit +import TableProTestSupport import TableProTextEngine import Testing @@ -36,10 +32,10 @@ struct SQLEditorCursorPropagationTests { } } - private func settle(_ window: NSWindow) { - for _ in 0 ..< 10 { + private func settle(_ window: NSWindow, until condition: () -> Bool) async throws { + try await waitUntil { window.contentView?.layoutSubtreeIfNeeded() - RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.02)) + return condition() } } @@ -53,7 +49,7 @@ struct SQLEditorCursorPropagationTests { } @Test("A caret moved while the editor is not first responder reaches the binding") - func caretMovedWithoutFocusReachesTheBinding() throws { + func caretMovedWithoutFocusReachesTheBinding() async throws { let model = Model() let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 600, height: 400), @@ -64,7 +60,7 @@ struct SQLEditorCursorPropagationTests { window.isReleasedWhenClosed = false window.contentView = NSHostingView(rootView: Host(model: model)) defer { window.orderOut(nil) } - settle(window) + try await settle(window) { textView(in: window.contentView)?.window != nil } let editor = try #require(textView(in: window.contentView)) _ = window.makeFirstResponder(nil) @@ -72,7 +68,7 @@ struct SQLEditorCursorPropagationTests { let match = NSRange(location: 42, length: 2) editor.selectionManager.setSelectedRange(match) - settle(window) + try await settle(window) { model.cursorPositions.map(\.range) == [match] } #expect(model.cursorPositions.map(\.range) == [match]) } diff --git a/TableProTests/Views/Editor/StatementRunControllerTests.swift b/TableProTests/Views/Editor/StatementRunControllerTests.swift index fa59d83833..8a07ff59de 100644 --- a/TableProTests/Views/Editor/StatementRunControllerTests.swift +++ b/TableProTests/Views/Editor/StatementRunControllerTests.swift @@ -218,4 +218,25 @@ struct StatementRunControllerTests { #expect(!controller.statementRunControlsEnabled) } + + // MARK: - Result jumps + + @Test("Selecting a result moves the caret to its statement and leaves the query as it was") + func resultJumpMovesOnlyTheCaret() throws { + let controller = makeController(text: threeStatements) + let coordinator = SQLEditorCoordinator() + coordinator.controller = controller + let statements = SQLStatementScanner.executableStatements(in: threeStatements, grammar: .ansi) + try #require(statements.count == 3) + + for index in [2, 0, 0] { + #expect(coordinator.jumpToStatement(StatementAnchor(statements[index])), "result \(index)") + #expect( + controller.textView.selectionManager.textSelections.first?.range + == NSRange(location: statements[index].range.location, length: 0), + "result \(index)" + ) + #expect(controller.textView.string == threeStatements, "result \(index)") + } + } } diff --git a/TableProTests/Views/Filter/CellFilterMenuBuilderTests.swift b/TableProTests/Views/Filter/CellFilterMenuBuilderTests.swift index 7f8bc059bf..7501f7e580 100644 --- a/TableProTests/Views/Filter/CellFilterMenuBuilderTests.swift +++ b/TableProTests/Views/Filter/CellFilterMenuBuilderTests.swift @@ -143,4 +143,27 @@ struct CellFilterMenuBuilderTests { #expect(applied.first?.filterOperator == .notEqual) #expect(applied.first?.value == "paid") } + + @Test("A data cell's menu offers Highlight, which ends with Highlight Rules… that opens the rules") + func highlightOffersTheRules() throws { + var opened = 0 + let item = try #require(HighlightMenuBuilder.menuItem( + for: HighlightMenuBuilder.CellContext( + columnName: "Title", + columnOccurrence: 0, + columnType: .text(rawType: "NVARCHAR(160)"), + value: "For Those About To Rock", + existingRules: [] + ), + actions: HighlightMenuBuilder.Actions(apply: { _ in }, remove: { _ in }, showRules: { opened += 1 }) + )) + #expect(item.title == String(localized: "Highlight")) + let rules = try #require(item.submenu?.items.last) + #expect(rules.title == String(localized: "Highlight Rules…")) + + let action = try #require(rules.action) + NSApp.sendAction(action, to: rules.target, from: rules) + + #expect(opened == 1) + } } diff --git a/TableProTests/Views/Import/RowImportMappingTests.swift b/TableProTests/Views/Import/RowImportMappingTests.swift index 927409b69e..d7561bff0d 100644 --- a/TableProTests/Views/Import/RowImportMappingTests.swift +++ b/TableProTests/Views/Import/RowImportMappingTests.swift @@ -1,8 +1,3 @@ -// -// RowImportMappingTests.swift -// TableProTests -// - import Foundation import TableProPluginKit import Testing @@ -11,12 +6,17 @@ import Testing @MainActor struct RowImportMappingTests { + private let suite: EphemeralDefaults + + init() throws { + suite = try EphemeralDefaults() + } + private let columns = ["id", "full_name", "email"] private let people = TableScope(connectionId: UUID(), database: "shop", schema: nil, table: "people") private func makeStore() throws -> ImportColumnMappingStore { - let defaults = try #require(UserDefaults(suiteName: "RowImportMappingTests.\(UUID().uuidString)")) - return ImportColumnMappingStore(defaults: defaults) + ImportColumnMappingStore(defaults: suite.defaults) } private func fields(_ names: [String]) -> [PluginImportField] { diff --git a/TableProTests/Views/Main/CatalogChangeWindowTests.swift b/TableProTests/Views/Main/CatalogChangeWindowTests.swift index e2fc06df6b..e8bc27ad9d 100644 --- a/TableProTests/Views/Main/CatalogChangeWindowTests.swift +++ b/TableProTests/Views/Main/CatalogChangeWindowTests.swift @@ -1,32 +1,26 @@ -// -// CatalogChangeWindowTests.swift -// TableProTests -// -// What each window does with its connection's catalog changes: close the tabs on an object or a -// container that went, and follow a rename, matching by the object rather than a bare name. -// +// What each window does with its connection's catalog changes: close the tabs on an object or a +// container that went, and follow a rename, matching by the object rather than a bare name. import Foundation @testable import TablePro import TableProPluginKit +import TableProTestSupport import Testing @Suite("Catalog changes in a window", .serialized) @MainActor struct CatalogChangeWindowTests { - private static func makeCoordinator( + private let fixtures = CoordinatorFixtures() + + private func makeCoordinator( connection: DatabaseConnection ) -> (MainContentCoordinator, QueryTabManager) { var session = ConnectionSession(connection: connection) session.browseDatabase = "shop" DatabaseManager.shared.injectSession(session, for: connection.id) - let tabManager = QueryTabManager() - let coordinator = MainContentCoordinator( - connection: connection, - tabManager: tabManager, - changeManager: DataChangeManager(), - toolbarState: ConnectionToolbarState() - ) + let fixture = fixtures.make(connection: connection) + let tabManager = fixture.tabManager + let coordinator = fixture.coordinator return (coordinator, tabManager) } @@ -80,7 +74,7 @@ struct CatalogChangeWindowTests { func droppedTableClosesOnlyItsOwnTabs() { let connection = TestFixtures.makeConnection(database: "shop") defer { DatabaseManager.shared.removeSession(for: connection.id) } - let (coordinator, tabManager) = Self.makeCoordinator(connection: connection) + let (coordinator, tabManager) = makeCoordinator(connection: connection) let dropped = Self.tableTab("users", database: "shop", schema: "analytics") let survivor = Self.tableTab("users", database: "shop", schema: "public") let query = QueryTab(title: "Query", query: "DROP TABLE analytics.users", tabType: .query) @@ -99,7 +93,7 @@ struct CatalogChangeWindowTests { func renamedTableRetitlesItsTabs() { let connection = TestFixtures.makeConnection(database: "shop") defer { DatabaseManager.shared.removeSession(for: connection.id) } - let (coordinator, tabManager) = Self.makeCoordinator(connection: connection) + let (coordinator, tabManager) = makeCoordinator(connection: connection) let renamed = Self.tableTab("orders", database: "shop", schema: nil) let other = Self.tableTab("orders", database: "warehouse", schema: nil) tabManager.tabs = [renamed, other] @@ -117,7 +111,7 @@ struct CatalogChangeWindowTests { func droppedDatabaseClosesItsTabs() { let connection = TestFixtures.makeConnection(database: "shop") defer { DatabaseManager.shared.removeSession(for: connection.id) } - let (coordinator, tabManager) = Self.makeCoordinator(connection: connection) + let (coordinator, tabManager) = makeCoordinator(connection: connection) let inside = Self.tableTab("events", database: "staging", schema: nil) let outside = Self.tableTab("orders", database: "shop", schema: nil) tabManager.tabs = [inside, outside] @@ -133,7 +127,7 @@ struct CatalogChangeWindowTests { func renamedSchemaRetargetsItsTabs() { let connection = TestFixtures.makeConnection(database: "shop") defer { DatabaseManager.shared.removeSession(for: connection.id) } - let (coordinator, tabManager) = Self.makeCoordinator(connection: connection) + let (coordinator, tabManager) = makeCoordinator(connection: connection) let moved = Self.tableTab("invoices", database: "shop", schema: "billing") let untouched = Self.tableTab("orders", database: "shop", schema: "public") tabManager.tabs = [moved, untouched] @@ -154,7 +148,7 @@ struct CatalogChangeWindowTests { func otherConnectionIsIgnored() { let connection = TestFixtures.makeConnection(database: "shop") defer { DatabaseManager.shared.removeSession(for: connection.id) } - let (coordinator, tabManager) = Self.makeCoordinator(connection: connection) + let (coordinator, tabManager) = makeCoordinator(connection: connection) let tab = Self.tableTab("orders", database: "shop", schema: nil) tabManager.tabs = [tab] @@ -169,7 +163,7 @@ struct CatalogChangeWindowTests { func rowsChangeMarksBackgroundTabsOnItsTable() { let connection = TestFixtures.makeConnection(database: "shop") defer { DatabaseManager.shared.removeSession(for: connection.id) } - let (coordinator, tabManager) = Self.makeCoordinator(connection: connection) + let (coordinator, tabManager) = makeCoordinator(connection: connection) let loaded = Self.loadedTab("users", rowCount: 3, totalRowCount: 3, in: coordinator) let empty = Self.loadedTab("users", rowCount: 0, in: coordinator) let unrelated = Self.loadedTab("orders", rowCount: 2, totalRowCount: 42, in: coordinator) @@ -195,7 +189,7 @@ struct CatalogChangeWindowTests { func rowsChangeReloadsACleanSelectedTab() { let connection = TestFixtures.makeConnection(database: "shop") defer { DatabaseManager.shared.removeSession(for: connection.id) } - let (coordinator, tabManager) = Self.makeCoordinator(connection: connection) + let (coordinator, tabManager) = makeCoordinator(connection: connection) let selected = Self.loadedTab("users", rowCount: 3, totalRowCount: 42, in: coordinator) tabManager.tabs = [selected] tabManager.selectedTabId = selected.id @@ -209,7 +203,7 @@ struct CatalogChangeWindowTests { func rowsChangeLeavesASelectedTabWithEditsAlone() { let connection = TestFixtures.makeConnection(database: "shop") defer { DatabaseManager.shared.removeSession(for: connection.id) } - let (coordinator, tabManager) = Self.makeCoordinator(connection: connection) + let (coordinator, tabManager) = makeCoordinator(connection: connection) var selected = Self.loadedTab("users", rowCount: 3, totalRowCount: 42, in: coordinator) selected.pendingChanges.deletedRowIDs = [.existing(0)] tabManager.tabs = [selected] @@ -226,7 +220,7 @@ struct CatalogChangeWindowTests { func rowsChangeSkipsItsOriginTab() { let connection = TestFixtures.makeConnection(database: "shop") defer { DatabaseManager.shared.removeSession(for: connection.id) } - let (coordinator, tabManager) = Self.makeCoordinator(connection: connection) + let (coordinator, tabManager) = makeCoordinator(connection: connection) let origin = Self.loadedTab("users", rowCount: 3, totalRowCount: 42, in: coordinator) let duplicate = Self.loadedTab("users", rowCount: 3, in: coordinator) tabManager.tabs = [origin, duplicate] @@ -245,7 +239,7 @@ struct CatalogChangeWindowTests { func rowsChangeReloadsBehindTheStructureView() { let connection = TestFixtures.makeConnection(database: "shop") defer { DatabaseManager.shared.removeSession(for: connection.id) } - let (coordinator, tabManager) = Self.makeCoordinator(connection: connection) + let (coordinator, tabManager) = makeCoordinator(connection: connection) var selected = Self.loadedTab("users", rowCount: 3, in: coordinator) selected.display.resultsViewMode = .structure tabManager.tabs = [selected] @@ -260,7 +254,7 @@ struct CatalogChangeWindowTests { func structureChangeMarksStructureSessionsWithoutStagedEdits() { let connection = TestFixtures.makeConnection(database: "shop") defer { DatabaseManager.shared.removeSession(for: connection.id) } - let (coordinator, tabManager) = Self.makeCoordinator(connection: connection) + let (coordinator, tabManager) = makeCoordinator(connection: connection) let clean = Self.loadedTab("users", rowCount: 1, in: coordinator) let editing = Self.loadedTab("users", rowCount: 1, in: coordinator) let unrelated = Self.loadedTab("orders", rowCount: 1, in: coordinator) @@ -290,7 +284,7 @@ struct CatalogChangeWindowTests { func commentChangeMarksStructureSessions() { let connection = TestFixtures.makeConnection(database: "shop") defer { DatabaseManager.shared.removeSession(for: connection.id) } - let (coordinator, tabManager) = Self.makeCoordinator(connection: connection) + let (coordinator, tabManager) = makeCoordinator(connection: connection) let clean = Self.loadedTab("users", rowCount: 1, in: coordinator) let editing = Self.loadedTab("users", rowCount: 1, in: coordinator) let unrelated = Self.loadedTab("orders", rowCount: 1, in: coordinator) @@ -319,7 +313,7 @@ struct CatalogChangeWindowTests { func structureChangeForgetsCachedSchemaColumns() { let connection = TestFixtures.makeConnection(database: "shop") defer { DatabaseManager.shared.removeSession(for: connection.id) } - let (coordinator, tabManager) = Self.makeCoordinator(connection: connection) + let (coordinator, tabManager) = makeCoordinator(connection: connection) let tab = Self.loadedTab("users", rowCount: 1, in: coordinator) tabManager.tabs = [tab] let key = coordinator.schemaColumnsKey("users", scope: coordinator.scope(for: tab)) @@ -358,7 +352,7 @@ struct CatalogChangeWindowTests { func structureChangeRetiresTheCachedDefinition() { let connection = TestFixtures.makeConnection(database: "shop") defer { DatabaseManager.shared.removeSession(for: connection.id) } - let (coordinator, tabManager) = Self.makeCoordinator(connection: connection) + let (coordinator, tabManager) = makeCoordinator(connection: connection) let background = Self.tabWithCachedMetadata("users", in: coordinator) let unrelated = Self.tabWithCachedMetadata("orders", in: coordinator) var editing = Self.tabWithCachedMetadata("users", in: coordinator) @@ -383,7 +377,7 @@ struct CatalogChangeWindowTests { func changeRestartsOnlyALoadThatStartedBeforeIt() { let connection = TestFixtures.makeConnection(database: "shop") defer { DatabaseManager.shared.removeSession(for: connection.id) } - let (coordinator, tabManager) = Self.makeCoordinator(connection: connection) + let (coordinator, tabManager) = makeCoordinator(connection: connection) let selected = Self.loadedTab("users", rowCount: 3, in: coordinator) tabManager.tabs = [selected] tabManager.selectedTabId = selected.id @@ -428,7 +422,7 @@ struct CatalogChangeWindowTests { func answeringReadRetiresALateTotal() { let connection = TestFixtures.makeConnection(database: "shop") defer { DatabaseManager.shared.removeSession(for: connection.id) } - let (coordinator, tabManager) = Self.makeCoordinator(connection: connection) + let (coordinator, tabManager) = makeCoordinator(connection: connection) let background = Self.loadedTab("users", rowCount: 3, totalRowCount: 5_000_000, in: coordinator) let unrelated = Self.loadedTab("orders", rowCount: 2, in: coordinator) tabManager.tabs = [background, unrelated] @@ -464,7 +458,7 @@ struct CatalogChangeWindowTests { func aPreChangeDefinitionStaysOutOfTheColumnCache() throws { let connection = TestFixtures.makeConnection(database: "shop") defer { DatabaseManager.shared.removeSession(for: connection.id) } - let (coordinator, tabManager) = Self.makeCoordinator(connection: connection) + let (coordinator, tabManager) = makeCoordinator(connection: connection) let background = Self.loadedTab("users", rowCount: 1, in: coordinator) tabManager.tabs = [background] let scope = try #require(coordinator.scope(for: background)) @@ -502,7 +496,7 @@ struct CatalogChangeWindowTests { func deferredChangeReloadsOnceTheEditsAreGone() { let connection = TestFixtures.makeConnection(database: "shop") defer { DatabaseManager.shared.removeSession(for: connection.id) } - let (coordinator, tabManager) = Self.makeCoordinator(connection: connection) + let (coordinator, tabManager) = makeCoordinator(connection: connection) var selected = Self.loadedTab("users", rowCount: 3, totalRowCount: 42, in: coordinator) selected.pendingChanges.deletedRowIDs = [.existing(0)] tabManager.tabs = [selected] @@ -519,10 +513,20 @@ struct CatalogChangeWindowTests { #expect(Self.totalRowCount(of: selected.id, in: tabManager) == nil) } - /// Waits a few turns for a reload the change manager's cleared edits start on a later one. + /// Waits for a reload the change manager's cleared edits start on a later turn. A timeout + /// returns too, so the case's own expectation reports it. private static func waitForReload(of tabId: UUID, in tabManager: QueryTabManager) async throws { - for _ in 0..<50 where totalRowCount(of: tabId, in: tabManager) != nil { - try await Task.sleep(for: .milliseconds(10)) + _ = try? await waitUntil { totalRowCount(of: tabId, in: tabManager) == nil } + } + + /// The edits' state is heard through `RunLoop.main` and a reload starts as main-actor work, so a + /// reload that was not put off has started by the time a block queued behind both runs. + private static func afterQueuedMainWork() async { + await withCheckedContinuation { continuation in + RunLoop.main.perform { continuation.resume() } + } + await withCheckedContinuation { continuation in + DispatchQueue.main.async { continuation.resume() } } } @@ -533,7 +537,7 @@ struct CatalogChangeWindowTests { func deferredChangeReloadsOnceTheLastEditIsTakenBack() async throws { let connection = TestFixtures.makeConnection(database: "shop") defer { DatabaseManager.shared.removeSession(for: connection.id) } - let (coordinator, tabManager) = Self.makeCoordinator(connection: connection) + let (coordinator, tabManager) = makeCoordinator(connection: connection) let selected = Self.loadedTab("users", rowCount: 3, totalRowCount: 42, in: coordinator) tabManager.tabs = [selected] tabManager.selectedTabId = selected.id @@ -541,7 +545,7 @@ struct CatalogChangeWindowTests { coordinator.changeManager.recordRowDeletion(rowID: .existing(0), originalRow: [.text("0")]) coordinator.applyObjectChange(Self.change(connection, name: "users", database: "shop", schema: nil, kind: .rows)) - try await Task.sleep(for: .milliseconds(50)) + await Self.afterQueuedMainWork() #expect(Self.totalRowCount(of: selected.id, in: tabManager) == 42) coordinator.changeManager.undoRowDeletion(rowID: .existing(0)) @@ -557,7 +561,7 @@ struct CatalogChangeWindowTests { func aTabShownAgainDropsItsSavedEditsOnceRestored() async throws { let connection = TestFixtures.makeConnection(database: "shop") defer { DatabaseManager.shared.removeSession(for: connection.id) } - let (coordinator, tabManager) = Self.makeCoordinator(connection: connection) + let (coordinator, tabManager) = makeCoordinator(connection: connection) let edited = Self.loadedTab("users", rowCount: 3, totalRowCount: 42, in: coordinator) let other = Self.loadedTab("orders", rowCount: 1, in: coordinator) tabManager.tabs = [edited, other] @@ -573,7 +577,7 @@ struct CatalogChangeWindowTests { #expect(tabManager.tabs.first { $0.id == edited.id }?.pendingChanges.hasChanges == false) coordinator.applyObjectChange(Self.change(connection, name: "users", database: "shop", schema: nil, kind: .rows)) - try await Task.sleep(for: .milliseconds(50)) + await Self.afterQueuedMainWork() #expect(Self.totalRowCount(of: edited.id, in: tabManager) == 42) coordinator.changeManager.undoRowDeletion(rowID: .existing(0)) @@ -597,7 +601,7 @@ struct CatalogChangeWindowTests { func resumingDuringTeardownReloadsNothing() { let connection = TestFixtures.makeConnection(database: "shop") defer { DatabaseManager.shared.removeSession(for: connection.id) } - let (coordinator, tabManager) = Self.makeCoordinator(connection: connection) + let (coordinator, tabManager) = makeCoordinator(connection: connection) let selected = Self.loadedTab("users", rowCount: 3, totalRowCount: 42, in: coordinator) tabManager.tabs = [selected] tabManager.selectedTabId = selected.id @@ -614,7 +618,7 @@ struct CatalogChangeWindowTests { func resumingAFreshTabReloadsNothing() { let connection = TestFixtures.makeConnection(database: "shop") defer { DatabaseManager.shared.removeSession(for: connection.id) } - let (coordinator, tabManager) = Self.makeCoordinator(connection: connection) + let (coordinator, tabManager) = makeCoordinator(connection: connection) let selected = Self.loadedTab("users", rowCount: 3, totalRowCount: 42, in: coordinator) tabManager.tabs = [selected] tabManager.selectedTabId = selected.id diff --git a/TableProTests/Views/Main/CommandActionsBulkCloseTests.swift b/TableProTests/Views/Main/CommandActionsBulkCloseTests.swift index fc42c25c56..3a2be6832b 100644 --- a/TableProTests/Views/Main/CommandActionsBulkCloseTests.swift +++ b/TableProTests/Views/Main/CommandActionsBulkCloseTests.swift @@ -1,16 +1,12 @@ -// -// CommandActionsBulkCloseTests.swift -// TableProTests -// -// Covers the bulk tab-close commands: which of the connection's tabs they target and -// that the surviving window empties in place instead of closing (#1972). -// +// The bulk tab-close commands: which of the connection's tabs they target, and that the surviving +// window empties in place instead of closing. import AppKit import Foundation import SwiftUI @testable import TablePro import TableProPluginKit +import TableProTestSupport import Testing @MainActor @@ -193,12 +189,7 @@ struct CommandActionsBulkCloseTests { #expect(current.coordinator.tabManager.tabs.count == 2) current.actions.closeTabsForOtherDatabases() - - var spins = 0 - while current.coordinator.tabManager.tabs.count > 1, spins < 500 { - await Task.yield() - spins += 1 - } + _ = try? await waitUntil { current.coordinator.tabManager.tabs.count <= 1 } #expect(current.coordinator.tabManager.tabs.count == 1) #expect(current.coordinator.tabManager.tabs.first?.tabType == .query) diff --git a/TableProTests/Views/Main/CommandActionsDispatchTests.swift b/TableProTests/Views/Main/CommandActionsDispatchTests.swift index bd5f5c10de..32cb8c7f4e 100644 --- a/TableProTests/Views/Main/CommandActionsDispatchTests.swift +++ b/TableProTests/Views/Main/CommandActionsDispatchTests.swift @@ -1,10 +1,5 @@ -// -// CommandActionsDispatchTests.swift -// TableProTests -// -// Tests that MainContentCommandActions correctly forwards calls -// to MainContentCoordinator and its sub-handlers. -// +// Tests that MainContentCommandActions correctly forwards calls +// to MainContentCoordinator and its sub-handlers. import AppKit import Foundation @@ -13,26 +8,6 @@ import SwiftUI import TableProPluginKit import Testing -@MainActor -private final class CommandActionsClipboard: ClipboardProvider { - var text: String? - var hasGridRowsValue = false - - func readText() -> String? { text } - func readGridRows() -> GridRowsClipboardPayload? { nil } - func writeText(_ text: String) { self.text = text; hasGridRowsValue = false } - func writeCsv(_ csv: String) { text = csv; hasGridRowsValue = false } - - var copiedImages: [NSImage] = [] - - func writeImage(_ image: NSImage) { - copiedImages.append(image) - } - func writeRows(tsv: String, html: String?, gridRows: GridRowsClipboardPayload) { text = tsv; hasGridRowsValue = true } - var hasText: Bool { text != nil } - var hasGridRows: Bool { hasGridRowsValue } -} - @MainActor private final class CommandActionsLayoutPersister: ColumnLayoutPersisting { func load(for key: ColumnLayoutTableKey) -> ColumnLayoutState? { nil } @@ -40,335 +15,334 @@ private final class CommandActionsLayoutPersister: ColumnLayoutPersisting { func clear(for key: ColumnLayoutTableKey) {} } -@MainActor -struct CommandActionsDispatchTests { - // MARK: - Helpers - - private func makeSUT() -> (MainContentCommandActions, MainContentCoordinator) { - let connection = TestFixtures.makeConnection() - let state = SessionStateFactory.create(connection: connection, payload: nil) - let coordinator = state.coordinator - - var selectedTables: Set = [] - var pendingTruncates: Set = [] - var pendingDeletes: Set = [] - var tableOperationOptions: [DatabaseTreeTableRef: TableOperationOptions] = [:] - let trailingPaneState = TrailingPaneState() - - let actions = MainContentCommandActions( - coordinator: coordinator, - connection: connection, - selectionState: coordinator.selectionState, - selectedTables: Binding(get: { selectedTables }, set: { selectedTables = $0 }), - pendingTruncates: Binding(get: { pendingTruncates }, set: { pendingTruncates = $0 }), - pendingDeletes: Binding(get: { pendingDeletes }, set: { pendingDeletes = $0 }), - tableOperationOptions: Binding( - get: { tableOperationOptions }, - set: { tableOperationOptions = $0 } - ), - trailingPaneState: trailingPaneState - ) - - return (actions, coordinator) - } - - // MARK: - Query text +extension GlobalStateSuites { + @Suite(.recordingClipboard) + @MainActor + struct CommandActionsDispatchTests { + // MARK: - Helpers + + private func makeSUT() -> (MainContentCommandActions, MainContentCoordinator) { + let connection = TestFixtures.makeConnection() + let state = SessionStateFactory.create(connection: connection, payload: nil) + let coordinator = state.coordinator + + var selectedTables: Set = [] + var pendingTruncates: Set = [] + var pendingDeletes: Set = [] + var tableOperationOptions: [DatabaseTreeTableRef: TableOperationOptions] = [:] + let trailingPaneState = TrailingPaneState() + + let actions = MainContentCommandActions( + coordinator: coordinator, + connection: connection, + selectionState: coordinator.selectionState, + selectedTables: Binding(get: { selectedTables }, set: { selectedTables = $0 }), + pendingTruncates: Binding(get: { pendingTruncates }, set: { pendingTruncates = $0 }), + pendingDeletes: Binding(get: { pendingDeletes }, set: { pendingDeletes = $0 }), + tableOperationOptions: Binding( + get: { tableOperationOptions }, + set: { tableOperationOptions = $0 } + ), + trailingPaneState: trailingPaneState + ) - /// The Query menu used to test the text for emptiness while the editor bar trimmed it, so - /// Run and Explain Query stayed enabled over an editor holding only whitespace. - @Test("An editor holding only whitespace or invisible characters has no query text for the menu") - func blankEditorHasNoQueryText() { - let (actions, coordinator) = makeSUT() - coordinator.tabManager.addTab(initialQuery: " \n\t\u{200B}\u{FEFF}", databaseName: "testdb") + return (actions, coordinator) + } - #expect(!actions.hasQueryText) - #expect(!actions.canSaveAsFavorite) - #expect(actions.canClearQuery) - } + // MARK: - Query text - @Test("An editor with a statement has query text for the menu") - func editorWithStatementHasQueryText() { - let (actions, coordinator) = makeSUT() - coordinator.tabManager.addTab(initialQuery: " SELECT 1 ", databaseName: "testdb") + /// The Query menu used to test the text for emptiness while the editor bar trimmed it, so + /// Run and Explain Query stayed enabled over an editor holding only whitespace. + @Test("An editor holding only whitespace or invisible characters has no query text for the menu") + func blankEditorHasNoQueryText() { + let (actions, coordinator) = makeSUT() + coordinator.tabManager.addTab(initialQuery: " \n\t\u{200B}\u{FEFF}", databaseName: "testdb") - #expect(actions.hasQueryText) - #expect(actions.canSaveAsFavorite) - } + #expect(!actions.hasQueryText) + #expect(!actions.canSaveAsFavorite) + #expect(actions.canClearQuery) + } - // MARK: - loadQueryIntoEditor + @Test("An editor with a statement has query text for the menu") + func editorWithStatementHasQueryText() { + let (actions, coordinator) = makeSUT() + coordinator.tabManager.addTab(initialQuery: " SELECT 1 ", databaseName: "testdb") - @Test("loadQueryIntoEditor forwards query to coordinator and updates tab") - func loadQueryIntoEditor_forwardsToCoordinator() { - let (actions, coordinator) = makeSUT() - coordinator.tabManager.addTab(databaseName: "testdb") + #expect(actions.hasQueryText) + #expect(actions.canSaveAsFavorite) + } - actions.loadQueryIntoEditor("SELECT 1") + // MARK: - loadQueryIntoEditor - let tab = coordinator.tabManager.selectedTab - #expect(tab?.content.query == "SELECT 1") - } + @Test("loadQueryIntoEditor forwards query to coordinator and updates tab") + func loadQueryIntoEditor_forwardsToCoordinator() { + let (actions, coordinator) = makeSUT() + coordinator.tabManager.addTab(databaseName: "testdb") - // MARK: - insertQueryFromAI + actions.loadQueryIntoEditor("SELECT 1") - @Test("insertQueryFromAI forwards query to coordinator and updates tab") - func insertQueryFromAI_forwardsToCoordinator() { - let (actions, coordinator) = makeSUT() - coordinator.tabManager.addTab(databaseName: "testdb") + let tab = coordinator.tabManager.selectedTab + #expect(tab?.content.query == "SELECT 1") + } - actions.insertQueryFromAI("SELECT 2") + // MARK: - insertQueryFromAI - let tab = coordinator.tabManager.selectedTab - #expect(tab?.content.query == "SELECT 2") - } + @Test("insertQueryFromAI forwards query to coordinator and updates tab") + func insertQueryFromAI_forwardsToCoordinator() { + let (actions, coordinator) = makeSUT() + coordinator.tabManager.addTab(databaseName: "testdb") - @Test("insertQueryFromAI leaves a tab the user has typed into alone") - func insertQueryFromAI_appendsToExisting() { - let (actions, coordinator) = makeSUT() - coordinator.tabManager.addTab(databaseName: "testdb") + actions.insertQueryFromAI("SELECT 2") - // Set an initial query on the tab - if let idx = coordinator.tabManager.selectedTabIndex { - coordinator.tabManager.tabs[idx].content.query = "SELECT 1" + let tab = coordinator.tabManager.selectedTab + #expect(tab?.content.query == "SELECT 2") } - actions.insertQueryFromAI("SELECT 2") - - /// Left alone. Appending was removed on purpose in #1257, and `aiInsertReusesSelectedQueryTab` - /// is true only for a query tab that is empty, so generated SQL takes over a blank tab and - /// otherwise opens its own. A tab the user has typed into is never rewritten, which is the - /// property worth holding; this case asserted the concatenation that fix removed. - #expect(coordinator.tabManager.selectedTab?.content.query == "SELECT 1") - } + @Test("insertQueryFromAI leaves a tab the user has typed into alone") + func insertQueryFromAI_appendsToExisting() { + let (actions, coordinator) = makeSUT() + coordinator.tabManager.addTab(databaseName: "testdb") - @Test("insertQueryFromAI reuses the selected query tab when it is empty") - @MainActor - func insertQueryFromAI_reusesAnEmptyTab() { - let (actions, coordinator) = makeSUT() - coordinator.tabManager.addTab(databaseName: "testdb") + // Set an initial query on the tab + if let idx = coordinator.tabManager.selectedTabIndex { + coordinator.tabManager.tabs[idx].content.query = "SELECT 1" + } - actions.insertQueryFromAI("SELECT 2") + actions.insertQueryFromAI("SELECT 2") - #expect(coordinator.tabManager.selectedTab?.content.query == "SELECT 2") - } + /// Left alone. Appending was removed on purpose in #1257, and `aiInsertReusesSelectedQueryTab` + /// is true only for a query tab that is empty, so generated SQL takes over a blank tab and + /// otherwise opens its own. A tab the user has typed into is never rewritten, which is the + /// property worth holding; this case asserted the concatenation that fix removed. + #expect(coordinator.tabManager.selectedTab?.content.query == "SELECT 1") + } - // MARK: - copySelectedRows (structure mode) + @Test("insertQueryFromAI reuses the selected query tab when it is empty") + @MainActor + func insertQueryFromAI_reusesAnEmptyTab() { + let (actions, coordinator) = makeSUT() + coordinator.tabManager.addTab(databaseName: "testdb") - @Test("copySelectedRows in structure mode calls structureActions.copyRows") - func copySelectedRows_structureMode_callsStructureActions() { - let (actions, coordinator) = makeSUT() - coordinator.tabManager.addTab(databaseName: "testdb") + actions.insertQueryFromAI("SELECT 2") - // Enable structure mode on the selected tab - if let idx = coordinator.tabManager.selectedTabIndex { - coordinator.tabManager.tabs[idx].display.resultsViewMode = .structure + #expect(coordinator.tabManager.selectedTab?.content.query == "SELECT 2") } - // Install a spy handler - let handler = StructureViewActionHandler() - var copyRowsCalled = false - handler.copyRows = { copyRowsCalled = true } - coordinator.structureActions = handler + // MARK: - copySelectedRows (structure mode) - actions.copySelectedRows() + @Test("copySelectedRows in structure mode calls structureActions.copyRows") + func copySelectedRows_structureMode_callsStructureActions() { + let (actions, coordinator) = makeSUT() + coordinator.tabManager.addTab(databaseName: "testdb") - #expect(copyRowsCalled) - } + // Enable structure mode on the selected tab + if let idx = coordinator.tabManager.selectedTabIndex { + coordinator.tabManager.tabs[idx].display.resultsViewMode = .structure + } - // MARK: - pasteRows (structure mode) + // Install a spy handler + let handler = StructureViewActionHandler() + var copyRowsCalled = false + handler.copyRows = { copyRowsCalled = true } + coordinator.structureActions = handler - @Test("pasteRows in structure mode calls structureActions.pasteRows") - func pasteRows_structureMode_callsStructureActions() { - let (actions, coordinator) = makeSUT() - coordinator.tabManager.addTab(databaseName: "testdb") + actions.copySelectedRows() - // Enable structure mode on the selected tab - if let idx = coordinator.tabManager.selectedTabIndex { - coordinator.tabManager.tabs[idx].display.resultsViewMode = .structure + #expect(copyRowsCalled) } - // Install a spy handler - let handler = StructureViewActionHandler() - var pasteRowsCalled = false - handler.pasteRows = { pasteRowsCalled = true } - coordinator.structureActions = handler + // MARK: - pasteRows (structure mode) - actions.pasteRows() + @Test("pasteRows in structure mode calls structureActions.pasteRows") + func pasteRows_structureMode_callsStructureActions() { + let (actions, coordinator) = makeSUT() + coordinator.tabManager.addTab(databaseName: "testdb") - #expect(pasteRowsCalled) - } + // Enable structure mode on the selected tab + if let idx = coordinator.tabManager.selectedTabIndex { + coordinator.tabManager.tabs[idx].display.resultsViewMode = .structure + } - // MARK: - addNewRow (structure mode) + // Install a spy handler + let handler = StructureViewActionHandler() + var pasteRowsCalled = false + handler.pasteRows = { pasteRowsCalled = true } + coordinator.structureActions = handler - @Test("addNewRow in structure mode calls structureActions.addRow") - func addNewRow_structureMode_callsStructureActions() { - let (actions, coordinator) = makeSUT() - coordinator.tabManager.addTab(databaseName: "testdb") + actions.pasteRows() - if let idx = coordinator.tabManager.selectedTabIndex { - coordinator.tabManager.tabs[idx].display.resultsViewMode = .structure + #expect(pasteRowsCalled) } - let handler = StructureViewActionHandler() - var addRowCalled = false - handler.addRow = { addRowCalled = true } - coordinator.structureActions = handler + // MARK: - addNewRow (structure mode) - actions.addNewRow() + @Test("addNewRow in structure mode calls structureActions.addRow") + func addNewRow_structureMode_callsStructureActions() { + let (actions, coordinator) = makeSUT() + coordinator.tabManager.addTab(databaseName: "testdb") - #expect(addRowCalled) - } + if let idx = coordinator.tabManager.selectedTabIndex { + coordinator.tabManager.tabs[idx].display.resultsViewMode = .structure + } - // MARK: - deleteSelectedRows (structure mode) + let handler = StructureViewActionHandler() + var addRowCalled = false + handler.addRow = { addRowCalled = true } + coordinator.structureActions = handler - @Test("deleteSelectedRows in structure mode calls structureActions.removeRow") - func deleteSelectedRows_structureMode_callsStructureActions() { - let (actions, coordinator) = makeSUT() - coordinator.tabManager.addTab(databaseName: "testdb") + actions.addNewRow() - if let idx = coordinator.tabManager.selectedTabIndex { - coordinator.tabManager.tabs[idx].display.resultsViewMode = .structure + #expect(addRowCalled) } - let handler = StructureViewActionHandler() - var removeRowCalled = false - handler.removeRow = { removeRowCalled = true } - coordinator.structureActions = handler + // MARK: - deleteSelectedRows (structure mode) - actions.deleteSelectedRows() + @Test("deleteSelectedRows in structure mode calls structureActions.removeRow") + func deleteSelectedRows_structureMode_callsStructureActions() { + let (actions, coordinator) = makeSUT() + coordinator.tabManager.addTab(databaseName: "testdb") - #expect(removeRowCalled) - } + if let idx = coordinator.tabManager.selectedTabIndex { + coordinator.tabManager.tabs[idx].display.resultsViewMode = .structure + } - // MARK: - saveChanges (createTable tab) - - /// A Create Table tab is answered by `createTableActions` and nothing else. The structure arm - /// sits below it and would otherwise try to apply the staged edits of whatever session the tab - /// id happens to carry. - @Test("saveChanges dispatches createTableActions when the selected tab is createTable") - func saveChanges_createTableTab_callsCreateTableAction() { - let (actions, coordinator) = makeSUT() - coordinator.tabManager.addCreateTableTab(databaseName: "testdb") - guard let id = coordinator.tabManager.selectedTabId else { - Issue.record("expected a selected tab") - return - } + let handler = StructureViewActionHandler() + var removeRowCalled = false + handler.removeRow = { removeRowCalled = true } + coordinator.structureActions = handler - let createHandler = CreateTableActionHandler() - var createCalled = false - createHandler.createTable = { createCalled = true } - coordinator.createTableActions = createHandler + actions.deleteSelectedRows() - let session = TestFixtures.makeStructureSession() - coordinator.structureSessions[id] = session - session.changeManager.loadSchema( - tableName: "users", columns: [], indexes: [], foreignKeys: [], primaryKey: [] - ) - session.changeManager.addNewColumn() + #expect(removeRowCalled) + } - actions.saveChanges() + // MARK: - saveChanges (createTable tab) + + /// A Create Table tab is answered by `createTableActions` and nothing else. The structure arm + /// sits below it and would otherwise try to apply the staged edits of whatever session the tab + /// id happens to carry. + @Test("saveChanges dispatches createTableActions when the selected tab is createTable") + func saveChanges_createTableTab_callsCreateTableAction() { + let (actions, coordinator) = makeSUT() + coordinator.tabManager.addCreateTableTab(databaseName: "testdb") + guard let id = coordinator.tabManager.selectedTabId else { + Issue.record("expected a selected tab") + return + } + + let createHandler = CreateTableActionHandler() + var createCalled = false + createHandler.createTable = { createCalled = true } + coordinator.createTableActions = createHandler + + let session = TestFixtures.makeStructureSession() + coordinator.structureSessions[id] = session + session.changeManager.loadSchema( + tableName: "users", columns: [], indexes: [], foreignKeys: [], primaryKey: [] + ) + session.changeManager.addNewColumn() - #expect(createCalled) - #expect(session.appliedVersion == 0) - #expect(session.changeManager.hasChanges) - } + actions.saveChanges() - @Test("saveChanges without createTableActions is a no-op for a createTable tab") - func saveChanges_createTableTab_withoutAction_doesNothing() { - let (actions, coordinator) = makeSUT() - coordinator.tabManager.addCreateTableTab(databaseName: "testdb") - guard let id = coordinator.tabManager.selectedTabId else { - Issue.record("expected a selected tab") - return + #expect(createCalled) + #expect(session.appliedVersion == 0) + #expect(session.changeManager.hasChanges) } - let session = TestFixtures.makeStructureSession() - coordinator.structureSessions[id] = session - actions.saveChanges() + @Test("saveChanges without createTableActions is a no-op for a createTable tab") + func saveChanges_createTableTab_withoutAction_doesNothing() { + let (actions, coordinator) = makeSUT() + coordinator.tabManager.addCreateTableTab(databaseName: "testdb") + guard let id = coordinator.tabManager.selectedTabId else { + Issue.record("expected a selected tab") + return + } + let session = TestFixtures.makeStructureSession() + coordinator.structureSessions[id] = session - #expect(session.appliedVersion == 0) - } + actions.saveChanges() - // MARK: - Row selection resolution - - private func seedRows(_ coordinator: MainContentCoordinator) { - guard let tabId = coordinator.tabManager.selectedTabId else { return } - let tableRows = TableRows.from( - queryRows: [ - [.text("1"), .text("Alice")], - [.text("2"), .text("Bob")] - ], - columns: ["id", "name"], - columnTypes: [.text(rawType: nil), .text(rawType: nil)] - ) - coordinator.setActiveTableRows(tableRows, for: tabId) - } + #expect(session.appliedVersion == 0) + } - private func attachGridWithRangeSelection( - to coordinator: MainContentCoordinator - ) -> (DataTabGridDelegate, TableViewCoordinator) { - let delegate = DataTabGridDelegate() - let tableViewCoordinator = TableViewCoordinator( - changeManager: AnyChangeManager(DataChangeManager()), - isEditable: true, - selectedRowIndices: .constant([]), - delegate: delegate, - layoutPersister: CommandActionsLayoutPersister() - ) - tableViewCoordinator.selectionController.update( - .single( - GridRect(rows: 0...1, columns: 0...0), - anchor: GridCoord(row: 0, displayColumn: 0), - active: GridCoord(row: 1, displayColumn: 0) + // MARK: - Row selection resolution + + private func seedRows(_ coordinator: MainContentCoordinator) { + guard let tabId = coordinator.tabManager.selectedTabId else { return } + let tableRows = TableRows.from( + queryRows: [ + [.text("1"), .text("Alice")], + [.text("2"), .text("Bob")] + ], + columns: ["id", "name"], + columnTypes: [.text(rawType: nil), .text(rawType: nil)] ) - ) - delegate.dataGridAttach(tableViewCoordinator: tableViewCoordinator) - coordinator.dataTabDelegate = delegate - return (delegate, tableViewCoordinator) - } + coordinator.setActiveTableRows(tableRows, for: tabId) + } - @Test("copySelectedRowsAsJson honors the grid range selection") - func copySelectedRowsAsJson_usesRangeSelection() { - let clipboard = CommandActionsClipboard() - ClipboardService.shared = clipboard - defer { ClipboardService.shared = NSPasteboardClipboardProvider() } + private func attachGridWithRangeSelection( + to coordinator: MainContentCoordinator + ) -> (DataTabGridDelegate, TableViewCoordinator) { + let delegate = DataTabGridDelegate() + let tableViewCoordinator = TableViewCoordinator( + changeManager: AnyChangeManager(DataChangeManager()), + isEditable: true, + selectedRowIndices: .constant([]), + delegate: delegate, + layoutPersister: CommandActionsLayoutPersister() + ) + tableViewCoordinator.selectionController.update( + .single( + GridRect(rows: 0...1, columns: 0...0), + anchor: GridCoord(row: 0, displayColumn: 0), + active: GridCoord(row: 1, displayColumn: 0) + ) + ) + delegate.dataGridAttach(tableViewCoordinator: tableViewCoordinator) + coordinator.dataTabDelegate = delegate + return (delegate, tableViewCoordinator) + } - let (actions, coordinator) = makeSUT() - coordinator.tabManager.addTab(databaseName: "testdb") - seedRows(coordinator) - let attached = attachGridWithRangeSelection(to: coordinator) + @Test("copySelectedRowsAsJson honors the grid range selection") + func copySelectedRowsAsJson_usesRangeSelection() throws { + let clipboard = try #require(RecordingClipboard.current) - actions.copySelectedRowsAsJson() + let (actions, coordinator) = makeSUT() + coordinator.tabManager.addTab(databaseName: "testdb") + seedRows(coordinator) + let attached = attachGridWithRangeSelection(to: coordinator) - #expect(clipboard.text?.contains("Alice") == true) - #expect(clipboard.text?.contains("Bob") == true) - withExtendedLifetime(attached) {} - } + actions.copySelectedRowsAsJson() - @Test("copySelectedRowsAsJson falls back to the row selection without a grid") - func copySelectedRowsAsJson_fallsBackWithoutGrid() { - let clipboard = CommandActionsClipboard() - ClipboardService.shared = clipboard - defer { ClipboardService.shared = NSPasteboardClipboardProvider() } + #expect(clipboard.text?.contains("Alice") == true) + #expect(clipboard.text?.contains("Bob") == true) + withExtendedLifetime(attached) {} + } - let (actions, coordinator) = makeSUT() - coordinator.tabManager.addTab(databaseName: "testdb") - seedRows(coordinator) - coordinator.selectionState.indices = [0] + @Test("copySelectedRowsAsJson falls back to the row selection without a grid") + func copySelectedRowsAsJson_fallsBackWithoutGrid() throws { + let clipboard = try #require(RecordingClipboard.current) - actions.copySelectedRowsAsJson() + let (actions, coordinator) = makeSUT() + coordinator.tabManager.addTab(databaseName: "testdb") + seedRows(coordinator) + coordinator.selectionState.indices = [0] - #expect(clipboard.text?.contains("Alice") == true) - #expect(clipboard.text?.contains("Bob") != true) - } + actions.copySelectedRowsAsJson() - @Test("hasRowSelection reflects a grid range selection") - func hasRowSelection_reflectsRangeSelection() { - let (actions, coordinator) = makeSUT() - coordinator.tabManager.addTab(databaseName: "testdb") - seedRows(coordinator) - let attached = attachGridWithRangeSelection(to: coordinator) + #expect(clipboard.text?.contains("Alice") == true) + #expect(clipboard.text?.contains("Bob") != true) + } - #expect(actions.hasRowSelection) - withExtendedLifetime(attached) {} + @Test("hasRowSelection reflects a grid range selection") + func hasRowSelection_reflectsRangeSelection() { + let (actions, coordinator) = makeSUT() + coordinator.tabManager.addTab(databaseName: "testdb") + seedRows(coordinator) + let attached = attachGridWithRangeSelection(to: coordinator) + + #expect(actions.hasRowSelection) + withExtendedLifetime(attached) {} + } } } diff --git a/TableProTests/Views/Main/CoordinatorActivationTests.swift b/TableProTests/Views/Main/CoordinatorActivationTests.swift index d68d67afde..8c834d35d7 100644 --- a/TableProTests/Views/Main/CoordinatorActivationTests.swift +++ b/TableProTests/Views/Main/CoordinatorActivationTests.swift @@ -1,15 +1,11 @@ -// -// CoordinatorActivationTests.swift -// TableProTests -// -// A connection switch takes a connection's panes out of the window and puts them back, which -// runs `onAppear` again. Activation must treat that as the same connection coming back, not as a -// new one opening: nothing it already loaded is fetched again. -// +// A connection switch takes a connection's panes out of the window and puts them back, which runs +// `onAppear` again. Activation must treat that as the same connection coming back: nothing it +// already loaded is fetched again. import AppKit import Foundation @testable import TablePro +import TableProTestSupport import Testing @Suite("Coordinator activation", .serialized) @@ -22,7 +18,7 @@ struct CoordinatorActivationTests { let mock = harness.connectBackground() harness.controller.workspaces.select(harness.background.connectionId) let coordinator = try #require(harness.background.sessionState?.coordinator) - await harness.settle { coordinator.isActivated && harness.isAutocompletePopulated && coordinator.schemaLoadTask == nil } + try await harness.settle { coordinator.isActivated && harness.isAutocompletePopulated && coordinator.schemaLoadTask == nil } try #require(harness.isAutocompletePopulated) let tablesFetched = mock.fetchTablesCallCount @@ -30,7 +26,7 @@ struct CoordinatorActivationTests { await harness.switchAway() let activations = coordinator.activationCount harness.controller.workspaces.select(harness.background.connectionId) - await harness.settle { coordinator.activationCount > activations && coordinator.schemaLoadTask == nil } + try await harness.settle { coordinator.activationCount > activations && coordinator.schemaLoadTask == nil } await harness.settle(for: 0.5) } @@ -43,7 +39,7 @@ struct CoordinatorActivationTests { SchemaProviderRegistry.shared.clear(for: harness.backgroundConnection.id) await harness.switchAway() harness.controller.workspaces.select(harness.background.connectionId) - await harness.settle { harness.isAutocompletePopulated && coordinator.schemaLoadTask == nil } + try await harness.settle { harness.isAutocompletePopulated && coordinator.schemaLoadTask == nil } #expect(harness.isAutocompletePopulated) #expect(mock.fetchTablesCallCount == tablesFetched) @@ -51,7 +47,7 @@ struct CoordinatorActivationTests { await harness.switchAway() await SchemaService.shared.invalidate(connectionId: harness.backgroundConnection.id) harness.controller.workspaces.select(harness.background.connectionId) - await harness.settle { mock.fetchTablesCallCount > tablesFetched } + try await harness.settle { mock.fetchTablesCallCount > tablesFetched } #expect(mock.fetchTablesCallCount > tablesFetched) #expect(mock.fetchAllColumnsCallCount == 0) @@ -65,7 +61,7 @@ struct CoordinatorActivationTests { harness.controller.workspaces.select(harness.background.connectionId) let coordinator = try #require(harness.background.sessionState?.coordinator) - await harness.settle { coordinator.isActivated && harness.isAutocompletePopulated && coordinator.schemaLoadTask == nil } + try await harness.settle { coordinator.isActivated && harness.isAutocompletePopulated && coordinator.schemaLoadTask == nil } await harness.settle(for: 0.2) #expect(harness.isAutocompletePopulated) @@ -82,7 +78,7 @@ struct CoordinatorActivationTests { let mock = harness.connectBackground(bulkForeignKeyFetch: true) harness.controller.workspaces.select(harness.background.connectionId) let coordinator = try #require(harness.background.sessionState?.coordinator) - await harness.settle { coordinator.isActivated && harness.isAutocompletePopulated && coordinator.schemaLoadTask == nil } + try await harness.settle { coordinator.isActivated && harness.isAutocompletePopulated && coordinator.schemaLoadTask == nil } let scope = try #require(DatabaseManager.shared.browseScope(for: harness.backgroundConnection.id)) #expect(mock.providesBulkForeignKeyFetch) @@ -136,9 +132,7 @@ struct CoordinatorActivationTests { try #require(tree.isTablesCurrent(connectionId: connection.id, database: connection.database, schema: nil)) coordinator.markActivated() try #require(coordinator.fileWatcher != nil) - for _ in 0 ..< 50 where coordinator.schemaLoadTask != nil { - try await Task.sleep(for: .milliseconds(20)) - } + try await waitUntil { coordinator.schemaLoadTask == nil } try #require( tree.isTablesCurrent(connectionId: connection.id, database: connection.database, schema: nil), "Opening the connection marked the list before the file changed" @@ -147,8 +141,8 @@ struct CoordinatorActivationTests { let handle = try FileHandle(forWritingTo: file) try handle.write(contentsOf: Data("changed".utf8)) try handle.close() - for _ in 0 ..< 300 where tree.isTablesCurrent(connectionId: connection.id, database: connection.database, schema: nil) { - try await Task.sleep(for: .milliseconds(20)) + try await waitUntil { + !tree.isTablesCurrent(connectionId: connection.id, database: connection.database, schema: nil) } #expect(!tree.isTablesCurrent(connectionId: connection.id, database: connection.database, schema: nil)) @@ -212,10 +206,10 @@ struct CoordinatorActivationTests { /// SwiftUI mounts a pane on the next layout pass, and the schema load runs as a main-actor /// task, so a test that asks what a switch did has to give the main actor up between passes. /// Spinning the run loop from inside a synchronous test holds the actor and runs neither. - func settle(until isSatisfied: () -> Bool) async { - let deadline = Date(timeIntervalSinceNow: 10) - while !isSatisfied(), Date() < deadline { - await pump() + func settle(until isSatisfied: () -> Bool) async throws { + try await waitUntil { + layOut() + return isSatisfied() } } @@ -231,9 +225,13 @@ struct CoordinatorActivationTests { } } - private func pump() async { + private func layOut() { window.contentView?.layoutSubtreeIfNeeded() controller.view.layoutSubtreeIfNeeded() + } + + private func pump() async { + layOut() try? await Task.sleep(for: .milliseconds(20)) } diff --git a/TableProTests/Views/Main/CoordinatorFixtures.swift b/TableProTests/Views/Main/CoordinatorFixtures.swift new file mode 100644 index 0000000000..1c8974794b --- /dev/null +++ b/TableProTests/Views/Main/CoordinatorFixtures.swift @@ -0,0 +1,16 @@ +@testable import TablePro + +/// Keeps every fixture a case built until the suite instance for that case goes away, so a helper +/// can hand back the bare coordinator and its teardown still runs before the next case. +@MainActor +final class CoordinatorFixtures { + private var kept: [CoordinatorFixture] = [] + + nonisolated init() {} + + func make(connection: DatabaseConnection = TestFixtures.makeConnection()) -> CoordinatorFixture { + let fixture = CoordinatorFixture(connection: connection) + kept.append(fixture) + return fixture + } +} diff --git a/TableProTests/Views/Main/CoordinatorQueryRoutingTests.swift b/TableProTests/Views/Main/CoordinatorQueryRoutingTests.swift index c9fbe3e2d5..0cfbebea14 100644 --- a/TableProTests/Views/Main/CoordinatorQueryRoutingTests.swift +++ b/TableProTests/Views/Main/CoordinatorQueryRoutingTests.swift @@ -61,6 +61,49 @@ struct CoordinatorQueryRoutingTests { #expect(tabManager.pendingFocusTabId == tabManager.tabs[1].id) } + /// Holds the first request it is asked about and refuses it, so the run stops at the gate with no + /// driver behind it. + private actor RunRecordingGate: ExecutionGate { + private var requests: [OperationRequest] = [] + private var waiters: [CheckedContinuation] = [] + + func authorize(_ request: OperationRequest) async -> OperationDecision { + requests.append(request) + let waiting = waiters + waiters = [] + waiting.forEach { $0.resume(returning: request) } + return .denied(reason: "Recorded") + } + + func firstRequest() async -> OperationRequest { + if let first = requests.first { return first } + return await withCheckedContinuation { waiters.append($0) } + } + } + + @Test("Run in New Tab sends the query it opened to be run, not just loaded", .timeLimit(.minutes(1))) + @MainActor + func runInNewTabStartsTheRun() async { + let (coordinator, tabManager) = makeCoordinator() + defer { coordinator.teardown() } + let gate = RunRecordingGate() + coordinator.executionGate = gate + tabManager.addTab(initialQuery: "SELECT 1", databaseName: "testdb") + + coordinator.openQuery( + "SELECT * FROM Genre", + on: coordinator.connectionId, + databaseName: "testdb", + intent: .runInNewTab + ) + + #expect(tabManager.tabs.count == 2) + #expect(tabManager.selectedTab?.content.query == "SELECT * FROM Genre") + let request = await gate.firstRequest() + #expect(request.sql == "SELECT * FROM Genre") + #expect(request.connectionId == coordinator.connectionId) + } + /// The drawer lists other connections' queries once the scope widens, and running one of those /// against whatever is in front of the user is worse than the dead click this replaced. @Test("A query from another connection goes to that connection, never into this tab list") diff --git a/TableProTests/Views/Main/DataRefreshScopeTests.swift b/TableProTests/Views/Main/DataRefreshScopeTests.swift index d677ec5b5f..d3b1c24f81 100644 --- a/TableProTests/Views/Main/DataRefreshScopeTests.swift +++ b/TableProTests/Views/Main/DataRefreshScopeTests.swift @@ -14,10 +14,11 @@ import Foundation import TableProPluginKit import Testing -@Suite("Data refresh scoping", .serialized) @MainActor struct DataRefreshScopeTests { - private static func makeCoordinator( + private let fixtures = CoordinatorFixtures() + + private func makeCoordinator( connection: DatabaseConnection, browseDatabase: String, tabDatabase: String? = nil @@ -26,13 +27,9 @@ struct DataRefreshScopeTests { session.browseDatabase = browseDatabase DatabaseManager.shared.injectSession(session, for: connection.id) - let tabManager = QueryTabManager() - let coordinator = MainContentCoordinator( - connection: connection, - tabManager: tabManager, - changeManager: DataChangeManager(), - toolbarState: ConnectionToolbarState() - ) + let fixture = fixtures.make(connection: connection) + let tabManager = fixture.tabManager + let coordinator = fixture.coordinator if let tabDatabase { var tab = QueryTab(title: "orders", query: "SELECT 1", tabType: .table, tableName: "orders") @@ -48,7 +45,7 @@ struct DataRefreshScopeTests { func scopedRefreshSkipsAWindowBrowsingElsewhere() throws { let connection = TestFixtures.makeConnection(database: "saved_default") defer { DatabaseManager.shared.removeSession(for: connection.id) } - let (coordinator, _) = Self.makeCoordinator( + let (coordinator, _) = makeCoordinator( connection: connection, browseDatabase: "inventory", tabDatabase: "inventory" @@ -65,7 +62,7 @@ struct DataRefreshScopeTests { func unscopedRefreshReachesEveryone() { let connection = TestFixtures.makeConnection(database: "saved_default") defer { DatabaseManager.shared.removeSession(for: connection.id) } - let (coordinator, _) = Self.makeCoordinator( + let (coordinator, _) = makeCoordinator( connection: connection, browseDatabase: "inventory", tabDatabase: "orders" @@ -82,7 +79,7 @@ struct DataRefreshScopeTests { func scopedRefreshReachesItsOwnTab() throws { let connection = TestFixtures.makeConnection(database: "saved_default") defer { DatabaseManager.shared.removeSession(for: connection.id) } - let (coordinator, _) = Self.makeCoordinator( + let (coordinator, _) = makeCoordinator( connection: connection, browseDatabase: "inventory", tabDatabase: "orders" @@ -104,7 +101,7 @@ struct DataRefreshScopeTests { func browseScopeAndTabScopeAreIndependent() throws { let connection = TestFixtures.makeConnection(database: "saved_default") defer { DatabaseManager.shared.removeSession(for: connection.id) } - let (coordinator, _) = Self.makeCoordinator( + let (coordinator, _) = makeCoordinator( connection: connection, browseDatabase: "inventory", tabDatabase: "orders" @@ -122,7 +119,7 @@ struct DataRefreshScopeTests { func refreshForAnotherConnectionIsIgnored() throws { let connection = TestFixtures.makeConnection(database: "saved_default") defer { DatabaseManager.shared.removeSession(for: connection.id) } - let (coordinator, _) = Self.makeCoordinator( + let (coordinator, _) = makeCoordinator( connection: connection, browseDatabase: "orders", tabDatabase: "orders" @@ -156,7 +153,7 @@ struct DataRefreshScopeTests { func scopedRefreshMarksTheTableTabsInItsScope() { let connection = TestFixtures.makeConnection(database: "saved_default") defer { DatabaseManager.shared.removeSession(for: connection.id) } - let (coordinator, tabManager) = Self.makeCoordinator(connection: connection, browseDatabase: "inventory") + let (coordinator, tabManager) = makeCoordinator(connection: connection, browseDatabase: "inventory") let inScope = Self.loadedTab("orders", database: "orders", in: coordinator) let queryInScope = Self.loadedTab("orders", database: "orders", tabType: .query, in: coordinator) let elsewhere = Self.loadedTab("orders", database: "inventory", in: coordinator) @@ -181,7 +178,7 @@ struct DataRefreshScopeTests { func unscopedRefreshMarksEveryTableTab() { let connection = TestFixtures.makeConnection(database: "saved_default") defer { DatabaseManager.shared.removeSession(for: connection.id) } - let (coordinator, tabManager) = Self.makeCoordinator(connection: connection, browseDatabase: "inventory") + let (coordinator, tabManager) = makeCoordinator(connection: connection, browseDatabase: "inventory") let first = Self.loadedTab("orders", database: "orders", in: coordinator) let second = Self.loadedTab("stock", database: "inventory", in: coordinator) tabManager.tabs = [first, second] @@ -196,7 +193,7 @@ struct DataRefreshScopeTests { func refreshForgetsCachedColumnsBeforeTheReload() throws { let connection = TestFixtures.makeConnection(database: "saved_default") defer { DatabaseManager.shared.removeSession(for: connection.id) } - let (coordinator, tabManager) = Self.makeCoordinator(connection: connection, browseDatabase: "orders") + let (coordinator, tabManager) = makeCoordinator(connection: connection, browseDatabase: "orders") var tab = Self.loadedTab("orders", database: "orders", in: coordinator) tab.columnLayout.hiddenColumns = ["note"] tabManager.tabs = [tab] @@ -241,7 +238,7 @@ struct DataRefreshScopeTests { func refreshMarksTheStructureOfEveryTabItReaches() { let connection = TestFixtures.makeConnection(database: "saved_default") defer { DatabaseManager.shared.removeSession(for: connection.id) } - let (coordinator, tabManager) = Self.makeCoordinator(connection: connection, browseDatabase: "orders") + let (coordinator, tabManager) = makeCoordinator(connection: connection, browseDatabase: "orders") let inScope = Self.loadedTab("orders", database: "orders", in: coordinator) let editing = Self.loadedTab("orders", database: "orders", in: coordinator) let elsewhere = Self.loadedTab("stock", database: "inventory", in: coordinator) @@ -273,7 +270,7 @@ struct DataRefreshScopeTests { func refreshForAnotherConnectionMarksNothing() { let connection = TestFixtures.makeConnection(database: "saved_default") defer { DatabaseManager.shared.removeSession(for: connection.id) } - let (coordinator, tabManager) = Self.makeCoordinator(connection: connection, browseDatabase: "orders") + let (coordinator, tabManager) = makeCoordinator(connection: connection, browseDatabase: "orders") let tab = Self.loadedTab("orders", database: "orders", in: coordinator) tabManager.tabs = [tab] diff --git a/TableProTests/Views/Main/DefaultSortInitialQueryTests.swift b/TableProTests/Views/Main/DefaultSortInitialQueryTests.swift index abcb64aff4..1a91062480 100644 --- a/TableProTests/Views/Main/DefaultSortInitialQueryTests.swift +++ b/TableProTests/Views/Main/DefaultSortInitialQueryTests.swift @@ -4,316 +4,314 @@ import Testing @testable import TablePro -@MainActor -struct DefaultSortInitialQueryTests { - private func makeCoordinator( - tableName: String, - type: DatabaseType = .mysql - ) -> (MainContentCoordinator, QueryTabManager, Int) { - let tabManager = QueryTabManager() - let coordinator = MainContentCoordinator( - connection: TestFixtures.makeConnection(type: type), - tabManager: tabManager, - changeManager: DataChangeManager(), - toolbarState: ConnectionToolbarState() - ) - var tab = QueryTab(title: tableName, query: "SELECT * FROM `\(tableName)` LIMIT 200", tabType: .table) - tab.tableContext.tableName = tableName - tab.tableContext.isEditable = true - tabManager.tabs.append(tab) - tabManager.selectedTabId = tab.id - return (coordinator, tabManager, tabManager.tabs.count - 1) - } - - private func withDefaultSortBehavior( - _ behavior: DefaultSortBehavior, - body: () async -> Void - ) async { - let previous = AppSettingsManager.shared.dataGrid.defaultSortBehavior - AppSettingsManager.shared.dataGrid.defaultSortBehavior = behavior - defer { AppSettingsManager.shared.dataGrid.defaultSortBehavior = previous } - await body() - } - - @Test("prepareTableTabFirstLoad bakes the primary key ORDER BY into the first query") - func firstQueryContainsPrimaryKeyOrderBy() async { - let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") - coordinator.schemaColumns.store( - SchemaColumnStore.Entry(columns: ["id", "name", "email"], primaryKeys: ["id"], columnTypes: [:]), - for: coordinator.schemaColumnsKey("users", scope: coordinator.selectedTabScope) - ) - - await withDefaultSortBehavior(.primaryKey) { - let ready = await coordinator.prepareTableTabFirstLoad(tabId: tabManager.tabs[index].id) - #expect(ready) +extension GlobalStateSuites { + @MainActor + struct DefaultSortInitialQueryTests { + private let fixtures = CoordinatorFixtures() + + private func makeCoordinator( + tableName: String, + type: DatabaseType = .mysql + ) -> (MainContentCoordinator, QueryTabManager, Int) { + let fixture = fixtures.make(connection: TestFixtures.makeConnection(type: type)) + let tabManager = fixture.tabManager + let coordinator = fixture.coordinator + var tab = QueryTab(title: tableName, query: "SELECT * FROM `\(tableName)` LIMIT 200", tabType: .table) + tab.tableContext.tableName = tableName + tab.tableContext.isEditable = true + tabManager.tabs.append(tab) + tabManager.selectedTabId = tab.id + return (coordinator, tabManager, tabManager.tabs.count - 1) } - let query = tabManager.tabs[index].content.query - #expect(query.localizedCaseInsensitiveContains("ORDER BY")) - #expect(query.contains("id")) - #expect(tabManager.tabs[index].sortState.source == .defaultSort) - } + /// Writes the shared setting across an await, which nesting under `GlobalStateSuites` keeps + /// apart from every other suite that swaps process-wide state. + private func withDefaultSortBehavior( + _ behavior: DefaultSortBehavior, + body: () async -> Void + ) async { + let previous = AppSettingsManager.shared.dataGrid.defaultSortBehavior + AppSettingsManager.shared.dataGrid.defaultSortBehavior = behavior + defer { AppSettingsManager.shared.dataGrid.defaultSortBehavior = previous } + await body() + } - @Test("Composite primary key sorts by every key column in key order") - func compositePrimaryKeySortsAllKeyColumns() async { - let (coordinator, tabManager, index) = makeCoordinator(tableName: "invoices") - coordinator.schemaColumns.store( - SchemaColumnStore.Entry( - columns: ["customer_uid", "order_uid", "total"], - primaryKeys: ["customer_uid", "order_uid"], - columnTypes: [:] - ), - for: coordinator.schemaColumnsKey("invoices", scope: coordinator.selectedTabScope) - ) - - await withDefaultSortBehavior(.primaryKey) { - await coordinator.prepareTableTabFirstLoad(tabId: tabManager.tabs[index].id) + @Test("prepareTableTabFirstLoad bakes the primary key ORDER BY into the first query") + func firstQueryContainsPrimaryKeyOrderBy() async { + let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") + coordinator.schemaColumns.store( + SchemaColumnStore.Entry(columns: ["id", "name", "email"], primaryKeys: ["id"], columnTypes: [:]), + for: coordinator.schemaColumnsKey("users", scope: coordinator.selectedTabScope) + ) + + await withDefaultSortBehavior(.primaryKey) { + let ready = await coordinator.prepareTableTabFirstLoad(tabId: tabManager.tabs[index].id) + #expect(ready) + } + + let query = tabManager.tabs[index].content.query + #expect(query.localizedCaseInsensitiveContains("ORDER BY")) + #expect(query.contains("id")) + #expect(tabManager.tabs[index].sortState.source == .defaultSort) } - let sortState = tabManager.tabs[index].sortState - #expect(sortState.columns.map(\.columnIndex) == [0, 1]) - let query = tabManager.tabs[index].content.query - #expect(query.localizedCaseInsensitiveContains("ORDER BY")) - #expect(query.contains("customer_uid")) - #expect(query.contains("order_uid")) - } + @Test("Composite primary key sorts by every key column in key order") + func compositePrimaryKeySortsAllKeyColumns() async { + let (coordinator, tabManager, index) = makeCoordinator(tableName: "invoices") + coordinator.schemaColumns.store( + SchemaColumnStore.Entry( + columns: ["customer_uid", "order_uid", "total"], + primaryKeys: ["customer_uid", "order_uid"], + columnTypes: [:] + ), + for: coordinator.schemaColumnsKey("invoices", scope: coordinator.selectedTabScope) + ) + + await withDefaultSortBehavior(.primaryKey) { + await coordinator.prepareTableTabFirstLoad(tabId: tabManager.tabs[index].id) + } + + let sortState = tabManager.tabs[index].sortState + #expect(sortState.columns.map(\.columnIndex) == [0, 1]) + let query = tabManager.tabs[index].content.query + #expect(query.localizedCaseInsensitiveContains("ORDER BY")) + #expect(query.contains("customer_uid")) + #expect(query.contains("order_uid")) + } - @Test("Table without a primary key dispatches unsorted with no ORDER BY") - func noPrimaryKeyProducesNoOrderBy() async { - let (coordinator, tabManager, index) = makeCoordinator(tableName: "logs") - coordinator.schemaColumns.store( - SchemaColumnStore.Entry(columns: ["message", "level"], primaryKeys: [], columnTypes: [:]), - for: coordinator.schemaColumnsKey("logs", scope: coordinator.selectedTabScope) - ) - - await withDefaultSortBehavior(.primaryKey) { - let ready = await coordinator.prepareTableTabFirstLoad(tabId: tabManager.tabs[index].id) - #expect(ready) + @Test("Table without a primary key dispatches unsorted with no ORDER BY") + func noPrimaryKeyProducesNoOrderBy() async { + let (coordinator, tabManager, index) = makeCoordinator(tableName: "logs") + coordinator.schemaColumns.store( + SchemaColumnStore.Entry(columns: ["message", "level"], primaryKeys: [], columnTypes: [:]), + for: coordinator.schemaColumnsKey("logs", scope: coordinator.selectedTabScope) + ) + + await withDefaultSortBehavior(.primaryKey) { + let ready = await coordinator.prepareTableTabFirstLoad(tabId: tabManager.tabs[index].id) + #expect(ready) + } + + let query = tabManager.tabs[index].content.query + #expect(!query.localizedCaseInsensitiveContains("ORDER BY")) + #expect(query.contains("`logs`")) + #expect(query.contains("LIMIT \(tabManager.tabs[index].pagination.pageSize)")) + #expect(!tabManager.tabs[index].sortState.isSorting) } - let query = tabManager.tabs[index].content.query - #expect(!query.localizedCaseInsensitiveContains("ORDER BY")) - #expect(query.contains("`logs`")) - #expect(query.contains("LIMIT \(tabManager.tabs[index].pagination.pageSize)")) - #expect(!tabManager.tabs[index].sortState.isSorting) - } + @Test("Schema fetch failure dispatches unsorted instead of blocking the first load") + func schemaFetchFailureStillDispatches() async { + let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") - @Test("Schema fetch failure dispatches unsorted instead of blocking the first load") - func schemaFetchFailureStillDispatches() async { - let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") + await withDefaultSortBehavior(.primaryKey) { + let ready = await coordinator.prepareTableTabFirstLoad(tabId: tabManager.tabs[index].id) + #expect(ready) + } - await withDefaultSortBehavior(.primaryKey) { - let ready = await coordinator.prepareTableTabFirstLoad(tabId: tabManager.tabs[index].id) - #expect(ready) + let query = tabManager.tabs[index].content.query + #expect(!query.localizedCaseInsensitiveContains("ORDER BY")) + #expect(query.contains("`users`")) + #expect(!tabManager.tabs[index].sortState.isSorting) } - let query = tabManager.tabs[index].content.query - #expect(!query.localizedCaseInsensitiveContains("ORDER BY")) - #expect(query.contains("`users`")) - #expect(!tabManager.tabs[index].sortState.isSorting) - } + @Test("None behavior takes the fast path and regenerates the browse query from current state") + func noneBehaviorRegeneratesQueryOnFastPath() async { + let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") + let pageSize = tabManager.tabs[index].pagination.pageSize - @Test("None behavior takes the fast path and regenerates the browse query from current state") - func noneBehaviorRegeneratesQueryOnFastPath() async { - let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") - let pageSize = tabManager.tabs[index].pagination.pageSize + await withDefaultSortBehavior(DefaultSortBehavior.none) { + let ready = await coordinator.prepareTableTabFirstLoad(tabId: tabManager.tabs[index].id) + #expect(ready) + } - await withDefaultSortBehavior(DefaultSortBehavior.none) { - let ready = await coordinator.prepareTableTabFirstLoad(tabId: tabManager.tabs[index].id) - #expect(ready) + let query = tabManager.tabs[index].content.query + #expect(query.contains("LIMIT \(pageSize)")) + #expect(!query.localizedCaseInsensitiveContains("ORDER BY")) + #expect(!tabManager.tabs[index].sortState.isSorting) } - let query = tabManager.tabs[index].content.query - #expect(query.contains("LIMIT \(pageSize)")) - #expect(!query.localizedCaseInsensitiveContains("ORDER BY")) - #expect(!tabManager.tabs[index].sortState.isSorting) - } - - @Test("A restored table tab loads with the live page size, not the persisted query LIMIT") - func restoredTableTabUsesLivePageSize() async { - let tabManager = QueryTabManager() - let coordinator = MainContentCoordinator( - connection: TestFixtures.makeConnection(), - tabManager: tabManager, - changeManager: DataChangeManager(), - toolbarState: ConnectionToolbarState() - ) - let persisted = PersistedTab( - id: UUID(), - title: "users", - query: "SELECT * FROM `users` LIMIT 500 OFFSET 0", - tabType: .table, - tableName: "users" - ) - let tab = QueryTab(from: persisted, defaultPageSize: 1_000) - tabManager.tabs.append(tab) - tabManager.selectedTabId = tab.id - - await withDefaultSortBehavior(DefaultSortBehavior.none) { - let ready = await coordinator.prepareTableTabFirstLoad(tabId: tab.id) - #expect(ready) + @Test("A restored table tab loads with the live page size, not the persisted query LIMIT") + func restoredTableTabUsesLivePageSize() async { + let fixture = fixtures.make() + let tabManager = fixture.tabManager + let coordinator = fixture.coordinator + let persisted = PersistedTab( + id: UUID(), + title: "users", + query: "SELECT * FROM `users` LIMIT 500 OFFSET 0", + tabType: .table, + tableName: "users" + ) + let tab = QueryTab(from: persisted, defaultPageSize: 1_000) + tabManager.tabs.append(tab) + tabManager.selectedTabId = tab.id + + await withDefaultSortBehavior(DefaultSortBehavior.none) { + let ready = await coordinator.prepareTableTabFirstLoad(tabId: tab.id) + #expect(ready) + } + + let query = tabManager.tabs[0].content.query + #expect(query.contains("LIMIT 1000")) + #expect(!query.contains("500")) } - let query = tabManager.tabs[0].content.query - #expect(query.contains("LIMIT 1000")) - #expect(!query.contains("500")) - } + @Test("A Cassandra table never takes the primary key default sort, which CQL refuses to run") + func cassandraSkipsTheDefaultSort() async { + let (coordinator, tabManager, index) = makeCoordinator(tableName: "events", type: .cassandra) + coordinator.schemaColumns.store( + SchemaColumnStore.Entry(columns: ["id", "day"], primaryKeys: ["id"], columnTypes: [:]), + for: coordinator.schemaColumnsKey("events", scope: coordinator.selectedTabScope) + ) - @Test("A Cassandra table never takes the primary key default sort, which CQL refuses to run") - func cassandraSkipsTheDefaultSort() async { - let (coordinator, tabManager, index) = makeCoordinator(tableName: "events", type: .cassandra) - coordinator.schemaColumns.store( - SchemaColumnStore.Entry(columns: ["id", "day"], primaryKeys: ["id"], columnTypes: [:]), - for: coordinator.schemaColumnsKey("events", scope: coordinator.selectedTabScope) - ) + await withDefaultSortBehavior(.primaryKey) { + await coordinator.prepareTableTabFirstLoad(tabId: tabManager.tabs[index].id) + } - await withDefaultSortBehavior(.primaryKey) { - await coordinator.prepareTableTabFirstLoad(tabId: tabManager.tabs[index].id) + #expect(!tabManager.tabs[index].sortState.isSorting) + #expect(!tabManager.tabs[index].content.query.localizedCaseInsensitiveContains("ORDER BY")) } - #expect(!tabManager.tabs[index].sortState.isSorting) - #expect(!tabManager.tabs[index].content.query.localizedCaseInsensitiveContains("ORDER BY")) - } - - @Test("A sort restored onto a Cassandra table is consumed without being applied") - func cassandraDropsARestoredSort() async { - let (coordinator, tabManager, index) = makeCoordinator(tableName: "events", type: .cassandra) - coordinator.schemaColumns.store( - SchemaColumnStore.Entry(columns: ["id", "day"], primaryKeys: ["id"], columnTypes: [:]), - for: coordinator.schemaColumnsKey("events", scope: coordinator.selectedTabScope) - ) - tabManager.mutate(at: index) { - $0.pendingRestoredSort = [PersistedSortColumn(columnName: "day", direction: .descending)] - $0.restoredSortSource = .user + @Test("A sort restored onto a Cassandra table is consumed without being applied") + func cassandraDropsARestoredSort() async { + let (coordinator, tabManager, index) = makeCoordinator(tableName: "events", type: .cassandra) + coordinator.schemaColumns.store( + SchemaColumnStore.Entry(columns: ["id", "day"], primaryKeys: ["id"], columnTypes: [:]), + for: coordinator.schemaColumnsKey("events", scope: coordinator.selectedTabScope) + ) + tabManager.mutate(at: index) { + $0.pendingRestoredSort = [PersistedSortColumn(columnName: "day", direction: .descending)] + $0.restoredSortSource = .user + } + + await withDefaultSortBehavior(.none) { + await coordinator.prepareTableTabFirstLoad(tabId: tabManager.tabs[index].id) + } + + #expect(tabManager.tabs[index].pendingRestoredSort == nil) + #expect(!tabManager.tabs[index].sortState.isSorting) + #expect(!tabManager.tabs[index].content.query.localizedCaseInsensitiveContains("ORDER BY")) } - await withDefaultSortBehavior(.none) { - await coordinator.prepareTableTabFirstLoad(tabId: tabManager.tabs[index].id) + @Test("A restored user sort is never overwritten by the default sort") + func userSortSurvivesFirstLoad() async { + let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") + coordinator.schemaColumns.store( + SchemaColumnStore.Entry(columns: ["id", "name"], primaryKeys: ["id"], columnTypes: [:]), + for: coordinator.schemaColumnsKey("users", scope: coordinator.selectedTabScope) + ) + let userSort = SortState(columns: [SortColumn(columnIndex: 1, direction: .descending)], source: .user) + tabManager.mutate(at: index) { $0.sortState = userSort } + + await withDefaultSortBehavior(.primaryKey) { + await coordinator.prepareTableTabFirstLoad(tabId: tabManager.tabs[index].id) + } + + #expect(tabManager.tabs[index].sortState == userSort) } - #expect(tabManager.tabs[index].pendingRestoredSort == nil) - #expect(!tabManager.tabs[index].sortState.isSorting) - #expect(!tabManager.tabs[index].content.query.localizedCaseInsensitiveContains("ORDER BY")) - } - - @Test("A restored user sort is never overwritten by the default sort") - func userSortSurvivesFirstLoad() async { - let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") - coordinator.schemaColumns.store( - SchemaColumnStore.Entry(columns: ["id", "name"], primaryKeys: ["id"], columnTypes: [:]), - for: coordinator.schemaColumnsKey("users", scope: coordinator.selectedTabScope) - ) - let userSort = SortState(columns: [SortColumn(columnIndex: 1, direction: .descending)], source: .user) - tabManager.mutate(at: index) { $0.sortState = userSort } - - await withDefaultSortBehavior(.primaryKey) { - await coordinator.prepareTableTabFirstLoad(tabId: tabManager.tabs[index].id) + @Test("Default sort resolves against scoped columns when leading columns are hidden") + func sortsAgainstScopedColumnsWithHiddenColumns() async { + let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") + coordinator.schemaColumns.store( + SchemaColumnStore.Entry(columns: ["a", "id", "name"], primaryKeys: ["id"], columnTypes: [:]), + for: coordinator.schemaColumnsKey("users", scope: coordinator.selectedTabScope) + ) + tabManager.mutate(at: index) { $0.columnLayout.hiddenColumns = ["a"] } + + let resultColumns = coordinator.effectiveResultColumns(for: tabManager.tabs[index]) + #expect(resultColumns == ["id", "name"]) + + await withDefaultSortBehavior(.primaryKey) { + await coordinator.prepareTableTabFirstLoad(tabId: tabManager.tabs[index].id) + } + + let query = tabManager.tabs[index].content.query + #expect(query.localizedCaseInsensitiveContains("ORDER BY")) + #expect(query.contains("id")) + #expect(!query.contains("`a`")) } - #expect(tabManager.tabs[index].sortState == userSort) - } + @Test("prepareTableTabFirstLoad bails when the tab is no longer selected") + func bailsWhenTabDeselected() async { + let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") + let tabId = tabManager.tabs[index].id + tabManager.selectedTabId = nil - @Test("Default sort resolves against scoped columns when leading columns are hidden") - func sortsAgainstScopedColumnsWithHiddenColumns() async { - let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") - coordinator.schemaColumns.store( - SchemaColumnStore.Entry(columns: ["a", "id", "name"], primaryKeys: ["id"], columnTypes: [:]), - for: coordinator.schemaColumnsKey("users", scope: coordinator.selectedTabScope) - ) - tabManager.mutate(at: index) { $0.columnLayout.hiddenColumns = ["a"] } - - let resultColumns = coordinator.effectiveResultColumns(for: tabManager.tabs[index]) - #expect(resultColumns == ["id", "name"]) - - await withDefaultSortBehavior(.primaryKey) { - await coordinator.prepareTableTabFirstLoad(tabId: tabManager.tabs[index].id) + await withDefaultSortBehavior(.primaryKey) { + let ready = await coordinator.prepareTableTabFirstLoad(tabId: tabId) + #expect(!ready) + } } - let query = tabManager.tabs[index].content.query - #expect(query.localizedCaseInsensitiveContains("ORDER BY")) - #expect(query.contains("id")) - #expect(!query.contains("`a`")) - } - - @Test("prepareTableTabFirstLoad bails when the tab is no longer selected") - func bailsWhenTabDeselected() async { - let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") - let tabId = tabManager.tabs[index].id - tabManager.selectedTabId = nil - - await withDefaultSortBehavior(.primaryKey) { - let ready = await coordinator.prepareTableTabFirstLoad(tabId: tabId) - #expect(!ready) + @Test("wantsDefaultSort is true for a fresh table tab when the default sort is primary key") + func gateTrueForPrimaryKeyBehavior() async { + let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") + await withDefaultSortBehavior(.primaryKey) { + #expect(coordinator.wantsDefaultSort(for: tabManager.tabs[index], hint: .useAppDefault)) + } } - } - @Test("wantsDefaultSort is true for a fresh table tab when the default sort is primary key") - func gateTrueForPrimaryKeyBehavior() async { - let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") - await withDefaultSortBehavior(.primaryKey) { - #expect(coordinator.wantsDefaultSort(for: tabManager.tabs[index], hint: .useAppDefault)) + @Test("wantsDefaultSort is false when the user already sorted") + func gateFalseWhenUserSorting() async { + let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") + tabManager.mutate(at: index) { + $0.sortState = SortState(columns: [SortColumn(columnIndex: 1, direction: .descending)], source: .user) + } + await withDefaultSortBehavior(.primaryKey) { + #expect(!coordinator.wantsDefaultSort(for: tabManager.tabs[index], hint: .useAppDefault)) + } } - } - @Test("wantsDefaultSort is false when the user already sorted") - func gateFalseWhenUserSorting() async { - let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") - tabManager.mutate(at: index) { - $0.sortState = SortState(columns: [SortColumn(columnIndex: 1, direction: .descending)], source: .user) + @Test("wantsDefaultSort is false when the default sort behavior is none") + func gateFalseForNoneBehavior() async { + let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") + await withDefaultSortBehavior(DefaultSortBehavior.none) { + #expect(!coordinator.wantsDefaultSort(for: tabManager.tabs[index], hint: .useAppDefault)) + } } - await withDefaultSortBehavior(.primaryKey) { - #expect(!coordinator.wantsDefaultSort(for: tabManager.tabs[index], hint: .useAppDefault)) - } - } - @Test("wantsDefaultSort is false when the default sort behavior is none") - func gateFalseForNoneBehavior() async { - let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") - await withDefaultSortBehavior(DefaultSortBehavior.none) { - #expect(!coordinator.wantsDefaultSort(for: tabManager.tabs[index], hint: .useAppDefault)) + @Test("wantsDefaultSort is false for non-table tabs") + func gateFalseForQueryTab() async { + let (coordinator, _, _) = makeCoordinator(tableName: "users") + let queryTab = QueryTab(title: "Q", query: "SELECT 1", tabType: .query) + await withDefaultSortBehavior(.primaryKey) { + #expect(!coordinator.wantsDefaultSort(for: queryTab, hint: .useAppDefault)) + } } - } - - @Test("wantsDefaultSort is false for non-table tabs") - func gateFalseForQueryTab() async { - let (coordinator, _, _) = makeCoordinator(tableName: "users") - let queryTab = QueryTab(title: "Q", query: "SELECT 1", tabType: .query) - await withDefaultSortBehavior(.primaryKey) { - #expect(!coordinator.wantsDefaultSort(for: queryTab, hint: .useAppDefault)) - } - } - @Test("wantsDefaultSort is false when the plugin suppresses default sorting") - func gateFalseForSuppressHint() async { - let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") - await withDefaultSortBehavior(.primaryKey) { - #expect(!coordinator.wantsDefaultSort(for: tabManager.tabs[index], hint: .suppress)) + @Test("wantsDefaultSort is false when the plugin suppresses default sorting") + func gateFalseForSuppressHint() async { + let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") + await withDefaultSortBehavior(.primaryKey) { + #expect(!coordinator.wantsDefaultSort(for: tabManager.tabs[index], hint: .suppress)) + } } - } - @Test("wantsDefaultSort is true when the plugin forces sort columns even with behavior none") - func gateTrueForForceColumnsHint() async { - let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") - await withDefaultSortBehavior(DefaultSortBehavior.none) { - #expect(coordinator.wantsDefaultSort(for: tabManager.tabs[index], hint: .forceColumns(["id"]))) + @Test("wantsDefaultSort is true when the plugin forces sort columns even with behavior none") + func gateTrueForForceColumnsHint() async { + let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") + await withDefaultSortBehavior(DefaultSortBehavior.none) { + #expect(coordinator.wantsDefaultSort(for: tabManager.tabs[index], hint: .forceColumns(["id"]))) + } } - } - @Test("None behavior skips the schema wait unless columns are hidden") - func firstLoadSchemaWaitDecision() async { - let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") + @Test("None behavior skips the schema wait unless columns are hidden") + func firstLoadSchemaWaitDecision() async { + let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") - await withDefaultSortBehavior(DefaultSortBehavior.none) { - #expect(!coordinator.firstLoadNeedsSchemaColumns(for: tabManager.tabs[index], hint: .useAppDefault)) - } + await withDefaultSortBehavior(DefaultSortBehavior.none) { + #expect(!coordinator.firstLoadNeedsSchemaColumns(for: tabManager.tabs[index], hint: .useAppDefault)) + } - await withDefaultSortBehavior(.primaryKey) { - #expect(coordinator.firstLoadNeedsSchemaColumns(for: tabManager.tabs[index], hint: .useAppDefault)) - } + await withDefaultSortBehavior(.primaryKey) { + #expect(coordinator.firstLoadNeedsSchemaColumns(for: tabManager.tabs[index], hint: .useAppDefault)) + } - tabManager.mutate(at: index) { $0.columnLayout.hiddenColumns = ["name"] } - await withDefaultSortBehavior(DefaultSortBehavior.none) { - #expect(coordinator.firstLoadNeedsSchemaColumns(for: tabManager.tabs[index], hint: .useAppDefault)) + tabManager.mutate(at: index) { $0.columnLayout.hiddenColumns = ["name"] } + await withDefaultSortBehavior(DefaultSortBehavior.none) { + #expect(coordinator.firstLoadNeedsSchemaColumns(for: tabManager.tabs[index], hint: .useAppDefault)) + } } } } diff --git a/TableProTests/Views/Main/DetailPaneBackdropTests.swift b/TableProTests/Views/Main/DetailPaneBackdropTests.swift index 541cfca040..ac871a7d4f 100644 --- a/TableProTests/Views/Main/DetailPaneBackdropTests.swift +++ b/TableProTests/Views/Main/DetailPaneBackdropTests.swift @@ -8,7 +8,6 @@ import SwiftUI @testable import TablePro import Testing -@Suite("The detail pane backdrop stops below the toolbar until the toolbar takes the theme", .serialized) @MainActor struct DetailPaneBackdropTests { private static let insetHeight: CGFloat = 20 diff --git a/TableProTests/Views/Main/DocumentEditingAvailabilityTests.swift b/TableProTests/Views/Main/DocumentEditingAvailabilityTests.swift index 72b48b5d2b..b3724e0a37 100644 --- a/TableProTests/Views/Main/DocumentEditingAvailabilityTests.swift +++ b/TableProTests/Views/Main/DocumentEditingAvailabilityTests.swift @@ -11,6 +11,8 @@ import Testing @MainActor struct DocumentEditingAvailabilityTests { + private let fixtures = CoordinatorFixtures() + private let locators: [String?] = [#"{"$numberInt":"1"}"#, #"{"$numberInt":"2"}"#, nil, #"{"$numberInt":"4"}"#] private func makeCoordinator( @@ -18,13 +20,9 @@ struct DocumentEditingAvailabilityTests { tabType: TabType = .table, locators: [String?]? = nil ) -> MainContentCoordinator { - let tabManager = QueryTabManager() - let coordinator = MainContentCoordinator( - connection: TestFixtures.makeConnection(type: type), - tabManager: tabManager, - changeManager: DataChangeManager(), - toolbarState: ConnectionToolbarState() - ) + let fixture = fixtures.make(connection: TestFixtures.makeConnection(type: type)) + let tabManager = fixture.tabManager + let coordinator = fixture.coordinator var tab = tabType == .table ? QueryTab(title: "people", query: "db.people.find({})", tabType: .table, tableName: "people") : QueryTab(title: "Query", query: "db.people.find({})", tabType: .query) diff --git a/TableProTests/Views/Main/EditorTabActivationTests.swift b/TableProTests/Views/Main/EditorTabActivationTests.swift index 4a28436ca1..96909dfba9 100644 --- a/TableProTests/Views/Main/EditorTabActivationTests.swift +++ b/TableProTests/Views/Main/EditorTabActivationTests.swift @@ -1,8 +1,3 @@ -// -// EditorTabActivationTests.swift -// TableProTests -// - import AppKit import Foundation import Testing @@ -28,17 +23,20 @@ struct EditorTabActivationTests { #expect(Self.resolve(clickCount: 1) == .select) } - @Test("A second click on the same tab keeps it open") - func doubleClickKeepsTheTab() { - #expect(Self.resolve(clickCount: 2) == .selectAndKeep) + /// A double-click that follows a nearby click arrives as counts two and three, so refusing + /// the three would leave a genuine double-click doing nothing. Keeping is idempotent, so + /// acting on it costs nothing. + @Test("A second click on the same tab keeps it open, and so does a count above two", arguments: [2, 3]) + func repeatedClickKeepsTheTab(clickCount: Int) { + #expect(Self.resolve(clickCount: clickCount) == .selectAndKeep) } /// Two clicks close enough in time and space arrive as one click of count two whichever view /// each of them hit, and tabs sit flush against each other. Without this the pair would keep a /// tab the user only meant to select. - @Test("A second click on a different tab only selects") - func doubleClickAcrossTabsOnlySelects() { - #expect(Self.resolve(clickCount: 2, sameTab: false) == .select) + @Test("A second click on a different tab only selects, and so does a count above two", arguments: [2, 3]) + func repeatedClickAcrossTabsOnlySelects(clickCount: Int) { + #expect(Self.resolve(clickCount: clickCount, sameTab: false) == .select) } @Test("The first click of a session only selects") @@ -57,19 +55,6 @@ struct EditorTabActivationTests { #expect(Self.resolve(clickCount: 2, hasModifiers: true) == .select) } - /// A double-click that follows a nearby click arrives as counts two and three, so refusing - /// the three would leave a genuine double-click doing nothing. Keeping is idempotent, so - /// acting on it costs nothing. - @Test("A count above two still keeps the tab") - func tripleClickStillKeepsTheTab() { - #expect(Self.resolve(clickCount: 3) == .selectAndKeep) - } - - @Test("A count above two on a different tab still only selects") - func tripleClickAcrossTabsOnlySelects() { - #expect(Self.resolve(clickCount: 3, sameTab: false) == .select) - } - /// The keyboard and VoiceOver both activate the tab's button with no mouse event current. @Test("An activation with no click behind it only selects") func activationWithoutAClickSelects() { diff --git a/TableProTests/Views/Main/EditorTabStripTrackThemeTests.swift b/TableProTests/Views/Main/EditorTabStripTrackThemeTests.swift index 6d4c8303e1..e3d13ed26a 100644 --- a/TableProTests/Views/Main/EditorTabStripTrackThemeTests.swift +++ b/TableProTests/Views/Main/EditorTabStripTrackThemeTests.swift @@ -8,7 +8,6 @@ import SwiftUI @testable import TablePro import Testing -@Suite("The tab strip track follows a theme's window color", .serialized) @MainActor struct EditorTabStripTrackThemeTests { private func resolved(_ color: Color, in name: NSAppearance.Name) throws -> NSColor { diff --git a/TableProTests/Views/Main/EditorTabStripWindowDragTests.swift b/TableProTests/Views/Main/EditorTabStripWindowDragTests.swift index 3b71e4f623..d16d50e3d8 100644 --- a/TableProTests/Views/Main/EditorTabStripWindowDragTests.swift +++ b/TableProTests/Views/Main/EditorTabStripWindowDragTests.swift @@ -9,7 +9,6 @@ import Testing @testable import TablePro -@Suite("Editor tab strip window drag", .serialized) @MainActor struct EditorTabStripWindowDragTests { private static let tabCount = 3 diff --git a/TableProTests/Views/Main/EvictionTests.swift b/TableProTests/Views/Main/EvictionTests.swift index f2d556b275..351d593627 100644 --- a/TableProTests/Views/Main/EvictionTests.swift +++ b/TableProTests/Views/Main/EvictionTests.swift @@ -13,18 +13,11 @@ import Testing @MainActor struct EvictionTests { + private let fixtures = CoordinatorFixtures() + private func makeCoordinator() -> (MainContentCoordinator, QueryTabManager) { - let tabManager = QueryTabManager() - let changeManager = DataChangeManager() - let toolbarState = ConnectionToolbarState() - let connection = TestFixtures.makeConnection() - let coordinator = MainContentCoordinator( - connection: connection, - tabManager: tabManager, - changeManager: changeManager, - toolbarState: toolbarState - ) - return (coordinator, tabManager) + let fixture = fixtures.make() + return (fixture.coordinator, fixture.tabManager) } private func addLoadedTab( diff --git a/TableProTests/Views/Main/ExtractTableNameTests.swift b/TableProTests/Views/Main/ExtractTableNameTests.swift index 6b4332e89e..1f4fe74e28 100644 --- a/TableProTests/Views/Main/ExtractTableNameTests.swift +++ b/TableProTests/Views/Main/ExtractTableNameTests.swift @@ -1,10 +1,4 @@ -// -// ExtractTableNameTests.swift -// TableProTests -// -// Tests for extractTableName(from:) — verifies SQL and MQL -// query patterns including the MongoDB bracket notation fix. -// +// extractTableName(from:) over SQL and MQL query patterns, including the MongoDB bracket notation. import Foundation import TableProPluginKit @@ -135,30 +129,14 @@ struct ExtractTableNameTests { // MARK: - Edge cases - @Test("Empty string returns nil") - func emptyString() { + @Test("An empty string, plain text or non-SELECT SQL names no table", arguments: [ + "", "hello world this is not a query", "INSERT INTO users VALUES (1)", + ]) + func textWithoutASelectNamesNoTable(text: String) { let coordinator = makeCoordinator() defer { coordinator.teardown() } - let result = coordinator.extractTableName(from: "") - #expect(result == nil) - } - - @Test("Random text returns nil") - func randomText() { - let coordinator = makeCoordinator() - defer { coordinator.teardown() } - - let result = coordinator.extractTableName(from: "hello world this is not a query") - #expect(result == nil) - } - - @Test("Non-SELECT SQL returns nil") - func nonSelectSql() { - let coordinator = makeCoordinator() - defer { coordinator.teardown() } - - let result = coordinator.extractTableName(from: "INSERT INTO users VALUES (1)") + let result = coordinator.extractTableName(from: text) #expect(result == nil) } diff --git a/TableProTests/Views/Main/FetchAllQueryTaskTests.swift b/TableProTests/Views/Main/FetchAllQueryTaskTests.swift index 10f9feb4cb..3db6076a36 100644 --- a/TableProTests/Views/Main/FetchAllQueryTaskTests.swift +++ b/TableProTests/Views/Main/FetchAllQueryTaskTests.swift @@ -1,14 +1,10 @@ -// -// FetchAllQueryTaskTests.swift -// TableProTests -// -// Fetch All installs a query handle under the tab's id and has to retire it on every exit. The -// cancelled exit did not: it cleared the loading flag and returned, so a finished fetch stayed -// installed and the next execution on that tab read it as a live displaced entry and cancelled it. -// +// Fetch All installs a query handle under the tab's id and has to retire it on every exit. The +// cancelled exit did not: it cleared the loading flag and returned, so a finished fetch stayed +// installed and the next execution on that tab read it as a live displaced entry and cancelled it. import Foundation @testable import TablePro +import TableProTestSupport import Testing @Suite("Fetch All query handle", .serialized) @@ -49,7 +45,7 @@ struct FetchAllQueryTaskTests { return } handle.cancel() - driver.release() + await driver.release() await handle.value #expect(coordinator.queryTasks.hasTask(for: tab.id) == false) @@ -65,30 +61,24 @@ private final class GatedQueryDriver: DatabaseDriver, @unchecked Sendable { var status: ConnectionStatus = .connected var serverVersion: String? { nil } - private let lock = NSLock() - private var startedQuery = false - private var released = false + private let answer = AsyncGate() init(connection: DatabaseConnection) { self.connection = connection } + /// Bounded, so a fetch that never queries fails on the missing handle rather than hanging. func waitUntilRunning() async { - for _ in 0 ..< 500 { - if lock.withLock({ startedQuery }) { return } - try? await Task.sleep(for: .milliseconds(10)) - } + _ = await BoundedCall.result { [answer] in await answer.arrived(1) } } - func release() { - lock.withLock { released = true } + func release() async { + await answer.open() } + /// The gate ignores cancellation, so the answer still comes back after the task is cancelled. func executeUserQuery(query: String, rowCap: Int?, parameters: [Any?]?) async throws -> QueryResult { - lock.withLock { startedQuery = true } - while !lock.withLock({ released }) { - await Task.yield() - } + await answer.wait() return Self.emptyResult } diff --git a/TableProTests/Views/Main/FilterPersistenceRoundTripTests.swift b/TableProTests/Views/Main/FilterPersistenceRoundTripTests.swift index 9481d3cacf..4d1e26ec8a 100644 --- a/TableProTests/Views/Main/FilterPersistenceRoundTripTests.swift +++ b/TableProTests/Views/Main/FilterPersistenceRoundTripTests.swift @@ -9,19 +9,16 @@ import TableProPluginKit import Testing /// The reopen journey end to end: what a table's filter rows do between one tab and the next. -@Suite("FilterPersistenceRoundTrip", .serialized) @MainActor struct FilterPersistenceRoundTripTests { + private let fixtures = CoordinatorFixtures() + private static let settingsKey = "com.TablePro.filter.settings" private func makeCoordinator() -> (MainContentCoordinator, QueryTabManager) { - let tabManager = QueryTabManager() - let coordinator = MainContentCoordinator( - connection: TestFixtures.makeConnection(), - tabManager: tabManager, - changeManager: DataChangeManager(), - toolbarState: ConnectionToolbarState() - ) + let fixture = fixtures.make() + let tabManager = fixture.tabManager + let coordinator = fixture.coordinator return (coordinator, tabManager) } diff --git a/TableProTests/Views/Main/FilterTypingBeforeFirstLoadTests.swift b/TableProTests/Views/Main/FilterTypingBeforeFirstLoadTests.swift index e4747ddd57..433c1b4011 100644 --- a/TableProTests/Views/Main/FilterTypingBeforeFirstLoadTests.swift +++ b/TableProTests/Views/Main/FilterTypingBeforeFirstLoadTests.swift @@ -4,167 +4,166 @@ import Testing @testable import TablePro -@MainActor -struct FilterTypingBeforeFirstLoadTests { - private static let schema = SchemaColumnStore.Entry( - columns: ["id", "code"], - primaryKeys: ["id"], - columnTypes: ["id": .integer(rawType: "INT"), "code": .text(rawType: "VARCHAR(20)")] - ) - - private func makeCoordinator( - filter: TableFilter - ) -> (coordinator: MainContentCoordinator, tabManager: QueryTabManager, tabId: UUID) { - let tabManager = QueryTabManager() - let coordinator = MainContentCoordinator( - connection: TestFixtures.makeConnection(), - tabManager: tabManager, - changeManager: DataChangeManager(), - toolbarState: ConnectionToolbarState() +extension GlobalStateSuites { + @MainActor + struct FilterTypingBeforeFirstLoadTests { + private static let schema = SchemaColumnStore.Entry( + columns: ["id", "code"], + primaryKeys: ["id"], + columnTypes: ["id": .integer(rawType: "INT"), "code": .text(rawType: "VARCHAR(20)")] ) - var tab = QueryTab(title: "items", query: "SELECT * FROM `items` LIMIT 200", tabType: .table) - tab.tableContext.tableName = "items" - tab.filterState = TabFilterState(filters: [filter], commit: .all, isVisible: true, filterLogicMode: .and) - tabManager.tabs.append(tab) - tabManager.selectedTabId = tab.id - return (coordinator, tabManager, tab.id) - } - - private func storeSchema(in coordinator: MainContentCoordinator) { - coordinator.schemaColumns.store( - Self.schema, - for: coordinator.schemaColumnsKey("items", scope: coordinator.selectedTabScope) - ) - } - - private func firstLoadQuery( - _ coordinator: MainContentCoordinator, - _ tabManager: QueryTabManager, - tabId: UUID - ) async -> String? { - let previous = AppSettingsManager.shared.dataGrid.defaultSortBehavior - AppSettingsManager.shared.dataGrid.defaultSortBehavior = .none - defer { AppSettingsManager.shared.dataGrid.defaultSortBehavior = previous } - - guard await coordinator.prepareTableTabFirstLoad(tabId: tabId) else { return nil } - return tabManager.tabs.first { $0.id == tabId }?.content.query - } - @Test("A numeric-looking value on a text column is quoted in the first query") - func textColumnValueIsQuotedOnFirstLoad() async throws { - let (coordinator, tabManager, tabId) = makeCoordinator( - filter: TestFixtures.makeTableFilter(column: "code", value: "0123") - ) - defer { coordinator.teardown() } - storeSchema(in: coordinator) - - let query = try #require(await firstLoadQuery(coordinator, tabManager, tabId: tabId)) - - #expect(query.contains("'0123'")) - #expect(!query.contains("= 0123")) + private func makeCoordinator( + filter: TableFilter + ) -> (coordinator: MainContentCoordinator, tabManager: QueryTabManager, tabId: UUID) { + let tabManager = QueryTabManager() + let coordinator = MainContentCoordinator( + connection: TestFixtures.makeConnection(), + tabManager: tabManager, + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + var tab = QueryTab(title: "items", query: "SELECT * FROM `items` LIMIT 200", tabType: .table) + tab.tableContext.tableName = "items" + tab.filterState = TabFilterState(filters: [filter], commit: .all, isVisible: true, filterLogicMode: .and) + tabManager.tabs.append(tab) + tabManager.selectedTabId = tab.id + return (coordinator, tabManager, tab.id) + } + + private func storeSchema(in coordinator: MainContentCoordinator) { + coordinator.schemaColumns.store( + Self.schema, + for: coordinator.schemaColumnsKey("items", scope: coordinator.selectedTabScope) + ) + } + + private func firstLoadQuery( + _ coordinator: MainContentCoordinator, + _ tabManager: QueryTabManager, + tabId: UUID + ) async -> String? { + guard await coordinator.prepareTableTabFirstLoad(tabId: tabId) else { return nil } + return tabManager.tabs.first { $0.id == tabId }?.content.query + } + + @Test("A numeric-looking value on a text column is quoted in the first query", .noDefaultSort) + func textColumnValueIsQuotedOnFirstLoad() async throws { + let (coordinator, tabManager, tabId) = makeCoordinator( + filter: TestFixtures.makeTableFilter(column: "code", value: "0123") + ) + defer { coordinator.teardown() } + storeSchema(in: coordinator) + + let query = try #require(await firstLoadQuery(coordinator, tabManager, tabId: tabId)) + + #expect(query.contains("'0123'")) + #expect(!query.contains("= 0123")) + } + + @Test("A numeric value on an integer column stays unquoted in the first query", .noDefaultSort) + func integerColumnValueStaysUnquotedOnFirstLoad() async throws { + let (coordinator, tabManager, tabId) = makeCoordinator( + filter: TestFixtures.makeTableFilter(column: "id", value: "123") + ) + defer { coordinator.teardown() } + storeSchema(in: coordinator) + + let query = try #require(await firstLoadQuery(coordinator, tabManager, tabId: tabId)) + + #expect(query.contains("`id` = 123")) + #expect(!query.contains("'123'")) + } + + @Test("A TRUE value on a text column is not rewritten into a boolean literal", .noDefaultSort) + func textColumnKeepsBooleanShapedValue() async throws { + let (coordinator, tabManager, tabId) = makeCoordinator( + filter: TestFixtures.makeTableFilter(column: "code", value: "TRUE") + ) + defer { coordinator.teardown() } + storeSchema(in: coordinator) + + let query = try #require(await firstLoadQuery(coordinator, tabManager, tabId: tabId)) + + #expect(query.contains("'TRUE'")) + } + + @Test("A schema that cannot be fetched still dispatches the first load", .noDefaultSort) + func unavailableSchemaStillDispatches() async throws { + let (coordinator, tabManager, tabId) = makeCoordinator( + filter: TestFixtures.makeTableFilter(column: "code", value: "0123") + ) + defer { coordinator.teardown() } + + let query = try #require(await firstLoadQuery(coordinator, tabManager, tabId: tabId)) + + #expect(query.contains("WHERE")) + } + + @Test("Applied filters make the first load wait for the schema", .noDefaultSort) + func appliedFiltersNeedTheSchema() { + let (coordinator, tabManager, tabId) = makeCoordinator( + filter: TestFixtures.makeTableFilter(column: "code", value: "0123") + ) + defer { coordinator.teardown() } + + let filtered = tabManager.tabs.first { $0.id == tabId } + var unfiltered = filtered + unfiltered?.filterState = TabFilterState() + + #expect(filtered.map { coordinator.firstLoadNeedsSchemaColumns(for: $0, hint: .useAppDefault) } == true) + #expect(unfiltered.map { coordinator.firstLoadNeedsSchemaColumns(for: $0, hint: .useAppDefault) } == false) + } + + @Test("The filter preview is typed from the schema before any rows arrive") + func previewIsTypedBeforeRowsArrive() { + let (coordinator, _, _) = makeCoordinator( + filter: TestFixtures.makeTableFilter(column: "code", value: "0123") + ) + defer { coordinator.teardown() } + storeSchema(in: coordinator) + + let preview = coordinator.filterCoordinator.generateFilterPreviewSQL(databaseType: .mysql) + + #expect(preview.contains("'0123'")) + } + + @Test("Loaded rows decide the types once they exist") + func loadedRowsWinOverTheSchema() throws { + let (coordinator, tabManager, tabId) = makeCoordinator( + filter: TestFixtures.makeTableFilter(column: "code", value: "0123") + ) + defer { coordinator.teardown() } + storeSchema(in: coordinator) + coordinator.setActiveTableRows( + TableRows.from(queryRows: [], columns: ["code"], columnTypes: [.integer(rawType: "INT")]), + for: tabId + ) + + let tab = try #require(tabManager.tabs.first { $0.id == tabId }) + let resolved = coordinator.queryColumns(for: tab) + + #expect(resolved.columns == ["code"]) + #expect(resolved.columnTypes == [.integer(rawType: "INT")]) + } + + @Test("Before any rows the schema supplies a type for every query column") + func schemaSuppliesTypesBeforeRows() throws { + let (coordinator, tabManager, tabId) = makeCoordinator( + filter: TestFixtures.makeTableFilter(column: "code", value: "0123") + ) + defer { coordinator.teardown() } + storeSchema(in: coordinator) + + let tab = try #require(tabManager.tabs.first { $0.id == tabId }) + let resolved = coordinator.queryColumns(for: tab) + + #expect(resolved.columns == ["id", "code"]) + #expect(resolved.columnTypes == [.integer(rawType: "INT"), .text(rawType: "VARCHAR(20)")]) + } } +} - @Test("A numeric value on an integer column stays unquoted in the first query") - func integerColumnValueStaysUnquotedOnFirstLoad() async throws { - let (coordinator, tabManager, tabId) = makeCoordinator( - filter: TestFixtures.makeTableFilter(column: "id", value: "123") - ) - defer { coordinator.teardown() } - storeSchema(in: coordinator) - - let query = try #require(await firstLoadQuery(coordinator, tabManager, tabId: tabId)) - - #expect(query.contains("`id` = 123")) - #expect(!query.contains("'123'")) - } - - @Test("A TRUE value on a text column is not rewritten into a boolean literal") - func textColumnKeepsBooleanShapedValue() async throws { - let (coordinator, tabManager, tabId) = makeCoordinator( - filter: TestFixtures.makeTableFilter(column: "code", value: "TRUE") - ) - defer { coordinator.teardown() } - storeSchema(in: coordinator) - - let query = try #require(await firstLoadQuery(coordinator, tabManager, tabId: tabId)) - - #expect(query.contains("'TRUE'")) - } - - @Test("A schema that cannot be fetched still dispatches the first load") - func unavailableSchemaStillDispatches() async throws { - let (coordinator, tabManager, tabId) = makeCoordinator( - filter: TestFixtures.makeTableFilter(column: "code", value: "0123") - ) - defer { coordinator.teardown() } - - let query = try #require(await firstLoadQuery(coordinator, tabManager, tabId: tabId)) - - #expect(query.contains("WHERE")) - } - - @Test("Applied filters make the first load wait for the schema") - func appliedFiltersNeedTheSchema() { - let (coordinator, tabManager, tabId) = makeCoordinator( - filter: TestFixtures.makeTableFilter(column: "code", value: "0123") - ) - defer { coordinator.teardown() } - let previous = AppSettingsManager.shared.dataGrid.defaultSortBehavior - AppSettingsManager.shared.dataGrid.defaultSortBehavior = .none - defer { AppSettingsManager.shared.dataGrid.defaultSortBehavior = previous } - - let filtered = tabManager.tabs.first { $0.id == tabId } - var unfiltered = filtered - unfiltered?.filterState = TabFilterState() - - #expect(filtered.map { coordinator.firstLoadNeedsSchemaColumns(for: $0, hint: .useAppDefault) } == true) - #expect(unfiltered.map { coordinator.firstLoadNeedsSchemaColumns(for: $0, hint: .useAppDefault) } == false) - } - - @Test("The filter preview is typed from the schema before any rows arrive") - func previewIsTypedBeforeRowsArrive() { - let (coordinator, _, _) = makeCoordinator( - filter: TestFixtures.makeTableFilter(column: "code", value: "0123") - ) - defer { coordinator.teardown() } - storeSchema(in: coordinator) - - let preview = coordinator.filterCoordinator.generateFilterPreviewSQL(databaseType: .mysql) - - #expect(preview.contains("'0123'")) - } - - @Test("Loaded rows decide the types once they exist") - func loadedRowsWinOverTheSchema() throws { - let (coordinator, tabManager, tabId) = makeCoordinator( - filter: TestFixtures.makeTableFilter(column: "code", value: "0123") - ) - defer { coordinator.teardown() } - storeSchema(in: coordinator) - coordinator.setActiveTableRows( - TableRows.from(queryRows: [], columns: ["code"], columnTypes: [.integer(rawType: "INT")]), - for: tabId - ) - - let tab = try #require(tabManager.tabs.first { $0.id == tabId }) - let resolved = coordinator.queryColumns(for: tab) - - #expect(resolved.columns == ["code"]) - #expect(resolved.columnTypes == [.integer(rawType: "INT")]) - } - - @Test("Before any rows the schema supplies a type for every query column") - func schemaSuppliesTypesBeforeRows() throws { - let (coordinator, tabManager, tabId) = makeCoordinator( - filter: TestFixtures.makeTableFilter(column: "code", value: "0123") - ) - defer { coordinator.teardown() } - storeSchema(in: coordinator) - - let tab = try #require(tabManager.tabs.first { $0.id == tabId }) - let resolved = coordinator.queryColumns(for: tab) - - #expect(resolved.columns == ["id", "code"]) - #expect(resolved.columnTypes == [.integer(rawType: "INT"), .text(rawType: "VARCHAR(20)")]) - } +private extension Trait where Self == AppSettingsTrait { + static var noDefaultSort: Self { .appSettings { $0.dataGrid.defaultSortBehavior = .none } } } diff --git a/TableProTests/Views/Main/GridStandInBackgroundTests.swift b/TableProTests/Views/Main/GridStandInBackgroundTests.swift index 8b35fa0479..8a76fb7b79 100644 --- a/TableProTests/Views/Main/GridStandInBackgroundTests.swift +++ b/TableProTests/Views/Main/GridStandInBackgroundTests.swift @@ -9,7 +9,6 @@ import SwiftUI import TableProPluginKit import Testing -@Suite("Result states that stand in for the grid", .serialized) @MainActor struct GridStandInBackgroundTests { private static let center = CGPoint(x: 0.5, y: 0.5) diff --git a/TableProTests/Views/Main/MainContentCoordinatorAddRowTests.swift b/TableProTests/Views/Main/MainContentCoordinatorAddRowTests.swift index e5bf59f5f8..cd3ff2d2fb 100644 --- a/TableProTests/Views/Main/MainContentCoordinatorAddRowTests.swift +++ b/TableProTests/Views/Main/MainContentCoordinatorAddRowTests.swift @@ -12,6 +12,8 @@ import Testing @MainActor struct MainContentCoordinatorAddRowTests { + private let fixtures = CoordinatorFixtures() + private func makeCoordinator( viewMode: ResultsViewMode = .data, tableName: String? = "users", @@ -19,13 +21,9 @@ struct MainContentCoordinatorAddRowTests { isView: Bool = false, hasAuthoritativeSchema: Bool = true ) -> MainContentCoordinator { - let tabManager = QueryTabManager() - let coordinator = MainContentCoordinator( - connection: TestFixtures.makeConnection(), - tabManager: tabManager, - changeManager: DataChangeManager(), - toolbarState: ConnectionToolbarState() - ) + let fixture = fixtures.make() + let tabManager = fixture.tabManager + let coordinator = fixture.coordinator var tab = QueryTab(title: "users", query: "SELECT * FROM users", tabType: .table) tab.tableContext.tableName = tableName tab.tableContext.isEditable = isEditable @@ -83,13 +81,9 @@ struct MainContentCoordinatorAddRowTests { } private func makeQueryCoordinator(keysResolved: Bool) throws -> MainContentCoordinator { - let tabManager = QueryTabManager() - let coordinator = MainContentCoordinator( - connection: TestFixtures.makeConnection(), - tabManager: tabManager, - changeManager: DataChangeManager(), - toolbarState: ConnectionToolbarState() - ) + let fixture = fixtures.make() + let tabManager = fixture.tabManager + let coordinator = fixture.coordinator tabManager.addTab(databaseName: "") let index = try #require(tabManager.selectedTabIndex) let result = ResultSet( diff --git a/TableProTests/Views/Main/MainContentCoordinatorDisplayStateTests.swift b/TableProTests/Views/Main/MainContentCoordinatorDisplayStateTests.swift index c9fe02c5aa..50736d9ec4 100644 --- a/TableProTests/Views/Main/MainContentCoordinatorDisplayStateTests.swift +++ b/TableProTests/Views/Main/MainContentCoordinatorDisplayStateTests.swift @@ -14,14 +14,12 @@ import Testing @MainActor struct MainContentCoordinatorDisplayStateTests { + private let fixtures = CoordinatorFixtures() + private func makeCoordinator() -> (MainContentCoordinator, QueryTabManager) { - let tabManager = QueryTabManager() - let coordinator = MainContentCoordinator( - connection: TestFixtures.makeConnection(), - tabManager: tabManager, - changeManager: DataChangeManager(), - toolbarState: ConnectionToolbarState() - ) + let fixture = fixtures.make() + let tabManager = fixture.tabManager + let coordinator = fixture.coordinator return (coordinator, tabManager) } diff --git a/TableProTests/Views/Main/MainContentCoordinatorGridSelectionTests.swift b/TableProTests/Views/Main/MainContentCoordinatorGridSelectionTests.swift index 7989124e02..3dbb77e614 100644 --- a/TableProTests/Views/Main/MainContentCoordinatorGridSelectionTests.swift +++ b/TableProTests/Views/Main/MainContentCoordinatorGridSelectionTests.swift @@ -24,14 +24,12 @@ private final class StubColumnLayoutPersister: ColumnLayoutPersisting { @MainActor struct MainContentCoordinatorGridSelectionTests { + private let fixtures = CoordinatorFixtures() + private func makeCoordinator() -> (MainContentCoordinator, QueryTabManager) { - let tabManager = QueryTabManager() - let coordinator = MainContentCoordinator( - connection: TestFixtures.makeConnection(), - tabManager: tabManager, - changeManager: DataChangeManager(), - toolbarState: ConnectionToolbarState() - ) + let fixture = fixtures.make() + let tabManager = fixture.tabManager + let coordinator = fixture.coordinator return (coordinator, tabManager) } diff --git a/TableProTests/Views/Main/MainContentCoordinatorLazyLoadTests.swift b/TableProTests/Views/Main/MainContentCoordinatorLazyLoadTests.swift index b1004a8fd9..8d1ddebb19 100644 --- a/TableProTests/Views/Main/MainContentCoordinatorLazyLoadTests.swift +++ b/TableProTests/Views/Main/MainContentCoordinatorLazyLoadTests.swift @@ -16,14 +16,12 @@ import Testing @MainActor struct MainContentCoordinatorLazyLoadTests { + private let fixtures = CoordinatorFixtures() + private func makeCoordinator() -> (MainContentCoordinator, QueryTabManager) { - let tabManager = QueryTabManager() - let coordinator = MainContentCoordinator( - connection: TestFixtures.makeConnection(), - tabManager: tabManager, - changeManager: DataChangeManager(), - toolbarState: ConnectionToolbarState() - ) + let fixture = fixtures.make() + let tabManager = fixture.tabManager + let coordinator = fixture.coordinator return (coordinator, tabManager) } diff --git a/TableProTests/Views/Main/MainContentCoordinatorRefreshTests.swift b/TableProTests/Views/Main/MainContentCoordinatorRefreshTests.swift index 5079b91349..6c0c822cd1 100644 --- a/TableProTests/Views/Main/MainContentCoordinatorRefreshTests.swift +++ b/TableProTests/Views/Main/MainContentCoordinatorRefreshTests.swift @@ -1,12 +1,6 @@ -// -// MainContentCoordinatorRefreshTests.swift -// TableProTests -// -// Tests for handleRefresh, the entry point behind the Refresh toolbar -// button and Cmd+R. Regression coverage for #1637: a refresh issued while -// a query was in flight was silently dropped because the in-flight -// cancellation cleared isExecuting asynchronously. -// +// handleRefresh is the entry point behind the Refresh toolbar button and Cmd+R. A refresh issued +// while a query was in flight was silently dropped, because the in-flight cancellation cleared +// isExecuting asynchronously. import Combine import Foundation @@ -16,6 +10,8 @@ import Testing @MainActor struct MainContentCoordinatorRefreshTests { + private let fixtures = CoordinatorFixtures() + private func makeCoordinator() -> (MainContentCoordinator, QueryTabManager) { makeCoordinator(connection: TestFixtures.makeConnection()) } @@ -23,13 +19,9 @@ struct MainContentCoordinatorRefreshTests { private func makeCoordinator( connection: DatabaseConnection ) -> (MainContentCoordinator, QueryTabManager) { - let tabManager = QueryTabManager() - let coordinator = MainContentCoordinator( - connection: connection, - tabManager: tabManager, - changeManager: DataChangeManager(), - toolbarState: ConnectionToolbarState() - ) + let fixture = fixtures.make(connection: connection) + let tabManager = fixture.tabManager + let coordinator = fixture.coordinator return (coordinator, tabManager) } @@ -356,7 +348,7 @@ struct MainContentCoordinatorRefreshTests { } @Test("A single requestRefresh fires once and schedules no trailing refresh") - func singleRequestRefreshHasNoTrailing() async { + func singleRequestRefreshHasNoTrailing() async throws { let (coordinator, tabManager) = makeCoordinator() let tabId = addTableTab(to: tabManager) guard let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { @@ -369,7 +361,8 @@ struct MainContentCoordinatorRefreshTests { coordinator.requestRefresh(hasPendingTableOps: false, onDiscard: {}) let epochAfterLeading = coordinator.tabExecution.contentEpoch(for: tabId) - try? await Task.sleep(for: .milliseconds(400)) + let coalesceWindow = try #require(coordinator.refreshCoalesceTask) + await coalesceWindow.value #expect(coordinator.tabExecution.contentEpoch(for: tabId) == epochAfterLeading) #expect(coordinator.refreshPendingTrailing == false) diff --git a/TableProTests/Views/Main/MainContentCoordinatorSelectionResetTests.swift b/TableProTests/Views/Main/MainContentCoordinatorSelectionResetTests.swift index 90b71e4c21..ea3ebab713 100644 --- a/TableProTests/Views/Main/MainContentCoordinatorSelectionResetTests.swift +++ b/TableProTests/Views/Main/MainContentCoordinatorSelectionResetTests.swift @@ -16,14 +16,12 @@ import Testing @MainActor struct MainContentCoordinatorSelectionResetTests { + private let fixtures = CoordinatorFixtures() + private func makeCoordinator() -> (MainContentCoordinator, QueryTabManager) { - let tabManager = QueryTabManager() - let coordinator = MainContentCoordinator( - connection: TestFixtures.makeConnection(), - tabManager: tabManager, - changeManager: DataChangeManager(), - toolbarState: ConnectionToolbarState() - ) + let fixture = fixtures.make() + let tabManager = fixture.tabManager + let coordinator = fixture.coordinator return (coordinator, tabManager) } diff --git a/TableProTests/Views/Main/MainContentCoordinatorSortTests.swift b/TableProTests/Views/Main/MainContentCoordinatorSortTests.swift index b639fe3f3b..47cd3e727d 100644 --- a/TableProTests/Views/Main/MainContentCoordinatorSortTests.swift +++ b/TableProTests/Views/Main/MainContentCoordinatorSortTests.swift @@ -1,10 +1,6 @@ -// -// MainContentCoordinatorSortTests.swift -// TableProTests -// - import Foundation import TableProPluginKit +import TableProTestSupport import Testing @testable import TablePro @@ -435,7 +431,11 @@ struct MainContentCoordinatorSortTests { tab.display.resultSets.append(other) tab.display.activeResultSetId = other.id } - try await Task.sleep(for: .milliseconds(300)) + // The reload holds the safe-mode flag until it is authorized, and a started run holds the tab. + try await waitUntil { + !harness.coordinator.isShowingSafeModePrompt + && !harness.coordinator.tabExecution.isExecuting(harness.tabId) + } #expect(harness.driver.sent.count == alreadySent) let display = try #require(harness.tabManager.tabs.first { $0.id == harness.tabId }?.display) @@ -660,9 +660,8 @@ struct MainContentCoordinatorSortTests { coordinator.setActiveTableRows(rows, for: tab.id) coordinator.handleSortStateChanged(sortState([(0, .ascending)])) - for _ in 0 ..< 500 { - if !coordinator.isShowingSafeModePrompt, !coordinator.tabExecution.isExecuting(tab.id) { break } - try await Task.sleep(for: .milliseconds(10)) + try await waitUntil { + !coordinator.isShowingSafeModePrompt && !coordinator.tabExecution.isExecuting(tab.id) } #expect(driver.sentSQL.isEmpty) @@ -865,11 +864,11 @@ private struct ParameterizedRunHarness { } private func waitUntilIdle(_ condition: () -> Bool) async throws { - for _ in 0 ..< 500 { - if condition(), !coordinator.tabExecution.isExecuting(tabId) { return } - try await Task.sleep(for: .milliseconds(10)) + do { + try await waitUntil { condition() && !coordinator.tabExecution.isExecuting(tabId) } + } catch is WaitTimeout { + Issue.record("The run never settled. Sent: \(driver.sent.map(\.sql))") } - Issue.record("The run never settled. Sent: \(driver.sent.map(\.sql))") } } diff --git a/TableProTests/Views/Main/MaterializedViewRowWriteTests.swift b/TableProTests/Views/Main/MaterializedViewRowWriteTests.swift index 082a777944..4fa522ec04 100644 --- a/TableProTests/Views/Main/MaterializedViewRowWriteTests.swift +++ b/TableProTests/Views/Main/MaterializedViewRowWriteTests.swift @@ -1,10 +1,5 @@ -// -// MaterializedViewRowWriteTests.swift -// TableProTests -// -// PostgreSQL 17.11 refuses every row write on a materialized view ("cannot change materialized -// view"), and the sidebar opened one as an editable table, so the edits queued and failed at Save. -// +// PostgreSQL 17.11 refuses every row write on a materialized view ("cannot change materialized +// view"), and the sidebar opened one as an editable table, so the edits queued and failed at Save. import AppKit import Foundation @@ -12,246 +7,232 @@ import Foundation import TableProPluginKit import Testing -private final class MaterializedViewClipboard: ClipboardProvider { - var gridRows: GridRowsClipboardPayload? - - func readText() -> String? { nil } - func readGridRows() -> GridRowsClipboardPayload? { gridRows } - func writeText(_ text: String) {} - func writeCsv(_ csv: String) {} - func writeImage(_ image: NSImage) {} - func writeRows(tsv: String, html: String?, gridRows: GridRowsClipboardPayload) {} - var hasText: Bool { false } - var hasGridRows: Bool { gridRows != nil } -} - -@MainActor -struct MaterializedViewRowWriteTests { - private func makeCoordinator() -> MainContentCoordinator { - MainContentCoordinator( - connection: TestFixtures.makeConnection(database: "shop", type: .postgresql), - tabManager: QueryTabManager(), - changeManager: DataChangeManager(), - toolbarState: ConnectionToolbarState() - ) - } - - private func finishLoad(_ coordinator: MainContentCoordinator) throws { - let index = try #require(coordinator.tabManager.selectedTabIndex) - coordinator.tabManager.tabs[index].tableContext.isEditable = true - coordinator.tabManager.tabs[index].display.resultsViewMode = .data - let tabId = coordinator.tabManager.tabs[index].id - coordinator.setActiveTableRows( - TableRows.from( - queryRows: [[.text("1"), .text("Alice")], [.text("2"), .text("Bob")]], +extension GlobalStateSuites { + @Suite(.recordingClipboard) + @MainActor + struct MaterializedViewRowWriteTests { + private func makeCoordinator() -> MainContentCoordinator { + MainContentCoordinator( + connection: TestFixtures.makeConnection(database: "shop", type: .postgresql), + tabManager: QueryTabManager(), + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + } + + private func finishLoad(_ coordinator: MainContentCoordinator) throws { + let index = try #require(coordinator.tabManager.selectedTabIndex) + coordinator.tabManager.tabs[index].tableContext.isEditable = true + coordinator.tabManager.tabs[index].display.resultsViewMode = .data + let tabId = coordinator.tabManager.tabs[index].id + coordinator.setActiveTableRows( + TableRows.from( + queryRows: [[.text("1"), .text("Alice")], [.text("2"), .text("Bob")]], + columns: ["id", "name"], + columnTypes: [.text(rawType: nil), .text(rawType: nil)], + hasAuthoritativeSchema: true + ), + for: tabId + ) + coordinator.changeManager.configureForTable( + tableName: coordinator.tabManager.tabs[index].tableContext.tableName ?? "", columns: ["id", "name"], - columnTypes: [.text(rawType: nil), .text(rawType: nil)], - hasAuthoritativeSchema: true - ), - for: tabId - ) - coordinator.changeManager.configureForTable( - tableName: coordinator.tabManager.tabs[index].tableContext.tableName ?? "", - columns: ["id", "name"], - primaryKeyColumns: ["id"], - databaseType: .postgresql, - generatedColumns: [] - ) - } - - private func openedFromSidebar(_ type: TableInfo.TableType) throws -> MainContentCoordinator { - let coordinator = makeCoordinator() - coordinator.openTableTab(TableInfo(name: "daily_totals", type: type, rowCount: nil, schema: "public")) - try finishLoad(coordinator) - return coordinator - } - - private func restoredTab(objectType: TableInfo.TableType?, isView: Bool) throws -> MainContentCoordinator { - var tab = QueryTab( - title: "daily_totals", - query: "SELECT * FROM daily_totals", - tabType: .table, - tableName: "daily_totals" - ) - tab.tableContext.isView = isView - tab.tableContext.objectType = objectType - let restored = QueryTab(from: tab.toPersistedTab(), defaultPageSize: 1_000) - - let coordinator = makeCoordinator() - coordinator.tabManager.tabs.append(restored) - coordinator.tabManager.selectedTabId = restored.id - try finishLoad(coordinator) - return coordinator - } - - private func rowCount(_ coordinator: MainContentCoordinator) throws -> Int { - let tabId = try #require(coordinator.tabManager.selectedTabId) - return coordinator.tabSessionRegistry.tableRows(for: tabId).count - } - - // MARK: - Opening from the sidebar - - @Test("A materialized view opened from the sidebar refuses row edits and Add Row") - func sidebarMaterializedViewIsReadOnly() throws { - let coordinator = try openedFromSidebar(.materializedView) - defer { coordinator.teardown() } - - let context = try #require(coordinator.tabManager.selectedTab?.tableContext) - #expect(context.isView) - #expect(context.objectType == .materializedView) - #expect(!coordinator.canEditActiveResult) - #expect(!coordinator.canAddRow) - } - - @Test("A table opened from the sidebar the same way still edits and adds rows") - func sidebarTableStaysWritable() throws { - let coordinator = try openedFromSidebar(.table) - defer { coordinator.teardown() } - - #expect(coordinator.tabManager.selectedTab?.tableContext.isView == false) - #expect(coordinator.canEditActiveResult) - #expect(coordinator.canAddRow) - } - - @Test("The sidebar and Open Quickly open a materialized view with the same gate") - func sidebarAndOpenQuicklyAgree() throws { - let fromSidebar = try openedFromSidebar(.materializedView) - defer { fromSidebar.teardown() } - - let fromSwitcher = makeCoordinator() - defer { fromSwitcher.teardown() } - let target = QuickSwitcherTarget( - connectionId: fromSwitcher.connectionId, - connectionName: "Primary", - databaseName: "shop", - schemaName: "public" - ) - let item = try #require( - QuickSwitcherViewModel.makeCrossConnectionItems( - tables: [TableInfo(name: "daily_totals", type: .materializedView, rowCount: nil)], - target: target, - connectionSwitchesDatabases: true - ).first - ) - fromSwitcher.handleQuickSwitcherSelection(item) - try finishLoad(fromSwitcher) - - let sidebarContext = try #require(fromSidebar.tabManager.selectedTab?.tableContext) - let switcherContext = try #require(fromSwitcher.tabManager.selectedTab?.tableContext) - #expect(sidebarContext.isView == switcherContext.isView) - #expect(sidebarContext.objectType == switcherContext.objectType) - #expect(fromSidebar.canEditActiveResult == fromSwitcher.canEditActiveResult) - #expect(!fromSwitcher.canEditActiveResult) - } - - // MARK: - A tab saved by an older build - - @Test("A restored tab that kept isView false over a materialized view stays read-only") - func restoredStaleTabIsReadOnly() throws { - let coordinator = try restoredTab(objectType: .materializedView, isView: false) - defer { coordinator.teardown() } - - let context = try #require(coordinator.tabManager.selectedTab?.tableContext) - #expect(context.isEditable) - #expect(!context.isView) - #expect(!coordinator.canEditActiveResult) - #expect(!coordinator.canAddRow) - } - - @Test("A restored table tab with the same history still edits and adds rows") - func restoredTableTabStaysWritable() throws { - let coordinator = try restoredTab(objectType: .table, isView: false) - defer { coordinator.teardown() } - - #expect(coordinator.canEditActiveResult) - #expect(coordinator.canAddRow) - } - - @Test("A restored tab with no kind follows its Bool") - func restoredTabWithoutAKindFollowsTheBool() throws { - let writable = try restoredTab(objectType: nil, isView: false) - defer { writable.teardown() } - let readOnly = try restoredTab(objectType: nil, isView: true) - defer { readOnly.teardown() } - - #expect(writable.canEditActiveResult) - #expect(!readOnly.canEditActiveResult) - } - - // MARK: - The row commands themselves - - @Test("Add, Duplicate, Delete and Paste stage nothing on a materialized view") - func rowCommandsRefuseAMaterializedView() throws { - let clipboard = MaterializedViewClipboard() - clipboard.gridRows = GridRowsClipboardPayload(columns: ["id", "name"], rows: [[.text("3"), .text("Cleo")]]) - ClipboardService.shared = clipboard - defer { ClipboardService.shared = NSPasteboardClipboardProvider() } - - let coordinator = try restoredTab(objectType: .materializedView, isView: false) - defer { coordinator.teardown() } - - coordinator.addNewRow() - coordinator.duplicateSelectedRow(index: 0) - coordinator.pasteRows() - #expect(try rowCount(coordinator) == 2) - - coordinator.deleteSelectedRows(indices: [0]) - #expect(!coordinator.changeManager.hasChanges) - } - - @Test("Delete stages nothing on a view whose load marked it editable") - func deleteRefusesALoadedView() throws { - let coordinator = try restoredTab(objectType: .view, isView: true) - defer { coordinator.teardown() } - - coordinator.deleteSelectedRows(indices: [0]) - #expect(!coordinator.changeManager.hasChanges) - } - - @Test("Add, Duplicate and Paste still stage rows on a table") - func rowCommandsStillWorkOnATable() throws { - let clipboard = MaterializedViewClipboard() - clipboard.gridRows = GridRowsClipboardPayload(columns: ["id", "name"], rows: [[.text("3"), .text("Cleo")]]) - ClipboardService.shared = clipboard - defer { ClipboardService.shared = NSPasteboardClipboardProvider() } - - let coordinator = try restoredTab(objectType: .table, isView: false) - defer { coordinator.teardown() } - - coordinator.addNewRow() - #expect(try rowCount(coordinator) == 3) - coordinator.duplicateSelectedRow(index: 0) - #expect(try rowCount(coordinator) == 4) - coordinator.pasteRows() - #expect(try rowCount(coordinator) == 5) - } - - @Test("Delete still stages on a table") - func deleteStillStagesOnATable() throws { - let coordinator = try restoredTab(objectType: .table, isView: false) - defer { coordinator.teardown() } - - coordinator.deleteSelectedRows(indices: [0]) - #expect(coordinator.changeManager.hasChanges) - } - - // MARK: - The grid's empty-space menu - - @Test("The grid's empty space offers Add Row on a table and not on a materialized view") - func emptySpaceMenuFollowsTheGate() throws { - let matview = try restoredTab(objectType: .materializedView, isView: false) - defer { matview.teardown() } - let table = try restoredTab(objectType: .table, isView: false) - defer { table.teardown() } - - let matviewDelegate = DataTabGridDelegate() - matviewDelegate.coordinator = matview - matviewDelegate.onAddRow = { matview.addNewRow() } - let tableDelegate = DataTabGridDelegate() - tableDelegate.coordinator = table - tableDelegate.onAddRow = { table.addNewRow() } - - #expect(matviewDelegate.dataGridEmptySpaceMenu() == nil) - #expect(tableDelegate.dataGridEmptySpaceMenu() != nil) + primaryKeyColumns: ["id"], + databaseType: .postgresql, + generatedColumns: [] + ) + } + + private func openedFromSidebar(_ type: TableInfo.TableType) throws -> MainContentCoordinator { + let coordinator = makeCoordinator() + coordinator.openTableTab(TableInfo(name: "daily_totals", type: type, rowCount: nil, schema: "public")) + try finishLoad(coordinator) + return coordinator + } + + private func restoredTab(objectType: TableInfo.TableType?, isView: Bool) throws -> MainContentCoordinator { + var tab = QueryTab( + title: "daily_totals", + query: "SELECT * FROM daily_totals", + tabType: .table, + tableName: "daily_totals" + ) + tab.tableContext.isView = isView + tab.tableContext.objectType = objectType + let restored = QueryTab(from: tab.toPersistedTab(), defaultPageSize: 1_000) + + let coordinator = makeCoordinator() + coordinator.tabManager.tabs.append(restored) + coordinator.tabManager.selectedTabId = restored.id + try finishLoad(coordinator) + return coordinator + } + + private func rowCount(_ coordinator: MainContentCoordinator) throws -> Int { + let tabId = try #require(coordinator.tabManager.selectedTabId) + return coordinator.tabSessionRegistry.tableRows(for: tabId).count + } + + // MARK: - Opening from the sidebar + + @Test("A materialized view opened from the sidebar refuses row edits and Add Row") + func sidebarMaterializedViewIsReadOnly() throws { + let coordinator = try openedFromSidebar(.materializedView) + defer { coordinator.teardown() } + + let context = try #require(coordinator.tabManager.selectedTab?.tableContext) + #expect(context.isView) + #expect(context.objectType == .materializedView) + #expect(!coordinator.canEditActiveResult) + #expect(!coordinator.canAddRow) + } + + @Test("A table opened from the sidebar the same way still edits and adds rows") + func sidebarTableStaysWritable() throws { + let coordinator = try openedFromSidebar(.table) + defer { coordinator.teardown() } + + #expect(coordinator.tabManager.selectedTab?.tableContext.isView == false) + #expect(coordinator.canEditActiveResult) + #expect(coordinator.canAddRow) + } + + @Test("The sidebar and Open Quickly open a materialized view with the same gate") + func sidebarAndOpenQuicklyAgree() throws { + let fromSidebar = try openedFromSidebar(.materializedView) + defer { fromSidebar.teardown() } + + let fromSwitcher = makeCoordinator() + defer { fromSwitcher.teardown() } + let target = QuickSwitcherTarget( + connectionId: fromSwitcher.connectionId, + connectionName: "Primary", + databaseName: "shop", + schemaName: "public" + ) + let item = try #require( + QuickSwitcherViewModel.makeCrossConnectionItems( + tables: [TableInfo(name: "daily_totals", type: .materializedView, rowCount: nil)], + target: target, + connectionSwitchesDatabases: true + ).first + ) + fromSwitcher.handleQuickSwitcherSelection(item) + try finishLoad(fromSwitcher) + + let sidebarContext = try #require(fromSidebar.tabManager.selectedTab?.tableContext) + let switcherContext = try #require(fromSwitcher.tabManager.selectedTab?.tableContext) + #expect(sidebarContext.isView == switcherContext.isView) + #expect(sidebarContext.objectType == switcherContext.objectType) + #expect(fromSidebar.canEditActiveResult == fromSwitcher.canEditActiveResult) + #expect(!fromSwitcher.canEditActiveResult) + } + + // MARK: - A tab saved by an older build + + @Test("A restored tab that kept isView false over a materialized view stays read-only") + func restoredStaleTabIsReadOnly() throws { + let coordinator = try restoredTab(objectType: .materializedView, isView: false) + defer { coordinator.teardown() } + + let context = try #require(coordinator.tabManager.selectedTab?.tableContext) + #expect(context.isEditable) + #expect(!context.isView) + #expect(!coordinator.canEditActiveResult) + #expect(!coordinator.canAddRow) + } + + @Test("A restored table tab with the same history still edits and adds rows") + func restoredTableTabStaysWritable() throws { + let coordinator = try restoredTab(objectType: .table, isView: false) + defer { coordinator.teardown() } + + #expect(coordinator.canEditActiveResult) + #expect(coordinator.canAddRow) + } + + @Test("A restored tab with no kind follows its Bool") + func restoredTabWithoutAKindFollowsTheBool() throws { + let writable = try restoredTab(objectType: nil, isView: false) + defer { writable.teardown() } + let readOnly = try restoredTab(objectType: nil, isView: true) + defer { readOnly.teardown() } + + #expect(writable.canEditActiveResult) + #expect(!readOnly.canEditActiveResult) + } + + // MARK: - The row commands themselves + + @Test("Add, Duplicate, Delete and Paste stage nothing on a materialized view") + func rowCommandsRefuseAMaterializedView() throws { + let clipboard = try #require(RecordingClipboard.current) + clipboard.gridRows = GridRowsClipboardPayload(columns: ["id", "name"], rows: [[.text("3"), .text("Cleo")]]) + + let coordinator = try restoredTab(objectType: .materializedView, isView: false) + defer { coordinator.teardown() } + + coordinator.addNewRow() + coordinator.duplicateSelectedRow(index: 0) + coordinator.pasteRows() + #expect(try rowCount(coordinator) == 2) + + coordinator.deleteSelectedRows(indices: [0]) + #expect(!coordinator.changeManager.hasChanges) + } + + @Test("Delete stages nothing on a view whose load marked it editable") + func deleteRefusesALoadedView() throws { + let coordinator = try restoredTab(objectType: .view, isView: true) + defer { coordinator.teardown() } + + coordinator.deleteSelectedRows(indices: [0]) + #expect(!coordinator.changeManager.hasChanges) + } + + @Test("Add, Duplicate and Paste still stage rows on a table") + func rowCommandsStillWorkOnATable() throws { + let clipboard = try #require(RecordingClipboard.current) + clipboard.gridRows = GridRowsClipboardPayload(columns: ["id", "name"], rows: [[.text("3"), .text("Cleo")]]) + + let coordinator = try restoredTab(objectType: .table, isView: false) + defer { coordinator.teardown() } + + coordinator.addNewRow() + #expect(try rowCount(coordinator) == 3) + coordinator.duplicateSelectedRow(index: 0) + #expect(try rowCount(coordinator) == 4) + coordinator.pasteRows() + #expect(try rowCount(coordinator) == 5) + } + + @Test("Delete still stages on a table") + func deleteStillStagesOnATable() throws { + let coordinator = try restoredTab(objectType: .table, isView: false) + defer { coordinator.teardown() } + + coordinator.deleteSelectedRows(indices: [0]) + #expect(coordinator.changeManager.hasChanges) + } + + // MARK: - The grid's empty-space menu + + @Test("The grid's empty space offers Add Row on a table and not on a materialized view") + func emptySpaceMenuFollowsTheGate() throws { + let matview = try restoredTab(objectType: .materializedView, isView: false) + defer { matview.teardown() } + let table = try restoredTab(objectType: .table, isView: false) + defer { table.teardown() } + + let matviewDelegate = DataTabGridDelegate() + matviewDelegate.coordinator = matview + matviewDelegate.onAddRow = { matview.addNewRow() } + let tableDelegate = DataTabGridDelegate() + tableDelegate.coordinator = table + tableDelegate.onAddRow = { table.addNewRow() } + + #expect(matviewDelegate.dataGridEmptySpaceMenu() == nil) + #expect(tableDelegate.dataGridEmptySpaceMenu() != nil) + } } } diff --git a/TableProTests/Views/Main/MultiConnectionNavigationTests.swift b/TableProTests/Views/Main/MultiConnectionNavigationTests.swift index 4b3990db4d..4ca61ab10e 100644 --- a/TableProTests/Views/Main/MultiConnectionNavigationTests.swift +++ b/TableProTests/Views/Main/MultiConnectionNavigationTests.swift @@ -1,11 +1,5 @@ -// -// MultiConnectionNavigationTests.swift -// TableProTests -// -// Tests for multi-connection navigation — openTableTab paths not covered -// by OpenTableTabTests, SidebarNavigationResult in multi-database-type -// context, and coordinator connection scoping isolation. -// +// Multi-connection navigation: the openTableTab paths OpenTableTabTests does not cover, +// SidebarNavigationResult across database types, and coordinator connection scoping. import Foundation import TableProPluginKit @@ -173,32 +167,12 @@ struct MultiConnectionNavigationTests { // MARK: - SidebarNavigationResult: reuseActiveTab for all database types with no tabs - @Test("resolve returns reuseActiveTab for mysql with no existing tabs") - func resolveReuseActiveTabForMysqlNoTabs() { + @Test("resolve returns reuseActiveTab when there are no existing tabs", arguments: [ + "users", "accounts", "items", + ]) + func resolveReuseActiveTabWithNoTabs(tableName: String) { let result = SidebarNavigationResult.resolve( - clickedTableName: "users", - currentTabTableName: nil, - hasExistingTabs: false, - isActiveTabReusable: false - ) - #expect(result == .reuseActiveTab) - } - - @Test("resolve returns reuseActiveTab for postgresql with no existing tabs") - func resolveReuseActiveTabForPostgresqlNoTabs() { - let result = SidebarNavigationResult.resolve( - clickedTableName: "accounts", - currentTabTableName: nil, - hasExistingTabs: false, - isActiveTabReusable: false - ) - #expect(result == .reuseActiveTab) - } - - @Test("resolve returns reuseActiveTab for sqlite with no existing tabs") - func resolveReuseActiveTabForSqliteNoTabs() { - let result = SidebarNavigationResult.resolve( - clickedTableName: "items", + clickedTableName: tableName, currentTabTableName: nil, hasExistingTabs: false, isActiveTabReusable: false diff --git a/TableProTests/Views/Main/PaginationCoordinatorTests.swift b/TableProTests/Views/Main/PaginationCoordinatorTests.swift index 279f041cca..05b0fc25ec 100644 --- a/TableProTests/Views/Main/PaginationCoordinatorTests.swift +++ b/TableProTests/Views/Main/PaginationCoordinatorTests.swift @@ -11,17 +11,15 @@ import Testing @MainActor struct PaginationCoordinatorTests { + private let fixtures = CoordinatorFixtures() + private func makeCoordinator( pagination: PaginationState, loadedRowCount: Int ) -> (MainContentCoordinator, QueryTabManager, UUID) { - let tabManager = QueryTabManager() - let coordinator = MainContentCoordinator( - connection: TestFixtures.makeConnection(), - tabManager: tabManager, - changeManager: DataChangeManager(), - toolbarState: ConnectionToolbarState() - ) + let fixture = fixtures.make() + let tabManager = fixture.tabManager + let coordinator = fixture.coordinator var tab = QueryTab(title: "users", query: "SELECT * FROM users", tabType: .table) tab.tableContext.tableName = "users" tab.pagination = pagination diff --git a/TableProTests/Views/Main/Phase2RowCountGuardTests.swift b/TableProTests/Views/Main/Phase2RowCountGuardTests.swift index 5468f800fa..f1beb7e82a 100644 --- a/TableProTests/Views/Main/Phase2RowCountGuardTests.swift +++ b/TableProTests/Views/Main/Phase2RowCountGuardTests.swift @@ -11,9 +11,10 @@ import Foundation @testable import TablePro import Testing -@Suite("Phase 2 row count guard", .serialized) @MainActor struct Phase2RowCountGuardTests { + private let fixtures = CoordinatorFixtures() + /// The registry contract phase 2 depends on. A claim is dead the moment phase 1 settles it, so /// anything that runs afterwards and still belongs to the same content must ask about content. @Test("A settled claim is no longer current but its content identity holds") @@ -35,7 +36,7 @@ struct Phase2RowCountGuardTests { /// synchronously, so the handle it registers is observable without waiting on anything. @Test("Phase 2 still requests a row count after phase 1 has settled the claim") func phase2RequestsRowCountAfterSettle() { - let (coordinator, tabManager) = Self.makeCoordinator() + let (coordinator, tabManager) = makeCoordinator() let tabId = Self.addTableTab(to: tabManager) let claim = coordinator.tabExecution.claim(tabId) let settled = coordinator.tabExecution.settle(claim) @@ -63,14 +64,10 @@ struct Phase2RowCountGuardTests { #expect(registry.isSameContent(contentEpoch, for: tabId) == false) } - private static func makeCoordinator() -> (MainContentCoordinator, QueryTabManager) { - let tabManager = QueryTabManager() - let coordinator = MainContentCoordinator( - connection: TestFixtures.makeConnection(), - tabManager: tabManager, - changeManager: DataChangeManager(), - toolbarState: ConnectionToolbarState() - ) + private func makeCoordinator() -> (MainContentCoordinator, QueryTabManager) { + let fixture = fixtures.make() + let tabManager = fixture.tabManager + let coordinator = fixture.coordinator return (coordinator, tabManager) } diff --git a/TableProTests/Views/Main/PluginBrowsedQueryTabEditabilityTests.swift b/TableProTests/Views/Main/PluginBrowsedQueryTabEditabilityTests.swift index ab19c3e21d..51419aa268 100644 --- a/TableProTests/Views/Main/PluginBrowsedQueryTabEditabilityTests.swift +++ b/TableProTests/Views/Main/PluginBrowsedQueryTabEditabilityTests.swift @@ -9,7 +9,7 @@ import Testing @testable import TablePro -private final class BrowseBuildingDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class BrowseBuildingDriver: PluginDriverStub, @unchecked Sendable { var supportsSchemas: Bool { false } var supportsTransactions: Bool { false } var currentSchema: String? { nil } @@ -25,31 +25,15 @@ private final class BrowseBuildingDriver: PluginDatabaseDriver, @unchecked Senda "SELECT * FROM \(table)" } - func connect() async throws {} - func disconnect() {} func ping() async throws {} func execute(query: String) async throws -> PluginQueryResult { PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) } - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } /// A query tab keeps the table name of whatever result it last showed, and an older build filled it from the /// query's text. On a SQL engine whose plugin writes its own browse, the app no longer reads that text, so a name /// left on the tab could point the grid's edits at a different table from the one the rows came from. -@Suite(.serialized) @MainActor struct PluginBrowsedQueryTabEditabilityTests { private func withCoordinator( diff --git a/TableProTests/Views/Main/QueryFailureReportingTests.swift b/TableProTests/Views/Main/QueryFailureReportingTests.swift index 4088aa1f7a..0a501c2aa0 100644 --- a/TableProTests/Views/Main/QueryFailureReportingTests.swift +++ b/TableProTests/Views/Main/QueryFailureReportingTests.swift @@ -12,16 +12,17 @@ import Foundation import TableProPluginKit import Testing -@Suite("Query failure reporting", .serialized) @MainActor struct QueryFailureReportingTests { + private let fixtures = CoordinatorFixtures() + private struct DriverFailure: LocalizedError { let errorDescription: String? = "relation \"stu\" does not exist" } @Test("A failed query reports the database's error on the tab") func failureSetsTheErrorMessage() { - let (coordinator, tabManager) = Self.makeCoordinator() + let (coordinator, tabManager) = makeCoordinator() let tabId = Self.addQueryTab(to: tabManager) let claim = coordinator.tabExecution.claim(tabId) @@ -37,7 +38,7 @@ struct QueryFailureReportingTests { /// that says the query failed. Every success path opens it; the failure paths did not. @Test("A failure opens a collapsed results pane") func failureOpensCollapsedResults() { - let (coordinator, tabManager) = Self.makeCoordinator() + let (coordinator, tabManager) = makeCoordinator() let tabId = Self.addQueryTab(to: tabManager) tabManager.mutate(tabId: tabId) { $0.display.isResultsCollapsed = true } let claim = coordinator.tabExecution.claim(tabId) @@ -52,7 +53,7 @@ struct QueryFailureReportingTests { /// showed the previous run's timing beside a red banner. @Test("A failure clears the duration left over from the last successful run") func failureClearsTheStaleDuration() { - let (coordinator, tabManager) = Self.makeCoordinator() + let (coordinator, tabManager) = makeCoordinator() let tabId = Self.addQueryTab(to: tabManager) coordinator.toolbarState.recordQueryTiming(PluginQueryTiming(total: 1.5), for: tabId) let claim = coordinator.tabExecution.claim(tabId) @@ -65,7 +66,7 @@ struct QueryFailureReportingTests { @Test("Reporting a failure releases the tab so the next run is not blocked") func failureReleasesTheTab() { - let (coordinator, tabManager) = Self.makeCoordinator() + let (coordinator, tabManager) = makeCoordinator() let tabId = Self.addQueryTab(to: tabManager) let claim = coordinator.tabExecution.claim(tabId) #expect(coordinator.tabExecution.isExecuting(tabId)) @@ -77,7 +78,7 @@ struct QueryFailureReportingTests { @Test("A cancelled query is not reported as an error") func cancellationIsNotAnError() { - let (coordinator, tabManager) = Self.makeCoordinator() + let (coordinator, tabManager) = makeCoordinator() let tabId = Self.addQueryTab(to: tabManager) let claim = coordinator.tabExecution.claim(tabId) @@ -90,7 +91,7 @@ struct QueryFailureReportingTests { /// take down the busy state belonging to the navigation that replaced it. @Test("A superseded failure writes nothing and leaves the successor running") func supersededFailureIsSilent() { - let (coordinator, tabManager) = Self.makeCoordinator() + let (coordinator, tabManager) = makeCoordinator() let tabId = Self.addQueryTab(to: tabManager) let stale = coordinator.tabExecution.claim(tabId) _ = coordinator.tabExecution.claim(tabId) @@ -106,7 +107,7 @@ struct QueryFailureReportingTests { /// owner: taking the handle a successor installed leaves a live query with nothing to stop it. @Test("Retiring the task handle only works for the execution that installed it") func onlyTheInstallerRetiresTheTaskHandle() { - let (coordinator, tabManager) = Self.makeCoordinator() + let (coordinator, tabManager) = makeCoordinator() let tabId = Self.addQueryTab(to: tabManager) let stranger = coordinator.tabExecution.claim(tabId) let running = coordinator.tabExecution.claim(tabId) @@ -132,7 +133,7 @@ struct QueryFailureReportingTests { /// with it, leaving a running query with no spinner and nothing for Stop to reach. @Test("Resetting after a cancel leaves a successor's task handle alone") func cancelResetRespectsTheHandleOwner() { - let (coordinator, tabManager) = Self.makeCoordinator() + let (coordinator, tabManager) = makeCoordinator() let tabId = Self.addQueryTab(to: tabManager) let otherTabId = Self.addQueryTab(to: tabManager, title: "Query 2") let cancelled = coordinator.tabExecution.claim(tabId) @@ -154,7 +155,7 @@ struct QueryFailureReportingTests { /// window chrome around whatever the user is actually looking at. @Test("A background tab's failure leaves the toolbar describing the selected tab") func backgroundFailureLeavesTheToolbarAlone() { - let (coordinator, tabManager) = Self.makeCoordinator() + let (coordinator, tabManager) = makeCoordinator() let backgroundTabId = Self.addQueryTab(to: tabManager) let selectedTabId = Self.addQueryTab(to: tabManager, title: "Query 2") tabManager.selectedTabId = selectedTabId @@ -174,7 +175,7 @@ struct QueryFailureReportingTests { /// which is not the case for a navigation that superseded work that had already finished. @Test("Superseding a tab with no successor leaves the window reporting idle") func supersedeWithoutSuccessorLeavesTheWindowIdle() { - let (coordinator, tabManager) = Self.makeCoordinator() + let (coordinator, tabManager) = makeCoordinator() let tabId = Self.addQueryTab(to: tabManager) _ = coordinator.tabExecution.claim(tabId) #expect(coordinator.tabExecution.isAnyExecuting) @@ -189,7 +190,7 @@ struct QueryFailureReportingTests { /// completion on a different tab throws away edits and undo history the user can never get back. @Test("Clearing pending changes never reaches a tab the user has switched to") func pendingChangesOfAnotherTabSurvive() { - let (coordinator, tabManager) = Self.makeCoordinator() + let (coordinator, tabManager) = makeCoordinator() let backgroundTabId = Self.addQueryTab(to: tabManager) let selectedTabId = Self.addQueryTab(to: tabManager, title: "Query 2") let claim = coordinator.tabExecution.claim(backgroundTabId) @@ -229,14 +230,10 @@ struct QueryFailureReportingTests { ) } - private static func makeCoordinator() -> (MainContentCoordinator, QueryTabManager) { - let tabManager = QueryTabManager() - let coordinator = MainContentCoordinator( - connection: TestFixtures.makeConnection(), - tabManager: tabManager, - changeManager: DataChangeManager(), - toolbarState: ConnectionToolbarState() - ) + private func makeCoordinator() -> (MainContentCoordinator, QueryTabManager) { + let fixture = fixtures.make() + let tabManager = fixture.tabManager + let coordinator = fixture.coordinator return (coordinator, tabManager) } diff --git a/TableProTests/Views/Main/QueryParameterDetectionTests.swift b/TableProTests/Views/Main/QueryParameterDetectionTests.swift index 6188c376a9..1cd4d7e40c 100644 --- a/TableProTests/Views/Main/QueryParameterDetectionTests.swift +++ b/TableProTests/Views/Main/QueryParameterDetectionTests.swift @@ -1,69 +1,63 @@ -// -// QueryParameterDetectionTests.swift -// TableProTests -// - import Foundation import Testing @testable import TablePro -@MainActor -struct QueryParameterDetectionTests { - @Test( - "Only SQL reads :name as a bind parameter", - arguments: [ - (DatabaseType.mysql, true), (.postgresql, true), (.sqlite, true), - (.elasticsearch, false), (.typesense, false), (.weaviate, false), (.mongodb, false), - (.redis, false), (.etcd, false), (.surrealdb, false), (.kafka, false) - ] - ) - func onlySQLBindsNamedParameters(type: DatabaseType, binds: Bool) { - let harness = QueryTabHarness(type: type, query: "") - defer { harness.tearDown() } - #expect(harness.coordinator.bindsNamedParameters == binds) - } +extension GlobalStateSuites { + /// Query parameters are on for every case, so the run paths answer to the shipped default + /// rather than to the machine running them. + @Suite(.appSettings { $0.editor.queryParametersEnabled = true }) + @MainActor + struct QueryParameterDetectionTests { + @Test( + "Only SQL reads :name as a bind parameter", + arguments: [ + (DatabaseType.mysql, true), (.postgresql, true), (.sqlite, true), + (.elasticsearch, false), (.typesense, false), (.weaviate, false), (.mongodb, false), + (.redis, false), (.etcd, false), (.surrealdb, false), (.kafka, false) + ] + ) + func onlySQLBindsNamedParameters(type: DatabaseType, binds: Bool) { + let harness = QueryTabHarness(type: type, query: "") + defer { harness.tearDown() } + #expect(harness.coordinator.bindsNamedParameters == binds) + } - @Test("A field query in an Elasticsearch URL runs instead of asking for a parameter") - func elasticsearchFieldQueryRuns() throws { - let query = "GET /products/_search?q=name:lamp" - let harness = QueryTabHarness(type: .elasticsearch, query: query) - defer { harness.tearDown() } + @Test("A field query in an Elasticsearch URL runs instead of asking for a parameter") + func elasticsearchFieldQueryRuns() throws { + let query = "GET /products/_search?q=name:lamp" + let harness = QueryTabHarness(type: .elasticsearch, query: query) + defer { harness.tearDown() } - #expect(harness.coordinator.runStatement(query, sourceOffset: 0)) + #expect(harness.coordinator.runStatement(query, sourceOffset: 0)) - let tab = try #require(harness.tabManager.tabs.first) - #expect(tab.content.queryParameters.isEmpty) - #expect(!tab.content.isParameterPanelVisible) - } + let tab = try #require(harness.tabManager.tabs.first) + #expect(tab.content.queryParameters.isEmpty) + #expect(!tab.content.isParameterPanelVisible) + } - @Test("A SQL :name still opens the parameter panel before anything runs") - func sqlParameterOpensPanel() throws { - let query = "SELECT * FROM products WHERE name = :name" - let harness = QueryTabHarness(type: .mysql, query: query) - defer { harness.tearDown() } + @Test("A SQL :name still opens the parameter panel before anything runs") + func sqlParameterOpensPanel() throws { + let query = "SELECT * FROM products WHERE name = :name" + let harness = QueryTabHarness(type: .mysql, query: query) + defer { harness.tearDown() } - #expect(!harness.coordinator.runStatement(query, sourceOffset: 0)) + #expect(!harness.coordinator.runStatement(query, sourceOffset: 0)) - let tab = try #require(harness.tabManager.tabs.first) - #expect(tab.content.queryParameters.map(\.name) == ["name"]) - #expect(tab.content.isParameterPanelVisible) + let tab = try #require(harness.tabManager.tabs.first) + #expect(tab.content.queryParameters.map(\.name) == ["name"]) + #expect(tab.content.isParameterPanelVisible) + } } } /// A coordinator with one query tab selected and no session behind it. -/// -/// Query parameters are switched on for the length of the test and the user's own setting put back, -/// so the run paths answer to the shipped default rather than to the machine running them. @MainActor private struct QueryTabHarness { let coordinator: MainContentCoordinator let tabManager: QueryTabManager - private let previousParametersSetting: Bool init(type: DatabaseType, query: String) { - previousParametersSetting = AppSettingsManager.shared.editor.queryParametersEnabled - AppSettingsManager.shared.editor.queryParametersEnabled = true tabManager = QueryTabManager() coordinator = MainContentCoordinator( connection: TestFixtures.makeConnection(type: type), @@ -79,6 +73,5 @@ private struct QueryTabHarness { func tearDown() { coordinator.cancelAllQueryTasks() coordinator.teardown() - AppSettingsManager.shared.editor.queryParametersEnabled = previousParametersSetting } } diff --git a/TableProTests/Views/Main/QuickHighlightOrderingTests.swift b/TableProTests/Views/Main/QuickHighlightOrderingTests.swift index e62d0afea2..2baecf1e8a 100644 --- a/TableProTests/Views/Main/QuickHighlightOrderingTests.swift +++ b/TableProTests/Views/Main/QuickHighlightOrderingTests.swift @@ -11,14 +11,12 @@ import Testing @MainActor struct QuickHighlightOrderingTests { + private let fixtures = CoordinatorFixtures() + private func makeCoordinator() -> (MainContentCoordinator, UUID) { - let tabManager = QueryTabManager() - let coordinator = MainContentCoordinator( - connection: TestFixtures.makeConnection(), - tabManager: tabManager, - changeManager: DataChangeManager(), - toolbarState: ConnectionToolbarState() - ) + let fixture = fixtures.make() + let tabManager = fixture.tabManager + let coordinator = fixture.coordinator let tab = QueryTab(title: "Q1", query: "SELECT status, country FROM orders", tabType: .query) tabManager.tabs.append(tab) tabManager.selectedTabId = tab.id diff --git a/TableProTests/Views/Main/RedisDatabaseSelectionGateTests.swift b/TableProTests/Views/Main/RedisDatabaseSelectionGateTests.swift index 5d0db30ba4..62ea39434b 100644 --- a/TableProTests/Views/Main/RedisDatabaseSelectionGateTests.swift +++ b/TableProTests/Views/Main/RedisDatabaseSelectionGateTests.swift @@ -1,38 +1,13 @@ -// -// RedisDatabaseSelectionGateTests.swift -// TableProTests -// -// Selecting a Redis database moves the connection's one shared driver. It used to send its SELECT -// around the driver gate, so the SELECT could land between a browse's SCAN and its TYPE/TTL -// pipeline, or in the middle of a key tree load, and both then read another database's keys. -// +// Selecting a Redis database moves the connection's one shared driver. It used to send its SELECT +// around the driver gate, so the SELECT could land between a browse's SCAN and its TYPE/TTL +// pipeline, or in the middle of a key tree load, and both then read another database's keys. import Foundation @testable import TablePro import TableProPluginKit +import TableProTestSupport import Testing -@MainActor -private final class Latch { - private var waiters: [CheckedContinuation] = [] - private var isOpen = false - - func open() { - guard !isOpen else { return } - isOpen = true - let pending = waiters - waiters = [] - for waiter in pending { - waiter.resume() - } - } - - func wait() async { - guard !isOpen else { return } - await withCheckedContinuation { waiters.append($0) } - } -} - @Suite("Redis database selection and the session driver gate", .serialized) @MainActor struct RedisDatabaseSelectionGateTests { @@ -64,11 +39,11 @@ struct RedisDatabaseSelectionGateTests { } /// Holds the connection's driver until `release` opens, and returns only once it holds it. - private func holdDriver(_ connectionId: UUID, until release: Latch) async -> Task { - let acquired = Latch() + private func holdDriver(_ connectionId: UUID, until release: AsyncGate) async -> Task { + let acquired = AsyncGate() let holder = Task { @MainActor in try await DatabaseManager.shared.sessionDriverGate.withExclusiveAccess(connectionId) { - acquired.open() + await acquired.open() await release.wait() } } @@ -79,9 +54,7 @@ struct RedisDatabaseSelectionGateTests { /// The bound is there so a caller that never queues, which is the regression these tests guard, /// fails the assertions after it rather than hanging the suite. private func waitForQueuedCallers(_ count: Int, on connectionId: UUID) async { - for _ in 0..<10_000 where DatabaseManager.shared.sessionDriverGate.waiterCount(for: connectionId) < count { - await Task.yield() - } + _ = try? await waitUntil { DatabaseManager.shared.sessionDriverGate.waiterCount(for: connectionId) >= count } } @Test("Selecting a database waits for the driver and switches once its turn comes") @@ -90,7 +63,7 @@ struct RedisDatabaseSelectionGateTests { defer { cleanUp(connection.id) } let coordinator = makeCoordinator(for: connection) defer { coordinator.teardown() } - let release = Latch() + let release = AsyncGate() let holder = await holdDriver(connection.id, until: release) coordinator.openTableTab("db2") @@ -99,7 +72,7 @@ struct RedisDatabaseSelectionGateTests { #expect(DatabaseManager.shared.sessionDriverGate.waiterCount(for: connection.id) == 1) #expect(recorder.switchedDatabases.isEmpty) - release.open() + await release.open() try await holder.value await coordinator.redisDatabaseSwitchTask?.value @@ -113,7 +86,7 @@ struct RedisDatabaseSelectionGateTests { defer { cleanUp(connection.id) } let coordinator = makeCoordinator(for: connection) defer { coordinator.teardown() } - let release = Latch() + let release = AsyncGate() let holder = await holdDriver(connection.id, until: release) coordinator.openTableTab("db2") @@ -124,7 +97,7 @@ struct RedisDatabaseSelectionGateTests { await superseded?.value await waitForQueuedCallers(1, on: connection.id) - release.open() + await release.open() try await holder.value await coordinator.redisDatabaseSwitchTask?.value @@ -140,7 +113,7 @@ struct RedisDatabaseSelectionGateTests { defer { cleanUp(connection.id) } let coordinator = makeCoordinator(for: connection) defer { coordinator.teardown() } - let release = Latch() + let release = AsyncGate() let holder = await holdDriver(connection.id, until: release) coordinator.openTableTab("db2") @@ -157,7 +130,7 @@ struct RedisDatabaseSelectionGateTests { #expect(coordinator.tabManager.selectedTab?.pagination.isLoading == false) #expect(recorder.switchedDatabases.isEmpty) - release.open() + await release.open() try await holder.value } @@ -167,7 +140,7 @@ struct RedisDatabaseSelectionGateTests { func selectionSwitchesTheDriverInstalledWhenItRuns() async throws { let (connection, original) = makeSession() defer { cleanUp(connection.id) } - let release = Latch() + let release = AsyncGate() let holder = await holdDriver(connection.id, until: release) let selection = Task { @MainActor in @@ -180,7 +153,7 @@ struct RedisDatabaseSelectionGateTests { DatabaseManager.shared.updateSession(connection.id) { session in session.driver = PluginDriverAdapter(connection: connection, pluginDriver: replacement) } - release.open() + await release.open() try await holder.value try await selection.value @@ -217,7 +190,7 @@ struct RedisDatabaseSelectionGateTests { recorder.refuseSelections(with: RefusedSelection()) let coordinator = makeCoordinator(for: connection) defer { coordinator.teardown() } - let release = Latch() + let release = AsyncGate() let holder = await holdDriver(connection.id, until: release) coordinator.openTableTab("db3") @@ -227,7 +200,7 @@ struct RedisDatabaseSelectionGateTests { let frontTabId = try #require(coordinator.tabManager.selectedTabId) #expect(frontTabId != retargetedTabId) - release.open() + await release.open() try await holder.value await coordinator.redisDatabaseSwitchTask?.value @@ -245,7 +218,7 @@ struct RedisDatabaseSelectionGateTests { defer { cleanUp(connection.id) } let coordinator = makeCoordinator(for: connection) defer { coordinator.teardown() } - let release = Latch() + let release = AsyncGate() let holder = await holdDriver(connection.id, until: release) coordinator.openTableTab("db3") @@ -253,7 +226,7 @@ struct RedisDatabaseSelectionGateTests { let retargetedTabId = try #require(coordinator.tabManager.selectedTabId) coordinator.tabManager.addTab(initialQuery: "PING") - release.open() + await release.open() try await holder.value await coordinator.redisDatabaseSwitchTask?.value @@ -267,7 +240,7 @@ struct RedisDatabaseSelectionGateTests { func keyTreeLoadWaitsForTheDriver() async throws { let (connection, recorder) = makeSession() defer { cleanUp(connection.id) } - let release = Latch() + let release = AsyncGate() let holder = await holdDriver(connection.id, until: release) let viewModel = RedisKeyTreeViewModel() @@ -278,7 +251,7 @@ struct RedisDatabaseSelectionGateTests { #expect(recorder.executedQueries.isEmpty) #expect(viewModel.state.erased == .loading) - release.open() + await release.open() try await holder.value await load.value @@ -289,9 +262,7 @@ struct RedisDatabaseSelectionGateTests { /// Query execution runs off the switch task, so the key's command lands a moment after it. The /// bound turns a command that never runs into a failed assertion rather than a hung suite. private func waitForExecution(of query: String, on recorder: RecordingRedisPluginDriver) async { - for _ in 0..<500 where !recorder.executedQueries.contains(query) { - try? await Task.sleep(nanoseconds: 10_000_000) - } + _ = try? await waitUntil { recorder.executedQueries.contains(query) } } @Test("The key tree first lists the database the session was left on after connecting") @@ -355,7 +326,7 @@ struct RedisDatabaseSelectionGateTests { defer { cleanUp(connection.id) } let coordinator = makeCoordinator(for: connection) defer { coordinator.teardown() } - let release = Latch() + let release = AsyncGate() let holder = await holdDriver(connection.id, until: release) coordinator.openTableTab("db3") @@ -363,7 +334,7 @@ struct RedisDatabaseSelectionGateTests { let clickedTabId = try #require(coordinator.tabManager.selectedTabId) coordinator.openRedisKey("three:a", keyType: "hash", inDatabase: 3) - release.open() + await release.open() try await holder.value await coordinator.redisDatabaseSwitchTask?.value let read = RedisKeyTreeCommand.openKey("three:a", keyType: "hash", inDatabase: 3) @@ -384,7 +355,7 @@ private struct RefusedSelection: LocalizedError { /// Records the calls that move or read the connection. The ping answers, because Redis declares a /// health monitor and a check before use would otherwise fail the session. -private final class RecordingRedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class RecordingRedisPluginDriver: PluginDriverStub, @unchecked Sendable { private let lock = NSLock() private var switched: [String] = [] private var executed: [String] = [] @@ -426,20 +397,4 @@ private final class RecordingRedisPluginDriver: PluginDatabaseDriver, @unchecked } return PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) } - - func connect() async throws {} - func disconnect() {} - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } diff --git a/TableProTests/Views/Main/ResultStatusBarLayoutTests.swift b/TableProTests/Views/Main/ResultStatusBarLayoutTests.swift index 43e29c2c6c..6c463b67e2 100644 --- a/TableProTests/Views/Main/ResultStatusBarLayoutTests.swift +++ b/TableProTests/Views/Main/ResultStatusBarLayoutTests.swift @@ -1,13 +1,9 @@ -// -// ResultStatusBarLayoutTests.swift -// TableProTests -// - import AppKit import Foundation import SwiftUI import TableProNumberFormatting import TableProPluginKit +import TableProTestSupport import Testing @testable import TablePro @@ -391,13 +387,11 @@ struct ResultStatusBarLayoutTests { } private func settle(_ host: NSView, until condition: () -> Bool) async -> Bool { - for _ in 0 ..< 150 { + let met: Void? = try? await waitUntil { host.layoutSubtreeIfNeeded() - if condition() { return true } - try? await Task.sleep(for: .milliseconds(20)) + return condition() } - host.layoutSubtreeIfNeeded() - return condition() + return met != nil } private func spinners(in view: NSView) -> [NSProgressIndicator] { diff --git a/TableProTests/Views/Main/ResultSwitchIdentityTests.swift b/TableProTests/Views/Main/ResultSwitchIdentityTests.swift index f8e8a8dd31..bd5a24ac93 100644 --- a/TableProTests/Views/Main/ResultSwitchIdentityTests.swift +++ b/TableProTests/Views/Main/ResultSwitchIdentityTests.swift @@ -17,14 +17,12 @@ import Testing @MainActor struct ResultSwitchIdentityTests { + private let fixtures = CoordinatorFixtures() + private func makeCoordinator() -> (MainContentCoordinator, QueryTabManager) { - let tabManager = QueryTabManager() - let coordinator = MainContentCoordinator( - connection: TestFixtures.makeConnection(), - tabManager: tabManager, - changeManager: DataChangeManager(), - toolbarState: ConnectionToolbarState() - ) + let fixture = fixtures.make() + let tabManager = fixture.tabManager + let coordinator = fixture.coordinator return (coordinator, tabManager) } diff --git a/TableProTests/Views/Main/SaveCompletionTests.swift b/TableProTests/Views/Main/SaveCompletionTests.swift index ebd92c2270..4834a90aec 100644 --- a/TableProTests/Views/Main/SaveCompletionTests.swift +++ b/TableProTests/Views/Main/SaveCompletionTests.swift @@ -15,33 +15,16 @@ import Testing /// Enough of a driver for `assemblePendingStatements` to produce SQL. Without one the builder has /// no adapter, emits nothing, and `saveChanges` bails at "Could not generate SQL for changes." /// before it reaches the inout parameters the cases below are about. -private final class StubSaveDriver: PluginDatabaseDriver, @unchecked Sendable { +private final class StubSaveDriver: PluginDriverStub, @unchecked Sendable { var supportsSchemas: Bool { false } var supportsTransactions: Bool { false } var currentSchema: String? { nil } var serverVersion: String? { nil } - func connect() async throws {} - func disconnect() {} func ping() async throws {} func execute(query: String) async throws -> PluginQueryResult { PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) } - - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } } @MainActor diff --git a/TableProTests/Views/Main/SeedBufferFromErrorResultTests.swift b/TableProTests/Views/Main/SeedBufferFromErrorResultTests.swift index c69c91aca1..4461ca02bc 100644 --- a/TableProTests/Views/Main/SeedBufferFromErrorResultTests.swift +++ b/TableProTests/Views/Main/SeedBufferFromErrorResultTests.swift @@ -1,15 +1,10 @@ +// A failed Run All makes an empty error result active and seeds the row buffer from it. The grid +// skips its whole update while a cell editor or viewer is open, and only a full replace closes +// them, so a seed that did not take that path left the previous result's headings, row and +// selection under the error for as long as the viewer stayed up. // -// SeedBufferFromErrorResultTests.swift -// TableProTests -// -// A failed Run All makes an empty error result active and seeds the row buffer from it. The grid -// skips its whole update while a cell editor or viewer is open, and only a full replace closes -// them, so a seed that did not take that path left the previous result's headings, row and -// selection under the error for as long as the viewer stayed up. -// -// The grid is a real `DataGridView` in an `NSHostingView`, fed from the coordinator's buffer the -// way the editor feeds it, because the early return lived in `updateNSView`. -// +// The grid is a real `DataGridView` in an `NSHostingView`, fed from the coordinator's buffer the +// way the editor feeds it, because the early return lived in `updateNSView`. import AppKit import Foundation @@ -75,7 +70,7 @@ private final class SeedFixture { tabManager.tabs.first { $0.id == tabId } } - func mountGrid() { + func mountGrid() async { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 600, height: 300), styleMask: [.titled], @@ -87,13 +82,13 @@ private final class SeedFixture { window.contentView = host self.window = window self.host = host - settle() + await settle() } /// A new root value, the way the editor hands the grid one on every render of the tab. - func render() { + func render() async { host?.rootView = makeGrid() - settle() + await settle() } func openViewer(row: Int) -> Bool { @@ -142,10 +137,14 @@ private final class SeedFixture { ) } - private func settle() { + /// Each pass lays out, then waits for one main-queue turn: SwiftUI applies a new root on a later + /// run-loop turn, and a main-actor task started by the update runs before the turn comes back. + private func settle() async { for _ in 0 ..< 10 { host?.layoutSubtreeIfNeeded() - RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.02)) + await withCheckedContinuation { continuation in + DispatchQueue.main.async { continuation.resume() } + } } } } @@ -159,15 +158,15 @@ struct SeedBufferFromErrorResultTests { ) @Test("a failed run over an open cell viewer closes it and takes down the previous headings") - func failureOverAnOpenViewerReconcilesTheGrid() throws { + func failureOverAnOpenViewerReconcilesTheGrid() async throws { let fixture = SeedFixture(previous: Self.previous) defer { fixture.close() } - fixture.mountGrid() + await fixture.mountGrid() try #require(fixture.presentedHeadings == ["third_col"]) try #require(fixture.openViewer(row: 0)) fixture.failRun() - fixture.render() + await fixture.render() #expect(fixture.grid?.overlayViewer?.isActive != true) #expect(fixture.presentedHeadings.isEmpty, "got \(fixture.presentedHeadings)") diff --git a/TableProTests/Views/Main/SidebarSaveCoverageTests.swift b/TableProTests/Views/Main/SidebarSaveCoverageTests.swift index b87147655a..b4ac0dbfec 100644 --- a/TableProTests/Views/Main/SidebarSaveCoverageTests.swift +++ b/TableProTests/Views/Main/SidebarSaveCoverageTests.swift @@ -10,14 +10,12 @@ import Testing @MainActor struct SidebarSaveCoverageTests { + private let fixtures = CoordinatorFixtures() + private func makeCoordinator(driver: any PluginDatabaseDriver) -> MainContentCoordinator { - let tabManager = QueryTabManager() - let coordinator = MainContentCoordinator( - connection: TestFixtures.makeConnection(), - tabManager: tabManager, - changeManager: DataChangeManager(), - toolbarState: ConnectionToolbarState() - ) + let fixture = fixtures.make() + let tabManager = fixture.tabManager + let coordinator = fixture.coordinator var tab = QueryTab(title: "items", query: "db.items.find({})", tabType: .table, tableName: "items") tab.execution.lastExecutedAt = Date() tabManager.tabs.append(tab) diff --git a/TableProTests/Views/Main/TabExecutionObservationTests.swift b/TableProTests/Views/Main/TabExecutionObservationTests.swift index dc6d429743..9a31211407 100644 --- a/TableProTests/Views/Main/TabExecutionObservationTests.swift +++ b/TableProTests/Views/Main/TabExecutionObservationTests.swift @@ -15,6 +15,8 @@ import Testing @MainActor struct TabExecutionObservationTests { + private let fixtures = CoordinatorFixtures() + private final class ChangeCounter { private(set) var sends = 0 private var subscription: AnyCancellable? @@ -25,12 +27,7 @@ struct TabExecutionObservationTests { } private func makeCoordinator() -> MainContentCoordinator { - MainContentCoordinator( - connection: TestFixtures.makeConnection(), - tabManager: QueryTabManager(), - changeManager: DataChangeManager(), - toolbarState: ConnectionToolbarState() - ) + fixtures.make().coordinator } @Test("a claim opening and settling each reach the observer") diff --git a/TableProTests/Views/Main/TableLoadConfirmationTests.swift b/TableProTests/Views/Main/TableLoadConfirmationTests.swift index 1a53eebba9..f5f2205af5 100644 --- a/TableProTests/Views/Main/TableLoadConfirmationTests.swift +++ b/TableProTests/Views/Main/TableLoadConfirmationTests.swift @@ -1,11 +1,7 @@ -// -// TableLoadConfirmationTests.swift -// TableProTests -// - import Foundation @testable import TablePro import TableProPluginKit +import TableProTestSupport import Testing /// Alert (Full) and Safe Mode (Full) confirm every table load, retargets included, because a @@ -66,9 +62,8 @@ struct TableLoadConfirmationTests { /// second load reaching the gate settles instead of waiting forever. private actor HeldGate: ExecutionGate { private let firstAnswer: OperationDecision - private var held: CheckedContinuation? - private var askedWaiters: [CheckedContinuation] = [] - private var isReleased = false + private let asked = AsyncGate() + private let released = AsyncGate() private var requestCount = 0 init(firstAnswer: OperationDecision) { @@ -78,23 +73,17 @@ struct TableLoadConfirmationTests { func authorize(_ request: OperationRequest) async -> OperationDecision { requestCount += 1 guard requestCount == 1 else { return .denied(reason: "Later load") } - askedWaiters.forEach { $0.resume() } - askedWaiters = [] - if !isReleased { - await withCheckedContinuation { held = $0 } - } + await asked.open() + await released.wait() return firstAnswer } func untilAsked() async { - guard requestCount == 0 else { return } - await withCheckedContinuation { askedWaiters.append($0) } + await asked.wait() } - func release() { - isReleased = true - held?.resume() - held = nil + func release() async { + await released.open() } } diff --git a/TableProTests/Views/Main/TableLoadForeignKeyPrefetchTests.swift b/TableProTests/Views/Main/TableLoadForeignKeyPrefetchTests.swift index 44d35880c8..9a6b18e4c9 100644 --- a/TableProTests/Views/Main/TableLoadForeignKeyPrefetchTests.swift +++ b/TableProTests/Views/Main/TableLoadForeignKeyPrefetchTests.swift @@ -1,10 +1,6 @@ -// -// TableLoadForeignKeyPrefetchTests.swift -// TableProTests -// - import Foundation @testable import TablePro +import TableProTestSupport import Testing @MainActor @@ -42,9 +38,7 @@ struct TableLoadForeignKeyPrefetchTests { let scope = try #require(coordinator.scope(for: tab)) coordinator.executeQueryInternal("SELECT * FROM orders") - for _ in 0 ..< 500 where !paused.isRaised { - try await Task.sleep(for: .milliseconds(10)) - } + _ = try? await waitUntil { paused.isRaised } try #require(paused.isRaised, "The row query never reached the driver") #expect(SchemaForeignKeyStore.shared.prefetch(scope: scope) { nil } == nil) diff --git a/TableProTests/Views/QueryPlan/QueryPlanThemeTests.swift b/TableProTests/Views/QueryPlan/QueryPlanThemeTests.swift index 4838d6df73..595f713920 100644 --- a/TableProTests/Views/QueryPlan/QueryPlanThemeTests.swift +++ b/TableProTests/Views/QueryPlan/QueryPlanThemeTests.swift @@ -1,18 +1,12 @@ -// -// QueryPlanThemeTests.swift -// TableProTests -// -// The plan outline is a records table whose rows stay AppKit's own until a theme draws them, its -// detail pane and the diagram's steps sit on the grid's slots, and an export renders the scheme and -// the backdrop the canvas shows. -// +// The plan outline is a records table whose rows stay AppKit's own until a theme draws them, its +// detail pane and the diagram's steps sit on the grid's slots, and an export renders the scheme and +// the backdrop the canvas shows. import AppKit import SwiftUI @testable import TablePro import Testing -@Suite("The query plan follows the theme", .serialized) @MainActor struct QueryPlanThemeTests { private typealias Fixture = ThemeTestFixture @@ -61,7 +55,8 @@ struct QueryPlanThemeTests { let coordinator: QueryPlanOutlineCoordinator } - /// Never ordered front and never closed: `close()` on a test window over-releases it. + /// Never ordered front and never closed: `close()` on a test window over-releases it. Mounted + /// without spinning the run loop, so no other main-actor work runs under a theme a case adopted. private func mountOutline() throws -> MountedOutline { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 480, height: 240), @@ -73,10 +68,7 @@ struct QueryPlanThemeTests { let host = NSHostingView(rootView: QueryPlanOutlineView(plan: plan(), metric: nil, selectedNodeId: .constant(nil))) host.sizingOptions = [] window.contentView = host - for _ in 0 ..< 5 { - host.layoutSubtreeIfNeeded() - RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.01)) - } + host.layoutSubtreeIfNeeded() let outline = try #require(outlineView(in: host)) let coordinator = try #require(outline.delegate as? QueryPlanOutlineCoordinator) return MountedOutline(window: window, outline: outline, coordinator: coordinator) diff --git a/TableProTests/Views/Results/CellOverlayAccessibilityTests.swift b/TableProTests/Views/Results/CellOverlayAccessibilityTests.swift index 591817894f..01cd99d942 100644 --- a/TableProTests/Views/Results/CellOverlayAccessibilityTests.swift +++ b/TableProTests/Views/Results/CellOverlayAccessibilityTests.swift @@ -12,7 +12,6 @@ private final class OverlayAccessibilityLayoutPersister: ColumnLayoutPersisting func clear(for key: ColumnLayoutTableKey) {} } -@Suite("Cell overlay accessibility", .serialized) @MainActor struct CellOverlayAccessibilityTests { private struct Grid { diff --git a/TableProTests/Views/Results/CellOverlayAppearanceTests.swift b/TableProTests/Views/Results/CellOverlayAppearanceTests.swift index 7ec99ced65..d3aabe4244 100644 --- a/TableProTests/Views/Results/CellOverlayAppearanceTests.swift +++ b/TableProTests/Views/Results/CellOverlayAppearanceTests.swift @@ -11,7 +11,6 @@ import Testing /// that from reaching a suite running in parallel is that the body is synchronous and `@MainActor`, /// so nothing else on the main actor can interleave before the `defer` restores it. `.serialized` /// only orders tests inside this suite. Adding an `await` would break it. -@Suite("Cell overlay layer colours follow the appearance", .serialized) @MainActor struct CellOverlayAppearanceTests { private func makeContainer() -> CellOverlayContainerView { diff --git a/TableProTests/Views/Results/DataGridEmptyResultColumnsTests.swift b/TableProTests/Views/Results/DataGridEmptyResultColumnsTests.swift index 2883b003fa..8382c3299b 100644 --- a/TableProTests/Views/Results/DataGridEmptyResultColumnsTests.swift +++ b/TableProTests/Views/Results/DataGridEmptyResultColumnsTests.swift @@ -1,16 +1,11 @@ +// A failed Run All leaves an error result active, and an error result has no columns and no rows. +// The grid stays mounted under the error banner, so its update pass is what decides the headings +// shown there. It reconciled the column pool only for a result that had columns, and kept the +// previous run's headings over an empty grid, while a grid mounted fresh over the same result +// showed the row-number heading alone. // -// DataGridEmptyResultColumnsTests.swift -// TableProTests -// -// A failed Run All leaves an error result active, and an error result has no columns and no rows. -// The grid stays mounted under the error banner, so its update pass is what decides the headings -// shown there. It reconciled the column pool only for a result that had columns, and kept the -// previous run's headings over an empty grid, while a grid mounted fresh over the same result -// showed the row-number heading alone. -// -// Driven through a real `NSHostingView`, because the defect lived in `updateNSView`, which only -// SwiftUI calls. -// +// Driven through a real `NSHostingView`, because the defect lived in `updateNSView`, which only +// SwiftUI calls. import AppKit import Foundation @@ -31,7 +26,7 @@ private final class HostedGrid { private let window: NSWindow private let host: NSHostingView - init(showing rows: TableRows) { + init(showing rows: TableRows) async { window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 600, height: 300), styleMask: [.titled], @@ -41,14 +36,14 @@ private final class HostedGrid { window.isReleasedWhenClosed = false host = NSHostingView(rootView: Self.makeGrid(showing: rows)) window.contentView = host - settle() + await settle() } /// A new root value, the way the editor hands the grid a new one on every render, which is what /// makes SwiftUI run `updateNSView` against the result it now provides. - func show(_ rows: TableRows) { + func show(_ rows: TableRows) async { host.rootView = Self.makeGrid(showing: rows) - settle() + await settle() } func close() { @@ -84,10 +79,14 @@ private final class HostedGrid { ) } - private func settle() { + /// Each pass lays out, then waits for one main-queue turn: SwiftUI applies a change on a later + /// run-loop turn, and a main-actor task started by the update runs before the turn comes back. + private func settle() async { for _ in 0 ..< 10 { host.layoutSubtreeIfNeeded() - RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.02)) + await withCheckedContinuation { continuation in + DispatchQueue.main.async { continuation.resume() } + } } } @@ -111,38 +110,38 @@ struct DataGridEmptyResultColumnsTests { } @Test("an error result takes down the headings of the result before it") - func errorResultDropsThePreviousHeadings() throws { - let grid = HostedGrid(showing: Self.result(["first_col", "second_col"])) + func errorResultDropsThePreviousHeadings() async throws { + let grid = await HostedGrid(showing: Self.result(["first_col", "second_col"])) defer { grid.close() } - grid.show(Self.result(["third_col"])) + await grid.show(Self.result(["third_col"])) try #require(grid.presentedHeadings == ["third_col"]) - grid.show(TableRows()) + await grid.show(TableRows()) #expect(grid.presentedHeadings.isEmpty, "got \(grid.presentedHeadings)") #expect(grid.tableView?.numberOfRows == 0) } @Test("updating to an error result leaves the grid a fresh mount over it would show") - func updateMatchesAFreshMount() throws { - let updated = HostedGrid(showing: Self.result(["third_col"])) + func updateMatchesAFreshMount() async throws { + let updated = await HostedGrid(showing: Self.result(["third_col"])) defer { updated.close() } - let fresh = HostedGrid(showing: TableRows()) + let fresh = await HostedGrid(showing: TableRows()) defer { fresh.close() } try #require(fresh.visibleColumnIdentifiers == [ColumnIdentitySchema.rowNumberIdentifier]) - updated.show(TableRows()) + await updated.show(TableRows()) #expect(updated.visibleColumnIdentifiers == fresh.visibleColumnIdentifiers) } @Test("the headings come back when a result with columns follows an error result") - func headingsReturnAfterAnErrorResult() { - let grid = HostedGrid(showing: Self.result(["third_col"])) + func headingsReturnAfterAnErrorResult() async { + let grid = await HostedGrid(showing: Self.result(["third_col"])) defer { grid.close() } - grid.show(TableRows()) + await grid.show(TableRows()) - grid.show(Self.result(["x"], rowCount: 2)) + await grid.show(Self.result(["x"], rowCount: 2)) #expect(grid.presentedHeadings == ["x"]) #expect(grid.tableView?.numberOfRows == 2) diff --git a/TableProTests/Views/Results/DataGridFieldRemovalTests.swift b/TableProTests/Views/Results/DataGridFieldRemovalTests.swift index f71068ac4a..f8c69fd73a 100644 --- a/TableProTests/Views/Results/DataGridFieldRemovalTests.swift +++ b/TableProTests/Views/Results/DataGridFieldRemovalTests.swift @@ -1,8 +1,3 @@ -// -// DataGridFieldRemovalTests.swift -// TableProTests -// - import AppKit import SwiftUI import TableProPluginKit @@ -17,19 +12,6 @@ private final class NoopFieldRemovalLayoutPersister: ColumnLayoutPersisting { func clear(for key: ColumnLayoutTableKey) {} } -private final class FieldRemovalClipboard: ClipboardProvider { - var written: GridRowsClipboardPayload? - - func readText() -> String? { nil } - func readGridRows() -> GridRowsClipboardPayload? { nil } - func writeText(_ text: String) {} - func writeCsv(_ csv: String) {} - func writeImage(_ image: NSImage) {} - func writeRows(tsv: String, html: String?, gridRows: GridRowsClipboardPayload) { written = gridRows } - var hasText: Bool { false } - var hasGridRows: Bool { written != nil } -} - @MainActor private final class FieldRemovalRowStore { var tableRows: TableRows @@ -39,101 +21,102 @@ private final class FieldRemovalRowStore { } } -@MainActor -struct DataGridFieldRemovalTests { - private static let columns = ["_id", "nick", "deletedAt"] - - private func makeGrid( - _ databaseType: DatabaseType = .mongodb - ) -> (TableViewCoordinator, FieldRemovalRowStore, DataChangeManager) { - let manager = DataChangeManager() - manager.configureForTable( - tableName: "items", - columns: Self.columns, - primaryKeyColumns: ["_id"], - databaseType: databaseType, - generatedColumns: [] - ) - let coordinator = TableViewCoordinator( - changeManager: AnyChangeManager(manager), - isEditable: true, - selectedRowIndices: .constant([]), - delegate: nil, - layoutPersister: NoopFieldRemovalLayoutPersister() - ) - coordinator.databaseType = databaseType - let store = FieldRemovalRowStore(TableRows.from( - queryRows: [["1", "Ada", .null]], - columns: Self.columns, - columnTypes: Array(repeating: .text(rawType: nil), count: Self.columns.count), - absentCells: [0: [2]] - )) - coordinator.tableRowsProvider = { store.tableRows } - coordinator.tableRowsMutator = { mutation in mutation(&store.tableRows) } - coordinator.updateCache() - return (coordinator, store, manager) - } - - @Test("A missing field reads No Field, not NULL") - func missingFieldIsNamed() { - let (coordinator, _, _) = makeGrid() - - #expect(coordinator.accessibilityText(row: 0, columnIndex: 2) == "No Field") - #expect(DataGridCellContent.placeholder(for: .null, isAbsent: true) == .absent) - #expect(DataGridCellContent.placeholder(for: .null) == .null) - } - - @Test("Remove Field takes the field out of the row and stages the removal") - func removeFieldStagesTheRemoval() throws { - let (coordinator, store, manager) = makeGrid() - - coordinator.removeField(row: 0, columnIndex: 1) - - #expect(store.tableRows.isAbsent(row: 0, column: 1)) - let cell = try #require(manager.changes.first?.cellChanges.first) - #expect(cell.oldValue == "Ada" && !cell.oldIsAbsent) - #expect(cell.newIsAbsent) - #expect(coordinator.accessibilityText(row: 0, columnIndex: 1) == "No Field") - } - - @Test("Set NULL on a missing field puts the field back holding null") - func nullOverMissingFieldIsAnEdit() throws { - let (coordinator, store, manager) = makeGrid() - - coordinator.commitCellEdit(row: 0, columnIndex: 2, newValue: nil) - - #expect(!store.tableRows.isAbsent(row: 0, column: 2)) - let change = try #require(manager.changes.first) - #expect(change.absentColumns == [2]) - let cell = try #require(change.cellChanges.first) - #expect(cell.oldIsAbsent && !cell.newIsAbsent) - #expect(cell.newValue == .null) - #expect(coordinator.accessibilityText(row: 0, columnIndex: 2) == "NULL") - } - - @Test("Copy with Headers records which fields each copied row lacked") - func copyWithHeadersCarriesAbsence() throws { - let (coordinator, _, _) = makeGrid() - let clipboard = FieldRemovalClipboard() - ClipboardService.shared = clipboard - defer { ClipboardService.shared = NSPasteboardClipboardProvider() } - - coordinator.copyRowsWithHeaders(at: [0]) - - let payload = try #require(clipboard.written) - #expect(payload.columns == Self.columns) - #expect(payload.rows == [["1", "Ada", .null]]) - #expect(payload.absentCells == [0: [2]]) - } - - @Test("An engine that cannot tell a missing field from NULL offers no removal") - func removalNeedsTheCapability() { - let (coordinator, store, manager) = makeGrid(.mysql) - - coordinator.removeField(row: 0, columnIndex: 1) - - #expect(!coordinator.supportsFieldRemoval) - #expect(!store.tableRows.isAbsent(row: 0, column: 1)) - #expect(!manager.hasChanges) +extension GlobalStateSuites { + @Suite(.recordingClipboard) + @MainActor + struct DataGridFieldRemovalTests { + private static let columns = ["_id", "nick", "deletedAt"] + + private func makeGrid( + _ databaseType: DatabaseType = .mongodb + ) -> (TableViewCoordinator, FieldRemovalRowStore, DataChangeManager) { + let manager = DataChangeManager() + manager.configureForTable( + tableName: "items", + columns: Self.columns, + primaryKeyColumns: ["_id"], + databaseType: databaseType, + generatedColumns: [] + ) + let coordinator = TableViewCoordinator( + changeManager: AnyChangeManager(manager), + isEditable: true, + selectedRowIndices: .constant([]), + delegate: nil, + layoutPersister: NoopFieldRemovalLayoutPersister() + ) + coordinator.databaseType = databaseType + let store = FieldRemovalRowStore(TableRows.from( + queryRows: [["1", "Ada", .null]], + columns: Self.columns, + columnTypes: Array(repeating: .text(rawType: nil), count: Self.columns.count), + absentCells: [0: [2]] + )) + coordinator.tableRowsProvider = { store.tableRows } + coordinator.tableRowsMutator = { mutation in mutation(&store.tableRows) } + coordinator.updateCache() + return (coordinator, store, manager) + } + + @Test("A missing field reads No Field, not NULL") + func missingFieldIsNamed() { + let (coordinator, _, _) = makeGrid() + + #expect(coordinator.accessibilityText(row: 0, columnIndex: 2) == "No Field") + #expect(DataGridCellContent.placeholder(for: .null, isAbsent: true) == .absent) + #expect(DataGridCellContent.placeholder(for: .null) == .null) + } + + @Test("Remove Field takes the field out of the row and stages the removal") + func removeFieldStagesTheRemoval() throws { + let (coordinator, store, manager) = makeGrid() + + coordinator.removeField(row: 0, columnIndex: 1) + + #expect(store.tableRows.isAbsent(row: 0, column: 1)) + let cell = try #require(manager.changes.first?.cellChanges.first) + #expect(cell.oldValue == "Ada" && !cell.oldIsAbsent) + #expect(cell.newIsAbsent) + #expect(coordinator.accessibilityText(row: 0, columnIndex: 1) == "No Field") + } + + @Test("Set NULL on a missing field puts the field back holding null") + func nullOverMissingFieldIsAnEdit() throws { + let (coordinator, store, manager) = makeGrid() + + coordinator.commitCellEdit(row: 0, columnIndex: 2, newValue: nil) + + #expect(!store.tableRows.isAbsent(row: 0, column: 2)) + let change = try #require(manager.changes.first) + #expect(change.absentColumns == [2]) + let cell = try #require(change.cellChanges.first) + #expect(cell.oldIsAbsent && !cell.newIsAbsent) + #expect(cell.newValue == .null) + #expect(coordinator.accessibilityText(row: 0, columnIndex: 2) == "NULL") + } + + @Test("Copy with Headers records which fields each copied row lacked") + func copyWithHeadersCarriesAbsence() throws { + let (coordinator, _, _) = makeGrid() + let clipboard = try #require(RecordingClipboard.current) + + coordinator.copyRowsWithHeaders(at: [0]) + + let payload = try #require(clipboard.gridRows) + #expect(payload.columns == Self.columns) + #expect(payload.rows == [["1", "Ada", .null]]) + #expect(payload.absentCells == [0: [2]]) + } + + @Test("An engine that cannot tell a missing field from NULL offers no removal") + func removalNeedsTheCapability() { + let (coordinator, store, manager) = makeGrid(.mysql) + + coordinator.removeField(row: 0, columnIndex: 1) + + #expect(!coordinator.supportsFieldRemoval) + #expect(!store.tableRows.isAbsent(row: 0, column: 1)) + #expect(!manager.hasChanges) + } } } diff --git a/TableProTests/Views/Results/DataGridFilterConfigurationTests.swift b/TableProTests/Views/Results/DataGridFilterConfigurationTests.swift index 555da265dc..f969021801 100644 --- a/TableProTests/Views/Results/DataGridFilterConfigurationTests.swift +++ b/TableProTests/Views/Results/DataGridFilterConfigurationTests.swift @@ -17,7 +17,6 @@ private final class FilterConfigurationLayoutPersister: ColumnLayoutPersisting { func clear(for key: ColumnLayoutTableKey) {} } -@Suite("Data grid filter configuration", .serialized) @MainActor struct DataGridFilterConfigurationTests { private static let textType = ColumnType.text(rawType: "TEXT") diff --git a/TableProTests/Views/Results/DataGridRowIdentityTests.swift b/TableProTests/Views/Results/DataGridRowIdentityTests.swift index ae88347e84..1b50aa1926 100644 --- a/TableProTests/Views/Results/DataGridRowIdentityTests.swift +++ b/TableProTests/Views/Results/DataGridRowIdentityTests.swift @@ -22,7 +22,6 @@ private final class NoopRowIdentityPersister: ColumnLayoutPersisting { /// different row from then on. Measured: after `removeRows(at: [1])` the view at display row 1 still /// carried 2 and an insert left two views both claiming 0, which sent a double-click to the wrong /// row and had an accessibility cell speak one row's value under another row's number. -@Suite("Data grid row identity", .serialized) @MainActor struct DataGridRowIdentityTests { private struct Grid { diff --git a/TableProTests/Views/Results/DataGridRowNumberRenderingTests.swift b/TableProTests/Views/Results/DataGridRowNumberRenderingTests.swift index 3f529463ec..1565483a5c 100644 --- a/TableProTests/Views/Results/DataGridRowNumberRenderingTests.swift +++ b/TableProTests/Views/Results/DataGridRowNumberRenderingTests.swift @@ -97,7 +97,6 @@ private func matches(_ lhs: NSColor, _ rhs: NSColor, tolerance: CGFloat = 0.02) && abs(lhs.alphaComponent - rhs.alphaComponent) <= tolerance } -@Suite("Pinned row-number column rendering", .serialized) @MainActor struct DataGridRowNumberRenderingTests { @MainActor diff --git a/TableProTests/Views/Results/DataGridRowTintThemeTests.swift b/TableProTests/Views/Results/DataGridRowTintThemeTests.swift index 84e1ddf29e..ac98961d50 100644 --- a/TableProTests/Views/Results/DataGridRowTintThemeTests.swift +++ b/TableProTests/Views/Results/DataGridRowTintThemeTests.swift @@ -31,7 +31,6 @@ private final class NoopColumnLayoutPersister: ColumnLayoutPersisting { /// main actor can interleave between activating the test theme and restoring the original one. /// `.serialized` only orders tests inside this suite and does not provide that. Adding an `await` /// anywhere between the two would break it. -@Suite("DataGridRowView tint follows the theme", .serialized) @MainActor struct DataGridRowTintThemeTests { private static let deleted = RowVisualState(isDeleted: true, isInserted: false, modifiedColumns: []) diff --git a/TableProTests/Views/Results/DataGridRowViewCopyTests.swift b/TableProTests/Views/Results/DataGridRowViewCopyTests.swift index a431fa31a1..167f467b19 100644 --- a/TableProTests/Views/Results/DataGridRowViewCopyTests.swift +++ b/TableProTests/Views/Results/DataGridRowViewCopyTests.swift @@ -5,26 +5,6 @@ import SwiftUI import TableProPluginKit import Testing -@MainActor -private final class DataGridRowViewCopyClipboard: ClipboardProvider { - var text: String? - var hasGridRowsValue = false - - func readText() -> String? { text } - func readGridRows() -> GridRowsClipboardPayload? { nil } - func writeText(_ text: String) { self.text = text; hasGridRowsValue = false } - func writeCsv(_ csv: String) { text = csv; hasGridRowsValue = false } - - var copiedImages: [NSImage] = [] - - func writeImage(_ image: NSImage) { - copiedImages.append(image) - } - func writeRows(tsv: String, html: String?, gridRows: GridRowsClipboardPayload) { text = tsv; hasGridRowsValue = true } - var hasText: Bool { text != nil } - var hasGridRows: Bool { hasGridRowsValue } -} - @MainActor private final class DataGridRowViewCopyLayoutPersister: ColumnLayoutPersisting { func load(for key: ColumnLayoutTableKey) -> ColumnLayoutState? { nil } @@ -46,593 +26,472 @@ private final class DataGridRowViewCopyDelegateSpy: DataGridViewDelegate { } } -@MainActor -struct DataGridRowViewCopyTests { - private func makeCoordinator( - rows: [[PluginCellValue]], - columnTypes: [ColumnType], - databaseType: DatabaseType? = nil, - selectedRows: Set = [], - delegate: (any DataGridViewDelegate)? = nil - ) -> TableViewCoordinator { - let columns = (0.. = [], + delegate: (any DataGridViewDelegate)? = nil + ) -> TableViewCoordinator { + let columns = (0.. KeyHandlingTableView { - let tableView = KeyHandlingTableView() - tableView.coordinator = coordinator - tableView.addTableColumn(DataGridView.makeRowNumberColumn()) - coordinator.tableView = tableView - coordinator.columnPool.reconcile( - tableView: tableView, - schema: coordinator.identitySchema, - columnTypes: [], - savedLayout: nil, - isEditable: true, - hiddenColumnNames: [], - firstClickSortDirection: .ascending, - widthCalculator: { _, _ in 100 } - ) - return tableView - } + /// Built through the column pool rather than by attaching columns by hand, so the positions + /// these tests focus are the ones the grid really has: the pool also attaches the window's two + /// spacers, one of them ahead of the first data column. + private func makeTableView(for coordinator: TableViewCoordinator) -> KeyHandlingTableView { + let tableView = KeyHandlingTableView() + tableView.coordinator = coordinator + tableView.addTableColumn(DataGridView.makeRowNumberColumn()) + coordinator.tableView = tableView + coordinator.columnPool.reconcile( + tableView: tableView, + schema: coordinator.identitySchema, + columnTypes: [], + savedLayout: nil, + isEditable: true, + hiddenColumnNames: [], + firstClickSortDirection: .ascending, + widthCalculator: { _, _ in 100 } + ) + return tableView + } - private func tableColumnIndex(of dataIndex: Int, in tableView: KeyHandlingTableView) -> Int { - guard let coordinator = tableView.coordinator, - let identifier = coordinator.identitySchema.identifier(for: dataIndex) - else { return -1 } - return tableView.column(withIdentifier: identifier) - } + private func tableColumnIndex(of dataIndex: Int, in tableView: KeyHandlingTableView) -> Int { + guard let coordinator = tableView.coordinator, + let identifier = coordinator.identitySchema.identifier(for: dataIndex) + else { return -1 } + return tableView.column(withIdentifier: identifier) + } - private func invokeCopy( - on rowView: DataGridRowView, - target: DataGridRowView.CopyContextTarget = .unresolved - ) { - let item = NSMenuItem(title: "Copy", action: nil, keyEquivalent: "") - item.representedObject = target - _ = rowView.perform(NSSelectorFromString("copyFromContextMenu:"), with: item) - } + private func invokeCopy( + on rowView: DataGridRowView, + target: DataGridRowView.CopyContextTarget = .unresolved + ) { + let item = NSMenuItem(title: "Copy", action: nil, keyEquivalent: "") + item.representedObject = target + _ = rowView.perform(NSSelectorFromString("copyFromContextMenu:"), with: item) + } - private func invokeCopyRows(on rowView: DataGridRowView) { - _ = rowView.perform(NSSelectorFromString("copySelectedOrCurrentRow")) - } + private func invokeCopyRows(on rowView: DataGridRowView) { + _ = rowView.perform(NSSelectorFromString("copySelectedOrCurrentRow")) + } - @Test("Copy uses clicked cell value instead of row TSV") - func copyUsesClickedCellValue() { - let clipboard = DataGridRowViewCopyClipboard() - ClipboardService.shared = clipboard - defer { ClipboardService.shared = NSPasteboardClipboardProvider() } + @Test("Copy uses clicked cell value instead of row TSV") + func copyUsesClickedCellValue() throws { + let clipboard = try #require(RecordingClipboard.current) - let coordinator = makeCoordinator( - rows: [[.text("1"), .bytes(Data([0xAA, 0xBB]))]], - columnTypes: [.integer(rawType: "INT"), .blob(rawType: "BYTEA")] - ) - let rowView = DataGridRowView() - rowView.coordinator = coordinator - rowView.rowIndex = 0 + let coordinator = makeCoordinator( + rows: [[.text("1"), .bytes(Data([0xAA, 0xBB]))]], + columnTypes: [.integer(rawType: "INT"), .blob(rawType: "BYTEA")] + ) + let rowView = DataGridRowView() + rowView.coordinator = coordinator + rowView.rowIndex = 0 - invokeCopy(on: rowView, target: .cell(1)) + invokeCopy(on: rowView, target: .cell(1)) - #expect(clipboard.text == "0xAABB") - #expect(clipboard.hasGridRows == false) - } + #expect(clipboard.text == "0xAABB") + #expect(clipboard.hasGridRows == false) + } - @Test("Copy applies the UUID display format to binary cells") - func copyAppliesBinaryUuidDisplayFormat() { - let clipboard = DataGridRowViewCopyClipboard() - ClipboardService.shared = clipboard - defer { ClipboardService.shared = NSPasteboardClipboardProvider() } + @Test("Copy applies the UUID display format to binary cells") + func copyAppliesBinaryUuidDisplayFormat() throws { + let clipboard = try #require(RecordingClipboard.current) - let data = Data([ - 0xAF, 0x49, 0x45, 0x3B, 0x7F, 0x2F, 0xFB, 0x58, - 0xFC, 0xD3, 0x2B, 0xD3, 0x99, 0x59, 0x9F, 0xA5, - ]) - let coordinator = makeCoordinator( - rows: [[.text("1"), .bytes(data)]], - columnTypes: [.integer(rawType: "INT"), .blob(rawType: "BYTEA")] - ) - coordinator.updateDisplayFormats([nil, .uuid]) - let rowView = DataGridRowView() - rowView.coordinator = coordinator - rowView.rowIndex = 0 - - invokeCopy(on: rowView, target: .cell(1)) - - #expect(clipboard.text == "af49453b-7f2f-fb58-fcd3-2bd399599fa5") - #expect(clipboard.hasGridRows == false) - } + let data = Data([ + 0xAF, 0x49, 0x45, 0x3B, 0x7F, 0x2F, 0xFB, 0x58, + 0xFC, 0xD3, 0x2B, 0xD3, 0x99, 0x59, 0x9F, 0xA5, + ]) + let coordinator = makeCoordinator( + rows: [[.text("1"), .bytes(data)]], + columnTypes: [.integer(rawType: "INT"), .blob(rawType: "BYTEA")] + ) + coordinator.updateDisplayFormats([nil, .uuid]) + let rowView = DataGridRowView() + rowView.coordinator = coordinator + rowView.rowIndex = 0 - @Test("Copy does not guess MongoDB legacy UUID byte order") - func copyKeepsUndecodedMongoUuidAsHex() { - let clipboard = DataGridRowViewCopyClipboard() - ClipboardService.shared = clipboard - defer { ClipboardService.shared = NSPasteboardClipboardProvider() } + invokeCopy(on: rowView, target: .cell(1)) - let javaLegacyBytes = Data([ - 0x24, 0x43, 0x25, 0x4A, 0xEB, 0x03, 0xD0, 0x8C, - 0x1A, 0x0D, 0xDA, 0xE2, 0xFC, 0x88, 0x32, 0x93, - ]) - let coordinator = makeCoordinator( - rows: [[.bytes(javaLegacyBytes)]], - columnTypes: [.blob(rawType: "BLOB(3)")], - databaseType: .mongodb - ) - coordinator.updateDisplayFormats([.uuid]) - let rowView = DataGridRowView() - rowView.coordinator = coordinator - rowView.rowIndex = 0 - - invokeCopy(on: rowView, target: .cell(0)) - - #expect(clipboard.text == "0x2443254AEB03D08C1A0DDAE2FC883293") - } + #expect(clipboard.text == "af49453b-7f2f-fb58-fcd3-2bd399599fa5") + #expect(clipboard.hasGridRows == false) + } - @Test("Copy applies the UUID display format to a command-clicked binary cell") - func copyAppliesBinaryUuidDisplayFormatToGridSelection() { - let clipboard = DataGridRowViewCopyClipboard() - ClipboardService.shared = clipboard - defer { ClipboardService.shared = NSPasteboardClipboardProvider() } + @Test("Copy does not guess MongoDB legacy UUID byte order") + func copyKeepsUndecodedMongoUuidAsHex() throws { + let clipboard = try #require(RecordingClipboard.current) - let data = Data([ - 0xAF, 0x49, 0x45, 0x3B, 0x7F, 0x2F, 0xFB, 0x58, - 0xFC, 0xD3, 0x2B, 0xD3, 0x99, 0x59, 0x9F, 0xA5, - ]) - let coordinator = makeCoordinator( - rows: [[.text("1"), .bytes(data)]], - columnTypes: [.integer(rawType: "INT"), .blob(rawType: "BYTEA")] - ) - coordinator.updateDisplayFormats([nil, .uuid]) - let tableView = makeTableView(for: coordinator) - let selectedCell = GridCoord(row: 0, displayColumn: 1) - _ = coordinator.selectionController.beginDrag(at: selectedCell, modifiers: .command) - coordinator.selectionController.endDrag(dragged: false, originalCoord: selectedCell) - - tableView.copy(nil) - - #expect(clipboard.text == "af49453b-7f2f-fb58-fcd3-2bd399599fa5") - #expect(clipboard.hasGridRows == false) - } + let javaLegacyBytes = Data([ + 0x24, 0x43, 0x25, 0x4A, 0xEB, 0x03, 0xD0, 0x8C, + 0x1A, 0x0D, 0xDA, 0xE2, 0xFC, 0x88, 0x32, 0x93, + ]) + let coordinator = makeCoordinator( + rows: [[.bytes(javaLegacyBytes)]], + columnTypes: [.blob(rawType: "BLOB(3)")], + databaseType: .mongodb + ) + coordinator.updateDisplayFormats([.uuid]) + let rowView = DataGridRowView() + rowView.coordinator = coordinator + rowView.rowIndex = 0 - @Test("Copy resolves the selection through the displayed row order") - func copyUsesDisplayedRowOrder() { - let clipboard = DataGridRowViewCopyClipboard() - ClipboardService.shared = clipboard - defer { ClipboardService.shared = NSPasteboardClipboardProvider() } - - let first = Data(repeating: 0xAA, count: 16) - let second = Data(repeating: 0xBB, count: 16) - let coordinator = makeCoordinator( - rows: [[.bytes(first)], [.bytes(second)]], - columnTypes: [.blob(rawType: "BYTEA")] - ) - coordinator.updateDisplayFormats([.uuid]) - coordinator.valueFilteredIDs = [.existing(1), .existing(0)] - let tableView = makeTableView(for: coordinator) - let selectedCell = GridCoord(row: 0, displayColumn: 0) - _ = coordinator.selectionController.beginDrag(at: selectedCell, modifiers: .command) - coordinator.selectionController.endDrag(dragged: false, originalCoord: selectedCell) - - tableView.copy(nil) - - #expect(clipboard.text == "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb") - } + invokeCopy(on: rowView, target: .cell(0)) - @Test("Copy keeps raw bytes for multi-cell grid selections") - func copyKeepsRawBytesForMultiCellGridSelection() { - let clipboard = DataGridRowViewCopyClipboard() - ClipboardService.shared = clipboard - defer { ClipboardService.shared = NSPasteboardClipboardProvider() } - - let first = Data(repeating: 0xAA, count: 16) - let second = Data(repeating: 0xBB, count: 16) - let coordinator = makeCoordinator( - rows: [[.bytes(first), .bytes(second)]], - columnTypes: [.blob(rawType: "BYTEA"), .blob(rawType: "BYTEA")] - ) - coordinator.updateDisplayFormats([.uuid, .uuid]) - let tableView = makeTableView(for: coordinator) - let sweptOrigin = GridCoord(row: 0, displayColumn: 0) - coordinator.selectionController.update( - .single(GridRect(rows: 0...0, columns: 0...1), anchor: sweptOrigin, active: sweptOrigin) - ) - - tableView.copy(nil) - - #expect(clipboard.text == "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\t0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB") - } + #expect(clipboard.text == "0x2443254AEB03D08C1A0DDAE2FC883293") + } - @Test("Copy falls back to focused cell when no clicked column is attached") - func copyFallsBackToFocusedCell() { - let clipboard = DataGridRowViewCopyClipboard() - ClipboardService.shared = clipboard - defer { ClipboardService.shared = NSPasteboardClipboardProvider() } + @Test("Copy applies the UUID display format to a command-clicked binary cell") + func copyAppliesBinaryUuidDisplayFormatToGridSelection() throws { + let clipboard = try #require(RecordingClipboard.current) - let coordinator = makeCoordinator( - rows: [[.text("1"), .null]], - columnTypes: [.integer(rawType: "INT"), .text(rawType: "TEXT")] - ) - let tableView = makeTableView(for: coordinator) - tableView.focusedRow = 0 - tableView.focusedColumn = tableColumnIndex(of: 1, in: tableView) + let data = Data([ + 0xAF, 0x49, 0x45, 0x3B, 0x7F, 0x2F, 0xFB, 0x58, + 0xFC, 0xD3, 0x2B, 0xD3, 0x99, 0x59, 0x9F, 0xA5, + ]) + let coordinator = makeCoordinator( + rows: [[.text("1"), .bytes(data)]], + columnTypes: [.integer(rawType: "INT"), .blob(rawType: "BYTEA")] + ) + coordinator.updateDisplayFormats([nil, .uuid]) + let tableView = makeTableView(for: coordinator) + let selectedCell = GridCoord(row: 0, displayColumn: 1) + _ = coordinator.selectionController.beginDrag(at: selectedCell, modifiers: .command) + coordinator.selectionController.endDrag(dragged: false, originalCoord: selectedCell) - let rowView = DataGridRowView() - rowView.coordinator = coordinator - rowView.rowIndex = 0 + tableView.copy(nil) - invokeCopy(on: rowView) + #expect(clipboard.text == "af49453b-7f2f-fb58-fcd3-2bd399599fa5") + #expect(clipboard.hasGridRows == false) + } - #expect(clipboard.text == "NULL") - #expect(clipboard.hasGridRows == false) - } + @Test("Copy resolves the selection through the displayed row order") + func copyUsesDisplayedRowOrder() throws { + let clipboard = try #require(RecordingClipboard.current) - @Test("Copy from row-number column copies row even when a data cell is focused") - func copyFromRowNumberColumnCopiesRow() { - let delegate = DataGridRowViewCopyDelegateSpy() - let coordinator = makeCoordinator( - rows: [[.text("1"), .text("Alice")]], - columnTypes: [.integer(rawType: "INT"), .text(rawType: "TEXT")], - selectedRows: [0], - delegate: delegate - ) - let tableView = makeTableView(for: coordinator) - tableView.focusedRow = 0 - tableView.focusedColumn = tableColumnIndex(of: 1, in: tableView) - - let rowView = DataGridRowView() - rowView.coordinator = coordinator - rowView.rowIndex = 0 - - invokeCopy(on: rowView, target: .row) - - #expect(delegate.copiedRows == Set([0])) - } + let first = Data(repeating: 0xAA, count: 16) + let second = Data(repeating: 0xBB, count: 16) + let coordinator = makeCoordinator( + rows: [[.bytes(first)], [.bytes(second)]], + columnTypes: [.blob(rawType: "BYTEA")] + ) + coordinator.updateDisplayFormats([.uuid]) + coordinator.valueFilteredIDs = [.existing(1), .existing(0)] + let tableView = makeTableView(for: coordinator) + let selectedCell = GridCoord(row: 0, displayColumn: 0) + _ = coordinator.selectionController.beginDrag(at: selectedCell, modifiers: .command) + coordinator.selectionController.endDrag(dragged: false, originalCoord: selectedCell) - @Test("Copy rows action still dispatches full-row copy") - func copyRowsActionStillCopiesRows() { - let delegate = DataGridRowViewCopyDelegateSpy() - let coordinator = makeCoordinator( - rows: [[.text("1"), .text("Alice")], [.text("2"), .text("Bob")]], - columnTypes: [.integer(rawType: "INT"), .text(rawType: "TEXT")], - selectedRows: [0, 1], - delegate: delegate - ) - let rowView = DataGridRowView() - rowView.coordinator = coordinator - rowView.rowIndex = 0 - - invokeCopyRows(on: rowView) - - #expect(delegate.copiedRows == Set([0, 1])) - } + tableView.copy(nil) - @Test("Copy falls back to row copy when no cell context exists") - func copyFallsBackToRowCopy() { - let delegate = DataGridRowViewCopyDelegateSpy() - let coordinator = makeCoordinator( - rows: [[.text("1"), .text("Alice")], [.text("2"), .text("Bob")]], - columnTypes: [.integer(rawType: "INT"), .text(rawType: "TEXT")], - selectedRows: [0, 1], - delegate: delegate - ) - let rowView = DataGridRowView() - rowView.coordinator = coordinator - rowView.rowIndex = 1 - - invokeCopy(on: rowView) - - #expect(delegate.copiedRows == Set([0, 1])) - } + #expect(clipboard.text == "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb") + } - @Test("Copy uses the rectangular grid selection when one exists") - func copyUsesGridSelection() { - let clipboard = DataGridRowViewCopyClipboard() - ClipboardService.shared = clipboard - defer { ClipboardService.shared = NSPasteboardClipboardProvider() } + @Test("Copy keeps raw bytes for multi-cell grid selections") + func copyKeepsRawBytesForMultiCellGridSelection() throws { + let clipboard = try #require(RecordingClipboard.current) - let coordinator = makeCoordinator( - rows: [[.text("1"), .text("Alice")], [.text("2"), .text("Bob")]], - columnTypes: [.integer(rawType: "INT"), .text(rawType: "TEXT")] - ) - let sweptOrigin = GridCoord(row: 0, displayColumn: 0) - coordinator.selectionController.update( - .single(GridRect(rows: 0...1, columns: 0...1), anchor: sweptOrigin, active: sweptOrigin) - ) + let first = Data(repeating: 0xAA, count: 16) + let second = Data(repeating: 0xBB, count: 16) + let coordinator = makeCoordinator( + rows: [[.bytes(first), .bytes(second)]], + columnTypes: [.blob(rawType: "BYTEA"), .blob(rawType: "BYTEA")] + ) + coordinator.updateDisplayFormats([.uuid, .uuid]) + let tableView = makeTableView(for: coordinator) + let sweptOrigin = GridCoord(row: 0, displayColumn: 0) + coordinator.selectionController.update( + .single(GridRect(rows: 0...0, columns: 0...1), anchor: sweptOrigin, active: sweptOrigin) + ) - let rowView = DataGridRowView() - rowView.coordinator = coordinator - rowView.rowIndex = 0 + tableView.copy(nil) - invokeCopy(on: rowView, target: .cell(0)) + #expect(clipboard.text == "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\t0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB") + } - #expect(clipboard.text == "1\tAlice\n2\tBob") - } + @Test("Copy falls back to focused cell when no clicked column is attached") + func copyFallsBackToFocusedCell() throws { + let clipboard = try #require(RecordingClipboard.current) - private func makeRangeSelectedRowView( - delegate: (any DataGridViewDelegate)? = nil - ) -> (DataGridRowView, TableViewCoordinator) { - let coordinator = makeCoordinator( - rows: [[.text("1"), .text("Alice")], [.text("2"), .text("Bob")]], - columnTypes: [.integer(rawType: "INT"), .text(rawType: "TEXT")], - selectedRows: [1], - delegate: delegate - ) - coordinator.selectionController.update( - .single( - GridRect(rows: 0...1, columns: 0...1), - anchor: GridCoord(row: 0, displayColumn: 0), - active: GridCoord(row: 1, displayColumn: 0) + let coordinator = makeCoordinator( + rows: [[.text("1"), .null]], + columnTypes: [.integer(rawType: "INT"), .text(rawType: "TEXT")] ) - ) - let rowView = DataGridRowView() - rowView.coordinator = coordinator - rowView.rowIndex = 1 - return (rowView, coordinator) - } + let tableView = makeTableView(for: coordinator) + tableView.focusedRow = 0 + tableView.focusedColumn = tableColumnIndex(of: 1, in: tableView) - private func withClipboard(_ body: (DataGridRowViewCopyClipboard) -> Void) { - let clipboard = DataGridRowViewCopyClipboard() - ClipboardService.shared = clipboard - defer { ClipboardService.shared = NSPasteboardClipboardProvider() } - body(clipboard) - } + let rowView = DataGridRowView() + rowView.coordinator = coordinator + rowView.rowIndex = 0 - @Test("Copy as Rows sends every row of the range selection") - func copyRowsUsesRangeSelection() { - let delegate = DataGridRowViewCopyDelegateSpy() - let (rowView, coordinator) = makeRangeSelectedRowView(delegate: delegate) + invokeCopy(on: rowView) - invokeCopyRows(on: rowView) + #expect(clipboard.text == "NULL") + #expect(clipboard.hasGridRows == false) + } - #expect(delegate.copiedRows == Set([0, 1])) - withExtendedLifetime(coordinator) {} - } + @Test("Copy from row-number column copies row even when a data cell is focused") + func copyFromRowNumberColumnCopiesRow() { + let delegate = DataGridRowViewCopyDelegateSpy() + let coordinator = makeCoordinator( + rows: [[.text("1"), .text("Alice")]], + columnTypes: [.integer(rawType: "INT"), .text(rawType: "TEXT")], + selectedRows: [0], + delegate: delegate + ) + let tableView = makeTableView(for: coordinator) + tableView.focusedRow = 0 + tableView.focusedColumn = tableColumnIndex(of: 1, in: tableView) - @Test("Copy as With Headers includes every row of the range selection") - func copyWithHeadersUsesRangeSelection() { - withClipboard { clipboard in - let (rowView, coordinator) = makeRangeSelectedRowView() + let rowView = DataGridRowView() + rowView.coordinator = coordinator + rowView.rowIndex = 0 - _ = rowView.perform(NSSelectorFromString("copySelectedOrCurrentRowWithHeaders")) + invokeCopy(on: rowView, target: .row) - #expect(clipboard.text == "c0\tc1\n1\tAlice\n2\tBob") - withExtendedLifetime(coordinator) {} + #expect(delegate.copiedRows == Set([0])) } - } - @Test("Copy as JSON includes every row of the range selection") - func copyAsJsonUsesRangeSelection() { - withClipboard { clipboard in - let (rowView, coordinator) = makeRangeSelectedRowView() + @Test("Copy rows action still dispatches full-row copy") + func copyRowsActionStillCopiesRows() { + let delegate = DataGridRowViewCopyDelegateSpy() + let coordinator = makeCoordinator( + rows: [[.text("1"), .text("Alice")], [.text("2"), .text("Bob")]], + columnTypes: [.integer(rawType: "INT"), .text(rawType: "TEXT")], + selectedRows: [0, 1], + delegate: delegate + ) + let rowView = DataGridRowView() + rowView.coordinator = coordinator + rowView.rowIndex = 0 - _ = rowView.perform(NSSelectorFromString("copyAsJson")) + invokeCopyRows(on: rowView) - #expect(clipboard.text?.contains("Alice") == true) - #expect(clipboard.text?.contains("Bob") == true) - withExtendedLifetime(coordinator) {} + #expect(delegate.copiedRows == Set([0, 1])) } - } - @Test("Copy as CSV includes every row of the range selection") - func copyAsCsvUsesRangeSelection() { - withClipboard { clipboard in - let (rowView, coordinator) = makeRangeSelectedRowView() + @Test("Copy falls back to row copy when no cell context exists") + func copyFallsBackToRowCopy() { + let delegate = DataGridRowViewCopyDelegateSpy() + let coordinator = makeCoordinator( + rows: [[.text("1"), .text("Alice")], [.text("2"), .text("Bob")]], + columnTypes: [.integer(rawType: "INT"), .text(rawType: "TEXT")], + selectedRows: [0, 1], + delegate: delegate + ) + let rowView = DataGridRowView() + rowView.coordinator = coordinator + rowView.rowIndex = 1 - _ = rowView.perform(NSSelectorFromString("copyAsCsv")) + invokeCopy(on: rowView) - #expect(clipboard.text?.contains("Alice") == true) - #expect(clipboard.text?.contains("Bob") == true) - withExtendedLifetime(coordinator) {} + #expect(delegate.copiedRows == Set([0, 1])) } - } - @Test("Copy as CSV with Headers includes every row of the range selection") - func copyAsCsvWithHeadersUsesRangeSelection() { - withClipboard { clipboard in - let (rowView, coordinator) = makeRangeSelectedRowView() + @Test("Copy uses the rectangular grid selection when one exists") + func copyUsesGridSelection() throws { + let clipboard = try #require(RecordingClipboard.current) - _ = rowView.perform(NSSelectorFromString("copyAsCsvWithHeaders")) + let coordinator = makeCoordinator( + rows: [[.text("1"), .text("Alice")], [.text("2"), .text("Bob")]], + columnTypes: [.integer(rawType: "INT"), .text(rawType: "TEXT")] + ) + let sweptOrigin = GridCoord(row: 0, displayColumn: 0) + coordinator.selectionController.update( + .single(GridRect(rows: 0...1, columns: 0...1), anchor: sweptOrigin, active: sweptOrigin) + ) - #expect(clipboard.text?.contains("c0") == true) - #expect(clipboard.text?.contains("Alice") == true) - #expect(clipboard.text?.contains("Bob") == true) - withExtendedLifetime(coordinator) {} - } - } + let rowView = DataGridRowView() + rowView.coordinator = coordinator + rowView.rowIndex = 0 - @Test("Copy as Markdown includes every row of the range selection") - func copyAsMarkdownUsesRangeSelection() { - withClipboard { clipboard in - let (rowView, coordinator) = makeRangeSelectedRowView() + invokeCopy(on: rowView, target: .cell(0)) - _ = rowView.perform(NSSelectorFromString("copyAsMarkdown")) + #expect(clipboard.text == "1\tAlice\n2\tBob") + } - #expect(clipboard.text?.contains("Alice") == true) - #expect(clipboard.text?.contains("Bob") == true) - withExtendedLifetime(coordinator) {} + private func makeRangeSelectedRowView( + delegate: (any DataGridViewDelegate)? = nil + ) -> (DataGridRowView, TableViewCoordinator) { + let coordinator = makeCoordinator( + rows: [[.text("1"), .text("Alice")], [.text("2"), .text("Bob")]], + columnTypes: [.integer(rawType: "INT"), .text(rawType: "TEXT")], + selectedRows: [1], + delegate: delegate + ) + coordinator.selectionController.update( + .single( + GridRect(rows: 0...1, columns: 0...1), + anchor: GridCoord(row: 0, displayColumn: 0), + active: GridCoord(row: 1, displayColumn: 0) + ) + ) + let rowView = DataGridRowView() + rowView.coordinator = coordinator + rowView.rowIndex = 1 + return (rowView, coordinator) } - } - @Test("Copy as IN Clause includes every row of the range selection") - func copyAsInClauseUsesRangeSelection() { - withClipboard { clipboard in - let (rowView, coordinator) = makeRangeSelectedRowView() - let item = NSMenuItem(title: "IN Clause", action: nil, keyEquivalent: "") - item.representedObject = 1 + private func withClipboard(_ body: (RecordingClipboard) throws -> Void) throws { + try body(try #require(RecordingClipboard.current)) + } + + @Test("Copy as Rows sends every row of the range selection") + func copyRowsUsesRangeSelection() { + let delegate = DataGridRowViewCopyDelegateSpy() + let (rowView, coordinator) = makeRangeSelectedRowView(delegate: delegate) - _ = rowView.perform(NSSelectorFromString("copyAsInClause:"), with: item) + invokeCopyRows(on: rowView) - #expect(clipboard.text?.contains("Alice") == true) - #expect(clipboard.text?.contains("Bob") == true) + #expect(delegate.copiedRows == Set([0, 1])) withExtendedLifetime(coordinator) {} } - } - @Test("Copy as INSERT emits a statement for every row of the range selection") - func copyAsInsertUsesRangeSelection() { - withClipboard { clipboard in - let (rowView, coordinator) = makeRangeSelectedRowView() - coordinator.tableName = "users" - coordinator.databaseType = .mysql + @Test("Copy as With Headers includes every row of the range selection") + func copyWithHeadersUsesRangeSelection() throws { + try withClipboard { clipboard in + let (rowView, coordinator) = makeRangeSelectedRowView() - _ = rowView.perform(NSSelectorFromString("copyAsInsert")) + _ = rowView.perform(NSSelectorFromString("copySelectedOrCurrentRowWithHeaders")) - let statements = clipboard.text?.components(separatedBy: "INSERT INTO").count ?? 0 - #expect(statements - 1 == 2) - #expect(clipboard.text?.contains("Alice") == true) - #expect(clipboard.text?.contains("Bob") == true) + #expect(clipboard.text == "c0\tc1\n1\tAlice\n2\tBob") + withExtendedLifetime(coordinator) {} + } } - } - @Test("Copy as UPDATE emits a statement for every row of the range selection") - func copyAsUpdateUsesRangeSelection() { - withClipboard { clipboard in - let (rowView, coordinator) = makeRangeSelectedRowView() - coordinator.tableName = "users" - coordinator.databaseType = .mysql - coordinator.primaryKeyColumns = ["c0"] + @Test("Copy as JSON, CSV or Markdown includes every row of the range selection", arguments: [ + "copyAsJson", "copyAsCsv", "copyAsMarkdown", + ]) + func copyAsFormatUsesRangeSelection(selector: String) throws { + try withClipboard { clipboard in + let (rowView, coordinator) = makeRangeSelectedRowView() - _ = rowView.perform(NSSelectorFromString("copyAsUpdate")) + _ = rowView.perform(NSSelectorFromString(selector)) - let statements = clipboard.text?.components(separatedBy: "UPDATE").count ?? 0 - #expect(statements - 1 == 2) - #expect(clipboard.text?.contains("Alice") == true) - #expect(clipboard.text?.contains("Bob") == true) + #expect(clipboard.text?.contains("Alice") == true) + #expect(clipboard.text?.contains("Bob") == true) + withExtendedLifetime(coordinator) {} + } } - } - @Test("Delete targets every row of the range selection") - func deleteUsesRangeSelection() { - let delegate = DataGridRowViewCopyDelegateSpy() - let (rowView, coordinator) = makeRangeSelectedRowView(delegate: delegate) + @Test("Copy as CSV with Headers includes every row of the range selection") + func copyAsCsvWithHeadersUsesRangeSelection() throws { + try withClipboard { clipboard in + let (rowView, coordinator) = makeRangeSelectedRowView() - _ = rowView.perform(NSSelectorFromString("deleteRow")) + _ = rowView.perform(NSSelectorFromString("copyAsCsvWithHeaders")) - #expect(delegate.deletedRows == Set([0, 1])) - withExtendedLifetime(coordinator) {} - } + #expect(clipboard.text?.contains("c0") == true) + #expect(clipboard.text?.contains("Alice") == true) + #expect(clipboard.text?.contains("Bob") == true) + withExtendedLifetime(coordinator) {} + } + } - @Test("Copy as JSON includes only the selected column") - func copyAsJsonScopesToSelectedColumn() { - withClipboard { clipboard in - let coordinator = makeCoordinator( - rows: [[.text("1"), .text("Alice")], [.text("2"), .text("Bob")]], - columnTypes: [.integer(rawType: "INT"), .text(rawType: "TEXT")] - ) - coordinator.selectionController.selectEntireColumn(0, totalRows: 2) - let rowView = DataGridRowView() - rowView.coordinator = coordinator - rowView.rowIndex = 0 + @Test("Copy as IN Clause includes every row of the range selection") + func copyAsInClauseUsesRangeSelection() throws { + try withClipboard { clipboard in + let (rowView, coordinator) = makeRangeSelectedRowView() + let item = NSMenuItem(title: "IN Clause", action: nil, keyEquivalent: "") + item.representedObject = 1 - _ = rowView.perform(NSSelectorFromString("copyAsJson")) + _ = rowView.perform(NSSelectorFromString("copyAsInClause:"), with: item) - #expect(clipboard.text?.contains("\"c0\"") == true) - #expect(clipboard.text?.contains("Alice") != true) - #expect(clipboard.text?.contains("Bob") != true) + #expect(clipboard.text?.contains("Alice") == true) + #expect(clipboard.text?.contains("Bob") == true) + withExtendedLifetime(coordinator) {} + } } - } - private func makeCellSelectedCoordinator() -> TableViewCoordinator { - let coordinator = makeCoordinator( - rows: [[.text("1"), .text("Alice"), .text("NYC"), .text("x")]], - columnTypes: [ - .integer(rawType: "INT"), .text(rawType: "TEXT"), - .text(rawType: "TEXT"), .text(rawType: "TEXT") - ] - ) - coordinator.tableName = "users" - coordinator.databaseType = .mysql - coordinator.selectionController.update( - GridSelection( - rectangles: [ - GridRect(cell: GridCoord(row: 0, displayColumn: 2)), - GridRect(cell: GridCoord(row: 0, displayColumn: 3)) - ], - activeCell: GridCoord(row: 0, displayColumn: 3), - anchor: GridCoord(row: 0, displayColumn: 2) - ) - ) - return coordinator - } + @Test("Copy as INSERT emits a statement for every row of the range selection") + func copyAsInsertUsesRangeSelection() throws { + try withClipboard { clipboard in + let (rowView, coordinator) = makeRangeSelectedRowView() + coordinator.tableName = "users" + coordinator.databaseType = .mysql - @Test("Copy as INSERT includes only the selected columns") - func copyAsInsertScopesToSelectedColumns() { - withClipboard { clipboard in - let coordinator = makeCellSelectedCoordinator() - let rowView = DataGridRowView() - rowView.coordinator = coordinator - rowView.rowIndex = 0 + _ = rowView.perform(NSSelectorFromString("copyAsInsert")) - _ = rowView.perform(NSSelectorFromString("copyAsInsert")) + let statements = clipboard.text?.components(separatedBy: "INSERT INTO").count ?? 0 + #expect(statements - 1 == 2) + #expect(clipboard.text?.contains("Alice") == true) + #expect(clipboard.text?.contains("Bob") == true) + } + } - #expect(clipboard.text == "INSERT INTO `users` (`c2`, `c3`) VALUES ('NYC', 'x');") + @Test("Copy as UPDATE emits a statement for every row of the range selection") + func copyAsUpdateUsesRangeSelection() throws { + try withClipboard { clipboard in + let (rowView, coordinator) = makeRangeSelectedRowView() + coordinator.tableName = "users" + coordinator.databaseType = .mysql + coordinator.primaryKeyColumns = ["c0"] + + _ = rowView.perform(NSSelectorFromString("copyAsUpdate")) + + let statements = clipboard.text?.components(separatedBy: "UPDATE").count ?? 0 + #expect(statements - 1 == 2) + #expect(clipboard.text?.contains("Alice") == true) + #expect(clipboard.text?.contains("Bob") == true) + } } - } - @Test("Copy as UPDATE sets only selected columns and keys WHERE on the primary key") - func copyAsUpdateScopesSetToSelectedColumns() { - withClipboard { clipboard in - let coordinator = makeCellSelectedCoordinator() - coordinator.primaryKeyColumns = ["c0"] - let rowView = DataGridRowView() - rowView.coordinator = coordinator - rowView.rowIndex = 0 + @Test("Delete targets every row of the range selection") + func deleteUsesRangeSelection() { + let delegate = DataGridRowViewCopyDelegateSpy() + let (rowView, coordinator) = makeRangeSelectedRowView(delegate: delegate) - _ = rowView.perform(NSSelectorFromString("copyAsUpdate")) + _ = rowView.perform(NSSelectorFromString("deleteRow")) - #expect(clipboard.text == "UPDATE `users` SET `c2` = 'NYC', `c3` = 'x' WHERE `c0` = '1';") + #expect(delegate.deletedRows == Set([0, 1])) + withExtendedLifetime(coordinator) {} } - } - @Test("Copy as UPDATE without a primary key keeps the full row in WHERE") - func copyAsUpdateNoPrimaryKeyKeysWhereOnFullRow() { - withClipboard { clipboard in - let coordinator = makeCoordinator( - rows: [[.text("1"), .text("Alice"), .text("NYC")]], - columnTypes: [.integer(rawType: "INT"), .text(rawType: "TEXT"), .text(rawType: "TEXT")] - ) - coordinator.tableName = "users" - coordinator.databaseType = .mysql - coordinator.selectionController.update( - GridSelection( - rectangles: [GridRect(cell: GridCoord(row: 0, displayColumn: 2))], - activeCell: GridCoord(row: 0, displayColumn: 2), - anchor: GridCoord(row: 0, displayColumn: 2) + @Test("Copy as JSON includes only the selected column") + func copyAsJsonScopesToSelectedColumn() throws { + try withClipboard { clipboard in + let coordinator = makeCoordinator( + rows: [[.text("1"), .text("Alice")], [.text("2"), .text("Bob")]], + columnTypes: [.integer(rawType: "INT"), .text(rawType: "TEXT")] ) - ) - let rowView = DataGridRowView() - rowView.coordinator = coordinator - rowView.rowIndex = 0 + coordinator.selectionController.selectEntireColumn(0, totalRows: 2) + let rowView = DataGridRowView() + rowView.coordinator = coordinator + rowView.rowIndex = 0 - _ = rowView.perform(NSSelectorFromString("copyAsUpdate")) + _ = rowView.perform(NSSelectorFromString("copyAsJson")) - #expect(clipboard.text == "UPDATE `users` SET `c2` = 'NYC' WHERE `c0` = '1' AND `c1` = 'Alice' AND `c2` = 'NYC';") + #expect(clipboard.text?.contains("\"c0\"") == true) + #expect(clipboard.text?.contains("Alice") != true) + #expect(clipboard.text?.contains("Bob") != true) + } } - } - @Test("Copy as INSERT flattens a discontiguous selection to the column-union rectangle") - func copyAsInsertFlattensDiscontiguousSelection() { - withClipboard { clipboard in + private func makeCellSelectedCoordinator() -> TableViewCoordinator { let coordinator = makeCoordinator( - rows: [ - [.text("1"), .text("Alice"), .text("NYC")], - [.text("2"), .text("Bob"), .text("LA")] - ], - columnTypes: [.integer(rawType: "INT"), .text(rawType: "TEXT"), .text(rawType: "TEXT")] + rows: [[.text("1"), .text("Alice"), .text("NYC"), .text("x")]], + columnTypes: [ + .integer(rawType: "INT"), .text(rawType: "TEXT"), + .text(rawType: "TEXT"), .text(rawType: "TEXT") + ] ) coordinator.tableName = "users" coordinator.databaseType = .mysql @@ -640,58 +499,139 @@ struct DataGridRowViewCopyTests { GridSelection( rectangles: [ GridRect(cell: GridCoord(row: 0, displayColumn: 2)), - GridRect(cell: GridCoord(row: 1, displayColumn: 0)) + GridRect(cell: GridCoord(row: 0, displayColumn: 3)) ], - activeCell: GridCoord(row: 1, displayColumn: 0), + activeCell: GridCoord(row: 0, displayColumn: 3), anchor: GridCoord(row: 0, displayColumn: 2) ) ) - let rowView = DataGridRowView() - rowView.coordinator = coordinator - rowView.rowIndex = 0 + return coordinator + } + + @Test("Copy as INSERT includes only the selected columns") + func copyAsInsertScopesToSelectedColumns() throws { + try withClipboard { clipboard in + let coordinator = makeCellSelectedCoordinator() + let rowView = DataGridRowView() + rowView.coordinator = coordinator + rowView.rowIndex = 0 - _ = rowView.perform(NSSelectorFromString("copyAsInsert")) + _ = rowView.perform(NSSelectorFromString("copyAsInsert")) - let text = clipboard.text ?? "" - #expect(text.contains("INSERT INTO `users` (`c0`, `c2`) VALUES ('1', 'NYC');")) - #expect(text.contains("INSERT INTO `users` (`c0`, `c2`) VALUES ('2', 'LA');")) + #expect(clipboard.text == "INSERT INTO `users` (`c2`, `c3`) VALUES ('NYC', 'x');") + } } - } - @Test("Copy as CSV includes only the selected column") - func copyAsCsvScopesToSelectedColumn() { - withClipboard { clipboard in - let coordinator = makeCoordinator( - rows: [[.text("1"), .text("Alice")], [.text("2"), .text("Bob")]], - columnTypes: [.integer(rawType: "INT"), .text(rawType: "TEXT")] - ) - coordinator.selectionController.selectEntireColumn(0, totalRows: 2) - let rowView = DataGridRowView() - rowView.coordinator = coordinator - rowView.rowIndex = 0 + @Test("Copy as UPDATE sets only selected columns and keys WHERE on the primary key") + func copyAsUpdateScopesSetToSelectedColumns() throws { + try withClipboard { clipboard in + let coordinator = makeCellSelectedCoordinator() + coordinator.primaryKeyColumns = ["c0"] + let rowView = DataGridRowView() + rowView.coordinator = coordinator + rowView.rowIndex = 0 - _ = rowView.perform(NSSelectorFromString("copyAsCsv")) + _ = rowView.perform(NSSelectorFromString("copyAsUpdate")) - #expect(clipboard.text?.contains("Alice") != true) - #expect(clipboard.text?.contains("Bob") != true) + #expect(clipboard.text == "UPDATE `users` SET `c2` = 'NYC', `c3` = 'x' WHERE `c0` = '1';") + } } - } - @Test("Copy as JSON falls back to the clicked row without any selection") - func copyAsJsonFallsBackToClickedRow() { - withClipboard { clipboard in - let coordinator = makeCoordinator( - rows: [[.text("1"), .text("Alice")], [.text("2"), .text("Bob")]], - columnTypes: [.integer(rawType: "INT"), .text(rawType: "TEXT")] - ) - let rowView = DataGridRowView() - rowView.coordinator = coordinator - rowView.rowIndex = 1 + @Test("Copy as UPDATE without a primary key keeps the full row in WHERE") + func copyAsUpdateNoPrimaryKeyKeysWhereOnFullRow() throws { + try withClipboard { clipboard in + let coordinator = makeCoordinator( + rows: [[.text("1"), .text("Alice"), .text("NYC")]], + columnTypes: [.integer(rawType: "INT"), .text(rawType: "TEXT"), .text(rawType: "TEXT")] + ) + coordinator.tableName = "users" + coordinator.databaseType = .mysql + coordinator.selectionController.update( + GridSelection( + rectangles: [GridRect(cell: GridCoord(row: 0, displayColumn: 2))], + activeCell: GridCoord(row: 0, displayColumn: 2), + anchor: GridCoord(row: 0, displayColumn: 2) + ) + ) + let rowView = DataGridRowView() + rowView.coordinator = coordinator + rowView.rowIndex = 0 + + _ = rowView.perform(NSSelectorFromString("copyAsUpdate")) + + #expect(clipboard.text == "UPDATE `users` SET `c2` = 'NYC' WHERE `c0` = '1' AND `c1` = 'Alice' AND `c2` = 'NYC';") + } + } + + @Test("Copy as INSERT flattens a discontiguous selection to the column-union rectangle") + func copyAsInsertFlattensDiscontiguousSelection() throws { + try withClipboard { clipboard in + let coordinator = makeCoordinator( + rows: [ + [.text("1"), .text("Alice"), .text("NYC")], + [.text("2"), .text("Bob"), .text("LA")] + ], + columnTypes: [.integer(rawType: "INT"), .text(rawType: "TEXT"), .text(rawType: "TEXT")] + ) + coordinator.tableName = "users" + coordinator.databaseType = .mysql + coordinator.selectionController.update( + GridSelection( + rectangles: [ + GridRect(cell: GridCoord(row: 0, displayColumn: 2)), + GridRect(cell: GridCoord(row: 1, displayColumn: 0)) + ], + activeCell: GridCoord(row: 1, displayColumn: 0), + anchor: GridCoord(row: 0, displayColumn: 2) + ) + ) + let rowView = DataGridRowView() + rowView.coordinator = coordinator + rowView.rowIndex = 0 + + _ = rowView.perform(NSSelectorFromString("copyAsInsert")) + + let text = clipboard.text ?? "" + #expect(text.contains("INSERT INTO `users` (`c0`, `c2`) VALUES ('1', 'NYC');")) + #expect(text.contains("INSERT INTO `users` (`c0`, `c2`) VALUES ('2', 'LA');")) + } + } + + @Test("Copy as CSV includes only the selected column") + func copyAsCsvScopesToSelectedColumn() throws { + try withClipboard { clipboard in + let coordinator = makeCoordinator( + rows: [[.text("1"), .text("Alice")], [.text("2"), .text("Bob")]], + columnTypes: [.integer(rawType: "INT"), .text(rawType: "TEXT")] + ) + coordinator.selectionController.selectEntireColumn(0, totalRows: 2) + let rowView = DataGridRowView() + rowView.coordinator = coordinator + rowView.rowIndex = 0 + + _ = rowView.perform(NSSelectorFromString("copyAsCsv")) + + #expect(clipboard.text?.contains("Alice") != true) + #expect(clipboard.text?.contains("Bob") != true) + } + } + + @Test("Copy as JSON falls back to the clicked row without any selection") + func copyAsJsonFallsBackToClickedRow() throws { + try withClipboard { clipboard in + let coordinator = makeCoordinator( + rows: [[.text("1"), .text("Alice")], [.text("2"), .text("Bob")]], + columnTypes: [.integer(rawType: "INT"), .text(rawType: "TEXT")] + ) + let rowView = DataGridRowView() + rowView.coordinator = coordinator + rowView.rowIndex = 1 - _ = rowView.perform(NSSelectorFromString("copyAsJson")) + _ = rowView.perform(NSSelectorFromString("copyAsJson")) - #expect(clipboard.text?.contains("Bob") == true) - #expect(clipboard.text?.contains("Alice") != true) + #expect(clipboard.text?.contains("Bob") == true) + #expect(clipboard.text?.contains("Alice") != true) + } } } } diff --git a/TableProTests/Views/Results/DataGridRowViewSetValueTests.swift b/TableProTests/Views/Results/DataGridRowViewSetValueTests.swift index 45bfda5570..580f421f5a 100644 --- a/TableProTests/Views/Results/DataGridRowViewSetValueTests.swift +++ b/TableProTests/Views/Results/DataGridRowViewSetValueTests.swift @@ -1,8 +1,3 @@ -// -// DataGridRowViewSetValueTests.swift -// TableProTests -// - import Foundation @testable import TablePro import TableProPluginKit @@ -28,15 +23,9 @@ struct DataGridRowViewSetValueTests { #expect(presets == ["NOW()", "CURRENT_TIMESTAMP"]) } - @Test("TIME column offers CURRENT_TIME only") - func timeColumnOffersCurrentTime() { - let presets = DataGridRowView.dateValueFunctions(for: .timestamp(rawType: "TIME")) - #expect(presets == ["CURRENT_TIME"]) - } - - @Test("TIMETZ column offers CURRENT_TIME only") - func timetzColumnOffersCurrentTime() { - let presets = DataGridRowView.dateValueFunctions(for: .timestamp(rawType: "TIMETZ")) + @Test("TIME and TIMETZ columns offer CURRENT_TIME only", arguments: ["TIME", "TIMETZ"]) + func timeColumnOffersCurrentTime(rawType: String) { + let presets = DataGridRowView.dateValueFunctions(for: .timestamp(rawType: rawType)) #expect(presets == ["CURRENT_TIME"]) } diff --git a/TableProTests/Views/Results/DataGridSelectionSummaryMenuTests.swift b/TableProTests/Views/Results/DataGridSelectionSummaryMenuTests.swift index cf7848ea9e..97fe8299f8 100644 --- a/TableProTests/Views/Results/DataGridSelectionSummaryMenuTests.swift +++ b/TableProTests/Views/Results/DataGridSelectionSummaryMenuTests.swift @@ -1,8 +1,3 @@ -// -// DataGridSelectionSummaryMenuTests.swift -// TableProTests -// - import AppKit import Foundation import SwiftUI @@ -11,20 +6,6 @@ import Testing @testable import TablePro -@MainActor -private final class SummaryMenuClipboard: ClipboardProvider { - var text: String? - - func readText() -> String? { text } - func readGridRows() -> GridRowsClipboardPayload? { nil } - func writeText(_ text: String) { self.text = text } - func writeCsv(_ csv: String) { text = csv } - func writeImage(_ image: NSImage) {} - func writeRows(tsv: String, html: String?, gridRows: GridRowsClipboardPayload) { text = tsv } - var hasText: Bool { text != nil } - var hasGridRows: Bool { false } -} - @MainActor private final class SummaryMenuLayoutPersister: ColumnLayoutPersisting { func load(for key: ColumnLayoutTableKey) -> ColumnLayoutState? { nil } @@ -32,147 +13,147 @@ private final class SummaryMenuLayoutPersister: ColumnLayoutPersisting { func clear(for key: ColumnLayoutTableKey) {} } -@Suite("Selection summary menus", .serialized) -@MainActor -struct DataGridSelectionSummaryMenuTests { - private static let columnTypes: [ColumnType] = [.integer(rawType: "INT"), .text(rawType: "TEXT")] - private static let selectColumnAction = #selector(TableViewCoordinator.selectColumnFromHeaderMenu(_:)) - private static let copyFigureAction = NSSelectorFromString("copySelectionSummaryFigure:") - - /// The coordinator holds its table view weakly, so the test keeps it alive across an await. - private struct Fixture { - let coordinator: TableViewCoordinator - let tableView: KeyHandlingTableView - } - - private func makeFixture() -> Fixture { - let coordinator = TableViewCoordinator( - changeManager: AnyChangeManager(DataChangeManager()), - isEditable: false, - selectedRowIndices: .constant([]), - delegate: nil, - layoutPersister: SummaryMenuLayoutPersister() - ) - let tableRows = TableRows.from( - queryRows: [ - [.text("1"), .text("a")], - [.text("2"), .text("b")], - [.text("3"), .text("c")], - ], - columns: ["amount", "name"], - columnTypes: Self.columnTypes - ) - coordinator.tableRowsProvider = { tableRows } - coordinator.rebuildColumnMetadataCache(from: tableRows) - coordinator.updateCache() - - let tableView = KeyHandlingTableView(frame: NSRect(x: 0, y: 0, width: 600, height: 200)) - tableView.coordinator = coordinator - tableView.dataSource = coordinator - tableView.delegate = coordinator - tableView.addTableColumn(DataGridView.makeRowNumberColumn()) - coordinator.tableView = tableView - coordinator.columnPool.reconcile( - tableView: tableView, - schema: coordinator.identitySchema, - columnTypes: Self.columnTypes, - savedLayout: nil, - isEditable: false, - hiddenColumnNames: [], - firstClickSortDirection: .ascending, - widthCalculator: { _, _ in 90 } - ) - return Fixture(coordinator: coordinator, tableView: tableView) - } - - private func headerMenu(_ coordinator: TableViewCoordinator, dataColumn: Int) throws -> NSMenu { - let tableView = try #require(coordinator.tableView) - let column = try #require(tableView.tableColumns.firstIndex { - coordinator.dataColumnIndex(from: $0.identifier) == dataColumn - }) - let menu = NSMenu() - coordinator.populateHeaderMenu(menu, forColumnAt: column) - return menu - } - - private func summarizeColumn(_ dataColumn: Int, in coordinator: TableViewCoordinator) async { - let tracker = coordinator.selectionSummaryTracker - tracker.attach(SelectionSummaryState()) - coordinator.selectColumn(dataColumn) - tracker.selectionDidChange() - await tracker.pendingTask?.value - } - - private func makeRowView(_ coordinator: TableViewCoordinator) -> DataGridRowView { - let rowView = DataGridRowView() - rowView.coordinator = coordinator - rowView.rowIndex = 0 - return rowView - } - - @Test("The header menu offers Select Column just before Copy Column Name") - func selectColumnPrecedesCopyColumnName() throws { - let fixture = makeFixture() - let coordinator = fixture.coordinator - let items = try headerMenu(coordinator, dataColumn: 1).items - - let select = try #require(items.firstIndex { $0.action == Self.selectColumnAction }) - let next = items.dropFirst(select + 1).first - #expect(next?.action == #selector(TableViewCoordinator.copyColumnName(_:))) - } - - @Test("Select Column selects every row of the column the menu was opened on") - func selectColumnSelectsTheWholeColumn() throws { - let fixture = makeFixture() - let coordinator = fixture.coordinator - let menu = try headerMenu(coordinator, dataColumn: 1) - let index = try #require(menu.items.firstIndex { $0.action == Self.selectColumnAction }) - - menu.performActionForItem(at: index) - - let position = try #require(coordinator.displayPosition(ofDataColumnIndex: 1)) - let selection = coordinator.selectionController.selection - #expect(selection.columns == IndexSet(integer: position)) - #expect(selection.affectedRows == IndexSet(integersIn: 0..<3)) - } - - @Test("The cell menu offers Copy Sum and Copy Average right after Copy as, as plain text") - func cellMenuCopiesTheSummary() async throws { - let fixture = makeFixture() - let coordinator = fixture.coordinator - await summarizeColumn(0, in: coordinator) - #expect(coordinator.selectionSummaryTracker.currentSummary?.numbers != nil) - - let rowView = makeRowView(coordinator) - let menu = try #require(rowView.contextMenu(target: .cell(dataColumn: 0))) - let titles = menu.items.map(\.title) - let copyAs = try #require(titles.firstIndex(of: String(localized: "Copy as"))) - #expect(Array(titles.dropFirst(copyAs + 1).prefix(2)) == [ - String(localized: "Copy Sum"), - String(localized: "Copy Average"), - ]) - - let clipboard = SummaryMenuClipboard() - ClipboardService.shared = clipboard - defer { ClipboardService.shared = NSPasteboardClipboardProvider() } - menu.performActionForItem(at: copyAs + 1) - #expect(clipboard.text == "6") - menu.performActionForItem(at: copyAs + 2) - #expect(clipboard.text == "2") - withExtendedLifetime(fixture) {} - } - - @Test("A selection holding no numbers offers no summary copies") - func cellMenuHidesSummaryCopiesWithoutNumbers() async throws { - let fixture = makeFixture() - let coordinator = fixture.coordinator - await summarizeColumn(1, in: coordinator) - let summary = try #require(coordinator.selectionSummaryTracker.currentSummary) - #expect(summary.numbers == nil) - - let rowView = makeRowView(coordinator) - let menu = try #require(rowView.contextMenu(target: .cell(dataColumn: 1))) - #expect(!menu.items.contains { $0.action == Self.copyFigureAction }) - withExtendedLifetime(fixture) {} +extension GlobalStateSuites { + @Suite("Selection summary menus", .recordingClipboard) + @MainActor + struct DataGridSelectionSummaryMenuTests { + private static let columnTypes: [ColumnType] = [.integer(rawType: "INT"), .text(rawType: "TEXT")] + private static let selectColumnAction = #selector(TableViewCoordinator.selectColumnFromHeaderMenu(_:)) + private static let copyFigureAction = NSSelectorFromString("copySelectionSummaryFigure:") + + /// The coordinator holds its table view weakly, so the test keeps it alive across an await. + private struct Fixture { + let coordinator: TableViewCoordinator + let tableView: KeyHandlingTableView + } + + private func makeFixture() -> Fixture { + let coordinator = TableViewCoordinator( + changeManager: AnyChangeManager(DataChangeManager()), + isEditable: false, + selectedRowIndices: .constant([]), + delegate: nil, + layoutPersister: SummaryMenuLayoutPersister() + ) + let tableRows = TableRows.from( + queryRows: [ + [.text("1"), .text("a")], + [.text("2"), .text("b")], + [.text("3"), .text("c")], + ], + columns: ["amount", "name"], + columnTypes: Self.columnTypes + ) + coordinator.tableRowsProvider = { tableRows } + coordinator.rebuildColumnMetadataCache(from: tableRows) + coordinator.updateCache() + + let tableView = KeyHandlingTableView(frame: NSRect(x: 0, y: 0, width: 600, height: 200)) + tableView.coordinator = coordinator + tableView.dataSource = coordinator + tableView.delegate = coordinator + tableView.addTableColumn(DataGridView.makeRowNumberColumn()) + coordinator.tableView = tableView + coordinator.columnPool.reconcile( + tableView: tableView, + schema: coordinator.identitySchema, + columnTypes: Self.columnTypes, + savedLayout: nil, + isEditable: false, + hiddenColumnNames: [], + firstClickSortDirection: .ascending, + widthCalculator: { _, _ in 90 } + ) + return Fixture(coordinator: coordinator, tableView: tableView) + } + + private func headerMenu(_ coordinator: TableViewCoordinator, dataColumn: Int) throws -> NSMenu { + let tableView = try #require(coordinator.tableView) + let column = try #require(tableView.tableColumns.firstIndex { + coordinator.dataColumnIndex(from: $0.identifier) == dataColumn + }) + let menu = NSMenu() + coordinator.populateHeaderMenu(menu, forColumnAt: column) + return menu + } + + private func summarizeColumn(_ dataColumn: Int, in coordinator: TableViewCoordinator) async { + let tracker = coordinator.selectionSummaryTracker + tracker.attach(SelectionSummaryState()) + coordinator.selectColumn(dataColumn) + tracker.selectionDidChange() + await tracker.pendingTask?.value + } + + private func makeRowView(_ coordinator: TableViewCoordinator) -> DataGridRowView { + let rowView = DataGridRowView() + rowView.coordinator = coordinator + rowView.rowIndex = 0 + return rowView + } + + @Test("The header menu offers Select Column just before Copy Column Name") + func selectColumnPrecedesCopyColumnName() throws { + let fixture = makeFixture() + let coordinator = fixture.coordinator + let items = try headerMenu(coordinator, dataColumn: 1).items + + let select = try #require(items.firstIndex { $0.action == Self.selectColumnAction }) + let next = items.dropFirst(select + 1).first + #expect(next?.action == #selector(TableViewCoordinator.copyColumnName(_:))) + } + + @Test("Select Column selects every row of the column the menu was opened on") + func selectColumnSelectsTheWholeColumn() throws { + let fixture = makeFixture() + let coordinator = fixture.coordinator + let menu = try headerMenu(coordinator, dataColumn: 1) + let index = try #require(menu.items.firstIndex { $0.action == Self.selectColumnAction }) + + menu.performActionForItem(at: index) + + let position = try #require(coordinator.displayPosition(ofDataColumnIndex: 1)) + let selection = coordinator.selectionController.selection + #expect(selection.columns == IndexSet(integer: position)) + #expect(selection.affectedRows == IndexSet(integersIn: 0..<3)) + } + + @Test("The cell menu offers Copy Sum and Copy Average right after Copy as, as plain text") + func cellMenuCopiesTheSummary() async throws { + let fixture = makeFixture() + let coordinator = fixture.coordinator + await summarizeColumn(0, in: coordinator) + #expect(coordinator.selectionSummaryTracker.currentSummary?.numbers != nil) + + let rowView = makeRowView(coordinator) + let menu = try #require(rowView.contextMenu(target: .cell(dataColumn: 0))) + let titles = menu.items.map(\.title) + let copyAs = try #require(titles.firstIndex(of: String(localized: "Copy as"))) + #expect(Array(titles.dropFirst(copyAs + 1).prefix(2)) == [ + String(localized: "Copy Sum"), + String(localized: "Copy Average"), + ]) + + let clipboard = try #require(RecordingClipboard.current) + menu.performActionForItem(at: copyAs + 1) + #expect(clipboard.text == "6") + menu.performActionForItem(at: copyAs + 2) + #expect(clipboard.text == "2") + withExtendedLifetime(fixture) {} + } + + @Test("A selection holding no numbers offers no summary copies") + func cellMenuHidesSummaryCopiesWithoutNumbers() async throws { + let fixture = makeFixture() + let coordinator = fixture.coordinator + await summarizeColumn(1, in: coordinator) + let summary = try #require(coordinator.selectionSummaryTracker.currentSummary) + #expect(summary.numbers == nil) + + let rowView = makeRowView(coordinator) + let menu = try #require(rowView.contextMenu(target: .cell(dataColumn: 1))) + #expect(!menu.items.contains { $0.action == Self.copyFigureAction }) + withExtendedLifetime(fixture) {} + } } } diff --git a/TableProTests/Views/Results/DataGridThemeDrawingTests.swift b/TableProTests/Views/Results/DataGridThemeDrawingTests.swift index be5ccf1716..2009d30c01 100644 --- a/TableProTests/Views/Results/DataGridThemeDrawingTests.swift +++ b/TableProTests/Views/Results/DataGridThemeDrawingTests.swift @@ -34,7 +34,6 @@ private final class DrawPathRowView: NSTableRowView { /// Each test adopts a pair on the shared `ThemeEngine` and restores it in a `defer`. The bodies are /// synchronous and `@MainActor`, so nothing else on the main actor runs between the two; an `await` /// added anywhere in between would break that. -@Suite("Data grid drawing follows the theme", .serialized) @MainActor struct DataGridThemeDrawingTests { private static let rowFrame = NSRect(x: 0, y: 0, width: 160, height: 24) diff --git a/TableProTests/Views/Results/DataGridTrailingSpaceTests.swift b/TableProTests/Views/Results/DataGridTrailingSpaceTests.swift index d51ca59187..1c000f6a1f 100644 --- a/TableProTests/Views/Results/DataGridTrailingSpaceTests.swift +++ b/TableProTests/Views/Results/DataGridTrailingSpaceTests.swift @@ -20,7 +20,6 @@ private final class TrailingSpaceLayoutPersister: ColumnLayoutPersisting { /// AppKit sizes the table to its columns, so scrolled fully right the last column's divider sat on the /// viewport's last point, where a resizable window takes the press for its own edge resize. Revealing /// the last column from the keyboard, Find or Jump to Column put the divider back there. -@Suite("Data grid trailing space", .serialized) @MainActor struct DataGridTrailingSpaceTests { @MainActor diff --git a/TableProTests/Views/Results/DataGridViewportPlacementTests.swift b/TableProTests/Views/Results/DataGridViewportPlacementTests.swift index 43c1b77448..fad613d16f 100644 --- a/TableProTests/Views/Results/DataGridViewportPlacementTests.swift +++ b/TableProTests/Views/Results/DataGridViewportPlacementTests.swift @@ -17,7 +17,6 @@ private final class NoopViewportLayoutPersister: ColumnLayoutPersisting { func clear(for key: ColumnLayoutTableKey) {} } -@Suite("Data grid viewport placement", .serialized) @MainActor struct DataGridViewportPlacementTests { private struct Grid { diff --git a/TableProTests/Views/Results/DrawnCellReachabilityTests.swift b/TableProTests/Views/Results/DrawnCellReachabilityTests.swift index f154292132..877a2e2e5d 100644 --- a/TableProTests/Views/Results/DrawnCellReachabilityTests.swift +++ b/TableProTests/Views/Results/DrawnCellReachabilityTests.swift @@ -21,7 +21,6 @@ private final class ReachabilityLayoutPersister: ColumnLayoutPersisting { /// nil. Twelve guards still asked it before opening an editor or a popover, and every one of them /// returned early: the JSON, blob, PHP, date, enum, set, array, dropdown and type-picker editors /// were all unreachable. `presentsCell(row:tableColumnIndex:)` is the single replacement (#2381). -@Suite("Drawn cell reachability", .serialized) @MainActor struct DrawnCellReachabilityTests { /// Holds the window: `TableViewCoordinator.tableView` is weak both ways, so a suite that diff --git a/TableProTests/Views/Results/Extensions/CellPasteRoutingTests.swift b/TableProTests/Views/Results/Extensions/CellPasteRoutingTests.swift index d77b4d48f4..e91b12664d 100644 --- a/TableProTests/Views/Results/Extensions/CellPasteRoutingTests.swift +++ b/TableProTests/Views/Results/Extensions/CellPasteRoutingTests.swift @@ -1,12 +1,7 @@ -// -// CellPasteRoutingTests.swift -// TableProTests -// -// Locks the contract that pasteCellsFromClipboard defers to row paste -// when the clipboard carries the in-app gridRows tag or has a row-shaped -// TSV. Without these checks, Cmd+V on a focused cell after Cmd+C on a row -// silently overwrites the row's tail columns. -// +// Locks the contract that pasteCellsFromClipboard defers to row paste +// when the clipboard carries the in-app gridRows tag or has a row-shaped +// TSV. Without these checks, Cmd+V on a focused cell after Cmd+C on a row +// silently overwrites the row's tail columns. import AppKit import Foundation @@ -22,166 +17,127 @@ private final class NoopColumnLayoutPersister: ColumnLayoutPersisting { func clear(for key: ColumnLayoutTableKey) {} } -@MainActor -private final class StubClipboard: ClipboardProvider { - var text: String? - var hasGridRowsValue = false - - func readText() -> String? { text } - func readGridRows() -> GridRowsClipboardPayload? { nil } - func writeText(_ text: String) { self.text = text; hasGridRowsValue = false } - func writeCsv(_ csv: String) { self.text = csv; hasGridRowsValue = false } - - var copiedImages: [NSImage] = [] - - func writeImage(_ image: NSImage) { - copiedImages.append(image) - } - func writeRows(tsv: String, html: String?, gridRows: GridRowsClipboardPayload) { - self.text = tsv - hasGridRowsValue = true - } - var hasText: Bool { text != nil } - var hasGridRows: Bool { hasGridRowsValue } -} - -@MainActor -struct CellPasteRoutingTests { - private func makeCoordinator(columns: [String], rowCount: Int) -> TableViewCoordinator { - let coordinator = TableViewCoordinator( - changeManager: AnyChangeManager(DataChangeManager()), - isEditable: true, - selectedRowIndices: .constant([]), - delegate: nil, - layoutPersister: NoopColumnLayoutPersister() - ) - let columnTypes: [ColumnType] = Array(repeating: .text(rawType: nil), count: columns.count) - let rows = (0.. TableViewCoordinator { + let coordinator = TableViewCoordinator( + changeManager: AnyChangeManager(DataChangeManager()), + isEditable: true, + selectedRowIndices: .constant([]), + delegate: nil, + layoutPersister: NoopColumnLayoutPersister() + ) + let columnTypes: [ColumnType] = Array(repeating: .text(rawType: nil), count: columns.count) + let rows = (0..