From 98abaea5770cd0f38c45f03a11872a2bdebecb28 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 16:57:33 +0700 Subject: [PATCH] test: fix the 12 UI flows that failed on the first CI run The switcher kept the current connection selected when a query fuzzy-matched its file path, so Return reopened it. --- CHANGELOG.md | 1 + .../Toolbar/ConnectionSwitcherPopover.swift | 40 +++++++++++++- .../ConnectionSwitcherFilterTests.swift | 55 +++++++++++++++++++ .../AppearanceSettingsUITests.swift | 16 +++++- .../ConnectionImportReviewUITests.swift | 7 ++- .../InspectorEditReachesGridUITests.swift | 3 +- .../SQLiteFirstTableLoadUITests.swift | 3 +- .../StructureSaveConfirmationUITests.swift | 3 +- .../Support/UITestCase+FixtureGrid.swift | 5 +- 9 files changed, 120 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 409e60586c..bde337d5a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,6 +102,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - SQL Server Edit View Definition template creating the view in the default schema instead of the view's own. - Oracle view DDL losing the view's column names, and cut off after 4000 bytes without saying so. - Trino materialized views left out of SQL exports. +- Return in Switch Connection reopening the current connection after typing another connection's name. ### Security diff --git a/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift b/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift index f7a0826bf2..ab35346cc2 100644 --- a/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift +++ b/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift @@ -10,6 +10,9 @@ import TableProConnectionLibrary import TableProPluginKit enum ConnectionSwitcherFilter { + // A file connection's database is a whole path, which a short query matches letter by letter. + private static let hostOrDatabaseWeight = 0.6 + static func matches(_ connection: DatabaseConnection, query: String) -> Bool { let trimmed = query.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty else { return true } @@ -17,6 +20,16 @@ enum ConnectionSwitcherFilter { || FuzzyMatcher.matches(query: trimmed, candidate: connection.host) || FuzzyMatcher.matches(query: trimmed, candidate: connection.database) } + + static func score(_ connection: DatabaseConnection, query: String) -> Double? { + let trimmed = query.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return nil } + let nameScore = FuzzyMatcher.match(query: trimmed, candidate: connection.name).map { Double($0.score) } + let otherScores = [connection.host, connection.database].compactMap { candidate in + FuzzyMatcher.match(query: trimmed, candidate: candidate).map { Double($0.score) * hostOrDatabaseWeight } + } + return ([nameScore].compactMap { $0 } + otherScores).max() + } } enum ConnectionSwitcherSelection { @@ -26,6 +39,20 @@ enum ConnectionSwitcherSelection { let newIndex = max(0, min(ids.count - 1, currentIndex + offset)) return ids[newIndex] } + + // Never the row selected before the query: it can still match through its database path. + static func preferred(in entries: [ConnectionSwitcherEntry], query: String, current: UUID?) -> UUID? { + guard !query.trimmingCharacters(in: .whitespaces).isEmpty else { + return entries.first { $0.id == current }?.id ?? entries.first?.id + } + var best: (id: UUID, score: Double)? + for entry in entries { + guard let score = ConnectionSwitcherFilter.score(entry.connection, query: query), + score > (best?.score ?? -.infinity) else { continue } + best = (entry.id, score) + } + return best?.id ?? entries.first?.id + } } /// The two sections list different things, a live session and a saved record, but the list shows one @@ -160,8 +187,7 @@ struct ConnectionSwitcherPopover: View { .onAppear { reload() if selectedConnectionId == nil { - selectedConnectionId = currentConnectionId.flatMap { id in orderedIds.contains(id) ? id : nil } - ?? orderedIds.first + selectPreferred() } } /// The subject itself, not a `receive(on:)` wrapper: that builds a new publisher on every @@ -183,7 +209,7 @@ struct ConnectionSwitcherPopover: View { settleSelection() } .onChange(of: searchText) { _ in - settleSelection() + selectPreferred() } } @@ -370,6 +396,14 @@ struct ConnectionSwitcherPopover: View { selectedConnectionId = ids.first } + private func selectPreferred() { + selectedConnectionId = ConnectionSwitcherSelection.preferred( + in: sections.flatMap(\.items), + query: searchText, + current: currentConnectionId + ) + } + private func moveSelection(by offset: Int) { if let next = ConnectionSwitcherSelection.moved(in: orderedIds, from: selectedConnectionId, by: offset) { selectedConnectionId = next diff --git a/TableProTests/Views/Toolbar/ConnectionSwitcherFilterTests.swift b/TableProTests/Views/Toolbar/ConnectionSwitcherFilterTests.swift index c09aeb4f71..47688fe988 100644 --- a/TableProTests/Views/Toolbar/ConnectionSwitcherFilterTests.swift +++ b/TableProTests/Views/Toolbar/ConnectionSwitcherFilterTests.swift @@ -79,6 +79,61 @@ struct ConnectionSwitcherSelectionTests { let (a, b, c) = (UUID(), UUID(), UUID()) #expect(ConnectionSwitcherSelection.moved(in: [a, b, c], from: c, by: 1) == c) } + + // MARK: - A new query + + private func entry(_ connection: DatabaseConnection, isActive: Bool = false) -> ConnectionSwitcherEntry { + ConnectionSwitcherEntry(id: connection.id, connection: connection, isActive: isActive, isConnected: isActive) + } + + @Test("A query selects the connection it names over the open one it matches only through a file path") + func aNameMatchBeatsAPathMatch() { + let folder = "/var/folders/5n/5t77mfb93jd70000gn/T/TableProUITests/c6da0d7486" + let open = TestFixtures.makeConnection(name: "Fixture", database: "\(folder)/seeded-0.sqlite", type: .sqlite) + let saved = TestFixtures.makeConnection(name: "Ledger", database: "\(folder)/seeded-1.sqlite", type: .sqlite) + let entries = [entry(open, isActive: true), entry(saved)] + + #expect(ConnectionSwitcherFilter.matches(open, query: "Ledger"), "The open connection must still be listed") + for query in ["L", "Led", "Ledger"] { + #expect(ConnectionSwitcherSelection.preferred(in: entries, query: query, current: open.id) == saved.id) + } + } + + @Test("A connection matched only through its database is still selected when no name matches") + func aDatabaseMatchIsSelectedWhenNoNameMatches() { + let open = TestFixtures.makeConnection(name: "Replica", database: "orders") + let saved = TestFixtures.makeConnection(name: "Primary", database: "analytics") + + let selected = ConnectionSwitcherSelection.preferred( + in: [entry(open, isActive: true), entry(saved)], query: "analy", current: open.id + ) + + #expect(selected == saved.id) + } + + @Test("Equal matches keep the order the list draws") + func equalMatchesKeepListOrder() { + let first = TestFixtures.makeConnection(name: "Replica") + let second = TestFixtures.makeConnection(name: "Replica") + + let selected = ConnectionSwitcherSelection.preferred( + in: [entry(first), entry(second)], query: "rep", current: nil + ) + + #expect(selected == first.id) + } + + @Test("A blank query selects the current connection, or the first row when it is not listed") + func blankQuerySelectsTheCurrentConnection() { + let open = TestFixtures.makeConnection(name: "Fixture") + let saved = TestFixtures.makeConnection(name: "Ledger") + let entries = [entry(saved), entry(open, isActive: true)] + + #expect(ConnectionSwitcherSelection.preferred(in: entries, query: "", current: open.id) == open.id) + #expect(ConnectionSwitcherSelection.preferred(in: entries, query: " ", current: open.id) == open.id) + #expect(ConnectionSwitcherSelection.preferred(in: entries, query: "", current: UUID()) == saved.id) + #expect(ConnectionSwitcherSelection.preferred(in: [], query: "Ledger", current: open.id) == nil) + } } struct ConnectionSwitcherSectionsTests { diff --git a/TableProUITests/AppearanceSettingsUITests.swift b/TableProUITests/AppearanceSettingsUITests.swift index baf06806d0..fda9cedf2e 100644 --- a/TableProUITests/AppearanceSettingsUITests.swift +++ b/TableProUITests/AppearanceSettingsUITests.swift @@ -100,7 +100,10 @@ final class AppearanceSettingsUITests: UITestCase { let reset = sheet.buttons["theme-color-reset-editor.background"] XCTAssertTrue(reset.waitToExist(timeout: 10), "Every color of a Dracula copy starts overridden") XCTAssertEqual(reset.label, "Use Default") - XCTAssertTrue(scrollUntilHittable(reset, in: sheet, timeout: 10)) + XCTAssertTrue( + scrollIntoView(reset, in: sheet.scrollViews.firstMatch, timeout: 10), + "The Editor Background row must scroll into the editor's list" + ) reset.click() XCTAssertTrue(waitForPredicate(timeout: 5) { !reset.exists }, "Use Default should clear the override") XCTAssertTrue( @@ -142,6 +145,17 @@ final class AppearanceSettingsUITests: UITestCase { item.click() } + // The list shows about 180pt of rows on the runner, so a fixed 400pt step skips the row; aim it at the middle. + private func scrollIntoView(_ element: XCUIElement, in list: XCUIElement, timeout: TimeInterval) -> Bool { + let deadline = Date(timeIntervalSinceNow: timeout) + while !(element.exists && element.isHittable) { + guard Date() < deadline, list.exists, element.exists else { return false } + list.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) + .scroll(byDeltaX: 0, deltaY: list.frame.midY - element.frame.midY) + } + return true + } + private func element(_ identifier: String, in container: XCUIElement) -> XCUIElement { container.descendants(matching: .any).matching(identifier: identifier).firstMatch } diff --git a/TableProUITests/ConnectionImportReviewUITests.swift b/TableProUITests/ConnectionImportReviewUITests.swift index 492901d35a..262166eb1b 100644 --- a/TableProUITests/ConnectionImportReviewUITests.swift +++ b/TableProUITests/ConnectionImportReviewUITests.swift @@ -85,10 +85,11 @@ final class ConnectionImportReviewUITests: UITestCase { ) } - // XCUITest reports a mixed checkbox as NSControl.StateValue.mixed (-1), not the AXValue of 2. + // Runner images have reported a mixed checkbox as NSControl.StateValue.mixed (-1) and as its raw AXValue (2). private func isMixed(_ checkbox: XCUIElement) -> Bool { - if let number = checkbox.value as? Int { return number == NSControl.StateValue.mixed.rawValue } - return (checkbox.value as? String) == String(NSControl.StateValue.mixed.rawValue) + let mixedValues = [NSControl.StateValue.mixed.rawValue, 2] + if let number = checkbox.value as? Int { return mixedValues.contains(number) } + return mixedValues.map(String.init).contains((checkbox.value as? String) ?? "") } private static func text(_ string: String) -> NSPredicate { diff --git a/TableProUITests/InspectorEditReachesGridUITests.swift b/TableProUITests/InspectorEditReachesGridUITests.swift index 88f0684f52..4a1fd7db92 100644 --- a/TableProUITests/InspectorEditReachesGridUITests.swift +++ b/TableProUITests/InspectorEditReachesGridUITests.swift @@ -11,7 +11,8 @@ final class InspectorEditReachesGridUITests: UITestCase { grid.clickCell(row: 1, column: 2) app.typeShortcut(.toggleInspector) - let labelField = window.textFields.matching(NSPredicate(format: "value == %@", "First")).firstMatch + // A SQLite TEXT column opens the multi-line editor, which is a text view. + let labelField = window.textViews.matching(NSPredicate(format: "value == %@", "First")).firstMatch XCTAssertTrue(waitUntilHittable(labelField, timeout: 30), "The inspector must show row 1's label field") labelField.click() app.typeText("Zq7") diff --git a/TableProUITests/SQLiteFirstTableLoadUITests.swift b/TableProUITests/SQLiteFirstTableLoadUITests.swift index 17ef47d1ff..a727015ac9 100644 --- a/TableProUITests/SQLiteFirstTableLoadUITests.swift +++ b/TableProUITests/SQLiteFirstTableLoadUITests.swift @@ -16,10 +16,9 @@ final class SQLiteFirstTableLoadUITests: UITestCase { "The first table opened after connecting must finish loading" ) - // The strip lists its tabs only while there are two or more. launch.app.typeShortcut(.closeTab) XCTAssertTrue( - waitForPredicate(timeout: 20) { self.editorTabCount(in: window) == 0 && editor.exists }, + waitForPredicate(timeout: 20) { self.tabsTitled("album", in: window).count == 0 && editor.exists }, "Command W must close the table tab and leave the query tab in front" ) launch.app.typeShortcut(.closeTab) diff --git a/TableProUITests/StructureSaveConfirmationUITests.swift b/TableProUITests/StructureSaveConfirmationUITests.swift index ebc25bd1c6..17cab44c64 100644 --- a/TableProUITests/StructureSaveConfirmationUITests.swift +++ b/TableProUITests/StructureSaveConfirmationUITests.swift @@ -24,7 +24,8 @@ final class StructureSaveConfirmationUITests: UITestCase { let remove = window.buttons["structure-footer-remove"].firstMatch XCTAssertTrue(remove.waitToExist(timeout: 20), "The Columns tab must offer a remove button") XCTAssertTrue(waitForPredicate(timeout: 10) { remove.isEnabled }, "Removing the selected column must be offered") - remove.click() + // On the 1024pt runner the structure editor overflows its pane and this button ends past the window edge. + app.typeShortcut(.deleteRow) app.typeShortcut(.save) let execute = gateExecuteButton(in: window) diff --git a/TableProUITests/Support/UITestCase+FixtureGrid.swift b/TableProUITests/Support/UITestCase+FixtureGrid.swift index 922d0a0d9c..cbc05c1fa9 100644 --- a/TableProUITests/Support/UITestCase+FixtureGrid.swift +++ b/TableProUITests/Support/UITestCase+FixtureGrid.swift @@ -1,8 +1,8 @@ import SQLite3 import XCTest -// A plain cell publishes as static text and a cell with a picker as a combo box, so a cell is found by -// the position its label opens with, whatever its type. +// AppKit wraps each cell view in an AXCell that repeats its label and carries no value, so a cell is the +// cell view, matched by its identifier whatever its type: static text, a combo box for a picker. internal struct FixtureGrid { internal let element: XCUIElement internal let app: XCUIApplication @@ -10,6 +10,7 @@ internal struct FixtureGrid { internal func cell(row: Int, column: Int) -> XCUIElement { element.descendants(matching: .any) + .matching(identifier: "DataGridCellAccessibilityView") .matching(NSPredicate(format: "label BEGINSWITH %@", "Row \(row), column \(column): ")) .firstMatch }