diff --git a/.github/workflows/macos-tests.yml b/.github/workflows/macos-tests.yml index a6c3e82a1..a95122f85 100644 --- a/.github/workflows/macos-tests.yml +++ b/.github/workflows/macos-tests.yml @@ -6,6 +6,7 @@ on: - "TablePro/**" - "Plugins/**" - "Packages/**" + - "LocalPackages/**" - "TableProTests/**" - "TablePro.xcodeproj/**" - "Libs/**" @@ -16,6 +17,7 @@ on: - "TablePro/**" - "Plugins/**" - "Packages/**" + - "LocalPackages/**" - "TableProTests/**" - "TablePro.xcodeproj/**" - "Libs/**" @@ -49,6 +51,21 @@ jobs: - name: Run package tests run: swift test --package-path Packages/TableProCore + editor-tests: + name: CodeEditTextView Package Tests + runs-on: macos-26 + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - name: Select Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '26.4.1' + + - name: Run editor package tests + run: swift test --package-path LocalPackages/CodeEditTextView + app-tests: name: macOS App Tests runs-on: macos-26 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fde443f9..d775fede0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Autocomplete now keeps suggesting tables and columns after a connection drops and reconnects on its own. The reconnect cleared the schema it had loaded and never asked for it again, so a window that looked connected offered nothing but keywords for the rest of the session. +- The JSON view of a result now updates when you run another query. It kept showing the previous result until you switched to the data grid and back, and a query that returned the same number of rows never updated at all. +- The JSON view shows the whole result again when no row is selected. A selection left over from an earlier query made it show an empty list, and a column filter made it show the wrong rows. +- Running a query now clears the row selection from the previous result, so the row inspector and Copy act on rows you actually picked. +- Double-clicking a word in the SQL editor selects it again, including the last word in the query. It selected nothing when text was already selected, and stayed dead until you clicked once somewhere else. +- Triple-clicking selects the line under the pointer, and both now act on the word or line you clicked rather than wherever the cursor happened to be. +- Text in a read-only editor, such as the JSON view of a result, can now be clicked and selected. Only dragging worked before. - AI chat replies now stream smoothly instead of arriving in visible jumps, and the panel no longer slows down as a reply grows. Every update used to redraw every message on screen. - The first words of an AI reply now appear as soon as they arrive, instead of waiting for the next batch. - Bold, italic, code, and link markers no longer flash as raw punctuation while an AI reply is still being written. diff --git a/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+clamped.swift b/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+clamped.swift new file mode 100644 index 000000000..241614a97 --- /dev/null +++ b/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+clamped.swift @@ -0,0 +1,19 @@ +// +// NSRange+clamped.swift +// CodeEditTextView +// + +import Foundation + +extension NSRange { + /// Returns the range moved inside `0.. NSRange { + let start = Swift.min(Swift.max(self.location, 0), length) + let end = Swift.min(Swift.max(self.max, 0), length) + return NSRange(location: start, length: Swift.max(0, end - start)) + } +} diff --git a/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift b/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift index 74b644e98..46569d0cb 100644 --- a/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift +++ b/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift @@ -75,6 +75,7 @@ public class TextSelectionManager: NSObject { /// - Parameter range: The range to set. public func setSelectedRange(_ range: NSRange) { textSelections.forEach { $0.view?.removeFromSuperview() } + let range = range.clamped(toLength: textStorage?.length ?? 0) let selection = TextSelection(range: range) selection.suggestedXPos = layoutManager?.rectForOffset(range.location)?.minX textSelections = [selection] @@ -88,12 +89,9 @@ public class TextSelectionManager: NSObject { let oldRanges = textSelections.map(\.range) textSelections.forEach { $0.view?.removeFromSuperview() } - // Remove duplicates, invalid ranges, update suggested X position. - textSelections = Set(ranges) - .filter { - (0...(textStorage?.length ?? 0)).contains($0.location) - && (0...(textStorage?.length ?? 0)).contains($0.max) - } + // Remove duplicates, clamp out-of-bounds ranges, update suggested X position. + let storageLength = textStorage?.length ?? 0 + textSelections = Set(ranges.map { $0.clamped(toLength: storageLength) }) .sorted(by: { $0.location < $1.location }) .map { let selection = TextSelection(range: $0) @@ -141,11 +139,21 @@ public class TextSelectionManager: NSObject { /// optionally reseting the blink timer. func updateSelectionViews(force: Bool = false, skipTimerReset: Bool = false) { guard textView?.isFirstResponder ?? false else { return } + // A selectable but non-editable view tracks a collapsed selection so the user can extend + // it or select a word, but it must not blink an insertion point at text it cannot edit. + // This mirrors `NSTextView` with `isEditable = false` and `isSelectable = true`. + let showsInsertionPoint = textView?.isEditable ?? true var didUpdate: Bool = false for textSelection in textSelections { if textSelection.range.isEmpty { - didUpdate = didUpdate || repositionCursorSelection(textSelection: textSelection) + if showsInsertionPoint { + didUpdate = didUpdate || repositionCursorSelection(textSelection: textSelection) + } else if textSelection.view != nil { + textSelection.view?.removeFromSuperview() + textSelection.view = nil + didUpdate = true + } } else if !textSelection.range.isEmpty && textSelection.view != nil { textSelection.view?.removeFromSuperview() textSelection.view = nil diff --git a/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextView/TextView+Mouse.swift b/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextView/TextView+Mouse.swift index 0609665f0..5c7a005d8 100644 --- a/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextView/TextView+Mouse.swift +++ b/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextView/TextView+Mouse.swift @@ -27,9 +27,9 @@ extension TextView { case 1: handleSingleClick(event: event, offset: offset) case 2: - handleDoubleClick(event: event) + handleDoubleClick(event: event, offset: offset) case 3: - handleTripleClick(event: event) + handleTripleClick(event: event, offset: offset) default: break } @@ -43,43 +43,58 @@ extension TextView { fileprivate func handleSingleClick(event: NSEvent, offset: Int) { cursorSelectionMode = .character - guard isEditable else { - super.mouseDown(with: event) - return - } let eventFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) if eventFlags == [.control, .shift] { + guard isEditable else { + super.mouseDown(with: event) + return + } unmarkText() selectionManager.addSelectedRange(NSRange(location: offset, length: 0)) } else if eventFlags.contains(.shift) { - unmarkText() + if isEditable { + unmarkText() + } shiftClickExtendSelection(to: offset) } else { selectionManager.setSelectedRange(NSRange(location: offset, length: 0)) - unmarkTextIfNeeded() + if isEditable { + unmarkTextIfNeeded() + } } } - fileprivate func handleDoubleClick(event: NSEvent) { - cursorSelectionMode = .word - + /// Selects the word under the pointer. + /// + /// The boundary is found from the clicked offset rather than from the current selection, so a + /// double click lands on the right word no matter what was selected before. The first click of + /// the pair does not always reach ``handleSingleClick``: the drag gesture holds it back when it + /// falls inside an existing selection, and a document swap can leave the view with no selection + /// at all. + fileprivate func handleDoubleClick(event: NSEvent, offset: Int) { guard !event.modifierFlags.contains(.shift) else { super.mouseDown(with: event) return } - unmarkText() - selectWord(nil) + if isEditable { + unmarkText() + } + selectionManager.setSelectedRange(findWordBoundary(at: offset)) + cursorSelectionMode = .word + needsDisplay = true } - fileprivate func handleTripleClick(event: NSEvent) { - cursorSelectionMode = .line - + fileprivate func handleTripleClick(event: NSEvent, offset: Int) { guard !event.modifierFlags.contains(.shift) else { super.mouseDown(with: event) return } - unmarkText() - selectLine(nil) + if isEditable { + unmarkText() + } + selectionManager.setSelectedRange(findLineBoundary(at: offset)) + cursorSelectionMode = .line + needsDisplay = true } fileprivate func handleAttachmentClick(event: NSEvent, offset: Int, attachment: AnyTextAttachment) { diff --git a/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextView/TextView+Select.swift b/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextView/TextView+Select.swift index 390b6225d..dcbd1a490 100644 --- a/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextView/TextView+Select.swift +++ b/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextView/TextView+Select.swift @@ -28,12 +28,9 @@ extension TextView { } override public func selectWord(_ sender: Any?) { - let newSelections = selectionManager.textSelections.compactMap { (textSelection) -> NSRange? in - guard textSelection.range.isEmpty else { - return nil - } - return findWordBoundary(at: textSelection.range.location) - } + let newSelections = selectionManager.textSelections.map { textSelection in + findWordBoundary(at: textSelection.range.location) + } selectionManager.setSelectedRanges(newSelections) unmarkTextIfNeeded() needsDisplay = true @@ -63,10 +60,14 @@ extension TextView { return NSRange(location: position, length: 0) } - guard let start = textStorage.findPrecedingOccurrenceOfCharacter(in: characterSet.inverted, from: position), - let end = textStorage.findNextOccurrenceOfCharacter(in: characterSet.inverted, from: position) else { - return NSRange(location: position, length: 0) - } + // The scan returns nil once it runs past the end of the storage, which is what happens for + // the last word in a document: there is no character after it to end the word. The start + // and the end of the document are word boundaries too. + let start = textStorage.findPrecedingOccurrenceOfCharacter(in: characterSet.inverted, from: position) ?? 0 + let end = textStorage.findNextOccurrenceOfCharacter( + in: characterSet.inverted, + from: position + ) ?? textStorage.length return NSRange(start: start, end: end) } diff --git a/LocalPackages/CodeEditTextView/Tests/CodeEditTextViewTests/TextSelectionManagerTests.swift b/LocalPackages/CodeEditTextView/Tests/CodeEditTextViewTests/TextSelectionManagerTests.swift index ecfa6ab84..da43fd6c9 100644 --- a/LocalPackages/CodeEditTextView/Tests/CodeEditTextViewTests/TextSelectionManagerTests.swift +++ b/LocalPackages/CodeEditTextView/Tests/CodeEditTextViewTests/TextSelectionManagerTests.swift @@ -223,4 +223,29 @@ final class TextSelectionManagerTests: XCTestCase { selectionManager.setSelectedRange(NSRange(location: 6, length: 0)) // Beyond text.length, end of doc XCTAssertNotNil(selectionManager.textSelections.first?.suggestedXPos) } + + func test_setSelectedRangesClampsBeyondStorageInsteadOfDropping() { + let selectionManager = selectionManager("short") + + selectionManager.setSelectedRanges([NSRange(location: 50, length: 0)]) + + XCTAssertEqual(selectionManager.textSelections.count, 1) + XCTAssertEqual(selectionManager.textSelections.first?.range, NSRange(location: 5, length: 0)) + } + + func test_setSelectedRangesClampsAnOverlongRangeToTheStorage() { + let selectionManager = selectionManager("short") + + selectionManager.setSelectedRanges([NSRange(location: 2, length: 40)]) + + XCTAssertEqual(selectionManager.textSelections.first?.range, NSRange(location: 2, length: 3)) + } + + func test_setSelectedRangeClampsBeyondStorage() { + let selectionManager = selectionManager("short") + + selectionManager.setSelectedRange(NSRange(location: 50, length: 10)) + + XCTAssertEqual(selectionManager.textSelections.first?.range, NSRange(location: 5, length: 0)) + } } diff --git a/LocalPackages/CodeEditTextView/Tests/CodeEditTextViewTests/WordSelectionTests.swift b/LocalPackages/CodeEditTextView/Tests/CodeEditTextViewTests/WordSelectionTests.swift new file mode 100644 index 000000000..6ab87b276 --- /dev/null +++ b/LocalPackages/CodeEditTextView/Tests/CodeEditTextViewTests/WordSelectionTests.swift @@ -0,0 +1,137 @@ +import Testing +import AppKit +@testable import CodeEditTextView + +@Suite +@MainActor +struct WordSelectionTests { + let text = "SELECT firstname FROM users" + + func makeTextView(isEditable: Bool = true) -> TextView { + let textView = TextView(string: text) + textView.isEditable = isEditable + textView.isSelectable = true + textView.frame = NSRect(x: 0, y: 0, width: 500, height: 100) + textView.layoutSubtreeIfNeeded() + return textView + } + + func offset(ofWord word: String) -> Int { + (text as NSString).range(of: word).location + } + + func mouseDown(on textView: TextView, atOffset offset: Int, clickCount: Int) { + guard let rect = textView.layoutManager.rectForOffset(offset) else { + Issue.record("No rect for offset \(offset)") + return + } + let point = textView.convert(CGPoint(x: rect.midX + 1, y: rect.midY), to: nil) + guard let event = NSEvent.mouseEvent( + with: .leftMouseDown, + location: point, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: 0, + context: nil, + eventNumber: 0, + clickCount: clickCount, + pressure: 1.0 + ) else { + Issue.record("Could not build a mouse event") + return + } + textView.mouseDown(with: event) + } + + @Test + func selectWordKeepsAWordWhenTheSelectionIsNotEmpty() { + let textView = makeTextView() + let firstname = (text as NSString).range(of: "firstname") + textView.selectionManager.setSelectedRange(firstname) + + textView.selectWord(nil) + + #expect(textView.selectionManager.textSelections.count == 1) + #expect(textView.selectionManager.textSelections.first?.range == firstname) + } + + @Test + func doubleClickSelectsTheWordUnderThePointer() { + let textView = makeTextView() + + mouseDown(on: textView, atOffset: offset(ofWord: "firstname"), clickCount: 1) + mouseDown(on: textView, atOffset: offset(ofWord: "firstname"), clickCount: 2) + + #expect(textView.selectionManager.textSelections.first?.range == (text as NSString).range(of: "firstname")) + } + + @Test + func doubleClickSelectsTheWordEvenWhenAnotherSelectionExists() { + let textView = makeTextView() + textView.selectionManager.setSelectedRange((text as NSString).range(of: "SELECT firstname")) + + mouseDown(on: textView, atOffset: offset(ofWord: "users"), clickCount: 2) + + #expect(textView.selectionManager.textSelections.first?.range == (text as NSString).range(of: "users")) + } + + @Test + func doubleClickSelectsAWordWhenNothingIsSelected() { + let textView = makeTextView() + textView.selectionManager.setSelectedRanges([]) + + mouseDown(on: textView, atOffset: offset(ofWord: "users"), clickCount: 2) + + #expect(textView.selectionManager.textSelections.first?.range == (text as NSString).range(of: "users")) + } + + @Test + func wordBoundaryTreatsTheEndOfTheDocumentAsABoundary() { + let textView = makeTextView() + + let range = textView.findWordBoundary(at: offset(ofWord: "users")) + + #expect(range == (text as NSString).range(of: "users")) + } + + @Test + func wordBoundaryTreatsTheStartOfTheDocumentAsABoundary() { + let textView = makeTextView() + + let range = textView.findWordBoundary(at: 0) + + #expect(range == (text as NSString).range(of: "SELECT")) + } + + @Test + func tripleClickSelectsTheLineUnderThePointer() { + let textView = TextView(string: "first line\nsecond line\n") + textView.frame = NSRect(x: 0, y: 0, width: 500, height: 100) + textView.layoutSubtreeIfNeeded() + + mouseDown(on: textView, atOffset: 14, clickCount: 3) + + let selected = textView.selectionManager.textSelections.first?.range + #expect(selected == NSRange(location: 11, length: 12)) + } + + @Test + func singleClickPlacesTheSelectionInANonEditableView() { + let textView = makeTextView(isEditable: false) + let target = offset(ofWord: "users") + + mouseDown(on: textView, atOffset: target, clickCount: 1) + + #expect(textView.selectionManager.textSelections.count == 1) + #expect(textView.selectionManager.textSelections.first?.range.isEmpty == true) + } + + @Test + func doubleClickSelectsAWordInANonEditableView() { + let textView = makeTextView(isEditable: false) + + mouseDown(on: textView, atOffset: offset(ofWord: "firstname"), clickCount: 2) + + #expect(textView.selectionManager.textSelections.first?.range == (text as NSString).range(of: "firstname")) + } +} diff --git a/TablePro/Core/Database/DatabaseManager+Health.swift b/TablePro/Core/Database/DatabaseManager+Health.swift index 8f0f4e80e..d60caab75 100644 --- a/TablePro/Core/Database/DatabaseManager+Health.swift +++ b/TablePro/Core/Database/DatabaseManager+Health.swift @@ -56,46 +56,7 @@ extension DatabaseManager { }, reconnectHandler: { [weak self] in guard let self else { return .abort } - guard let session = await self.activeSessions[connectionId] else { return .abort } - await SchemaService.shared.invalidate(connectionId: connectionId) - await DatabaseTreeMetadataService.shared.handleReconnect(connectionId: connectionId) - do { - guard let result = try await self.trackOperation(sessionId: connectionId, operation: { - try await self.reconnectDriver(for: session) - }) else { - await self.updateSession(connectionId) { session in - session.status = .disconnected - } - return .abort - } - await self.updateSession(connectionId) { session in - session.driver = result.driver - session.effectiveConnection = result.effectiveConnection - session.status = .connected - if let schemaDriver = result.driver as? SchemaSwitchable { - session.browseSchema = schemaDriver.currentSchema - } - if let cachedPassword = result.cachedPassword, - !session.connection.usesAWSIAM - { - session.cachedPassword = cachedPassword - } - } - return .success - } catch { - Self.logger.debug("Reconnect failed: \(error.localizedDescription)") - // Auth failures are not transient. Retrying with the same expired - // credential just re-prompts on every attempt, so stop the loop. - if await self.isAuthenticationFailure(error) { - await self.updateSession(connectionId) { session in - session.status = .error( - String(format: String(localized: "Reconnect failed: %@"), error.localizedDescription) - ) - } - return .abort - } - return .retry - } + return await self.performHealthMonitorReconnect(connectionId: connectionId) }, onStateChanged: { [weak self] id, state in guard let self else { return } @@ -128,6 +89,55 @@ extension DatabaseManager { await monitor.startMonitoring() } + /// Reconnects a session the health monitor found unreachable. + /// + /// The schema cache is only prepared for reload, never invalidated: a background reconnect + /// is not a teardown, and clearing the cache here leaves the sidebar and autocomplete empty + /// with nothing scheduled to refill them. Success publishes `databaseDidConnect` so the same + /// listeners that reload after a first connect or a manual reconnect run here too. + internal func performHealthMonitorReconnect(connectionId: UUID) async -> ConnectionHealthMonitor.ReconnectOutcome { + guard let session = activeSessions[connectionId] else { return .abort } + await SchemaService.shared.prepareForReload(connectionId: connectionId) + await DatabaseTreeMetadataService.shared.handleReconnect(connectionId: connectionId) + + do { + guard let result = try await trackOperation(sessionId: connectionId, operation: { + try await self.reconnectDriver(for: session) + }) else { + updateSession(connectionId) { session in + session.status = .disconnected + } + return .abort + } + updateSession(connectionId) { session in + session.driver = result.driver + session.effectiveConnection = result.effectiveConnection + session.status = .connected + if let schemaDriver = result.driver as? SchemaSwitchable { + session.browseSchema = schemaDriver.currentSchema + } + if let cachedPassword = result.cachedPassword, + !session.connection.usesAWSIAM + { + session.cachedPassword = cachedPassword + } + } + AppEvents.shared.databaseDidConnect.send(DatabaseDidConnect(connectionId: connectionId)) + return .success + } catch { + Self.logger.debug("Reconnect failed: \(error.localizedDescription)") + if isAuthenticationFailure(error) { + updateSession(connectionId) { session in + session.status = .error( + String(format: String(localized: "Reconnect failed: %@"), error.localizedDescription) + ) + } + return .abort + } + return .retry + } + } + /// Result of a driver reconnect, containing the new driver and its effective connection. internal struct ReconnectResult { let driver: DatabaseDriver @@ -237,7 +247,7 @@ extension DatabaseManager { session.status = .connecting } - await SchemaService.shared.invalidate(connectionId: sessionId) + await SchemaService.shared.prepareForReload(connectionId: sessionId) await DatabaseTreeMetadataService.shared.handleReconnect(connectionId: sessionId) await stopHealthMonitor(for: sessionId) diff --git a/TablePro/Core/Services/Query/SchemaRefreshService.swift b/TablePro/Core/Services/Query/SchemaRefreshService.swift index 244aa16c9..8301de797 100644 --- a/TablePro/Core/Services/Query/SchemaRefreshService.swift +++ b/TablePro/Core/Services/Query/SchemaRefreshService.swift @@ -75,17 +75,35 @@ final class SchemaRefreshService { /// must get one scoped to the browsed database rather than the shared session driver, /// which a tab's execution moves without writing session state. func syncAutocompleteProvider(connectionId: UUID) async { - guard case .loaded = schemaService.state(for: connectionId), - let provider = providerRegistry.provider(for: connectionId), - let browseDatabase = databaseManager?.browseScope(for: connectionId)?.database - else { + guard case .loaded = schemaService.state(for: connectionId) else { + Self.logger.debug( + "[schema] autocomplete sync skipped, schema not loaded connId=\(connectionId, privacy: .public)" + ) + return + } + guard let provider = providerRegistry.provider(for: connectionId) else { + Self.logger.debug( + "[schema] autocomplete sync skipped, no provider connId=\(connectionId, privacy: .public)" + ) + return + } + guard let browseDatabase = metadataDriverProvider.browseScope(for: connectionId)?.database else { + Self.logger.debug( + "[schema] autocomplete sync skipped, no browse scope connId=\(connectionId, privacy: .public)" + ) return } let tables = schemaService.allLoadedTables(for: connectionId) let schemas = schemaService.schemas(for: connectionId) - try? await databaseManager?.withBrowseMetadataDriver(connectionId: connectionId) { driver in - await provider.resetForDatabase(browseDatabase, tables: tables, driver: driver) - await provider.setNamespaces(schemas: schemas, databases: [browseDatabase]) + do { + try await metadataDriverProvider.withBrowseMetadataDriver(connectionId: connectionId) { driver in + await provider.resetForDatabase(browseDatabase, tables: tables, driver: driver) + await provider.setNamespaces(schemas: schemas, databases: [browseDatabase]) + } + } catch { + Self.logger.warning( + "[schema] autocomplete sync failed connId=\(connectionId, privacy: .public) error=\(error.localizedDescription, privacy: .public)" + ) } } diff --git a/TablePro/Models/Query/TabSession.swift b/TablePro/Models/Query/TabSession.swift index 528999338..1925e8297 100644 --- a/TablePro/Models/Query/TabSession.swift +++ b/TablePro/Models/Query/TabSession.swift @@ -79,6 +79,12 @@ final class TabSession: Identifiable { var tableRows: TableRows var isEvicted: Bool + /// Bumped by `TabSessionRegistry` on every mutation of `tableRows`. `TableRows` is a + /// value type with no `Equatable` conformance, so views that derive expensive content + /// from it key their rebuild on this counter instead of on a proxy like the row count, + /// which cannot distinguish two different results of the same size. + var dataRevision: Int + // MARK: - Init /// Lift a `QueryTab` value into a `TabSession` reference. Used at the @@ -106,6 +112,7 @@ final class TabSession: Identifiable { self.loadEpoch = queryTab.loadEpoch self.tableRows = TableRows() self.isEvicted = false + self.dataRevision = 0 } /// Build a `TabSession` from primitive parameters, mirroring `QueryTab.init`. @@ -139,6 +146,7 @@ final class TabSession: Identifiable { self.loadEpoch = 0 self.tableRows = TableRows() self.isEvicted = false + self.dataRevision = 0 } // MARK: - Conversion diff --git a/TablePro/Models/Query/TabSessionRegistry.swift b/TablePro/Models/Query/TabSessionRegistry.swift index d14b79ee5..f916b71fa 100644 --- a/TablePro/Models/Query/TabSessionRegistry.swift +++ b/TablePro/Models/Query/TabSessionRegistry.swift @@ -45,6 +45,7 @@ final class TabSessionRegistry { let session = ensureSession(for: tabId) session.tableRows = rows session.isEvicted = false + session.dataRevision &+= 1 } func updateTableRows(for tabId: UUID, _ mutate: (inout TableRows) -> Void) { @@ -53,12 +54,14 @@ final class TabSessionRegistry { mutate(&rows) session.tableRows = rows session.isEvicted = false + session.dataRevision &+= 1 } func removeTableRows(for tabId: UUID) { guard let session = sessions[tabId] else { return } session.tableRows = TableRows() session.isEvicted = false + session.dataRevision &+= 1 } func isEvicted(_ tabId: UUID) -> Bool { @@ -82,6 +85,7 @@ final class TabSessionRegistry { session.tableRows.rows = [] session.isEvicted = true session.loadEpoch &+= 1 + session.dataRevision &+= 1 } func evictAll(except activeTabId: UUID?) { @@ -90,6 +94,7 @@ final class TabSessionRegistry { session.tableRows.rows = [] session.isEvicted = true session.loadEpoch &+= 1 + session.dataRevision &+= 1 } } diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 39be2bc4c..cc060f345 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -555,8 +555,11 @@ struct MainEditorContentView: View { resultTabBarSection(tab: tab) ResultsJsonView( tableRows: resolvedTableRows(for: tab), - selectedRowIndices: selectionState.indices + selectedRowIndices: selectionState.indices, + displayIDs: coordinator.activeGridDisplayIDs, + dataRevision: coordinator.tabSessionRegistry.session(for: tab.id)?.dataRevision ?? 0 ) + .id(tab.id) case .data: if let explainText = tab.display.explainText { ExplainResultView(text: explainText, executionTime: tab.display.explainExecutionTime, plan: tab.display.explainPlan) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift index 96fb92c23..ee0a3248b 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift @@ -25,6 +25,7 @@ extension MainContentCoordinator { func setActiveTableRows(_ tableRows: TableRows, for tabId: UUID) { tabSessionRegistry.setTableRows(tableRows, for: tabId) + resetSelectionForNewResult(tabId: tabId) notifyFullReplaceIfActive(tabId: tabId) } @@ -36,11 +37,31 @@ extension MainContentCoordinator { tabManager.mutate(at: tabIdx) { $0.display.activeResultSetId = resultSetId } if let incoming = tabManager.tabs[tabIdx].display.activeResultSet { tabSessionRegistry.setTableRows(incoming.tableRows, for: tabId) + resetSelectionForNewResult(tabId: tabId) syncLoadMoreState(from: incoming, at: tabIdx) notifyFullReplaceIfActive(tabId: tabId) } } + /// Row selection is a set of display positions into the result that produced it, so it + /// means nothing once the rows are replaced wholesale. Leaving it in place points every + /// consumer, the JSON view and the row inspector included, at rows that no longer exist. + /// Incremental edits go through `mutateActiveTableRows` and keep their selection. + private func resetSelectionForNewResult(tabId: UUID) { + tabManager.mutate(tabId: tabId) { tab in + guard !tab.selectedRowIndices.isEmpty else { return } + tab.selectedRowIndices = [] + } + tabSessionRegistry.session(for: tabId)?.selectedRowIndices = [] + guard let idx = tabManager.selectedTabIndex, + idx < tabManager.tabs.count, + tabManager.tabs[idx].id == tabId else { return } + dataTabDelegate?.tableViewCoordinator?.clearRowSelection() + if !selectionState.indices.isEmpty { + selectionState.indices = [] + } + } + private func syncLoadMoreState(from resultSet: ResultSet, at tabIdx: Int) { guard tabManager.tabs[tabIdx].tabType == .query else { return } tabManager.mutate(at: tabIdx) { tab in diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 90fbe2f4b..d1c93efd8 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -348,6 +348,16 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData tableView.removeRows(at: indices, withAnimation: Self.rowAnimation(.slideUp)) } + /// Drops the row selection before a wholesale replacement. + /// + /// `reloadData()` leaves `selectedRowIndexes` alone when the new result happens to have as + /// many rows as the old one, so without this the grid keeps highlighting positions that now + /// hold different rows, and every consumer of the selection reads those stale positions. + func clearRowSelection() { + guard let tableView, !tableView.selectedRowIndexes.isEmpty else { return } + tableView.deselectAll(nil) + } + func applyFullReplace() { guard let tableView else { return } invalidateAllDisplayCaches() diff --git a/TablePro/Views/Results/ResultsJsonView.swift b/TablePro/Views/Results/ResultsJsonView.swift index ab08cff4b..6b20af1e5 100644 --- a/TablePro/Views/Results/ResultsJsonView.swift +++ b/TablePro/Views/Results/ResultsJsonView.swift @@ -9,6 +9,8 @@ import TableProPluginKit internal struct ResultsJsonView: View { let tableRows: TableRows let selectedRowIndices: Set + let displayIDs: [RowID]? + let dataRevision: Int @State private var viewMode: JSONViewMode @State private var treeSearchText = "" @@ -16,27 +18,42 @@ internal struct ResultsJsonView: View { @State private var parseError: JSONTreeParseError? @State private var prettyText = "" @State private var cachedJson = "" + @State private var resolvedRowCount: Int + @State private var hasRendered = false @State private var copied = false - @State private var renderToken: Int = 0 @State private var copyCooldownTask: Task? init( tableRows: TableRows, - selectedRowIndices: Set + selectedRowIndices: Set, + displayIDs: [RowID]?, + dataRevision: Int ) { self.tableRows = tableRows self.selectedRowIndices = selectedRowIndices + self.displayIDs = displayIDs + self.dataRevision = dataRevision self._viewMode = State(initialValue: AppSettingsManager.shared.editor.jsonViewerPreferredMode) + self._resolvedRowCount = State( + initialValue: selectedRowIndices.isEmpty ? tableRows.count : selectedRowIndices.count + ) + } + + private struct RenderKey: Equatable { + let dataRevision: Int + let selectedRowIndices: Set + } + + private var renderKey: RenderKey { + RenderKey(dataRevision: dataRevision, selectedRowIndices: selectedRowIndices) } private var rowCountText: String { let rowCount = tableRows.count - let selectedCount = selectedRowIndices.count - let displaying = selectedCount == 0 ? rowCount : selectedCount - if selectedRowIndices.isEmpty || displaying == rowCount { + if selectedRowIndices.isEmpty || resolvedRowCount == rowCount { return String(format: String(localized: "%d rows"), rowCount) } - return String(format: String(localized: "%d of %d rows"), displaying, rowCount) + return String(format: String(localized: "%d of %d rows"), resolvedRowCount, rowCount) } var body: some View { @@ -46,9 +63,9 @@ internal struct ResultsJsonView: View { content .frame(maxWidth: .infinity, maxHeight: .infinity) } - .onAppear { startRebuild() } - .onChange(of: selectedRowIndices) { startRebuild() } - .onChange(of: tableRows.count) { startRebuild() } + .task(id: renderKey) { + await rebuild() + } .onChange(of: viewMode) { AppSettingsManager.shared.editor.jsonViewerPreferredMode = viewMode } @@ -93,7 +110,7 @@ internal struct ResultsJsonView: View { } .buttonStyle(.borderless) .controlSize(.small) - .disabled(isInitialComputePending) + .disabled(!hasRendered) } .padding(.horizontal, 10) .padding(.vertical, 6) @@ -101,10 +118,6 @@ internal struct ResultsJsonView: View { // MARK: - Content - private var isInitialComputePending: Bool { - prettyText.isEmpty - } - @ViewBuilder private var content: some View { if tableRows.rows.isEmpty { @@ -113,7 +126,7 @@ internal struct ResultsJsonView: View { systemImage: "curlybraces", description: Text(String(localized: "Execute a query to view results as JSON")) ) - } else if isInitialComputePending { + } else if !hasRendered { ProgressView() .controlSize(.small) .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -152,57 +165,55 @@ internal struct ResultsJsonView: View { // MARK: - JSON Generation - private func startRebuild() { - renderToken &+= 1 - let token = renderToken - let columns = tableRows.columns - let columnTypes = tableRows.columnTypes - let rowsSnapshot = tableRows.rows + private func rebuild() async { + let snapshot = tableRows + let ids = displayIDs let selectedIndices = selectedRowIndices - Task { @MainActor in - let result = await Task.detached(priority: .userInitiated) { - Self.computeJson( - columns: columns, - columnTypes: columnTypes, - rows: rowsSnapshot, - selectedIndices: selectedIndices - ) - }.value - - guard token == renderToken else { return } - cachedJson = result.json - prettyText = result.pretty - switch result.parseResult { - case .success(let node): - parsedTree = node - parseError = nil - case .failure(let error): - parsedTree = nil - parseError = error - } + let result = await Task.detached(priority: .userInitiated) { + Self.computeJson(tableRows: snapshot, displayIDs: ids, selectedIndices: selectedIndices) + }.value + + guard !Task.isCancelled else { return } + cachedJson = result.json + prettyText = result.pretty + resolvedRowCount = result.resolvedCount + switch result.parseResult { + case .success(let node): + parsedTree = node + parseError = nil + case .failure(let error): + parsedTree = nil + parseError = error } + hasRendered = true } - nonisolated private static func computeJson( - columns: [String], - columnTypes: [ColumnType], - rows: ContiguousArray, + /// Selection indices are display positions, so they are resolved through + /// ``DisplayRowMapping`` rather than used to subscript `tableRows.rows` directly: a + /// per-column value filter or a sort makes the two diverge. + nonisolated static func computeJson( + tableRows: TableRows, + displayIDs: [RowID]?, selectedIndices: Set - ) -> (json: String, pretty: String, parseResult: Result) { - let allRows: [[PluginCellValue]] = rows.map { Array($0.values) } + ) -> (json: String, pretty: String, resolvedCount: Int, parseResult: Result) { let displayRows: [[PluginCellValue]] if selectedIndices.isEmpty { - displayRows = allRows + displayRows = tableRows.rows.map { Array($0.values) } } else { - displayRows = selectedIndices.sorted().compactMap { - allRows.indices.contains($0) ? allRows[$0] : nil + displayRows = selectedIndices.sorted().compactMap { displayIndex in + DisplayRowMapping.row(forDisplay: displayIndex, displayIDs: displayIDs, in: tableRows) + .map { Array($0.values) } } } - let converter = JsonRowConverter(columns: columns, columnTypes: columnTypes) + let converter = JsonRowConverter(columns: tableRows.columns, columnTypes: tableRows.columnTypes) let json = converter.generateJson(rows: displayRows) let pretty = json.prettyPrintedAsJson() ?? json - let parseResult = JSONTreeParser.parse(json) - return (json: json, pretty: pretty, parseResult: parseResult) + return ( + json: json, + pretty: pretty, + resolvedCount: displayRows.count, + parseResult: JSONTreeParser.parse(json) + ) } } diff --git a/TableProTests/Core/Database/HealthMonitorReconnectTests.swift b/TableProTests/Core/Database/HealthMonitorReconnectTests.swift new file mode 100644 index 000000000..56fee3746 --- /dev/null +++ b/TableProTests/Core/Database/HealthMonitorReconnectTests.swift @@ -0,0 +1,81 @@ +// +// HealthMonitorReconnectTests.swift +// TableProTests +// +// A background reconnect is not a teardown. It must leave the schema cache in place and +// announce itself the way a first connect does, or the sidebar and the SQL editor's +// autocomplete keep serving an empty schema with nothing scheduled to refill it. +// + +import Combine +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("Health monitor reconnect", .serialized) +@MainActor +struct HealthMonitorReconnectTests { + private func makeConnectedSession() -> DatabaseConnection { + FakeMSSQLPluginRegistration.registerIfNeeded() + var connection = TestFixtures.makeConnection(name: "Prod") + connection.type = DatabaseType(rawValue: FakeMSSQLPlugin.databaseTypeId) + var session = ConnectionSession(connection: connection) + session.status = .connected + DatabaseManager.shared.injectSession(session, for: connection.id) + return connection + } + + private func cleanUp(_ connectionId: UUID) async { + DatabaseManager.shared.removeSession(for: connectionId) + await SchemaService.shared.invalidate(connectionId: connectionId) + } + + @Test("a background reconnect keeps the loaded schema instead of clearing it") + func reconnectKeepsTheLoadedSchema() async { + let connection = makeConnectedSession() + let driver = MockDatabaseDriver() + driver.tablesToReturn = [TableInfo(name: "orders", type: .table, rowCount: 0, schema: nil)] + await SchemaService.shared.load( + connectionId: connection.id, + driver: driver, + connection: connection + ) + #expect(SchemaService.shared.state(for: connection.id) == .loaded(driver.tablesToReturn)) + + _ = await DatabaseManager.shared.performHealthMonitorReconnect(connectionId: connection.id) + + #expect(SchemaService.shared.state(for: connection.id) == .loaded(driver.tablesToReturn)) + await cleanUp(connection.id) + } + + @Test("a successful background reconnect announces itself so listeners reload") + func successfulReconnectPostsDatabaseDidConnect() async { + let connection = makeConnectedSession() + var received: [UUID] = [] + let cancellable = AppEvents.shared.databaseDidConnect.sink { payload in + received.append(payload.connectionId) + } + defer { cancellable.cancel() } + + let outcome = await DatabaseManager.shared.performHealthMonitorReconnect(connectionId: connection.id) + + #expect(outcome == .success) + #expect(received == [connection.id]) + await cleanUp(connection.id) + } + + @Test("a reconnect for a session that is gone aborts without announcing anything") + func missingSessionAborts() async { + var received: [UUID] = [] + let cancellable = AppEvents.shared.databaseDidConnect.sink { payload in + received.append(payload.connectionId) + } + defer { cancellable.cancel() } + + let outcome = await DatabaseManager.shared.performHealthMonitorReconnect(connectionId: UUID()) + + #expect(outcome == .abort) + #expect(received.isEmpty) + } +} diff --git a/TableProTests/Core/Services/Query/SchemaRefreshServiceTests.swift b/TableProTests/Core/Services/Query/SchemaRefreshServiceTests.swift index 6e9f14025..6903132af 100644 --- a/TableProTests/Core/Services/Query/SchemaRefreshServiceTests.swift +++ b/TableProTests/Core/Services/Query/SchemaRefreshServiceTests.swift @@ -52,10 +52,12 @@ private final class FakeScopedMetadataProvider: ScopedMetadataProviding { struct SchemaRefreshServiceTests { private func makeService( schemaService: SchemaService, - provider: FakeScopedMetadataProvider + provider: FakeScopedMetadataProvider, + providerRegistry: SchemaProviderRegistry? = nil ) -> SchemaRefreshService { SchemaRefreshService( schemaService: schemaService, + providerRegistry: providerRegistry ?? SchemaProviderRegistry(), metadataDriverProvider: provider, databaseManager: nil ) @@ -137,6 +139,51 @@ struct SchemaRefreshServiceTests { #expect(isFailed) } + @Test("a refresh pushes the loaded tables into the autocomplete provider") + func refreshPopulatesTheAutocompleteProvider() async { + let driver = MockDatabaseDriver() + driver.tablesToReturn = [ + TableInfo(name: "orders", type: .table, rowCount: 0, schema: nil), + TableInfo(name: "customers", type: .table, rowCount: 0, schema: nil) + ] + let provider = FakeScopedMetadataProvider(driver: driver) + let registry = SchemaProviderRegistry() + let connection = TestFixtures.makeConnection() + let schemaProvider = registry.getOrCreate(for: connection.id) + let service = makeService( + schemaService: SchemaService(), + provider: provider, + providerRegistry: registry + ) + + await service.refresh(connection: connection) + + let names = await schemaProvider.getTables().map(\.name) + #expect(names.sorted() == ["customers", "orders"]) + } + + @Test("no browse scope leaves the autocomplete provider untouched instead of clearing it") + func autocompleteSyncWithoutABrowseScopeKeepsTheCachedTables() async { + let driver = MockDatabaseDriver() + driver.tablesToReturn = [TableInfo(name: "orders", type: .table, rowCount: 0, schema: nil)] + let provider = FakeScopedMetadataProvider(driver: driver) + let registry = SchemaProviderRegistry() + let connection = TestFixtures.makeConnection() + let schemaProvider = registry.getOrCreate(for: connection.id) + let service = makeService( + schemaService: SchemaService(), + provider: provider, + providerRegistry: registry + ) + await service.refresh(connection: connection) + + provider.browseDatabase = nil + await service.syncAutocompleteProvider(connectionId: connection.id) + + let names = await schemaProvider.getTables().map(\.name) + #expect(names == ["orders"]) + } + @Test("a refresh requested after the previous one finished loads again") func sequentialRefreshesReload() async { let driver = MockDatabaseDriver() diff --git a/TableProTests/Models/Query/TabSessionRegistryTests.swift b/TableProTests/Models/Query/TabSessionRegistryTests.swift index 6c5b75d2d..c631e3adf 100644 --- a/TableProTests/Models/Query/TabSessionRegistryTests.swift +++ b/TableProTests/Models/Query/TabSessionRegistryTests.swift @@ -84,4 +84,73 @@ struct TabSessionRegistryTests { #expect(registry.session(for: first.id) === first) #expect(registry.session(for: second.id) === second) } + + // MARK: - dataRevision + + private func makeRows(_ names: [String]) -> TableRows { + let rows = ContiguousArray( + names.enumerated().map { index, name in + Row(id: .existing(index), values: [.text(name)]) + } + ) + return TableRows(rows: rows, columns: ["name"], columnTypes: [.text(rawType: nil)]) + } + + @Test("setTableRows bumps dataRevision so a same-size result still counts as a change") + func setTableRowsBumpsDataRevision() { + let registry = TabSessionRegistry() + let session = TabSession() + registry.register(session) + + registry.setTableRows(makeRows(["a", "b"]), for: session.id) + let afterFirst = session.dataRevision + registry.setTableRows(makeRows(["c", "d"]), for: session.id) + + #expect(afterFirst > 0) + #expect(session.dataRevision > afterFirst) + } + + @Test("updateTableRows bumps dataRevision") + func updateTableRowsBumpsDataRevision() { + let registry = TabSessionRegistry() + let session = TabSession() + registry.register(session) + registry.setTableRows(makeRows(["a"]), for: session.id) + let before = session.dataRevision + + registry.updateTableRows(for: session.id) { rows in + rows.rows.append(Row(id: .existing(1), values: [.text("b")])) + } + + #expect(session.dataRevision > before) + } + + @Test("removeTableRows bumps dataRevision") + func removeTableRowsBumpsDataRevision() { + let registry = TabSessionRegistry() + let session = TabSession() + registry.register(session) + registry.setTableRows(makeRows(["a"]), for: session.id) + let before = session.dataRevision + + registry.removeTableRows(for: session.id) + + #expect(session.dataRevision > before) + } + + @Test("evict bumps dataRevision, and leaves it alone when there is nothing to evict") + func evictBumpsDataRevision() { + let registry = TabSessionRegistry() + let session = TabSession() + registry.register(session) + registry.setTableRows(makeRows(["a"]), for: session.id) + let before = session.dataRevision + + registry.evict(for: session.id) + let afterEvict = session.dataRevision + registry.evict(for: session.id) + + #expect(afterEvict > before) + #expect(session.dataRevision == afterEvict) + } } diff --git a/TableProTests/Views/Main/MainContentCoordinatorSelectionResetTests.swift b/TableProTests/Views/Main/MainContentCoordinatorSelectionResetTests.swift new file mode 100644 index 000000000..c695ccc33 --- /dev/null +++ b/TableProTests/Views/Main/MainContentCoordinatorSelectionResetTests.swift @@ -0,0 +1,105 @@ +// +// MainContentCoordinatorSelectionResetTests.swift +// TableProTests +// +// A row selection is a set of display positions into the result that produced it. Replacing +// the rows wholesale, by running a query or switching result set, has to drop it: consumers +// that keep reading it, the JSON results view and the row inspector, would otherwise resolve +// positions that belong to rows the user never selected. +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +@Suite("MainContentCoordinator selection reset") +@MainActor +struct MainContentCoordinatorSelectionResetTests { + private func makeCoordinator() -> (MainContentCoordinator, QueryTabManager) { + let tabManager = QueryTabManager() + let coordinator = MainContentCoordinator( + connection: TestFixtures.makeConnection(), + tabManager: tabManager, + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + return (coordinator, tabManager) + } + + @discardableResult + private func addQueryTab(to tabManager: QueryTabManager, select: Bool = true) -> UUID { + var tab = QueryTab(title: "Query", query: "SELECT 1", tabType: .query) + tab.execution.lastExecutedAt = Date() + tabManager.tabs.append(tab) + if select { + tabManager.selectedTabId = tab.id + } + return tab.id + } + + private func makeRows(_ count: Int) -> TableRows { + let rows = ContiguousArray( + (0.. TableRows { + let rows: ContiguousArray = [ + Row(id: .existing(0), values: [.text("active"), .text("a")]), + Row(id: .existing(1), values: [.text("inactive"), .text("b")]), + Row(id: .existing(2), values: [.text("active"), .text("c")]), + Row(id: .existing(3), values: [.null, .text("d")]), + ] + return TableRows( + rows: rows, + columns: ["status", "name"], + columnTypes: [.text(rawType: nil), .text(rawType: nil)] + ) + } + + private func compute( + displayIDs: [RowID]? = nil, + selectedIndices: Set + ) -> (json: String, pretty: String, resolvedCount: Int, parseResult: Result) { + ResultsJsonView.computeJson( + tableRows: makeTableRows(), + displayIDs: displayIDs, + selectedIndices: selectedIndices + ) + } + + @Test("no selection renders every row") + func noSelectionRendersEveryRow() { + let result = compute(selectedIndices: []) + + #expect(result.resolvedCount == 4) + #expect(result.json.contains("\"a\"")) + #expect(result.json.contains("\"d\"")) + } + + @Test("an empty result renders an empty array") + func emptyResultRendersEmptyArray() { + let result = ResultsJsonView.computeJson( + tableRows: TableRows(), + displayIDs: nil, + selectedIndices: [] + ) + + #expect(result.resolvedCount == 0) + #expect(result.json == "[]") + } + + @Test("a selection narrows the output to the selected rows") + func selectionNarrowsTheOutput() { + let result = compute(selectedIndices: [1]) + + #expect(result.resolvedCount == 1) + #expect(result.json.contains("\"b\"")) + #expect(!result.json.contains("\"a\"")) + } + + @Test("a selection resolves through the display order, not the raw row order") + func selectionResolvesThroughDisplayOrder() { + let result = compute(displayIDs: [.existing(0), .existing(2)], selectedIndices: [1]) + + #expect(result.resolvedCount == 1) + #expect(result.json.contains("\"c\"")) + #expect(!result.json.contains("\"b\"")) + } + + @Test("a display index past the filtered set is skipped instead of subscripting the rows") + func outOfRangeDisplayIndexIsSkipped() { + let result = compute(displayIDs: [.existing(0), .existing(2)], selectedIndices: [1, 7]) + + #expect(result.resolvedCount == 1) + #expect(result.json.contains("\"c\"")) + } + + @Test("a selection that resolves to nothing reports zero rows rather than a full result set") + func selectionResolvingToNothingReportsZero() { + let result = compute(selectedIndices: [9, 10]) + + #expect(result.resolvedCount == 0) + #expect(result.json == "[]") + } + + @Test("selected rows are emitted in display order regardless of selection order") + func selectedRowsKeepDisplayOrder() { + let result = compute(selectedIndices: [2, 0]) + + #expect(result.resolvedCount == 2) + let first = result.json.range(of: "\"a\"") + let second = result.json.range(of: "\"c\"") + #expect(first != nil) + #expect(second != nil) + if let first, let second { + #expect(first.lowerBound < second.lowerBound) + } + } +}