From 28b0dba422fbcbdd6bc8ab1e5982304ecbc2d95b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 4 Aug 2026 14:01:47 +0700 Subject: [PATCH] fix(connection-form): bring the New Connection window to the front on the first open --- CHANGELOG.md | 2 + .../Infrastructure/AppLaunchCoordinator.swift | 5 - .../ConnectionFormDraftStore.swift | 36 ++++++ .../PendingNewConnectionImport.swift | 24 ---- .../PendingNewConnectionType.swift | 24 ---- .../Infrastructure/WindowOpener.swift | 38 ++++--- .../Connection/ConnectionFormRequest.swift | 21 ++++ TablePro/TableProApp.swift | 5 +- .../WelcomeViewModel+ConnectionCreation.swift | 2 +- TablePro/ViewModels/WelcomeViewModel.swift | 6 - .../Connection/WelcomeContextMenus.swift | 1 - .../Views/Connection/WelcomeWindowView.swift | 13 ++- .../ConnectionForm/ConnectionFormView.swift | 22 ++-- .../Infrastructure/WindowOpenerBridge.swift | 2 +- .../Infrastructure/WindowSelfRaiser.swift | 26 +++++ .../ConnectionFormDraftStoreTests.swift | 69 ++++++++++++ .../Infrastructure/WindowOpenerTests.swift | 104 +++++++++++++++++- .../ConnectionFormRequestTests.swift | 69 ++++++++++++ 18 files changed, 375 insertions(+), 94 deletions(-) create mode 100644 TablePro/Core/Services/Infrastructure/ConnectionFormDraftStore.swift delete mode 100644 TablePro/Core/Services/Infrastructure/PendingNewConnectionImport.swift delete mode 100644 TablePro/Core/Services/Infrastructure/PendingNewConnectionType.swift create mode 100644 TablePro/Models/Connection/ConnectionFormRequest.swift create mode 100644 TablePro/Views/Infrastructure/WindowSelfRaiser.swift create mode 100644 TableProTests/Core/Services/Infrastructure/ConnectionFormDraftStoreTests.swift create mode 100644 TableProTests/Models/Connection/ConnectionFormRequestTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 830f5a9a9..059586a46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- The New Connection window now comes to the front on the first try instead of opening behind the Welcome window. This applies to Import from URL, creating a connection from a project folder, picking a database type, File > New Connection, and duplicating a connection. +- Importing a connection URL while a New Connection window was already open no longer throws the pasted URL away. Each import now opens its own window instead of re-using the one already on screen. - Saving a table structure change with more than one connection open no longer applies the change to a different connection or jumps the view back to it. The save now runs against the connection, database, and schema the edited table belongs to, and stops with an error instead of writing if it cannot reach them. (#2015) - Saving on a MySQL or MariaDB server that starts sessions read-only no longer fails with "Cannot execute statement in a READ ONLY transaction". TablePro marks a transaction read-write before it writes instead of inheriting the server default. Same for PostgreSQL, CockroachDB, and Redshift. (#2009) - Changing Safe Mode in the connection form now applies to an open connection instead of waiting for a reconnect. (#2009) diff --git a/TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift b/TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift index 8b82ded02..0cbad1d8f 100644 --- a/TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift +++ b/TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift @@ -186,11 +186,6 @@ internal final class AppLaunchCoordinator { return raw == SceneId.welcome || raw.hasPrefix("\(SceneId.welcome)-") } - internal static func isConnectionFormWindow(_ window: NSWindow) -> Bool { - guard let raw = window.identifier?.rawValue else { return false } - return raw == SceneId.connectionForm || raw.hasPrefix("\(SceneId.connectionForm)-") - } - private func showWelcomeWindow() { WindowOpener.shared.openWelcome() } diff --git a/TablePro/Core/Services/Infrastructure/ConnectionFormDraftStore.swift b/TablePro/Core/Services/Infrastructure/ConnectionFormDraftStore.swift new file mode 100644 index 000000000..7e3ce72d8 --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/ConnectionFormDraftStore.swift @@ -0,0 +1,36 @@ +// +// ConnectionFormDraftStore.swift +// TablePro +// + +import Foundation + +internal struct ConnectionFormDraft { + internal let type: DatabaseType? + internal let parsedURL: ParsedConnectionURL? + + internal init(type: DatabaseType? = nil, parsedURL: ParsedConnectionURL? = nil) { + self.type = type + self.parsedURL = parsedURL + } +} + +@MainActor +internal final class ConnectionFormDraftStore { + internal static let shared = ConnectionFormDraftStore() + + private var drafts: [UUID: ConnectionFormDraft] = [:] + + private init() {} + + internal func stage(_ draft: ConnectionFormDraft) -> UUID { + let draftId = UUID() + drafts[draftId] = draft + return draftId + } + + internal func consume(_ draftId: UUID) -> ConnectionFormDraft? { + defer { drafts[draftId] = nil } + return drafts[draftId] + } +} diff --git a/TablePro/Core/Services/Infrastructure/PendingNewConnectionImport.swift b/TablePro/Core/Services/Infrastructure/PendingNewConnectionImport.swift deleted file mode 100644 index 321671632..000000000 --- a/TablePro/Core/Services/Infrastructure/PendingNewConnectionImport.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// PendingNewConnectionImport.swift -// TablePro -// - -import Foundation - -@MainActor -final class PendingNewConnectionImport { - static let shared = PendingNewConnectionImport() - - private(set) var pending: ParsedConnectionURL? - - private init() {} - - func set(_ parsed: ParsedConnectionURL) { - pending = parsed - } - - func consume() -> ParsedConnectionURL? { - defer { pending = nil } - return pending - } -} diff --git a/TablePro/Core/Services/Infrastructure/PendingNewConnectionType.swift b/TablePro/Core/Services/Infrastructure/PendingNewConnectionType.swift deleted file mode 100644 index df8a55d8c..000000000 --- a/TablePro/Core/Services/Infrastructure/PendingNewConnectionType.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// PendingNewConnectionType.swift -// TablePro -// - -import Foundation - -@MainActor -final class PendingNewConnectionType { - static let shared = PendingNewConnectionType() - - private(set) var pending: DatabaseType? - - private init() {} - - func set(_ type: DatabaseType) { - pending = type - } - - func consume() -> DatabaseType? { - defer { pending = nil } - return pending - } -} diff --git a/TablePro/Core/Services/Infrastructure/WindowOpener.swift b/TablePro/Core/Services/Infrastructure/WindowOpener.swift index 3aaa8dc04..5c18241cd 100644 --- a/TablePro/Core/Services/Infrastructure/WindowOpener.swift +++ b/TablePro/Core/Services/Infrastructure/WindowOpener.swift @@ -15,9 +15,10 @@ internal final class WindowOpener { private static let logger = Logger(subsystem: "com.TablePro", category: "WindowOpener") @ObservationIgnored private var openWelcomeAction: (() -> Void)? - @ObservationIgnored private var openConnectionFormAction: ((UUID?) -> Void)? + @ObservationIgnored private var openConnectionFormAction: ((ConnectionFormRequest) -> Void)? @ObservationIgnored private var openIntegrationsActivityAction: (() -> Void)? @ObservationIgnored private var openSettingsAction: (() -> Void)? + @ObservationIgnored private var stagedDraftId: UUID? @ObservationIgnored private var pendingCalls: [() -> Void] = [] @ObservationIgnored private var isWired = false @@ -46,24 +47,33 @@ internal final class WindowOpener { } } - internal func openConnectionForm(editing connectionId: UUID? = nil) { - guard connectionId == nil else { - run { $0.openConnectionFormAction?(connectionId) } - return - } + internal func openConnectionForm(editing connectionId: UUID) { + run { $0.openConnectionFormAction?(.edit(connectionId: connectionId)) } + } + + internal func openConnectionForm() { presentTypeChooser(initialType: nil) { selected in - WindowOpener.shared.openConnectionForm(editing: nil, withType: selected) + WindowOpener.shared.stageConnectionFormDraft(type: selected) } } - internal func openConnectionForm(editing connectionId: UUID?, withType type: DatabaseType) { - PendingNewConnectionType.shared.set(type) - run { $0.openConnectionFormAction?(connectionId) } + internal func stageConnectionFormDraft(type: DatabaseType? = nil, parsedURL: ParsedConnectionURL? = nil) { + discardStagedDraft() + stagedDraftId = ConnectionFormDraftStore.shared.stage( + ConnectionFormDraft(type: type, parsedURL: parsedURL) + ) + } + + internal func openStagedConnectionForm() { + guard let draftId = stagedDraftId else { return } + stagedDraftId = nil + run { $0.openConnectionFormAction?(.create(draftId: draftId)) } } - internal func openConnectionFormFromURL(_ parsed: ParsedConnectionURL) { - PendingNewConnectionImport.shared.set(parsed) - run { $0.openConnectionFormAction?(nil) } + private func discardStagedDraft() { + guard let draftId = stagedDraftId else { return } + stagedDraftId = nil + _ = ConnectionFormDraftStore.shared.consume(draftId) } internal func presentTypeChooser( @@ -80,7 +90,7 @@ internal final class WindowOpener { internal func wire( openWelcome: @escaping () -> Void, - openConnectionForm: @escaping (UUID?) -> Void, + openConnectionForm: @escaping (ConnectionFormRequest) -> Void, openIntegrationsActivity: @escaping () -> Void, openSettings: @escaping () -> Void ) { diff --git a/TablePro/Models/Connection/ConnectionFormRequest.swift b/TablePro/Models/Connection/ConnectionFormRequest.swift new file mode 100644 index 000000000..fab2135ba --- /dev/null +++ b/TablePro/Models/Connection/ConnectionFormRequest.swift @@ -0,0 +1,21 @@ +// +// ConnectionFormRequest.swift +// TablePro +// + +import Foundation + +internal enum ConnectionFormRequest: Codable, Hashable { + case edit(connectionId: UUID) + case create(draftId: UUID) + + internal var editedConnectionId: UUID? { + guard case .edit(let connectionId) = self else { return nil } + return connectionId + } + + internal var draftId: UUID? { + guard case .create(let draftId) = self else { return nil } + return draftId + } +} diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index cbfdb392b..7e36b70cc 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -927,10 +927,11 @@ struct TableProApp: App { .windowStyle(.hiddenTitleBar) .commandsRemoved() - WindowGroup("New Connection", id: SceneId.connectionForm, for: UUID?.self) { $editingId in - ConnectionFormView(connectionId: editingId ?? nil) + WindowGroup("New Connection", id: SceneId.connectionForm, for: ConnectionFormRequest.self) { $request in + ConnectionFormView(request: request) .background(WindowOpenerBridge()) .background(WindowChromeConfigurator(restorable: false)) + .background(WindowSelfRaiser()) .environment(\.appServices, .live) } .windowResizability(.contentMinSize) diff --git a/TablePro/ViewModels/WelcomeViewModel+ConnectionCreation.swift b/TablePro/ViewModels/WelcomeViewModel+ConnectionCreation.swift index 84b01e64b..ee5a8efbf 100644 --- a/TablePro/ViewModels/WelcomeViewModel+ConnectionCreation.swift +++ b/TablePro/ViewModels/WelcomeViewModel+ConnectionCreation.swift @@ -38,6 +38,7 @@ extension WelcomeViewModel { guard let payload = pendingInstallPayload else { return } pendingInstallPayload = nil applySelectedDatabaseType(type, payload: payload) + WindowOpener.shared.openStagedConnectionForm() } func presentURLImport() { @@ -46,7 +47,6 @@ extension WelcomeViewModel { } private func applySelectedDatabaseType(_ type: DatabaseType, payload: DatabaseTypeChooserPayload) { - PendingNewConnectionType.shared.set(type) payload.onSelected(type) } } diff --git a/TablePro/ViewModels/WelcomeViewModel.swift b/TablePro/ViewModels/WelcomeViewModel.swift index df9832408..c9b1d8aa2 100644 --- a/TablePro/ViewModels/WelcomeViewModel.swift +++ b/TablePro/ViewModels/WelcomeViewModel.swift @@ -668,12 +668,6 @@ final class WelcomeViewModel { rebuildTree() } - func focusConnectionFormWindow() { - if let window = NSApp.windows.first(where: { AppLaunchCoordinator.isConnectionFormWindow($0) }) { - window.makeKeyAndOrderFront(nil) - } - } - // MARK: - Private Helpers private func handleConnectError(_ error: Error, connection: DatabaseConnection) { diff --git a/TablePro/Views/Connection/WelcomeContextMenus.swift b/TablePro/Views/Connection/WelcomeContextMenus.swift index 659296bfa..0fcc31775 100644 --- a/TablePro/Views/Connection/WelcomeContextMenus.swift +++ b/TablePro/Views/Connection/WelcomeContextMenus.swift @@ -131,7 +131,6 @@ extension WelcomeWindowView { Button { WindowOpener.shared.openConnectionForm(editing: connection.id) - vm.focusConnectionFormWindow() } label: { Label(String(localized: "Edit"), systemImage: "pencil") } diff --git a/TablePro/Views/Connection/WelcomeWindowView.swift b/TablePro/Views/Connection/WelcomeWindowView.swift index 221638be9..7a3b5a0b3 100644 --- a/TablePro/Views/Connection/WelcomeWindowView.swift +++ b/TablePro/Views/Connection/WelcomeWindowView.swift @@ -85,6 +85,7 @@ struct WelcomeWindowView: View { vm.pendingImportResultCount = nil } focus = .connectionList + WindowOpener.shared.openStagedConnectionForm() }) { sheet in switch sheet { case .newGroup(let parentId): @@ -110,7 +111,7 @@ struct WelcomeWindowView: View { rootURL: url, onSelect: { parsed in vm.activeSheet = nil - WindowOpener.shared.openConnectionFormFromURL(parsed) + WindowOpener.shared.stageConnectionFormDraft(parsedURL: parsed) }, onChooseAnotherFolder: { vm.activeSheet = nil @@ -702,7 +703,9 @@ private struct ConnectionCreationOverlays: ViewModifier { func body(content: Content) -> some View { content - .sheet(item: $vm.databaseTypeChooser) { payload in + .sheet(item: $vm.databaseTypeChooser, onDismiss: { + WindowOpener.shared.openStagedConnectionForm() + }) { payload in DatabaseTypeChooserSheet( initialType: payload.initialType, onSelected: { type in @@ -712,11 +715,13 @@ private struct ConnectionCreationOverlays: ViewModifier { onCancel: { vm.databaseTypeChooser = nil } ) } - .sheet(isPresented: $vm.urlImportPresented) { + .sheet(isPresented: $vm.urlImportPresented, onDismiss: { + WindowOpener.shared.openStagedConnectionForm() + }) { ImportFromURLSheet( onImported: { parsed in vm.urlImportPresented = false - WindowOpener.shared.openConnectionFormFromURL(parsed) + WindowOpener.shared.stageConnectionFormDraft(parsedURL: parsed) }, onCancel: { vm.urlImportPresented = false diff --git a/TablePro/Views/ConnectionForm/ConnectionFormView.swift b/TablePro/Views/ConnectionForm/ConnectionFormView.swift index 8b424f7b9..80deb97d5 100644 --- a/TablePro/Views/ConnectionForm/ConnectionFormView.swift +++ b/TablePro/Views/ConnectionForm/ConnectionFormView.swift @@ -7,7 +7,7 @@ import SwiftUI import TableProPluginKit struct ConnectionFormView: View { - let connectionId: UUID? + let request: ConnectionFormRequest? @State private var coordinator: ConnectionFormCoordinator? @Environment(\.dismiss) private var dismiss @@ -21,18 +21,13 @@ struct ConnectionFormView: View { .frame(minWidth: 720, minHeight: 560) } } - .task(id: connectionId) { + .task(id: request) { guard coordinator == nil else { return } - let pendingImport = connectionId == nil - ? PendingNewConnectionImport.shared.consume() - : nil - let pendingType = connectionId == nil - ? PendingNewConnectionType.shared.consume() - : nil + let draft = consumeDraft() let new = ConnectionFormCoordinator( - connectionId: connectionId, - initialType: pendingType, - initialParsedURL: pendingImport + connectionId: request?.editedConnectionId, + initialType: draft?.type, + initialParsedURL: draft?.parsedURL ) new.dismissAction = { dismiss() } new.start() @@ -40,6 +35,11 @@ struct ConnectionFormView: View { coordinator = new } } + + private func consumeDraft() -> ConnectionFormDraft? { + guard let draftId = request?.draftId else { return nil } + return ConnectionFormDraftStore.shared.consume(draftId) + } } private struct ConnectionFormContent: View { diff --git a/TablePro/Views/Infrastructure/WindowOpenerBridge.swift b/TablePro/Views/Infrastructure/WindowOpenerBridge.swift index 3ea2c86a7..e4f212fed 100644 --- a/TablePro/Views/Infrastructure/WindowOpenerBridge.swift +++ b/TablePro/Views/Infrastructure/WindowOpenerBridge.swift @@ -18,7 +18,7 @@ internal struct WindowOpenerBridge: View { private func wireUp() { WindowOpener.shared.wire( openWelcome: { openWindow(id: SceneId.welcome) }, - openConnectionForm: { id in openWindow(id: SceneId.connectionForm, value: id) }, + openConnectionForm: { request in openWindow(id: SceneId.connectionForm, value: request) }, openIntegrationsActivity: { openWindow(id: SceneId.integrationsActivity) }, openSettings: { openSettings() } ) diff --git a/TablePro/Views/Infrastructure/WindowSelfRaiser.swift b/TablePro/Views/Infrastructure/WindowSelfRaiser.swift new file mode 100644 index 000000000..16490d764 --- /dev/null +++ b/TablePro/Views/Infrastructure/WindowSelfRaiser.swift @@ -0,0 +1,26 @@ +// +// WindowSelfRaiser.swift +// TablePro +// + +import AppKit +import SwiftUI + +internal struct WindowSelfRaiser: NSViewRepresentable { + func makeNSView(context: Context) -> NSView { + RaisingHostView() + } + + func updateNSView(_ nsView: NSView, context: Context) {} +} + +private final class RaisingHostView: NSView { + private var hasRaised = false + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + guard let window, !hasRaised else { return } + hasRaised = true + window.makeKeyAndOrderFront(nil) + } +} diff --git a/TableProTests/Core/Services/Infrastructure/ConnectionFormDraftStoreTests.swift b/TableProTests/Core/Services/Infrastructure/ConnectionFormDraftStoreTests.swift new file mode 100644 index 000000000..267ba9b05 --- /dev/null +++ b/TableProTests/Core/Services/Infrastructure/ConnectionFormDraftStoreTests.swift @@ -0,0 +1,69 @@ +// +// ConnectionFormDraftStoreTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("ConnectionFormDraftStore", .serialized) +@MainActor +struct ConnectionFormDraftStoreTests { + private func parse(_ urlString: String) throws -> ParsedConnectionURL { + guard case .success(let parsed) = ConnectionURLParser.parse(urlString) else { + throw ConnectionURLParseError.invalidURL + } + return parsed + } + + @Test("A staged draft is returned once and then cleared") + func aStagedDraftIsReturnedOnceThenCleared() { + let store = ConnectionFormDraftStore.shared + let draftId = store.stage(ConnectionFormDraft(type: .mysql)) + + #expect(store.consume(draftId)?.type == .mysql) + #expect(store.consume(draftId) == nil) + } + + @Test("Consuming an unknown draft id returns nil") + func consumingAnUnknownDraftIdReturnsNil() { + #expect(ConnectionFormDraftStore.shared.consume(UUID()) == nil) + } + + @Test("Two staged drafts do not contaminate each other") + func twoStagedDraftsDoNotContaminateEachOther() throws { + let store = ConnectionFormDraftStore.shared + let shop = try parse("mysql://sam:secret@shop.example.com:3306/shop") + let books = try parse("postgres://ana:hunter2@books.example.com:5432/books") + + let shopId = store.stage(ConnectionFormDraft(parsedURL: shop)) + let booksId = store.stage(ConnectionFormDraft(parsedURL: books)) + + #expect(store.consume(booksId)?.parsedURL?.database == "books") + #expect(store.consume(shopId)?.parsedURL?.database == "shop") + } + + @Test("A staged URL keeps the credentials it was parsed with") + func aStagedURLKeepsItsCredentials() throws { + let store = ConnectionFormDraftStore.shared + let parsed = try parse("mysql://sam:secret@shop.example.com:3306/shop") + let draftId = store.stage(ConnectionFormDraft(parsedURL: parsed)) + + let draft = store.consume(draftId) + #expect(draft?.parsedURL?.username == "sam") + #expect(draft?.parsedURL?.password == "secret") + #expect(draft?.parsedURL?.host == "shop.example.com") + } + + @Test("A draft with no type and no URL stages an empty form") + func anEmptyDraftStagesAnEmptyForm() { + let store = ConnectionFormDraftStore.shared + let draftId = store.stage(ConnectionFormDraft()) + + let draft = store.consume(draftId) + #expect(draft != nil) + #expect(draft?.type == nil) + #expect(draft?.parsedURL == nil) + } +} diff --git a/TableProTests/Core/Services/Infrastructure/WindowOpenerTests.swift b/TableProTests/Core/Services/Infrastructure/WindowOpenerTests.swift index ed595ab02..860a08bde 100644 --- a/TableProTests/Core/Services/Infrastructure/WindowOpenerTests.swift +++ b/TableProTests/Core/Services/Infrastructure/WindowOpenerTests.swift @@ -8,9 +8,20 @@ import XCTest @MainActor final class WindowOpenerTests: XCTestCase { + private var openedRequests: [ConnectionFormRequest] = [] + override func setUp() { super.setUp() _ = WelcomeRouter.shared.consumePendingRequest() + openedRequests = [] + WindowOpener.shared.wire( + openWelcome: {}, + openConnectionForm: { [weak self] request in + self?.openedRequests.append(request) + }, + openIntegrationsActivity: {}, + openSettings: {} + ) } override func tearDown() { @@ -18,13 +29,14 @@ final class WindowOpenerTests: XCTestCase { super.tearDown() } - func testNewConnectionRoutesTheChooserWithoutABridge() { + func testNewConnectionRoutesTheChooserInsteadOfOpeningAWindow() { WindowOpener.shared.openConnectionForm() guard case .chooseDatabaseType(let payload) = WelcomeRouter.shared.pendingRequest else { return XCTFail("New Connection must queue a chooser request even with no window open") } XCTAssertNil(payload.initialType) + XCTAssertTrue(openedRequests.isEmpty) } func testPresentTypeChooserCarriesTheInitialType() { @@ -35,4 +47,94 @@ final class WindowOpenerTests: XCTestCase { } XCTAssertEqual(payload.initialType, .sqlite) } + + func testChoosingATypeStagesTheDraftWithoutOpeningTheWindow() { + WindowOpener.shared.openConnectionForm() + + guard case .chooseDatabaseType(let payload) = WelcomeRouter.shared.pendingRequest else { + return XCTFail("Expected a chooser request") + } + payload.onSelected(.mysql) + + XCTAssertTrue(openedRequests.isEmpty, "The window must not open while the chooser sheet is still up") + + WindowOpener.shared.openStagedConnectionForm() + + XCTAssertEqual(openedRequests.count, 1) + guard let request = openedRequests.first, case .create(let draftId) = request else { + return XCTFail("Expected a create request") + } + XCTAssertEqual(ConnectionFormDraftStore.shared.consume(draftId)?.type, .mysql) + } + + func testEachStagedDraftOpensItsOwnWindow() { + WindowOpener.shared.stageConnectionFormDraft(type: .mysql) + WindowOpener.shared.openStagedConnectionForm() + WindowOpener.shared.stageConnectionFormDraft(type: .postgresql) + WindowOpener.shared.openStagedConnectionForm() + + XCTAssertEqual(openedRequests.count, 2) + XCTAssertNotEqual(openedRequests.first, openedRequests.last) + } + + func testTheStagedURLReachesTheWindowOpenedForIt() { + guard case .success(let parsed) = ConnectionURLParser.parse("mysql://sam:secret@shop.example.com:3306/shop") + else { + return XCTFail("The fixture URL must parse") + } + + WindowOpener.shared.stageConnectionFormDraft(parsedURL: parsed) + WindowOpener.shared.openStagedConnectionForm() + + guard let request = openedRequests.first, case .create(let draftId) = request else { + return XCTFail("Expected a create request") + } + let draft = ConnectionFormDraftStore.shared.consume(draftId) + XCTAssertEqual(draft?.parsedURL?.host, "shop.example.com") + XCTAssertEqual(draft?.parsedURL?.database, "shop") + } + + func testOpeningWithNothingStagedDoesNothing() { + WindowOpener.shared.openStagedConnectionForm() + + XCTAssertTrue(openedRequests.isEmpty) + } + + func testAStagedDraftIsOpenedOnlyOnce() { + WindowOpener.shared.stageConnectionFormDraft(type: .mysql) + WindowOpener.shared.openStagedConnectionForm() + WindowOpener.shared.openStagedConnectionForm() + + XCTAssertEqual(openedRequests.count, 1) + } + + func testStagingASecondDraftDiscardsTheFirst() { + WindowOpener.shared.stageConnectionFormDraft(type: .mysql) + WindowOpener.shared.stageConnectionFormDraft(type: .postgresql) + WindowOpener.shared.openStagedConnectionForm() + + XCTAssertEqual(openedRequests.count, 1) + guard let request = openedRequests.first, case .create(let draftId) = request else { + return XCTFail("Expected a create request") + } + XCTAssertEqual(ConnectionFormDraftStore.shared.consume(draftId)?.type, .postgresql) + } + + func testEditingAConnectionRequestsThatConnectionsWindow() { + let connectionId = UUID() + + WindowOpener.shared.openConnectionForm(editing: connectionId) + + XCTAssertEqual(openedRequests, [.edit(connectionId: connectionId)]) + } + + func testEditingTheSameConnectionTwiceRequestsTheSameWindow() { + let connectionId = UUID() + + WindowOpener.shared.openConnectionForm(editing: connectionId) + WindowOpener.shared.openConnectionForm(editing: connectionId) + + XCTAssertEqual(openedRequests.count, 2) + XCTAssertEqual(openedRequests.first, openedRequests.last) + } } diff --git a/TableProTests/Models/Connection/ConnectionFormRequestTests.swift b/TableProTests/Models/Connection/ConnectionFormRequestTests.swift new file mode 100644 index 000000000..c3f7a9370 --- /dev/null +++ b/TableProTests/Models/Connection/ConnectionFormRequestTests.swift @@ -0,0 +1,69 @@ +// +// ConnectionFormRequestTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("ConnectionFormRequest") +struct ConnectionFormRequestTests { + @Test("Each create request is distinct so every new connection gets its own window") + func eachCreateRequestIsDistinct() { + let first = ConnectionFormRequest.create(draftId: UUID()) + let second = ConnectionFormRequest.create(draftId: UUID()) + #expect(first != second) + } + + @Test("Editing the same connection produces the same request so its window is reused") + func editRequestsForTheSameConnectionAreEqual() { + let connectionId = UUID() + let first = ConnectionFormRequest.edit(connectionId: connectionId) + let second = ConnectionFormRequest.edit(connectionId: connectionId) + #expect(first == second) + } + + @Test("Editing different connections produces different requests") + func editRequestsForDifferentConnectionsDiffer() { + let first = ConnectionFormRequest.edit(connectionId: UUID()) + let second = ConnectionFormRequest.edit(connectionId: UUID()) + #expect(first != second) + } + + @Test("A create request never collides with an edit request carrying the same id") + func createNeverCollidesWithEdit() { + let id = UUID() + let create = ConnectionFormRequest.create(draftId: id) + let edit = ConnectionFormRequest.edit(connectionId: id) + #expect(create != edit) + } + + @Test("Only an edit request exposes a connection id") + func onlyEditExposesAConnectionId() { + let connectionId = UUID() + #expect(ConnectionFormRequest.edit(connectionId: connectionId).editedConnectionId == connectionId) + #expect(ConnectionFormRequest.create(draftId: UUID()).editedConnectionId == nil) + } + + @Test("Only a create request exposes a draft id") + func onlyCreateExposesADraftId() { + let draftId = UUID() + #expect(ConnectionFormRequest.create(draftId: draftId).draftId == draftId) + #expect(ConnectionFormRequest.edit(connectionId: UUID()).draftId == nil) + } + + @Test("A request round trips through Codable") + func requestRoundTripsThroughCodable() throws { + let requests: [ConnectionFormRequest] = [ + .edit(connectionId: UUID()), + .create(draftId: UUID()) + ] + + for request in requests { + let data = try JSONEncoder().encode(request) + let decoded = try JSONDecoder().decode(ConnectionFormRequest.self, from: data) + #expect(decoded == request) + } + } +}