Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ios-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,11 @@ jobs:
-scheme "$XCODE_SCHEME" \
-skipPackagePluginValidation

# One simulator, not clones. TableProMobileTests is one bundle and xcodebuild never split it:
# every test in every run so far, passing and failing, executed on "Clone 1 of iPhone 17 Pro".
# The extra clone only booted, and on a 3-core runner booting a second iOS 26 simulator is what
# killed three of the last twelve runs with "Failed to launch app ... (ipc/mig) server died"
# while Clone 1 had already finished the suite.
- name: Run unit tests
run: |
set -o pipefail
Expand All @@ -143,6 +148,7 @@ jobs:
-scheme "$XCODE_SCHEME" \
-destination "$TEST_DESTINATION" \
-only-testing:TableProMobileTests \
-parallel-testing-enabled NO \
-skipPackagePluginValidation \
-resultBundlePath TestResults.xcresult \
CODE_SIGNING_ALLOWED=NO \
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,14 @@ struct CooperativePoolBlockingTests {
private static var blockerCount: Int { ProcessInfo.processInfo.activeProcessorCount + 4 }
private static let blockMilliseconds = 300

/// The high-water mark is the whole assertion, because `blockerCount` callers measured inside the
/// blocking call at one instant *is* "every caller at once". A wall clock cannot add to that and
/// only reports the host: libdispatch brings its worker threads up one at a time, so on a loaded
/// CI runner the group took 2.00s and 2.26s against a 0.9s budget while the peak was the full
/// `blockerCount` both times. The companion test below is a peak assertion for the same reason.
@Test("Blocking through its own queue runs every caller at once")
func runsEveryBlockerConcurrently() async throws {
let peak = ConcurrencyPeak()
let started = Date()

try await withThrowingTaskGroup(of: Void.self) { group in
for index in 0 ..< Self.blockerCount {
Expand All @@ -44,9 +48,7 @@ struct CooperativePoolBlockingTests {
try await group.waitForAll()
}

let elapsed = Date().timeIntervalSince(started)
#expect(peak.highWaterMark == Self.blockerCount)
#expect(elapsed < Double(Self.blockMilliseconds * 3) / 1_000)
}

@Test("Blocking a detached task instead caps at the cooperative pool width")
Expand Down
8 changes: 8 additions & 0 deletions TablePro/Views/Filter/FilterPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ import TableProPluginKit

struct FilterPanelView: View {
@ObservedObject var coordinator: MainContentCoordinator
/// The panel draws `coordinator.selectedTabFilterState`, which lives on `QueryTabManager.tabs`
/// and not on the coordinator, so the store that publishes it has to be named here. Without it
/// SwiftUI compares this view's own stored properties, finds them unchanged and skips `body`:
/// the row `onAppear` adds reached the model and never reached the screen, so ⌘⇧F opened an
/// empty bar with nothing to type into and the keystrokes went to the object list instead. That
/// is what the two closures this view used to carry were hiding, because a closure is never
/// equal to another one and forced a re-evaluation on every parent render. (#3026)
@ObservedObject var tabManager: QueryTabManager
let columns: [String]
let primaryKeyColumn: String?
let databaseType: DatabaseType
Expand Down
1 change: 1 addition & 0 deletions TablePro/Views/Main/Child/MainEditorContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -889,6 +889,7 @@ struct MainEditorContentView: View {
} else {
FilterPanelView(
coordinator: coordinator,
tabManager: tabManager,
columns: rows.columns,
primaryKeyColumn: changeManager.primaryKeyColumn,
databaseType: connection.type,
Expand Down
17 changes: 15 additions & 2 deletions TablePro/Views/RowInspector/TrailingPaneHeaderView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,18 @@ internal struct TrailingPaneHeaderView<MenuSectionContent: View>: View {
}
}

/// The label is a `Label` with its title hidden, not an image with an accessibility label on the
/// menu: `.accessibilityLabel` on a `Menu` replaces the name its label provides with nothing.
/// The ellipsis draws no text, so the only name VoiceOver has for this control is the one the
/// accessibility modifiers give it, and it takes all three of them.
///
/// Measured on macOS 27, naming it through the label does not work in any arrangement. A `Label`
/// whose title `.labelStyle(.iconOnly)` has resolved away carries no name, whichever view the
/// style sits on, and `.accessibilityLabel` inside the label closure, which is how
/// `ResultSetMenu` names a pull-down that also draws text, does not reach this one either.
/// `.accessibilityLabel` on the `Menu` alone is likewise ignored. In all three the control falls
/// back to AppKit's own name for an unnamed pull-down, which reads "More" locally and the empty
/// string on the CI runner. Only `.accessibilityElement` publishes the name, and it has to be
/// `.contain`: `.ignore` names the button and then takes the menu's own items out of the tree
/// with it, so nothing can reach Fields, JSON or any other command in it.
private func menu(_ model: TrailingPaneHeaderModel) -> some View {
Menu {
ForEach(Array(model.menuSections.enumerated()), id: \.element) { index, section in
Expand All @@ -115,6 +125,9 @@ internal struct TrailingPaneHeaderView<MenuSectionContent: View>: View {
.menuIndicator(.hidden)
.frame(width: 24, height: 22)
.help(model.menuLabel)
.accessibilityElement(children: .contain)
.accessibilityLabel(model.menuLabel)
.accessibilityAddTraits(.isButton)
.accessibilityIdentifier("trailing-pane-menu")
}
}
Expand Down
12 changes: 8 additions & 4 deletions TableProTests/Views/Main/DefaultSortInitialQueryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,28 +83,32 @@ struct DefaultSortInitialQueryTests {
SchemaColumnStore.Entry(columns: ["message", "level"], primaryKeys: [], columnTypes: [:]),
for: coordinator.schemaColumnsKey("logs", scope: coordinator.selectedTabScope)
)
let originalQuery = tabManager.tabs[index].content.query

await withDefaultSortBehavior(.primaryKey) {
let ready = await coordinator.prepareTableTabFirstLoad(tabId: tabManager.tabs[index].id)
#expect(ready)
}

#expect(tabManager.tabs[index].content.query == originalQuery)
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")
let originalQuery = tabManager.tabs[index].content.query

await withDefaultSortBehavior(.primaryKey) {
let ready = await coordinator.prepareTableTabFirstLoad(tabId: tabManager.tabs[index].id)
#expect(ready)
}

#expect(tabManager.tabs[index].content.query == originalQuery)
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")
Expand Down
Loading