diff --git a/CHANGELOG.md b/CHANGELOG.md index 0767f4655..bd65788e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -514,7 +514,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - App hanging for a minute when an import stopped on a failing statement several megabytes long. - SQL Server Windows Authentication to another realm failing when the service principal name is over 128 bytes. - Data grid's inline cell editor and cell viewer unreachable by VoiceOver. -- No Executing indicator or Stop button in the results status bar while a query tab runs its first query. +- No Executing indicator or Stop button for a query tab with no result grid, in Output mode or on a query plan. ### Security diff --git a/TablePro/Core/Storage/SessionRecoveryTracker.swift b/TablePro/Core/Storage/SessionRecoveryTracker.swift index a74f567e4..a419f01c3 100644 --- a/TablePro/Core/Storage/SessionRecoveryTracker.swift +++ b/TablePro/Core/Storage/SessionRecoveryTracker.swift @@ -7,6 +7,8 @@ import Foundation @MainActor enum SessionRecoveryTracker { + private static let storage: LastOpenConnectionsStorage? = NSClassFromString("XCTestCase") == nil ? .shared : nil + /// Connections eligible for "Reopen Last Session": one the user actually worked in, /// or one whose window is still holding the intent to reach it. A cancelled attempt /// and a closing window are both excluded, so neither is replayed on the next launch. @@ -40,7 +42,7 @@ enum SessionRecoveryTracker { /// changes so the file stays correct after a crash or a force quit, neither of /// which runs `applicationWillTerminate`. static func sync() { - guard !MainContentCoordinator.isAppTerminating else { return } - LastOpenConnectionsStorage.shared.save(connectionIds: connectionIds()) + guard !MainContentCoordinator.isAppTerminating, let storage else { return } + storage.save(connectionIds: connectionIds()) } } diff --git a/TablePro/Models/Query/QueryTab.swift b/TablePro/Models/Query/QueryTab.swift index 12402c632..a71a798d6 100644 --- a/TablePro/Models/Query/QueryTab.swift +++ b/TablePro/Models/Query/QueryTab.swift @@ -24,6 +24,10 @@ enum ResultsViewMode: String, CaseIterable, Equatable { self != .structure && self != .output } + var reportsExecution: Bool { + self != .structure + } + var showsColumnControls: Bool { self == .data || self == .json } diff --git a/TablePro/Models/Query/ResultStatusModel.swift b/TablePro/Models/Query/ResultStatusModel.swift index fb033f9bb..64810a742 100644 --- a/TablePro/Models/Query/ResultStatusModel.swift +++ b/TablePro/Models/Query/ResultStatusModel.swift @@ -36,7 +36,7 @@ enum ResultStatusReadout: Equatable { struct ResultStatusControls: Equatable { var showsModeSwitcher = false var showsReadout = false - var showsExecutionWithoutReadout = false + var showsExecution = false var showsLoadingMore = false var showsExactCountAction = false var showsCountInProgress = false @@ -90,6 +90,7 @@ struct ResultStatusModel: Equatable { controls.showsModeSwitcher = snapshot.availableModes.count > 1 controls.showsStructureActions = viewMode == .structure && snapshot.hasStructureActions + controls.showsExecution = viewMode.reportsExecution /// A plan keeps the bar so it stays choosable and pinnable, and gives up everything the bar /// says about rows. It has none, and reporting "No rows" under a plan states something @@ -105,7 +106,6 @@ struct ResultStatusModel: Equatable { let describesAResult = isTable ? snapshot.hasTableName : snapshot.hasColumns controls.showsReadout = viewMode.showsResultScope && describesAResult - controls.showsExecutionWithoutReadout = viewMode.showsResultScope && !describesAResult && pagination.isLoading controls.showsLoadingMore = controls.showsReadout && pagination.isLoadingMore /// Withheld until nothing is still resolving the total. Offered against a total that is diff --git a/TablePro/Views/Results/ExecutionIndicatorView.swift b/TablePro/Views/Results/ExecutionIndicatorView.swift index f156209fd..230560113 100644 --- a/TablePro/Views/Results/ExecutionIndicatorView.swift +++ b/TablePro/Views/Results/ExecutionIndicatorView.swift @@ -19,6 +19,7 @@ struct ExecutionIndicatorView: View { /// whose commit is on the wire passes false: the spinner stays and the button dims, rather than /// offering a cancel that cannot reach the server. var canStop = true + var leadsWithSeparator = false var onCancel: (() -> Void)? /// Held back rather than the spinner inside it, so a query too fast to report leaves the @@ -50,6 +51,19 @@ struct ExecutionIndicatorView: View { } var body: some View { + HStack(spacing: 6) { + if leadsWithSeparator, showsExecution || lastTiming != nil { + StatusBarSeparator() + } + report + } + .onChange(of: isExecuting) { nowExecuting in + if nowExecuting { showsBreakdown = false } + } + .loadingRevealGate(isActive: isExecuting, isRevealed: $showsExecution) + } + + private var report: some View { HStack(spacing: 4) { if showsExecution { ProgressView() @@ -75,10 +89,6 @@ struct ExecutionIndicatorView: View { durationReadout(timing) } } - .onChange(of: isExecuting) { nowExecuting in - if nowExecuting { showsBreakdown = false } - } - .loadingRevealGate(isActive: isExecuting, isRevealed: $showsExecution) } // MARK: - Readout diff --git a/TablePro/Views/Results/ExecutionReadout.swift b/TablePro/Views/Results/ExecutionReadout.swift index 762a0bf5a..1aaa509b9 100644 --- a/TablePro/Views/Results/ExecutionReadout.swift +++ b/TablePro/Views/Results/ExecutionReadout.swift @@ -38,12 +38,6 @@ struct ExecutionReadout: Equatable { execution.isStoppable(tabId) } - /// Nothing to draw when no query has run and none is running. The toolbar used to hold an - /// em-dash placeholder there, which spent width to say nothing. - var isActive: Bool { - isExecuting || lastTiming != nil - } - static func == (lhs: ExecutionReadout, rhs: ExecutionReadout) -> Bool { lhs.isExecuting == rhs.isExecuting && lhs.canStop == rhs.canStop && lhs.lastTiming == rhs.lastTiming } diff --git a/TablePro/Views/Results/ResultStatusBar.swift b/TablePro/Views/Results/ResultStatusBar.swift index 42e3cef9d..95b87371e 100644 --- a/TablePro/Views/Results/ResultStatusBar.swift +++ b/TablePro/Views/Results/ResultStatusBar.swift @@ -109,13 +109,7 @@ struct ResultStatusBar: View { onCloseOthers: onCloseOtherResultSets ) } - if model.controls.showsReadout { - readoutZone(readoutCluster) - } else if model.controls.showsExecutionWithoutReadout { - readoutZone(executionIndicator) - } else { - Spacer(minLength: 0) - } + readoutZone(readoutCluster) controlCluster(presentation) } } @@ -151,70 +145,67 @@ struct ResultStatusBar: View { /// clusters on either side keep their intrinsic widths. private var readoutCluster: some View { HStack(spacing: 6) { - if model.controls.showsLoadingMore { - ProgressView() - .controlSize(.small) - .accessibilityHidden(true) - Text("Loading…") - .font(.caption) - .foregroundStyle(.secondary) - } else { - ResultStatusReadoutView(readout: model.readout) + if model.controls.showsReadout { + resultReadout } - - if model.controls.showsCountInProgress { - ProgressView() - .controlSize(.small) - .accessibilityLabel(String(localized: "Counting rows")) + if model.controls.showsExecution { + executionIndicator } - - if model.controls.showsExactCountAction { - Button( - String(localized: "Count Exactly"), - action: paginationCallbacks.onRequestExactCount - ) - .accessoryBarActionStyle() - .help(String(localized: "Replace the estimate with an exact row count.")) - .accessibilityIdentifier("result-status-count-exactly") + if model.controls.showsReadout, isRefreshingSchema { + DelayedProgressIndicator(isActive: true) + .accessibilityLabel(String(localized: "Refreshing")) } + } + } - if model.controls.showsFetchAll, let onFetchAll { - Button(String(localized: "Fetch All"), action: onFetchAll) - .accessoryBarActionStyle() - .help(String(localized: "Load the rows the row cap left behind.")) - .accessibilityIdentifier("result-status-fetch-all") - } + @ViewBuilder + private var resultReadout: some View { + if model.controls.showsLoadingMore { + ProgressView() + .controlSize(.small) + .accessibilityHidden(true) + Text("Loading…") + .font(.caption) + .foregroundStyle(.secondary) + } else { + ResultStatusReadoutView(readout: model.readout) + } - if let statusMessage = model.statusMessage { - separator - /// Yields its width before the sentence beside it does, so a wordy driver message - /// truncates instead of squeezing out the row count. Which tier the bar draws is not - /// its business: the enclosing frame reports a constant ideal width so no message - /// length can change that choice. - Text(statusMessage) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.tail) - .layoutPriority(-1) - } + if model.controls.showsCountInProgress { + ProgressView() + .controlSize(.small) + .accessibilityLabel(String(localized: "Counting rows")) + } - executionReadout + if model.controls.showsExactCountAction { + Button( + String(localized: "Count Exactly"), + action: paginationCallbacks.onRequestExactCount + ) + .accessoryBarActionStyle() + .help(String(localized: "Replace the estimate with an exact row count.")) + .accessibilityIdentifier("result-status-count-exactly") } - } - /// Whether a query is running and how long the last one took, beside the rows it produced. It - /// used to be a hosted SwiftUI view in the centre of the toolbar, where AppKit dropped it whole - /// before any command as soon as the window narrowed. - @ViewBuilder - private var executionReadout: some View { - if execution.isActive { - separator - executionIndicator + if model.controls.showsFetchAll, let onFetchAll { + Button(String(localized: "Fetch All"), action: onFetchAll) + .accessoryBarActionStyle() + .help(String(localized: "Load the rows the row cap left behind.")) + .accessibilityIdentifier("result-status-fetch-all") } - if isRefreshingSchema { - DelayedProgressIndicator(isActive: true) - .accessibilityLabel(String(localized: "Refreshing")) + + if let statusMessage = model.statusMessage { + StatusBarSeparator() + /// Yields its width before the sentence beside it does, so a wordy driver message + /// truncates instead of squeezing out the row count. Which tier the bar draws is not + /// its business: the enclosing frame reports a constant ideal width so no message + /// length can change that choice. + Text(statusMessage) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .layoutPriority(-1) } } @@ -223,6 +214,7 @@ struct ResultStatusBar: View { isExecuting: execution.isExecuting, lastTiming: execution.lastTiming, canStop: execution.canStop, + leadsWithSeparator: model.controls.showsReadout, onCancel: execution.onCancel ) } @@ -236,14 +228,6 @@ struct ResultStatusBar: View { ) } - /// Punctuation, so VoiceOver must not read it as an element of its own. - private var separator: some View { - Text(verbatim: "·") - .font(.caption) - .foregroundStyle(.tertiary) - .accessibilityHidden(true) - } - // MARK: - Controls @ViewBuilder diff --git a/TablePro/Views/Results/StatusBarChrome.swift b/TablePro/Views/Results/StatusBarChrome.swift index e978e5b0e..842cacfef 100644 --- a/TablePro/Views/Results/StatusBarChrome.swift +++ b/TablePro/Views/Results/StatusBarChrome.swift @@ -19,6 +19,15 @@ enum StatusBarChrome { static let clusterSpacing: CGFloat = 8 } +internal struct StatusBarSeparator: View { + internal var body: some View { + Text(verbatim: "·") + .font(.caption) + .foregroundStyle(.tertiary) + .accessibilityHidden(true) + } +} + /// `NSVisualEffectView` rather than a flat colour because a bar is window chrome: AppKit desaturates /// the material when the window stops being key, and a `Color` never does. private struct StatusBarMaterial: NSViewRepresentable { diff --git a/TableProTests/Core/Services/Infrastructure/ConnectionWindowToolbarTests.swift b/TableProTests/Core/Services/Infrastructure/ConnectionWindowToolbarTests.swift new file mode 100644 index 000000000..e75bef998 --- /dev/null +++ b/TableProTests/Core/Services/Infrastructure/ConnectionWindowToolbarTests.swift @@ -0,0 +1,127 @@ +// +// ConnectionWindowToolbarTests.swift +// TableProTests +// + +import AppKit +import Foundation +@testable import TablePro +import Testing + +@Suite("Connection window toolbar", .serialized) +@MainActor +struct ConnectionWindowToolbarTests { + private static let pinnedWindowSize = CGSize(width: 1_512, height: 861) + + private static let buttonActions: [(NSToolbarItem.Identifier, String)] = [ + (.toggleSidebar, "toggleSidebar:"), + (MainWindowToolbar.connection, "performOpenConnectionSwitcher:"), + (MainWindowToolbar.refresh, "performRefresh:"), + (MainWindowToolbar.saveChanges, "performSaveChanges:"), + (MainWindowToolbar.inspector, "toggleInspector:"), + ] + + @Test("The default toolbar draws its controls in the titlebar and carries no mode control") + func defaultToolbarDrawsItsControls() throws { + let fixture = try makeFixture(named: "Default toolbar") + defer { fixture.tearDown() } + let toolbar = try #require(fixture.window.toolbar) + + #expect(toolbar.items.map(\.itemIdentifier) == MainWindowToolbar.defaultItemIdentifiers) + #expect(!toolbar.items.contains { $0 is NSToolbarItemGroup || $0.view is NSSegmentedControl }) + + let drawn = Set((toolbar.visibleItems ?? []).map(\.itemIdentifier)) + for (identifier, action) in Self.buttonActions { + #expect(drawn.contains(identifier), "\(identifier.rawValue) went to the overflow menu") + #expect(drawnControl(sending: action, in: fixture) != nil, "\(identifier.rawValue) is not in the titlebar") + } + for identifier in [MainWindowToolbar.actions, MainWindowToolbar.safeMode] { + #expect(item(identifier, in: toolbar) is NSMenuToolbarItem, "\(identifier.rawValue) is not a pull-down") + #expect(drawn.contains(identifier), "\(identifier.rawValue) went to the overflow menu") + } + + let database = try #require(item(MainWindowToolbar.database, in: toolbar)) + if #available(macOS 15.0, *) { + #expect(database.isHidden, "A file-based connection has no container to switch") + #expect(!drawn.contains(MainWindowToolbar.database)) + let hidden = toolbar.items.filter { $0.isHidden }.map(\.itemIdentifier) + #expect(hidden == [MainWindowToolbar.database]) + } else { + #expect(drawn.contains(MainWindowToolbar.database), "Below macOS 15 the container capsule stands") + } + } + + @available(macOS 15.0, *) + @Test("Refresh leaves the toolbar on a Create Table tab, and the commit control takes its verb") + func refreshLeavesTheToolbarOnACreateTableTab() async throws { + let fixture = try makeFixture(named: "Create table toolbar") + defer { fixture.tearDown() } + let toolbar = try #require(fixture.window.toolbar) + let refresh = try #require(item(MainWindowToolbar.refresh, in: toolbar)) + let commit = try #require(item(MainWindowToolbar.saveChanges, in: toolbar)) + + fixture.workspace.open(EditorTabPayload( + connectionId: fixture.workspace.connectionId, + tabType: .table, + tableName: "users" + )) + #expect( + await settle(fixture) { fixture.toolbarOwner.currentVisibilityKey().tabKind == .table }, + "The toolbar never followed the table tab" + ) + #expect(!refresh.isHidden, "A table tab shows Refresh, or its absence below would prove nothing") + #expect(commit.label == String(localized: "Save Changes")) + + #expect(drawnControl(sending: "performRefresh:", in: fixture) != nil) + + fixture.workspace.open(EditorTabPayload(connectionId: fixture.workspace.connectionId, tabType: .createTable)) + #expect( + await settle(fixture) { refresh.isHidden && drawnControl(sending: "performRefresh:", in: fixture) == nil }, + "An unsaved definition has nothing to reload, so Refresh leaves the titlebar" + ) + #expect(commit.label == String(localized: "Create Table"), "The commit control names the tab's verb") + #expect(drawnControl(sending: "performSaveChanges:", in: fixture) != nil) + } + + private func makeFixture(named name: String) throws -> OffscreenConnectionWindow { + let fixture = try OffscreenConnectionWindow( + size: Self.pinnedWindowSize, + connectedTo: TestFixtures.makeConnection(name: name, type: .sqlite) + ) + #expect(fixture.window.frame.size == Self.pinnedWindowSize) + #expect(fixture.toolbarOwner.coordinator === fixture.workspace.sessionState?.coordinator) + return fixture + } + + private func item(_ identifier: NSToolbarItem.Identifier, in toolbar: NSToolbar) -> NSToolbarItem? { + toolbar.items.first { $0.itemIdentifier == identifier } + } + + private func drawnControl(sending action: String, in fixture: OffscreenConnectionWindow) -> NSControl? { + guard let frame = fixture.themeFrame else { return nil } + let bounds = fixture.window.frame.size + return Self.firstControl(in: frame) { control in + guard control.action.map(NSStringFromSelector) == action, + !control.isHiddenOrHasHiddenAncestor else { return false } + let placed = control.convert(control.bounds, to: nil) + return placed.minX >= 0 && placed.maxX <= bounds.width && placed.maxY <= bounds.height + } + } + + private static func firstControl(in view: NSView, where matches: (NSControl) -> Bool) -> NSControl? { + if let control = view as? NSControl, matches(control) { return control } + for subview in view.subviews { + if let found = firstControl(in: subview, where: matches) { return found } + } + return nil + } + + private func settle(_ fixture: OffscreenConnectionWindow, until condition: () -> Bool) async -> Bool { + for _ in 0 ..< 150 { + fixture.window.layoutIfNeeded() + if condition() { return true } + try? await Task.sleep(for: .milliseconds(20)) + } + return condition() + } +} diff --git a/TableProTests/Core/Services/Infrastructure/InspectorToolbarPlacementTests.swift b/TableProTests/Core/Services/Infrastructure/InspectorToolbarPlacementTests.swift index 7afbce71d..7b5622728 100644 --- a/TableProTests/Core/Services/Infrastructure/InspectorToolbarPlacementTests.swift +++ b/TableProTests/Core/Services/Infrastructure/InspectorToolbarPlacementTests.swift @@ -52,7 +52,10 @@ struct InspectorToolbarPlacementTests { } private func toggleGapFromTrailingEdge(in fixture: OffscreenConnectionWindow) throws -> CGFloat { - let toggle = try #require(control(sending: "toggleInspector:", in: fixture), "No inspector toggle in the toolbar") + let toggle = try #require( + control(sending: "toggleInspector:", in: fixture), + "No inspector toggle in the toolbar" + ) return fixture.window.frame.width - toggle.convert(toggle.bounds, to: nil).maxX } diff --git a/TableProTests/Core/Services/Infrastructure/OffscreenConnectionWindowTests.swift b/TableProTests/Core/Services/Infrastructure/OffscreenConnectionWindowTests.swift new file mode 100644 index 000000000..d4047439b --- /dev/null +++ b/TableProTests/Core/Services/Infrastructure/OffscreenConnectionWindowTests.swift @@ -0,0 +1,85 @@ +// +// OffscreenConnectionWindowTests.swift +// TableProTests +// + +import AppKit +import Foundation +@testable import TablePro +import Testing + +@Suite("Offscreen connection window", .serialized) +@MainActor +struct OffscreenConnectionWindowTests { + private static let size = CGSize(width: 1_200, height: 800) + + @Test("Only the fixture's own toolbar reaches the window, and it follows the connection the window hosts") + func toolbarIsTheFixturesOwnAndFollowsTheConnection() throws { + let arrivals = ToolbarArrivals() + let observer = NotificationCenter.default.addObserver( + forName: NSToolbar.willAddItemNotification, + object: nil, + queue: nil + ) { notification in + let identifier = (notification.object as? NSToolbar)?.identifier + MainActor.assumeIsolated { arrivals.identifiers.insert(identifier) } + } + defer { NotificationCenter.default.removeObserver(observer) } + + let fixture = try OffscreenConnectionWindow( + size: Self.size, + connectedTo: TestFixtures.makeConnection(name: "Fixture toolbar", type: .mysql) + ) + defer { fixture.tearDown() } + + let coordinator = try #require(fixture.workspace.sessionState?.coordinator) + let toolbar = try #require(fixture.window.toolbar) + #expect( + arrivals.identifiers == [toolbar.identifier], + "Toolbars that reached the window: \(arrivals.identifiers)" + ) + #expect(fixture.split.toolbarOwner === fixture.toolbarOwner) + #expect(toolbar === fixture.toolbarOwner.managedToolbar) + #expect(toolbar.identifier != MainWindowToolbar.toolbarIdentifier) + #expect(!toolbar.autosavesConfiguration) + #expect(fixture.toolbarOwner.coordinator === coordinator) + } + + @Test("Building, driving and tearing down the fixture writes neither the app's toolbar nor its recovery list") + func fixtureLeavesTheAppsOwnStateAlone() throws { + let connection = TestFixtures.makeConnection(name: "Fixture isolation", type: .mysql) + let toolbarRecord = Self.appToolbarRecord() + + let fixture = try OffscreenConnectionWindow(size: Self.size, connectedTo: connection) + let coordinator = try #require(fixture.workspace.sessionState?.coordinator) + #expect(coordinator.isActivated, "Only an activated coordinator puts its connection on the recovery list") + #expect( + !LastOpenConnectionsStorage.shared.load().contains(connection.id), + "The fixture's connection reached the user's recovery list" + ) + fixture.setInspectorOpen(true) + fixture.setInspectorOpen(false) + fixture.tearDown() + + #expect(Self.appToolbarRecord() == toolbarRecord, "The fixture wrote the app's own toolbar configuration") + #expect(Self.appDefaultsKeys(naming: connection.id).isEmpty) + } + + private static func appDefaults() -> [String: Any] { + let domainName = Bundle.main.bundleIdentifier ?? "" + return AppStorageEnvironment.shared.defaults.persistentDomain(forName: domainName) ?? [:] + } + + private static func appToolbarRecord() -> NSDictionary? { + appDefaults()["NSToolbar Configuration \(MainWindowToolbar.toolbarIdentifier)"] as? NSDictionary + } + + private static func appDefaultsKeys(naming connectionId: UUID) -> [String] { + appDefaults().keys.filter { $0.contains(connectionId.uuidString) }.sorted() + } +} + +@MainActor +private final class ToolbarArrivals { + var identifiers: Set = [] +} diff --git a/TableProTests/Helpers/OffscreenConnectionWindow.swift b/TableProTests/Helpers/OffscreenConnectionWindow.swift index 87d5da7e7..1088d0d7c 100644 --- a/TableProTests/Helpers/OffscreenConnectionWindow.swift +++ b/TableProTests/Helpers/OffscreenConnectionWindow.swift @@ -14,6 +14,7 @@ internal struct OffscreenConnectionWindow { let window: NSWindow let split: MainSplitViewController let workspace: ConnectionWorkspace + let toolbarOwner: MainWindowToolbar private let hasInjectedSession: Bool init(size: CGSize, connectedTo connection: DatabaseConnection? = nil) throws { @@ -38,20 +39,20 @@ internal struct OffscreenConnectionWindow { split = try #require(built.contentViewController as? MainSplitViewController) hasInjectedSession = connection != nil + let toolbar = ContextValidatedToolbar( + identifier: NSToolbar.Identifier("com.TablePro.tests.offscreen.\(UUID().uuidString)") + ) + toolbarOwner = MainWindowToolbar(managedToolbar: toolbar) + toolbar.autosavesConfiguration = false + split.toolbarOwner = toolbarOwner + split.pointToolbar(at: nil) + if hasInjectedSession { var session = ConnectionSession(connection: subject, driver: MockDatabaseDriver(connection: subject)) session.status = .connected DatabaseManager.shared.injectSession(session, for: subject.id) split.refreshFromActiveSessions() } - - let toolbar = ContextValidatedToolbar( - identifier: NSToolbar.Identifier("com.TablePro.tests.offscreen.\(UUID().uuidString)") - ) - let owner = MainWindowToolbar(managedToolbar: toolbar) - toolbar.autosavesConfiguration = false - split.toolbarOwner = owner - split.pointToolbar(at: nil) resetPaneLayout() } diff --git a/TableProTests/Models/ResultStatusModelTests.swift b/TableProTests/Models/ResultStatusModelTests.swift index 34db9ca45..58a6eaff0 100644 --- a/TableProTests/Models/ResultStatusModelTests.swift +++ b/TableProTests/Models/ResultStatusModelTests.swift @@ -17,6 +17,7 @@ struct ResultStatusModelTests { hasColumns: Bool? = nil, hasTableName: Bool = true, hasStructureActions: Bool = false, + isQueryPlan: Bool = false, pagination: PaginationState = PaginationState(), statusMessage: String? = nil ) -> StatusBarSnapshot { @@ -35,6 +36,7 @@ struct ResultStatusModelTests { hasColumns: hasColumns ?? (rowCount > 0) ), hasStructureActions: hasStructureActions, + isQueryPlan: isQueryPlan, pagination: pagination, statusMessage: statusMessage ) @@ -65,50 +67,76 @@ struct ResultStatusModelTests { #expect(!result.controls.showsReadout) } - @Test("A query tab running before it has any result reports the execution alone") + @Test("A query tab running before it has any result reports the execution without a readout") func firstRunReportsTheExecution() { + let result = model(runningQueryTabWithoutResult()) + #expect(!result.controls.showsReadout) + #expect(result.controls.showsExecution) + } + + @Test("A running query with a result on screen reports the execution beside the readout") + func runWithAResultKeepsTheReadout() { + var running = PaginationState() + running.isLoading = true + let snapshot = makeSnapshot(tabType: .query, rowCount: 5, hasTableName: false, pagination: running) + let result = model(snapshot) + #expect(result.controls.showsReadout) + #expect(result.controls.showsExecution) + } + + @Test("Output mode reports the execution of a query that has no result yet") + func outputModeReportsTheExecution() { + let result = model(runningQueryTabWithoutResult(), viewMode: .output) + #expect(!result.controls.showsReadout) + #expect(result.controls.showsExecution) + } + + @Test("A query plan on screen gives up the readout and still reports the execution") + func queryPlanReportsTheExecution() { + let result = model(runningQueryTabWithoutResult(isQueryPlan: true)) + #expect(!result.controls.showsReadout) + #expect(result.controls.showsExecution) + } + + @Test("Structure mode never reports an execution") + func structureModeReportsNoExecution() { var running = PaginationState() running.isLoading = true let snapshot = makeSnapshot( - tabType: .query, + tabType: .table, rowCount: 0, hasColumns: false, hasTableName: false, pagination: running ) - let result = model(snapshot) - #expect(!result.controls.showsReadout) - #expect(result.controls.showsExecutionWithoutReadout) + #expect(!model(snapshot, viewMode: .structure).controls.showsExecution) } - @Test("A query tab with no result and nothing running reports nothing") - func idleTabWithoutResultReportsNoExecution() { - let snapshot = makeSnapshot(tabType: .query, rowCount: 0, hasColumns: false, hasTableName: false) - #expect(!model(snapshot).controls.showsExecutionWithoutReadout) - } + @Test("A query tab that never ran reads the fetch the registry reports as loading") + func neverRunQueryTabTakesTheFetchFromTheRegistry() { + let tab = QueryTab(title: "Query 1", query: "SELECT 1", tabType: .query) + #expect(!tab.pagination.isLoading) - @Test("A running query with a result on screen reports the execution inside the readout") - func runWithAResultKeepsTheReadout() { - var running = PaginationState() - running.isLoading = true - let snapshot = makeSnapshot(tabType: .query, rowCount: 5, hasTableName: false, pagination: running) + let snapshot = StatusBarSnapshot(tab: tab, tableRows: TableRows(), isFetching: true) let result = model(snapshot) - #expect(result.controls.showsReadout) - #expect(!result.controls.showsExecutionWithoutReadout) + + #expect(snapshot.pagination.isLoading) + #expect(result.readout == .loading) + #expect(!result.controls.showsReadout) + #expect(result.controls.showsExecution) } - @Test("Structure mode never reports an execution on its own") - func structureModeReportsNoBareExecution() { + private func runningQueryTabWithoutResult(isQueryPlan: Bool = false) -> StatusBarSnapshot { var running = PaginationState() running.isLoading = true - let snapshot = makeSnapshot( - tabType: .table, + return makeSnapshot( + tabType: .query, rowCount: 0, hasColumns: false, hasTableName: false, + isQueryPlan: isQueryPlan, pagination: running ) - #expect(!model(snapshot, viewMode: .structure).controls.showsExecutionWithoutReadout) } @Test("A table with a known total reports the offset range") diff --git a/TableProTests/Views/Main/ResultStatusBarLayoutTests.swift b/TableProTests/Views/Main/ResultStatusBarLayoutTests.swift index f4dc82d9f..cf2e4879f 100644 --- a/TableProTests/Views/Main/ResultStatusBarLayoutTests.swift +++ b/TableProTests/Views/Main/ResultStatusBarLayoutTests.swift @@ -6,6 +6,7 @@ import AppKit import Foundation import SwiftUI +import TableProPluginKit import Testing @testable import TablePro @@ -30,10 +31,12 @@ struct ResultStatusBarLayoutTests { viewMode: ResultsViewMode, pagination: PaginationState = PaginationState(), statusMessage: String? = nil, - structureFooter: StructureFooterCapability = StructureFooterCapability() + structureFooter: StructureFooterCapability = StructureFooterCapability(), + tabId: UUID = UUID(), + execution: ExecutionReadout? = nil ) -> ResultStatusBar { let snapshot = StatusBarSnapshot( - tabId: UUID(), + tabId: tabId, tabType: tabType, hasRows: rowCount > 0, hasColumns: hasColumns, @@ -83,8 +86,8 @@ struct ResultStatusBarLayoutTests { onRequestExactCount: {} ), structureFooter: structureFooter, - execution: ExecutionReadout( - tabId: UUID(), + execution: execution ?? ExecutionReadout( + tabId: tabId, execution: TabExecutionRegistry(), lastTiming: nil, onCancel: {} @@ -210,6 +213,70 @@ struct ResultStatusBarLayoutTests { #expect(!ResultsViewMode.structure.showsRowFilters) } + @Test("The indicator a first run revealed is the one still on screen when the result lands") + func revealedIndicatorOutlivesTheResultLanding() async throws { + let tabId = UUID() + var registry = TabExecutionRegistry() + let bar = { (hasColumns: Bool, timing: PluginQueryTiming?) in + self.makeBar( + rowCount: hasColumns ? 5 : 0, + hasColumns: hasColumns, + tabType: .query, + viewMode: .data, + tabId: tabId, + execution: ExecutionReadout(tabId: tabId, execution: registry, lastTiming: timing, onCancel: {}) + ) + } + let host = NSHostingView(rootView: bar(false, nil)) + let window = hostingWindow(for: host) + defer { window.contentView = nil } + + #expect(spinners(in: host).isEmpty, "An idle tab showed a spinner") + + let work = registry.beginUnclaimedWork(for: tabId) + host.rootView = bar(false, nil) + let revealed = await settle(host) { !spinners(in: host).isEmpty } + let spinner = try #require(revealed ? spinners(in: host).first : nil, "The first run never revealed a spinner") + + registry.endUnclaimedWork(work, for: tabId) + host.rootView = bar(true, PluginQueryTiming(total: 0.6)) + host.layoutSubtreeIfNeeded() + + #expect( + spinners(in: host).contains { $0 === spinner }, + "The result landing replaced the revealed indicator instead of letting it serve its dwell" + ) + #expect(await settle(host) { spinners(in: host).isEmpty }, "The spinner outlived its dwell") + } + + private func hostingWindow(for host: NSView) -> NSWindow { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 900, height: StatusBarChrome.height), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.contentView = host + host.layoutSubtreeIfNeeded() + return window + } + + private func settle(_ host: NSView, until condition: () -> Bool) async -> Bool { + for _ in 0 ..< 150 { + host.layoutSubtreeIfNeeded() + if condition() { return true } + try? await Task.sleep(for: .milliseconds(20)) + } + host.layoutSubtreeIfNeeded() + return condition() + } + + private func spinners(in view: NSView) -> [NSProgressIndicator] { + let own = (view as? NSProgressIndicator).map { [$0] } ?? [] + return own + view.subviews.flatMap(spinners(in:)) + } + // MARK: - Width private static let wordyDriverMessage = """ diff --git a/TableProUITests/ConnectionWindowChromeUITests.swift b/TableProUITests/ConnectionWindowChromeUITests.swift index dc1b79b46..5ab25df4e 100644 --- a/TableProUITests/ConnectionWindowChromeUITests.swift +++ b/TableProUITests/ConnectionWindowChromeUITests.swift @@ -1,4 +1,3 @@ -import AppKit import XCTest /// The window a user opens is the window they end up with. @@ -80,49 +79,6 @@ final class ConnectionWindowChromeUITests: UITestCase { ) } - // MARK: - The toolbar's controls - - /// The default set is eight controls, and the ones the revamp took out stay out: no Browse and - /// Agent segments, no Tables and Favorites segments, no Back and Forward pair. - /// - /// The sample is SQLite, which is file-based, so on macOS 15 and later the container capsule is - /// the eighth control and is hidden: SQLite has one database and it is the file the connection - /// capsule already names. Below 15 there is no `isHidden`, so the capsule stands and dims. - func testTheDefaultToolbarCarriesItsControlsAndNoModeControl() throws { - try skipUnlessTheScreenFitsThePinnedWindow() - let app = try launchWithSampleDatabase(environment: pinnedEnvironment, arguments: englishArguments) - let toolbar = try shownToolbar(of: connectionWindow(of: app), in: app) - - XCTAssertTrue( - toolbar.buttons.matching(NSPredicate(format: "label CONTAINS[c] %@", "Sidebar")).firstMatch - .waitToExist(timeout: 20), - "The sidebar toggle leads the default set" - ) - for label in ["Connection", "Refresh", "Save Changes", "Inspector"] { - XCTAssertTrue(toolbar.buttons[label].waitToExist(timeout: 10), "\(label) is in the default set") - } - for label in ["Actions", "Safe Mode"] { - XCTAssertTrue(toolbar.menuButtons[label].waitToExist(timeout: 10), "\(label) is a pull-down in the default set") - } - - let container = toolbar.buttons["Database"] - if #available(macOS 15.0, *) { - XCTAssertFalse(container.exists, "A file-based connection has no container to switch, so the capsule is hidden") - } else { - XCTAssertTrue(container.exists, "Below macOS 15 the container capsule stands and dims") - } - - for label in ["Browse", "Agent", "Tables", "Favorites"] { - XCTAssertFalse( - toolbar.descendants(matching: .any)[label].exists, - "\(label) moved out of the toolbar; it must not be drawn there" - ) - } - XCTAssertEqual(toolbar.radioGroups.count, 0, "The toolbar carries no segmented chooser at all") - XCTAssertFalse(toolbar.buttons["Back"].exists, "Back and Forward are offered by Customize Toolbar, not the default set") - XCTAssertFalse(toolbar.buttons["Forward"].exists) - } - /// The two sidebar lists are chosen from a control at the top of the sidebar, over the list it /// switches. The sample has no favorites, so the Favorites list settles on its empty state, and /// that state going away is what shows Tables took the sidebar back. @@ -154,95 +110,18 @@ final class ConnectionWindowChromeUITests: UITestCase { ) } - /// A definition that is not on the server yet has nothing to reload, so Refresh leaves the - /// titlebar on a Create Table tab, and the commit control is labelled with that tab's verb. - /// `NSToolbarItem.isHidden` is macOS 15; below it the item stays and dims, which is a different - /// assertion and one the unit suites make. - func testRefreshLeavesTheToolbarOnACreateTableTab() throws { - guard #available(macOS 15.0, *) else { - throw XCTSkip("NSToolbarItem.isHidden is macOS 15 and later; below it Refresh stays and dims") - } - try skipUnlessTheScreenFitsThePinnedWindow() - let app = try launchWithSampleDatabase(environment: pinnedEnvironment, arguments: englishArguments) - let window = try connectionWindow(of: app) - let toolbar = try shownToolbar(of: window, in: app) - - let refresh = toolbar.buttons["Refresh"] - XCTAssertTrue( - refresh.waitToExist(timeout: 30), - "A table tab shows Refresh, or its absence below would prove nothing" - ) - XCTAssertTrue(toolbar.buttons["Save Changes"].exists) - - let menuBar = app.menuBars.firstMatch - XCTAssertTrue(menuBar.waitToExist(timeout: 20)) - menuBar.menuBarItems["Database"].click() - menuBar.menuItems["New Table…"].click() - XCTAssertTrue( - window.buttons["create-table-commit"].firstMatch.waitToExist(timeout: 30), - "Database > New Table… must open a Create Table tab" - ) - - XCTAssertTrue( - waitForPredicate(timeout: 10) { !refresh.exists }, - "An unsaved definition has nothing to reload, so Refresh leaves the titlebar" - ) - XCTAssertTrue( - toolbar.buttons["Create Table"].waitToExist(timeout: 10), - "The commit control names the verb of the tab it commits" - ) - } - // MARK: - Helpers - /// The window the toolbar test measures is pinned, because a restored frame narrow enough to - /// overflow the toolbar would move items into the overflow menu for reasons that have nothing to - /// do with the context, and an item in the overflow menu is not in the toolbar to find. - private let pinnedWindowSize = CGSize(width: 1_512, height: 861) - - private var pinnedEnvironment: [String: String] { - ["TABLEPRO_SCREENSHOT_FRAME": "\(Int(pinnedWindowSize.width))x\(Int(pinnedWindowSize.height))"] - } - - /// An `NSToolbarItem` publishes no accessibility identifier. Measured on macOS 27, each item is - /// an `AXButton`, or an `AXMenuButton` for a pull-down, labelled with the item's label and with - /// an empty identifier, and nothing gives one without a custom view, which the toolbar does not - /// use. The labels are localized, so the app runs in English. `AppleLanguages` only takes effect - /// as a launch argument. + /// The labels are localized, so the app runs in English. `AppleLanguages` only takes effect as a + /// launch argument. private let englishArguments = ["-AppleLanguages", "(en)"] - /// The runner's screen is 1024pt wide, and a window pinned wider than its screen overflows the - /// toolbar's items into the overflow menu, where none of them is in the toolbar to find. That is - /// unmeasurable rather than wrong, so it skips. - private func skipUnlessTheScreenFitsThePinnedWindow() throws { - let width = NSScreen.main?.frame.width ?? 0 - try XCTSkipUnless( - width >= pinnedWindowSize.width, - """ - Needs a screen at least \(Int(pinnedWindowSize.width))pt wide to hold the pinned window; \ - this one is \(Int(width))pt, and anything narrower overflows toolbar items into its menu. - """ - ) - } - private func connectionWindow(of app: XCUIApplication) throws -> XCUIElement { let window = app.windows.matching(NSPredicate(format: "identifier != %@", "welcome")).firstMatch XCTAssertTrue(window.waitToExist(timeout: 60), "The sample database produced no window") return window } - /// Normalised rather than asserted, the way `SwitcherWithoutToolbarAnchorUITests` does it. - /// AppKit persists whether the toolbar is shown through its own defaults rather than the sandbox - /// `UITestCase` hands the app, so a run inherits whatever the last one left. - private func shownToolbar(of window: XCUIElement, in app: XCUIApplication) throws -> XCUIElement { - let toolbar = window.toolbars.firstMatch - if !toolbar.waitToExist(timeout: 10) { - app.typeKey("t", modifierFlags: [.command, .option]) - } - XCTAssertTrue(toolbar.waitToExist(timeout: 10), "Command Option T must show the toolbar") - return toolbar - } - /// Creates a connection that cannot answer and opens it. The form is driven the way a person /// drives it, because a hand-written `connections.json` would pin the storage format rather /// than the behaviour under test.