From d50dc9cc2970fe8e2a3bab3527ab114eba1e1a19 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 30 Apr 2026 01:59:57 +0700 Subject: [PATCH 01/14] fix(datagrid): re-resolve cell background colors on appearance change and use native focus ring --- TablePro/Views/Results/DataGridCellView.swift | 38 ++++++++++++++----- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/TablePro/Views/Results/DataGridCellView.swift b/TablePro/Views/Results/DataGridCellView.swift index 4d4486cf6..32b3fbc4d 100644 --- a/TablePro/Views/Results/DataGridCellView.swift +++ b/TablePro/Views/Results/DataGridCellView.swift @@ -13,7 +13,7 @@ final class DataGridCellView: NSTableCellView { var isFocusedCell: Bool = false { didSet { guard oldValue != isFocusedCell else { return } - updateFocusBorder() + updateFocusRing() } } @@ -46,18 +46,38 @@ final class DataGridCellView: NSTableCellView { override var backgroundStyle: NSView.BackgroundStyle { didSet { backgroundView.isHidden = (backgroundStyle == .emphasized) || (changeBackgroundColor == nil) - if isFocusedCell { updateFocusBorder() } + if isFocusedCell { updateFocusRing() } } } - private func updateFocusBorder() { + override var focusRingMaskBounds: NSRect { bounds } + + override func drawFocusRingMask() { + NSBezierPath(rect: bounds).fill() + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + guard isFocusedCell, backgroundStyle != .emphasized else { return } + NSGraphicsContext.saveGraphicsState() + NSFocusRingPlacement.only.set() + drawFocusRingMask() + NSGraphicsContext.restoreGraphicsState() + } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + if let color = changeBackgroundColor { + backgroundView.layer?.backgroundColor = color.cgColor + } if isFocusedCell { - layer?.borderWidth = 2 - layer?.borderColor = backgroundStyle == .emphasized - ? NSColor.white.withAlphaComponent(0.8).cgColor - : NSColor.keyboardFocusIndicatorColor.cgColor - } else { - layer?.borderWidth = 0 + needsDisplay = true } } + + private func updateFocusRing() { + focusRingType = isFocusedCell ? .exterior : .none + noteFocusRingMaskChanged() + needsDisplay = true + } } From ca3abe60601ca84608f414678934318a0af9af62 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 30 Apr 2026 02:00:01 +0700 Subject: [PATCH 02/14] fix(datagrid): discard overlay editor edits on column resize --- TablePro/Views/Results/CellOverlayEditor.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TablePro/Views/Results/CellOverlayEditor.swift b/TablePro/Views/Results/CellOverlayEditor.swift index 6bae466f9..119aa4e8b 100644 --- a/TablePro/Views/Results/CellOverlayEditor.swift +++ b/TablePro/Views/Results/CellOverlayEditor.swift @@ -140,7 +140,7 @@ final class CellOverlayEditor: NSObject, NSTextViewDelegate { queue: .main ) { [weak self] _ in Task { @MainActor [weak self] in - self?.dismiss(commit: true) + self?.dismiss(commit: false) } } } From 48da00f28bb664c8059f7f364596c319c53ecced Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 30 Apr 2026 02:00:06 +0700 Subject: [PATCH 03/14] docs(changelog): note multiline overlay discard and cell color appearance refresh --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab1164bb6..0e6ba5fa5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Date picker popover font follows the data grid font setting - Data grid undo/redo uses the window's UndoManager instead of a private instance, unifying Cmd+Z across editor and grid - Right-click during cell editing shows the native text context menu instead of the row menu +- Multiline cell overlay editor discards the in-progress edit when a column resize fires, instead of silently committing partial text +- Data grid cell change highlights (deleted, inserted, modified) re-resolve under the active appearance when the user toggles Light or Dark mode mid-session ### Fixed From f70df9fe16b26121aa29da0915ec75ae3c5c6823 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 30 Apr 2026 01:59:03 +0700 Subject: [PATCH 04/14] fix(a11y): announce sort direction and priority on data grid column headers --- CHANGELOG.md | 1 + TablePro/Views/Results/SortableHeaderCell.swift | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e6ba5fa5..16bb07334 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Click a focused cell to start editing without a second click - Data grid focus ring follows the system accent color and contrast settings - Data grid cells expose accessibility row and column index ranges to VoiceOver on all dataset sizes +- Data grid column headers announce sort direction and multi-sort priority to VoiceOver - Multi-cell paste: paste TSV data from the clipboard into the grid starting from the focused cell, grouped as a single undo action - Shift+Tab navigates to the previous cell in the data grid - Copy rows writes TSV, HTML table, and plain text to the clipboard for richer paste in spreadsheet apps diff --git a/TablePro/Views/Results/SortableHeaderCell.swift b/TablePro/Views/Results/SortableHeaderCell.swift index d6460b26a..316f8b8a8 100644 --- a/TablePro/Views/Results/SortableHeaderCell.swift +++ b/TablePro/Views/Results/SortableHeaderCell.swift @@ -73,6 +73,23 @@ final class SortableHeaderCell: NSTableHeaderCell { priority: Int ) {} + override func accessibilityLabel() -> String? { + let baseLabel = super.accessibilityLabel() ?? stringValue + guard let direction = sortDirection else { return baseLabel } + let directionSuffix: String + switch direction { + case .ascending: + directionSuffix = String(localized: "Sorted ascending") + case .descending: + directionSuffix = String(localized: "Sorted descending") + } + guard let sortPriority, sortPriority >= 2 else { + return "\(baseLabel), \(directionSuffix)" + } + let prioritySuffix = String(format: String(localized: "Priority %d"), sortPriority) + return "\(baseLabel), \(directionSuffix), \(prioritySuffix)" + } + private func priorityNumberString() -> String? { guard let sortPriority, sortPriority >= 2 else { return nil } return String(sortPriority) From 89f2cc054320bf7dca974a27fcca9c8e1466da58 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 30 Apr 2026 01:59:12 +0700 Subject: [PATCH 05/14] refactor(datagrid): drop redundant window-level mouse-moved events flag --- TablePro/Views/Results/SortableHeaderView.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/TablePro/Views/Results/SortableHeaderView.swift b/TablePro/Views/Results/SortableHeaderView.swift index 2d51d712c..a4660966b 100644 --- a/TablePro/Views/Results/SortableHeaderView.swift +++ b/TablePro/Views/Results/SortableHeaderView.swift @@ -119,7 +119,6 @@ final class SortableHeaderView: NSTableHeaderView { override func viewDidMoveToWindow() { super.viewDidMoveToWindow() - window?.acceptsMouseMovedEvents = true window?.invalidateCursorRects(for: self) } From 2b6d1f170296819a3ef63403ec9a7e586c33cbf0 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 30 Apr 2026 02:07:27 +0700 Subject: [PATCH 06/14] fix(datagrid): keep sortedIDs and cachedRowCount in sync to prevent reload mismatch --- CHANGELOG.md | 1 + TablePro/Views/Results/DataGridView.swift | 2 ++ 2 files changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16bb07334..5cd945b68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Right-click during cell editing shows the native text context menu instead of the row menu - Multiline cell overlay editor discards the in-progress edit when a column resize fires, instead of silently committing partial text - Data grid cell change highlights (deleted, inserted, modified) re-resolve under the active appearance when the user toggles Light or Dark mode mid-session +- Data grid keeps sortedIDs and cachedRowCount paired by calling updateCache() immediately after the SwiftUI bridge writes new sortedIDs to the coordinator, removing a window where the cached count and the sort permutation could disagree ### Fixed diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 779644b25..ebd88872d 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -142,6 +142,7 @@ struct DataGridView: NSViewRepresentable { context.coordinator.tableRowsProvider = tableRowsProvider context.coordinator.tableRowsMutator = tableRowsMutator context.coordinator.sortedIDs = sortedIDs + context.coordinator.updateCache() context.coordinator.syncDisplayFormats(displayFormats) context.coordinator.delegate = delegate delegate?.dataGridAttach(tableViewCoordinator: context.coordinator) @@ -227,6 +228,7 @@ struct DataGridView: NSViewRepresentable { coordinator.tableRowsProvider = tableRowsProvider coordinator.tableRowsMutator = tableRowsMutator coordinator.sortedIDs = sortedIDs + coordinator.updateCache() coordinator.syncDisplayFormats(displayFormats) coordinator.delegate = delegate delegate?.dataGridAttach(tableViewCoordinator: coordinator) From 47a965c58d5cb749fcb2b12ff91d7e8e124b280c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 30 Apr 2026 02:07:39 +0700 Subject: [PATCH 07/14] perf(datagrid): memoize displayFormats by tab schema version --- CHANGELOG.md | 1 + .../ValueDisplayFormatService.swift | 4 +++ .../Main/Child/MainEditorContentView.swift | 26 +++++++++++++++---- .../Views/Main/MainContentCoordinator.swift | 13 ++++++++++ 4 files changed, 39 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cd945b68..718a33a50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Multiline cell overlay editor discards the in-progress edit when a column resize fires, instead of silently committing partial text - Data grid cell change highlights (deleted, inserted, modified) re-resolve under the active appearance when the user toggles Light or Dark mode mid-session - Data grid keeps sortedIDs and cachedRowCount paired by calling updateCache() immediately after the SwiftUI bridge writes new sortedIDs to the coordinator, removing a window where the cached count and the sort permutation could disagree +- Display formats memoized per tab on MainContentCoordinator keyed by schema version, smart-detection setting, and format-overrides version, so ValueDisplayDetector.detect runs once per result schema instead of on every SwiftUI body evaluation ### Fixed diff --git a/TablePro/Core/Services/Formatting/ValueDisplayFormatService.swift b/TablePro/Core/Services/Formatting/ValueDisplayFormatService.swift index e149f5160..acb105235 100644 --- a/TablePro/Core/Services/Formatting/ValueDisplayFormatService.swift +++ b/TablePro/Core/Services/Formatting/ValueDisplayFormatService.swift @@ -18,6 +18,8 @@ final class ValueDisplayFormatService { /// Auto-detected formats keyed by "connectionId.tableName.columnName" for per-connection isolation. private var autoDetectedFormats: [String: ValueDisplayFormat] = [:] + private(set) var overridesVersion: Int = 0 + private init() {} // MARK: - Format Application @@ -102,6 +104,8 @@ final class ValueDisplayFormatService { } else { ValueDisplayFormatStorage.shared.save(overrides, for: tableName, connectionId: connectionId) } + + overridesVersion &+= 1 } // MARK: - Private Formatting diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 13a7d070e..a1c8a2214 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -536,16 +536,25 @@ struct MainEditorContentView: View { } private func displayFormats(for tab: QueryTab) -> [ValueDisplayFormat?] { + let settings = AppSettingsManager.shared.dataGrid + let service = ValueDisplayFormatService.shared + let smartDetectionEnabled = settings.enableSmartValueDetection + let overridesVersion = service.overridesVersion + + if let cached = coordinator.displayFormatsCache[tab.id], + cached.schemaVersion == tab.schemaVersion, + cached.smartDetectionEnabled == smartDetectionEnabled, + cached.overridesVersion == overridesVersion { + return cached.formats + } + let tableRows = coordinator.tableRowsStore.existingTableRows(for: tab.id) let columns = tableRows?.columns ?? [] let columnTypes = tableRows?.columnTypes ?? [] guard !columns.isEmpty else { return [] } - let settings = AppSettingsManager.shared.dataGrid - let service = ValueDisplayFormatService.shared - var detected: [ValueDisplayFormat?] = Array(repeating: nil, count: columns.count) - if settings.enableSmartValueDetection { + if smartDetectionEnabled { let sampleRows: [[String?]]? = { let rows = tableRows?.rows.prefix(10).map(\.values) ?? [] return rows.isEmpty ? nil : Array(rows) @@ -582,7 +591,14 @@ struct MainEditorContentView: View { } } - return merged.contains(where: { $0 != nil }) ? merged : [] + let result = merged.contains(where: { $0 != nil }) ? merged : [] + coordinator.displayFormatsCache[tab.id] = DisplayFormatsCacheEntry( + schemaVersion: tab.schemaVersion, + smartDetectionEnabled: smartDetectionEnabled, + overridesVersion: overridesVersion, + formats: result + ) + return result } /// Returns the display order as a permutation of `RowID`, or nil when no sort applies. diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index cfb7fd11a..49cc252c5 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -31,6 +31,13 @@ struct QuerySortCacheEntry { let schemaVersion: Int } +struct DisplayFormatsCacheEntry { + let schemaVersion: Int + let smartDetectionEnabled: Bool + let overridesVersion: Int + let formats: [ValueDisplayFormat?] +} + /// Sidebar table loading state — single source of truth for sidebar UI enum SidebarLoadingState: Equatable { case idle @@ -147,6 +154,8 @@ final class MainContentCoordinator { /// Cache for async-sorted query tab rows (large datasets sorted on background thread) @ObservationIgnored var querySortCache: [UUID: QuerySortCacheEntry] = [:] + @ObservationIgnored var displayFormatsCache: [UUID: DisplayFormatsCacheEntry] = [:] + @ObservationIgnored var pendingScrollToTopAfterReplace: Set = [] // MARK: - Internal State @@ -351,6 +360,9 @@ final class MainContentCoordinator { if querySortCache.keys.contains(where: { !openTabIds.contains($0) }) { querySortCache = querySortCache.filter { openTabIds.contains($0.key) } } + if displayFormatsCache.keys.contains(where: { !openTabIds.contains($0) }) { + displayFormatsCache = displayFormatsCache.filter { openTabIds.contains($0.key) } + } for (tabId, task) in activeSortTasks where !openTabIds.contains(tabId) { task.cancel() activeSortTasks.removeValue(forKey: tabId) @@ -585,6 +597,7 @@ final class MainContentCoordinator { tableRowsStore.tearDown() querySortCache.removeAll() + displayFormatsCache.removeAll() cachedTableColumnTypes.removeAll() cachedTableColumnNames.removeAll() From 91d7aca4d7991f4d28fd835fb810297839f95d7c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 30 Apr 2026 02:19:29 +0700 Subject: [PATCH 08/14] test(storage): cover FileColumnLayoutPersister isolation, overwrite, and clear edge cases --- .../FileColumnLayoutPersisterTests.swift | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/TableProTests/Storage/FileColumnLayoutPersisterTests.swift b/TableProTests/Storage/FileColumnLayoutPersisterTests.swift index 9b8ff1a35..ec0965577 100644 --- a/TableProTests/Storage/FileColumnLayoutPersisterTests.swift +++ b/TableProTests/Storage/FileColumnLayoutPersisterTests.swift @@ -150,4 +150,130 @@ struct FileColumnLayoutPersisterTests { .load(for: "users", connectionId: connectionId) #expect(restored?.columnWidths == ["id": 100]) } + + @Test("Clearing the only entry removes the connection's storage file") + func clearingLastEntryRemovesFile() { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("TableProTests-\(UUID().uuidString)", isDirectory: true) + defer { cleanup(directory) } + + let persister = FileColumnLayoutPersister(storageDirectory: directory) + let connectionId = UUID() + var layout = ColumnLayoutState() + layout.columnWidths = ["id": 100] + persister.save(layout, for: "users", connectionId: connectionId) + + let fileURL = directory.appendingPathComponent("\(connectionId.uuidString).json") + #expect(FileManager.default.fileExists(atPath: fileURL.path)) + + persister.clear(for: "users", connectionId: connectionId) + #expect(!FileManager.default.fileExists(atPath: fileURL.path)) + } + + @Test("Clearing one of multiple tables keeps the connection file with the rest") + func clearingOneOfManyKeepsFile() { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("TableProTests-\(UUID().uuidString)", isDirectory: true) + defer { cleanup(directory) } + + let persister = FileColumnLayoutPersister(storageDirectory: directory) + let connectionId = UUID() + var users = ColumnLayoutState() + users.columnWidths = ["id": 60] + var orders = ColumnLayoutState() + orders.columnWidths = ["total": 120] + persister.save(users, for: "users", connectionId: connectionId) + persister.save(orders, for: "orders", connectionId: connectionId) + + persister.clear(for: "users", connectionId: connectionId) + + let fresh = FileColumnLayoutPersister(storageDirectory: directory) + #expect(fresh.load(for: "users", connectionId: connectionId) == nil) + #expect(fresh.load(for: "orders", connectionId: connectionId)?.columnWidths == ["total": 120]) + } + + @Test("Clearing a missing entry is a no-op and never creates a file") + func clearingMissingEntryIsNoOp() { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("TableProTests-\(UUID().uuidString)", isDirectory: true) + defer { cleanup(directory) } + + let persister = FileColumnLayoutPersister(storageDirectory: directory) + let connectionId = UUID() + persister.clear(for: "missing", connectionId: connectionId) + + let fileURL = directory.appendingPathComponent("\(connectionId.uuidString).json") + #expect(!FileManager.default.fileExists(atPath: fileURL.path)) + } + + @Test("Connections are isolated even when table names match") + func sameTableNameAcrossConnectionsAreIsolated() { + let (persister, dir) = makeIsolatedPersister() + defer { cleanup(dir) } + + let connectionA = UUID() + let connectionB = UUID() + var layoutA = ColumnLayoutState() + layoutA.columnWidths = ["id": 60] + var layoutB = ColumnLayoutState() + layoutB.columnWidths = ["id": 200] + + persister.save(layoutA, for: "users", connectionId: connectionA) + persister.save(layoutB, for: "users", connectionId: connectionB) + + #expect(persister.load(for: "users", connectionId: connectionA)?.columnWidths == ["id": 60]) + #expect(persister.load(for: "users", connectionId: connectionB)?.columnWidths == ["id": 200]) + } + + @Test("Saving overwrites an existing entry instead of merging") + func saveOverwritesExistingEntry() { + let (persister, dir) = makeIsolatedPersister() + defer { cleanup(dir) } + + let connectionId = UUID() + var first = ColumnLayoutState() + first.columnWidths = ["id": 60, "name": 200] + first.columnOrder = ["id", "name"] + persister.save(first, for: "users", connectionId: connectionId) + + var second = ColumnLayoutState() + second.columnWidths = ["email": 240] + second.columnOrder = ["email"] + persister.save(second, for: "users", connectionId: connectionId) + + let restored = persister.load(for: "users", connectionId: connectionId) + #expect(restored?.columnWidths == ["email": 240]) + #expect(restored?.columnOrder == ["email"]) + } + + @Test("columnOrder nil is preserved through round-trip") + func columnOrderNilRoundTrips() { + let (persister, dir) = makeIsolatedPersister() + defer { cleanup(dir) } + + let connectionId = UUID() + var layout = ColumnLayoutState() + layout.columnWidths = ["id": 60] + layout.columnOrder = nil + persister.save(layout, for: "users", connectionId: connectionId) + + let restored = persister.load(for: "users", connectionId: connectionId) + #expect(restored?.columnOrder == nil) + #expect(restored?.columnWidths == ["id": 60]) + } + + @Test("Reading an empty JSON object returns nil for any table lookup") + func emptyEntriesFileReturnsNil() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("TableProTests-\(UUID().uuidString)", isDirectory: true) + defer { cleanup(directory) } + + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let connectionId = UUID() + let fileURL = directory.appendingPathComponent("\(connectionId.uuidString).json") + try Data("{}".utf8).write(to: fileURL) + + let persister = FileColumnLayoutPersister(storageDirectory: directory) + #expect(persister.load(for: "anything", connectionId: connectionId) == nil) + } } From 91f78b50c6f95e75661edc1ef585a4cd3b83d407 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 30 Apr 2026 02:19:33 +0700 Subject: [PATCH 09/14] test(datagrid): cover ColumnIdentitySchema duplicate-name and reserved-name edge cases --- .../Models/UI/ColumnIdentitySchemaTests.swift | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/TableProTests/Models/UI/ColumnIdentitySchemaTests.swift b/TableProTests/Models/UI/ColumnIdentitySchemaTests.swift index 21fd5ee85..081d0d78a 100644 --- a/TableProTests/Models/UI/ColumnIdentitySchemaTests.swift +++ b/TableProTests/Models/UI/ColumnIdentitySchemaTests.swift @@ -132,4 +132,70 @@ struct ColumnIdentitySchemaTests { #expect(before.dataIndex(from: columnId) == 2) #expect(after.dataIndex(from: columnId) == 0) } + + @Test("Duplicate names ignore the duplicate column name when looking up by raw name") + func positionalSchemaIgnoresDuplicateRawName() { + let schema = ColumnIdentitySchema(columns: ["a", "b", "a"]) + #expect(!schema.isNameBased) + + #expect(schema.dataIndex(from: NSUserInterfaceItemIdentifier("a")) == nil) + #expect(schema.dataIndex(from: NSUserInterfaceItemIdentifier("b")) == nil) + } + + @Test("Positional schema only resolves col_N identifiers within range") + func positionalSchemaResolvesOnlyValidPositions() { + let schema = ColumnIdentitySchema(columns: ["a", "b", "a"]) + + #expect(schema.dataIndex(from: NSUserInterfaceItemIdentifier("col_0")) == 0) + #expect(schema.dataIndex(from: NSUserInterfaceItemIdentifier("col_1")) == 1) + #expect(schema.dataIndex(from: NSUserInterfaceItemIdentifier("col_2")) == 2) + #expect(schema.dataIndex(from: NSUserInterfaceItemIdentifier("col_3")) == nil) + #expect(schema.dataIndex(from: NSUserInterfaceItemIdentifier("col_-1")) == nil) + } + + @Test("Name-based schema does not resolve positional identifiers like col_0") + func nameBasedSchemaDoesNotResolvePositional() { + let schema = ColumnIdentitySchema(columns: ["id", "name", "email"]) + + #expect(schema.dataIndex(from: NSUserInterfaceItemIdentifier("col_0")) == nil) + #expect(schema.dataIndex(from: NSUserInterfaceItemIdentifier("col_1")) == nil) + #expect(schema.dataIndex(from: NSUserInterfaceItemIdentifier("col_2")) == nil) + } + + @Test("Duplicate-name positional schema disagrees with the layout key for that name") + func positionalSchemaIdentifiersDoNotMatchColumnNames() { + let schema = ColumnIdentitySchema(columns: ["id", "name", "name"]) + + #expect(!schema.isNameBased) + #expect(schema.identifier(for: 0)?.rawValue == "col_0") + #expect(schema.identifier(for: 1)?.rawValue == "col_1") + #expect(schema.identifier(for: 2)?.rawValue == "col_2") + } + + @Test("Single column with duplicate-trigger reserved name produces positional id") + func singleReservedNameTriggersPositional() { + let schema = ColumnIdentitySchema(columns: ["__rowNumber__"]) + + #expect(!schema.isNameBased) + #expect(schema.identifier(for: 0)?.rawValue == "col_0") + #expect(schema.dataIndex(from: ColumnIdentitySchema.rowNumberIdentifier) == nil) + } + + @Test("Schema with three duplicates uses positional fallback consistently") + func tripleDuplicateUsesPositionalFallback() { + let schema = ColumnIdentitySchema(columns: ["x", "x", "x"]) + + #expect(!schema.isNameBased) + for index in 0..<3 { + #expect(schema.identifier(for: index)?.rawValue == "col_\(index)") + } + } + + @Test("Empty array column input is name-based and resolves nothing") + func emptyColumnsInputIsNameBased() { + let schema = ColumnIdentitySchema(columns: []) + #expect(schema.isNameBased) + #expect(schema.identifiers.isEmpty) + #expect(schema.dataIndex(from: NSUserInterfaceItemIdentifier("anything")) == nil) + } } From 439b95e6961efa84d1719297fe5510e783146129 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 30 Apr 2026 02:19:37 +0700 Subject: [PATCH 10/14] test(datagrid): cover MainContentCoordinator tab switch state save and restore --- ...MainContentCoordinatorTabSwitchTests.swift | 513 ++++++++++++++++++ 1 file changed, 513 insertions(+) create mode 100644 TableProTests/Views/Main/MainContentCoordinatorTabSwitchTests.swift diff --git a/TableProTests/Views/Main/MainContentCoordinatorTabSwitchTests.swift b/TableProTests/Views/Main/MainContentCoordinatorTabSwitchTests.swift new file mode 100644 index 000000000..b0d0f4395 --- /dev/null +++ b/TableProTests/Views/Main/MainContentCoordinatorTabSwitchTests.swift @@ -0,0 +1,513 @@ +// +// MainContentCoordinatorTabSwitchTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("MainContentCoordinator handleTabChange") +@MainActor +struct MainContentCoordinatorTabSwitchTests { + private func makeCoordinator() -> (MainContentCoordinator, QueryTabManager) { + let tabManager = QueryTabManager() + let coordinator = MainContentCoordinator( + connection: TestFixtures.makeConnection(), + tabManager: tabManager, + changeManager: DataChangeManager(), + filterStateManager: FilterStateManager(), + columnVisibilityManager: ColumnVisibilityManager(), + toolbarState: ConnectionToolbarState() + ) + return (coordinator, tabManager) + } + + private func addQueryTab( + to tabManager: QueryTabManager, + title: String = "Query 1", + query: String = "SELECT 1" + ) -> UUID { + var tab = QueryTab(title: title, query: query, tabType: .query) + tab.execution.lastExecutedAt = Date() + tabManager.tabs.append(tab) + tabManager.selectedTabId = tab.id + return tab.id + } + + private func addTableTab( + to tabManager: QueryTabManager, + tableName: String, + databaseName: String = "" + ) -> UUID { + var tab = QueryTab( + title: tableName, + query: "SELECT * FROM \(tableName)", + tabType: .table, + tableName: tableName + ) + tab.tableContext.databaseName = databaseName + tab.tableContext.isEditable = true + tab.execution.lastExecutedAt = Date() + tabManager.tabs.append(tab) + tabManager.selectedTabId = tab.id + return tab.id + } + + private func seedRows( + _ coordinator: MainContentCoordinator, + for tabId: UUID, + columns: [String] = ["id", "name"], + rowCount: Int = 3 + ) { + let rows = (0..