diff --git a/Packages/TableProEditor/Sources/TableProTextEngine/EmphasisManager/EmphasisManager.swift b/Packages/TableProEditor/Sources/TableProTextEngine/EmphasisManager/EmphasisManager.swift index 88229f739..af77a724b 100644 --- a/Packages/TableProEditor/Sources/TableProTextEngine/EmphasisManager/EmphasisManager.swift +++ b/Packages/TableProEditor/Sources/TableProTextEngine/EmphasisManager/EmphasisManager.swift @@ -179,6 +179,16 @@ public final class EmphasisManager { emphasisGroups[id, default: []].map(\.emphasis) } + /// The tool tip an emphasis presents at a point in the text view, or `nil` where none is registered. + /// + /// Emphases own their tool tips, and AppKit only ever asks the owner. This is how anything outside the + /// manager reads what the editor would actually show under the pointer. + public func toolTip(at point: CGPoint) -> String? { + guard let textView else { return nil } + let text = toolTips.view(textView, stringForToolTip: 0, point: point, userData: nil) + return text.isEmpty ? nil : text + } + private func registerToolTip(for emphasisLayer: EmphasisLayer) { guard emphasisLayer.emphasis.toolTip != nil, emphasisLayer.isAttached else { return } toolTips.register( diff --git a/TableProTests/Views/Editor/EditorKeyMonitorCompositionTests.swift b/Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/EditorKeyMonitorCompositionTests.swift similarity index 82% rename from TableProTests/Views/Editor/EditorKeyMonitorCompositionTests.swift rename to Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/EditorKeyMonitorCompositionTests.swift index cec6f2775..57098ab7a 100644 --- a/TableProTests/Views/Editor/EditorKeyMonitorCompositionTests.swift +++ b/Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/EditorKeyMonitorCompositionTests.swift @@ -1,6 +1,6 @@ // // EditorKeyMonitorCompositionTests.swift -// TableProTests +// TableProEditorKitTests // import AppKit @@ -63,7 +63,7 @@ internal struct EditorKeyChord: Sendable, CustomTestStringConvertible { @MainActor func event(in window: NSWindow?) -> NSEvent? { - EditorControllerFixture.keyDown(keyCode: keyCode, characters: characters, modifiers: modifiers, in: window) + Mock.keyDown(keyCode: keyCode, characters: characters, modifiers: modifiers, in: window) } } @@ -101,10 +101,10 @@ private struct StubSuggestionEntry: CodeSuggestionEntry { internal struct EditorKeyMonitorCompositionTests { @Test("The editor leaves its plain keys to the input method mid-composition", arguments: EditorKeyChord.inputMethodKeys) func editorKeysDeferToComposition(chord: EditorKeyChord) throws { - let (window, editor) = EditorControllerFixture.makeFocusedInWindow(string: " SELECT 1\nFROM ") + let (window, editor) = Mock.focusedTextViewController(string: " SELECT 1\nFROM ") let delegate = RecordingCompletionDelegate() editor.completionDelegate = delegate - EditorControllerFixture.beginComposition("le", in: editor.textView) + Mock.beginComposition("le", in: editor.textView) let composed = editor.textView.string let event = try #require(chord.event(in: window)) @@ -115,10 +115,10 @@ internal struct EditorKeyMonitorCompositionTests { @Test("The editor keeps its Command chords from the menu bar mid-composition and runs none", arguments: EditorKeyChord.commandChords) func editorCommandChordsWithheldDuringComposition(chord: EditorKeyChord) throws { - let (window, editor) = EditorControllerFixture.makeFocusedInWindow(string: " SELECT 1\nFROM ") + let (window, editor) = Mock.focusedTextViewController(string: " SELECT 1\nFROM ") let delegate = RecordingCompletionDelegate() editor.completionDelegate = delegate - EditorControllerFixture.beginComposition("le", in: editor.textView) + Mock.beginComposition("le", in: editor.textView) let composed = editor.textView.string let event = try #require(chord.event(in: window)) @@ -129,8 +129,8 @@ internal struct EditorKeyMonitorCompositionTests { @Test("A Command chord the editor does not own reaches the menu bar mid-composition", arguments: EditorKeyChord.foreignCommandChords) func foreignCommandChordsPassDuringComposition(chord: EditorKeyChord) throws { - let (window, editor) = EditorControllerFixture.makeFocusedInWindow(string: "SELECT ") - EditorControllerFixture.beginComposition("le", in: editor.textView) + let (window, editor) = Mock.focusedTextViewController(string: "SELECT ") + Mock.beginComposition("le", in: editor.textView) let event = try #require(chord.event(in: window)) #expect(editor.handleEvent(event: event) === event) @@ -139,11 +139,11 @@ internal struct EditorKeyMonitorCompositionTests { @Test("A composition the input method empties gives the editor its keys back", arguments: EditorKeyChord.editorCommands) func emptiedCompositionReturnsKeysToEditor(chord: EditorKeyChord) throws { - let (window, editor) = EditorControllerFixture.makeFocusedInWindow(string: " SELECT 1\nFROM ") + let (window, editor) = Mock.focusedTextViewController(string: " SELECT 1\nFROM ") let delegate = RecordingCompletionDelegate() editor.completionDelegate = delegate - EditorControllerFixture.beginComposition("le", in: editor.textView) - EditorControllerFixture.emptyComposition(in: editor.textView) + Mock.beginComposition("le", in: editor.textView) + Mock.emptyComposition(in: editor.textView) try #require(editor.textView.hasMarkedText() == false) let event = try #require(chord.event(in: window)) @@ -152,7 +152,7 @@ internal struct EditorKeyMonitorCompositionTests { @Test("The editor claims each of those keys when nothing is composing", arguments: EditorKeyChord.editorCommands) func editorCommandsClaimSettledText(chord: EditorKeyChord) throws { - let (window, editor) = EditorControllerFixture.makeFocusedInWindow(string: " SELECT 1\nFROM ") + let (window, editor) = Mock.focusedTextViewController(string: " SELECT 1\nFROM ") let delegate = RecordingCompletionDelegate() editor.completionDelegate = delegate let event = try #require(chord.event(in: window)) @@ -162,10 +162,10 @@ internal struct EditorKeyMonitorCompositionTests { @Test("The completion list leaves its keys to the input method mid-composition", arguments: EditorKeyChord.completionListKeys) func completionListDefersToComposition(chord: EditorKeyChord) throws { - let (window, editor) = EditorControllerFixture.makeFocusedInWindow(string: "SELECT ") + let (window, editor) = Mock.focusedTextViewController(string: "SELECT ") let delegate = RecordingCompletionDelegate() let panel = makeCompletionList(for: editor, delegate: delegate) - EditorControllerFixture.beginComposition("le", in: editor.textView) + Mock.beginComposition("le", in: editor.textView) let event = try #require(chord.event(in: window)) #expect(panel.handleKeyDown(event) === event) @@ -175,7 +175,7 @@ internal struct EditorKeyMonitorCompositionTests { @Test("The completion list claims its keys when nothing is composing", arguments: EditorKeyChord.completionListKeys) func completionListClaimsSettledText(chord: EditorKeyChord) throws { - let (window, editor) = EditorControllerFixture.makeFocusedInWindow(string: "SELECT ") + let (window, editor) = Mock.focusedTextViewController(string: "SELECT ") let delegate = RecordingCompletionDelegate() let panel = makeCompletionList(for: editor, delegate: delegate) let event = try #require(chord.event(in: window)) @@ -185,13 +185,13 @@ internal struct EditorKeyMonitorCompositionTests { @Test("Escape mid-composition in the editor leaves the find panel open") func findPanelDefersToEditorComposition() throws { - let (window, editor) = EditorControllerFixture.makeFocusedInWindow(string: "SELECT ") + let (window, editor) = Mock.focusedTextViewController(string: "SELECT ") let finder = try #require(editor.findViewController) finder.showFindPanel(animated: false) defer { finder.hideFindPanel(animated: false) } _ = window.makeFirstResponder(editor.textView) - EditorControllerFixture.beginComposition("le", in: editor.textView) - let escape = try #require(EditorControllerFixture.keyDown(keyCode: kVK_Escape, characters: "\u{1b}", in: window)) + Mock.beginComposition("le", in: editor.textView) + let escape = try #require(Mock.keyDown(keyCode: kVK_Escape, characters: "\u{1b}", in: window)) #expect(finder.findPanel.handleKeyDown(escape) === escape) #expect(finder.viewModel.isShowingFindPanel) @@ -199,7 +199,7 @@ internal struct EditorKeyMonitorCompositionTests { @Test("Escape mid-composition in a text field leaves the find panel open") func findPanelDefersToFieldComposition() throws { - let (window, editor) = EditorControllerFixture.makeFocusedInWindow(string: "SELECT ") + let (window, editor) = Mock.focusedTextViewController(string: "SELECT ") let finder = try #require(editor.findViewController) finder.showFindPanel(animated: false) defer { finder.hideFindPanel(animated: false) } @@ -213,7 +213,7 @@ internal struct EditorKeyMonitorCompositionTests { replacementRange: NSRange(location: NSNotFound, length: 0) ) try #require(fieldEditor.hasMarkedText()) - let escape = try #require(EditorControllerFixture.keyDown(keyCode: kVK_Escape, characters: "\u{1b}", in: window)) + let escape = try #require(Mock.keyDown(keyCode: kVK_Escape, characters: "\u{1b}", in: window)) #expect(finder.findPanel.handleKeyDown(escape) === escape) #expect(finder.viewModel.isShowingFindPanel) @@ -221,12 +221,12 @@ internal struct EditorKeyMonitorCompositionTests { @Test("Escape with nothing composing closes the find panel") func findPanelClosesOnSettledEscape() throws { - let (window, editor) = EditorControllerFixture.makeFocusedInWindow(string: "SELECT ") + let (window, editor) = Mock.focusedTextViewController(string: "SELECT ") let finder = try #require(editor.findViewController) finder.showFindPanel(animated: false) defer { finder.hideFindPanel(animated: false) } _ = window.makeFirstResponder(editor.textView) - let escape = try #require(EditorControllerFixture.keyDown(keyCode: kVK_Escape, characters: "\u{1b}", in: window)) + let escape = try #require(Mock.keyDown(keyCode: kVK_Escape, characters: "\u{1b}", in: window)) #expect(finder.findPanel.handleKeyDown(escape) == nil) #expect(finder.viewModel.isShowingFindPanel == false) diff --git a/TableProTests/Views/Editor/EditorPeripheralSurfaceTests.swift b/Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/EditorPeripheralSurfaceTests.swift similarity index 90% rename from TableProTests/Views/Editor/EditorPeripheralSurfaceTests.swift rename to Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/EditorPeripheralSurfaceTests.swift index b59d29b24..08acd50a9 100644 --- a/TableProTests/Views/Editor/EditorPeripheralSurfaceTests.swift +++ b/Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/EditorPeripheralSurfaceTests.swift @@ -1,10 +1,9 @@ // // EditorPeripheralSurfaceTests.swift -// TableProTests +// TableProEditorKitTests // import AppKit -@testable import TablePro @testable import TableProEditorKit import TableProGrammars import TableProTextEngine @@ -24,14 +23,21 @@ struct EditorPeripheralSurfaceTests { init(language: CodeLanguage = .default) { let configuration = SourceEditorConfiguration( appearance: .init( - theme: EditorControllerFixture.theme, + theme: Mock.theme(), font: .monospacedSystemFont(ofSize: 12, weight: .regular), lineHeightMultiple: 1.0, wrapLines: false, tabWidth: 4 ), layout: .init(contentInsets: NSEdgeInsets(top: 0, left: 0, bottom: 8, right: 0)), - peripherals: EditorPeripherals.editor(lineNumbers: true, folding: false) + peripherals: .init( + showGutter: true, + showLineNumbers: true, + showFoldingRibbon: false, + showStatementRunControls: false, + gutterFitsContent: false, + showSpecialCharacters: true + ) ) controller = TextViewController( string: "SELECT * FROM users;\nSELECT * FROM orders;", diff --git a/TableProTests/Views/Editor/SQLEditorLongLineScrollTests.swift b/Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/LongLineScrollTests.swift similarity index 91% rename from TableProTests/Views/Editor/SQLEditorLongLineScrollTests.swift rename to Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/LongLineScrollTests.swift index 847dc3b7a..8f5777e8d 100644 --- a/TableProTests/Views/Editor/SQLEditorLongLineScrollTests.swift +++ b/Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/LongLineScrollTests.swift @@ -1,6 +1,6 @@ // // SQLEditorLongLineScrollTests.swift -// TableProTests +// TableProEditorKitTests // import AppKit @@ -17,7 +17,7 @@ struct SQLEditorLongLineScrollTests { @Test("Undoing a long paste narrows the editor and brings back the start of every line") func undoingALongPasteReturnsToTheLineStarts() throws { - let controller = EditorControllerFixture.make(string: query) + let controller = Mock.loadedTextViewController(string: query) let end = (query as NSString).length let narrowWidth = controller.textView.frame.width @@ -36,7 +36,7 @@ struct SQLEditorLongLineScrollTests { @Test("The start of a line is never left under the gutter") func lineStartClearsTheGutter() throws { - let controller = EditorControllerFixture.make(string: pastedRow + "\n" + query) + let controller = Mock.loadedTextViewController(string: pastedRow + "\n" + query) controller.textView.scroll(NSPoint(x: 1_000_000, y: 0)) let lineStart = (pastedRow as NSString).length + 1 @@ -52,7 +52,7 @@ struct SQLEditorLongLineScrollTests { @Test("A long line scrolled a few points in stays there when the editor is laid out again (#2841)") func positionNearTheLineStartSurvivesLayout() throws { - let controller = EditorControllerFixture.make(string: pastedRow + "\n" + query) + let controller = Mock.loadedTextViewController(string: pastedRow + "\n" + query) controller.textView.layoutManager.layoutLines() controller.scrollPosition = CGPoint(x: 20, y: 0) try #require(abs(controller.scrollPosition.x - 20) <= 0.5, "Scrolled to \(controller.scrollPosition.x)") diff --git a/TableProTests/Views/Editor/GutterHighlightTests.swift b/Packages/TableProEditor/Tests/TableProEditorKitTests/GutterHighlightTests.swift similarity index 93% rename from TableProTests/Views/Editor/GutterHighlightTests.swift rename to Packages/TableProEditor/Tests/TableProEditorKitTests/GutterHighlightTests.swift index 34f605f81..871403487 100644 --- a/TableProTests/Views/Editor/GutterHighlightTests.swift +++ b/Packages/TableProEditor/Tests/TableProEditorKitTests/GutterHighlightTests.swift @@ -1,6 +1,6 @@ // // GutterHighlightTests.swift -// TableProTests +// TableProEditorKitTests // // Regression tests for gutter line-number highlighting at end of document. // Originally the gutter tested membership via `IndexSet.intersects(integersIn: lineRange)`, @@ -24,7 +24,7 @@ struct GutterHighlightTests { @Test("Caret at end of single-line query highlights the only line") func caretAtEndOfSingleLineHighlightsLine() throws { - let controller = EditorControllerFixture.make() + let controller = Mock.loadedTextViewController() setText("SELECT * FROM users", on: controller) let length = controller.textView.length controller.textView.selectionManager.setSelectedRange(NSRange(location: length, length: 0)) @@ -36,7 +36,7 @@ struct GutterHighlightTests { @Test("Caret at end of multi-line query highlights only the last line") func caretAtEndOfMultiLineHighlightsLastLine() throws { - let controller = EditorControllerFixture.make() + let controller = Mock.loadedTextViewController() setText("abc\ndef", on: controller) let length = controller.textView.length controller.textView.selectionManager.setSelectedRange(NSRange(location: length, length: 0)) @@ -50,7 +50,7 @@ struct GutterHighlightTests { @Test("Caret in middle of line highlights that line") func caretInMiddleOfLineHighlightsThatLine() throws { - let controller = EditorControllerFixture.make() + let controller = Mock.loadedTextViewController() setText("abc\ndef", on: controller) controller.textView.selectionManager.setSelectedRange(NSRange(location: 1, length: 0)) diff --git a/TableProTests/Editor/PasteHighlightCancelTests.swift b/Packages/TableProEditor/Tests/TableProEditorKitTests/Highlighting/PasteHighlightCancelTests.swift similarity index 99% rename from TableProTests/Editor/PasteHighlightCancelTests.swift rename to Packages/TableProEditor/Tests/TableProEditorKitTests/Highlighting/PasteHighlightCancelTests.swift index d2c9ecfda..69c664e1f 100644 --- a/TableProTests/Editor/PasteHighlightCancelTests.swift +++ b/Packages/TableProEditor/Tests/TableProEditorKitTests/Highlighting/PasteHighlightCancelTests.swift @@ -1,6 +1,6 @@ // // PasteHighlightCancelTests.swift -// TableProTests +// TableProEditorKitTests // // A cancelled tree-sitter edit used to invalidate the range as it was BEFORE the edit. That range // is empty for an insertion at a caret, and `HighlightProviderState.invalidate(_:)` returns without diff --git a/TableProTests/Views/Editor/QueryEditorLargePasteTests.swift b/Packages/TableProEditor/Tests/TableProEditorKitTests/LargePasteTests.swift similarity index 99% rename from TableProTests/Views/Editor/QueryEditorLargePasteTests.swift rename to Packages/TableProEditor/Tests/TableProEditorKitTests/LargePasteTests.swift index b908ee6c9..e4bf9ac33 100644 --- a/TableProTests/Views/Editor/QueryEditorLargePasteTests.swift +++ b/Packages/TableProEditor/Tests/TableProEditorKitTests/LargePasteTests.swift @@ -1,6 +1,6 @@ // // QueryEditorLargePasteTests.swift -// TableProTests +// TableProEditorKitTests // // Regression tests for issue #2158: pasting a large block into the query editor crashed the app. // A caret paste over `maxSyncEditLength` routes the tree-sitter edit to the async arm, and that arm diff --git a/Packages/TableProEditor/Tests/TableProEditorKitTests/LineFoldingTests/LineFoldChunkBoundaryTests.swift b/Packages/TableProEditor/Tests/TableProEditorKitTests/LineFoldingTests/LineFoldChunkBoundaryTests.swift index 658af8eca..d850bb193 100644 --- a/Packages/TableProEditor/Tests/TableProEditorKitTests/LineFoldingTests/LineFoldChunkBoundaryTests.swift +++ b/Packages/TableProEditor/Tests/TableProEditorKitTests/LineFoldingTests/LineFoldChunkBoundaryTests.swift @@ -47,9 +47,10 @@ struct LineFoldChunkBoundaryTests { controller.foldProvider = provider let model = LineFoldModel(controller: controller, foldView: NSView()) - // The calculation runs on the main actor, which other suites share, so wait for it to reach the last line - // rather than for a fixed time. - let deadline = ContinuousClock.now + .seconds(5) + // The calculation runs on the main actor, which every other suite in this target shares, so wait for it to + // reach the last line rather than for a fixed time. The deadline only turns a hang into a failure; it is not + // a budget, and five seconds of it was not enough once the editor's own suites moved into this target. + let deadline = ContinuousClock.now + .seconds(60) while provider.previousDepths[100] == nil, ContinuousClock.now < deadline { try await Task.sleep(for: .milliseconds(10)) } diff --git a/Packages/TableProEditor/Tests/TableProEditorKitTests/Mock.swift b/Packages/TableProEditor/Tests/TableProEditorKitTests/Mock.swift index daa44928f..6eea0031a 100644 --- a/Packages/TableProEditor/Tests/TableProEditorKitTests/Mock.swift +++ b/Packages/TableProEditor/Tests/TableProEditorKitTests/Mock.swift @@ -66,6 +66,92 @@ enum Mock { ) } + /// A controller whose view is loaded and laid out, for tests that need the editor's real AppKit + /// behaviour rather than a stand-in. + @MainActor + static func loadedTextViewController( + string: String = "", + wrapLines: Bool = false, + coordinators: [TextViewCoordinator] = [] + ) -> TextViewController { + let controller = TextViewController( + string: string, + language: .default, + configuration: SourceEditorConfiguration( + appearance: .init( + theme: theme(), + font: .monospacedSystemFont(ofSize: 12, weight: .regular), + lineHeightMultiple: 1.0, + wrapLines: wrapLines, + tabWidth: 4 + ) + ), + cursorPositions: [], + highlightProviders: [], + coordinators: coordinators + ) + controller.loadView() + controller.view.frame = NSRect(x: 0, y: 0, width: 1_000, height: 1_000) + controller.view.layoutSubtreeIfNeeded() + return controller + } + + @MainActor + static func focusedTextViewController(string: String) -> (NSWindow, TextViewController) { + let controller = loadedTextViewController(string: string) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 1_000, height: 1_000), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.contentView = controller.view + controller.view.layoutSubtreeIfNeeded() + _ = window.makeFirstResponder(controller.textView) + let end = (string as NSString).length + controller.setCursorPositions([CursorPosition(range: NSRange(location: end, length: 0))]) + return (window, controller) + } + + @MainActor + static func beginComposition(_ markedText: String, in textView: TextView) { + textView.setMarkedText( + markedText, + selectedRange: NSRange(location: (markedText as NSString).length, length: 0), + replacementRange: NSRange(location: NSNotFound, length: 0) + ) + } + + @MainActor + static func emptyComposition(in textView: TextView) { + textView.setMarkedText( + "", + selectedRange: NSRange(location: 0, length: 0), + replacementRange: NSRange(location: NSNotFound, length: 0) + ) + } + + static func keyDown( + keyCode: Int, + characters: String, + modifiers: NSEvent.ModifierFlags = [], + in window: NSWindow? + ) -> NSEvent? { + NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: modifiers, + timestamp: 0, + windowNumber: window?.windowNumber ?? 0, + context: nil, + characters: characters, + charactersIgnoringModifiers: characters, + isARepeat: false, + keyCode: UInt16(keyCode) + ) + } + @MainActor static func windowedTextViewController(theme: EditorTheme) -> (NSWindow, TextViewController) { let controller = textViewController(theme: theme) diff --git a/Packages/TableProEditor/Tests/TableProEditorKitTests/SourceEditorDismantleTests.swift b/Packages/TableProEditor/Tests/TableProEditorKitTests/SourceEditorDismantleTests.swift new file mode 100644 index 000000000..d710c5766 --- /dev/null +++ b/Packages/TableProEditor/Tests/TableProEditorKitTests/SourceEditorDismantleTests.swift @@ -0,0 +1,44 @@ +// +// SourceEditorDismantleTests.swift +// TableProEditorKitTests +// +// `onDisappear` fires on a live editor whenever its pane is unparented, so teardown hangs off +// `dismantleNSViewController`, which fires only when the representable's identity goes. A coordinator +// destroyed twice, or left in the list, is what that contract exists to prevent (#2236). +// + +import AppKit +import SwiftUI +@testable import TableProEditorKit +import Testing + +private final class RecordingCoordinator: TextViewCoordinator { + private(set) var destroyCount = 0 + + func prepareCoordinator(controller: TextViewController) {} + + func destroy() { + destroyCount += 1 + } +} + +@MainActor +@Suite("SourceEditor dismantle") +struct SourceEditorDismantleTests { + @Test("dismantleNSViewController destroys each text coordinator once and empties the list") + func dismantleDestroysCoordinatorsOnce() { + let recording = RecordingCoordinator() + let controller = Mock.loadedTextViewController(string: "SELECT 1", coordinators: [recording]) + let coordinator = SourceEditor.Coordinator( + text: .binding(.constant("SELECT 1")), + editorState: .constant(SourceEditorState()), + highlightProviders: [], + textCoordinators: [recording] + ) + + SourceEditor.dismantleNSViewController(controller, coordinator: coordinator) + + #expect(recording.destroyCount == 1) + #expect(controller.textCoordinators.values().isEmpty) + } +} diff --git a/TableProTests/Views/Editor/SyntaxHighlightingTests.swift b/Packages/TableProEditor/Tests/TableProEditorKitTests/SyntaxHighlightingTests.swift similarity index 93% rename from TableProTests/Views/Editor/SyntaxHighlightingTests.swift rename to Packages/TableProEditor/Tests/TableProEditorKitTests/SyntaxHighlightingTests.swift index c33562f82..ec86a9dd7 100644 --- a/TableProTests/Views/Editor/SyntaxHighlightingTests.swift +++ b/Packages/TableProEditor/Tests/TableProEditorKitTests/SyntaxHighlightingTests.swift @@ -1,6 +1,6 @@ // // SyntaxHighlightingTests.swift -// TableProTests +// TableProEditorKitTests // // The grammars' capture names and the editor's `CaptureName` vocabulary are two hand-maintained lists that must // agree. When they drifted, `TreeSitterClient` dropped every capture it could not name and the token kept the plain @@ -10,7 +10,6 @@ import AppKit import Foundation import SwiftTreeSitter -@testable import TablePro @testable import TableProEditorKit import TableProGrammars import Testing @@ -174,16 +173,6 @@ struct SyntaxHighlightingTests { // MARK: - The theme's own wiring - @MainActor - @Test("The theme's operator and function colours reach the editor") - func themeCarriesOperatorAndFunctionColors() { - let colors = ThemeEngine.shared.colors.editor - let theme = ThemeEngine.shared.makeEditorTheme() - - #expect(Self.sameColor(theme.operators.color, colors.operator)) - #expect(Self.sameColor(theme.functions.color, colors.function)) - } - // MARK: - Helpers struct Palette { @@ -289,11 +278,4 @@ struct SyntaxHighlightingTests { } return names } - - private static func sameColor(_ lhs: NSColor, _ rhs: NSColor) -> Bool { - guard let left = lhs.usingColorSpace(.sRGB), let right = rhs.usingColorSpace(.sRGB) else { return false } - return abs(left.redComponent - right.redComponent) < 0.001 - && abs(left.greenComponent - right.greenComponent) < 0.001 - && abs(left.blueComponent - right.blueComponent) < 0.001 - } } diff --git a/TableProTests/Views/Editor/TypesetterWrapLengthTests.swift b/Packages/TableProEditor/Tests/TableProTextEngineTests/TypesetterWrapLengthTests.swift similarity index 99% rename from TableProTests/Views/Editor/TypesetterWrapLengthTests.swift rename to Packages/TableProEditor/Tests/TableProTextEngineTests/TypesetterWrapLengthTests.swift index 967159efd..ba2c07cbf 100644 --- a/TableProTests/Views/Editor/TypesetterWrapLengthTests.swift +++ b/Packages/TableProEditor/Tests/TableProTextEngineTests/TypesetterWrapLengthTests.swift @@ -1,6 +1,6 @@ // // TypesetterWrapLengthTests.swift -// TableProTests +// TableProTextEngineTests // // Regression tests for wrapped line typesetting. `suggestLineBreak` returns an offset into the run, // not a length, but the typesetter passed it straight through as the CTLine length. Every fragment diff --git a/TableProTests/Views/Editor/EditorControllerFixture.swift b/TableProTests/Views/Editor/EditorControllerFixture.swift index 6bdbcfd11..2a364b60f 100644 --- a/TableProTests/Views/Editor/EditorControllerFixture.swift +++ b/TableProTests/Views/Editor/EditorControllerFixture.swift @@ -7,7 +7,7 @@ // import AppKit -@testable import TableProEditorKit +import TableProEditorKit import TableProGrammars import TableProTextEngine diff --git a/TableProTests/Views/Editor/EditorLifecycleTeardownTests.swift b/TableProTests/Views/Editor/EditorLifecycleTeardownTests.swift index 9ab8e1871..8797d9ec7 100644 --- a/TableProTests/Views/Editor/EditorLifecycleTeardownTests.swift +++ b/TableProTests/Views/Editor/EditorLifecycleTeardownTests.swift @@ -11,20 +11,10 @@ import AppKit import SwiftUI @testable import TablePro -@testable import TableProEditorKit +import TableProEditorKit import TableProTextEngine import Testing -private final class RecordingCoordinator: TextViewCoordinator { - private(set) var destroyCount = 0 - - func prepareCoordinator(controller: TextViewController) {} - - func destroy() { - destroyCount += 1 - } -} - private final class DismantleRecorder { var makeCount = 0 var dismantleCount = 0 @@ -84,23 +74,6 @@ struct EditorLifecycleTeardownTests { #expect(controller.textView.string == "SELECT 1") } - @Test("dismantleNSViewController destroys each text coordinator once and empties the list") - func dismantleDestroysCoordinatorsOnce() { - let recording = RecordingCoordinator() - let controller = EditorControllerFixture.make(string: "SELECT 1", coordinators: [recording]) - let coordinator = SourceEditor.Coordinator( - text: .binding(.constant("SELECT 1")), - editorState: .constant(SourceEditorState()), - highlightProviders: [], - textCoordinators: [recording] - ) - - SourceEditor.dismantleNSViewController(controller, coordinator: coordinator) - - #expect(recording.destroyCount == 1) - #expect(controller.textCoordinators.values().isEmpty) - } - /// The production shape: closing a connection removes it from the registry, which selects a /// neighbour and unparents this pane, so `teardown()` always runs on a detached hosting /// controller. Nothing lays a detached view out, so without the explicit layout pass SwiftUI diff --git a/TableProTests/Views/Editor/EditorThemeBridgeTests.swift b/TableProTests/Views/Editor/EditorThemeBridgeTests.swift new file mode 100644 index 000000000..d474ad657 --- /dev/null +++ b/TableProTests/Views/Editor/EditorThemeBridgeTests.swift @@ -0,0 +1,32 @@ +// +// EditorThemeBridgeTests.swift +// TableProTests +// +// The colours the app's theme engine hands the editor. Everything about how the editor then paints +// them lives with the editor, in TableProEditorKitTests. +// + +import AppKit +@testable import TablePro +import TableProEditorKit +import Testing + +@Suite("Editor theme bridge") +struct EditorThemeBridgeTests { + @MainActor + @Test("The theme's operator and function colours reach the editor") + func themeCarriesOperatorAndFunctionColors() { + let colors = ThemeEngine.shared.colors.editor + let theme = ThemeEngine.shared.makeEditorTheme() + + #expect(Self.sameColor(theme.operators.color, colors.operator)) + #expect(Self.sameColor(theme.functions.color, colors.function)) + } + + private static func sameColor(_ lhs: NSColor, _ rhs: NSColor) -> Bool { + guard let left = lhs.usingColorSpace(.sRGB), let right = rhs.usingColorSpace(.sRGB) else { return false } + return abs(left.redComponent - right.redComponent) < 0.001 + && abs(left.greenComponent - right.greenComponent) < 0.001 + && abs(left.blueComponent - right.blueComponent) < 0.001 + } +} diff --git a/TableProTests/Views/Editor/QueryDiagnosticsRefreshTests.swift b/TableProTests/Views/Editor/QueryDiagnosticsRefreshTests.swift index 93df95e3b..598ea4575 100644 --- a/TableProTests/Views/Editor/QueryDiagnosticsRefreshTests.swift +++ b/TableProTests/Views/Editor/QueryDiagnosticsRefreshTests.swift @@ -7,8 +7,8 @@ import AppKit import Foundation import SwiftUI @testable import TablePro -@testable import TableProEditorKit -@testable import TableProTextEngine +import TableProEditorKit +import TableProTextEngine import Testing @MainActor @@ -87,15 +87,17 @@ struct QueryDiagnosticsRefreshTests { #expect(underlines(in: controller).isEmpty) } - @Test("A tab switch pushed through the text binding re-checks the incoming tab") - func tabSwitchThroughBinding() async { + /// A tab switch replaces the whole document, which is what `SourceEditor`'s binding does for the editor. + /// The binding plumbing itself is the editor's, and `SourceEditorBindingSyncTests` covers it; what belongs + /// here is that a second replacement re-checks rather than leaving the first document's answer behind. + @Test("A second replacement re-checks the incoming document") + func secondReplacementIsChecked() async { let (coordinator, controller) = makeEditor() defer { coordinator.destroy() } - let sync = TextBindingSync(text: .binding(.constant("")), phase: RepresentableSyncPhase()) - sync.applyRepresentableText("SELECT 1)", controller: controller) + controller.setText("SELECT 1)") #expect(await waitForUnderlines(in: controller) == [NSRange(location: 8, length: 1)]) - sync.applyRepresentableText("SELECT name FROM t /* open", controller: controller) + controller.setText("SELECT name FROM t /* open") #expect(underlines(in: controller).isEmpty) #expect(await waitForUnderlines(in: controller) == [NSRange(location: 19, length: 2)]) @@ -273,12 +275,3 @@ struct QueryDiagnosticMessageTests { #expect(rotors.filter { $0.label == QueryDiagnosticsRotorSearch.label }.count == 1) } } - -private extension EmphasisManager { - @MainActor - func toolTip(at point: CGPoint) -> String? { - guard let textView else { return nil } - let text = toolTips.view(textView, stringForToolTip: 0, point: point, userData: nil) - return text.isEmpty ? nil : text - } -} diff --git a/TableProTests/Views/Editor/RemoveInvisibleCharactersCommandTests.swift b/TableProTests/Views/Editor/RemoveInvisibleCharactersCommandTests.swift index 0b0349299..4a2a40373 100644 --- a/TableProTests/Views/Editor/RemoveInvisibleCharactersCommandTests.swift +++ b/TableProTests/Views/Editor/RemoveInvisibleCharactersCommandTests.swift @@ -6,7 +6,7 @@ import AppKit import Foundation @testable import TablePro -@testable import TableProEditorKit +import TableProEditorKit import TableProTextEngine import Testing @@ -40,11 +40,21 @@ struct RemoveInvisibleCharactersCommandTests { #expect(controller.textView.string == original) } + /// The command turns the editor's typing filters off for the length of its own edit, which is what + /// `separatorIsNotReindented` above measures. Asserting on the flag that does it only says a flag was + /// cleared; typing a newline afterwards and getting the leading indent back says the same filter is + /// running again. @Test("Typing filters still run for ordinary edits afterwards") func filtersResume() { - let (coordinator, controller) = makeEditor("SELECT\u{A0}1") + let (coordinator, controller) = makeEditor(" SELECT\u{A0}1") coordinator.performRemoveInvisibleCharacters() - #expect(controller.isApplyingUnfilteredEdits == false) + #expect(controller.textView.string == " SELECT 1") + + let end = (controller.textView.string as NSString).length + controller.textView.selectionManager.setSelectedRange(NSRange(location: end, length: 0)) + controller.textView.insertText("\n", replacementRange: NSRange(location: end, length: 0)) + + #expect(controller.textView.string == " SELECT 1\n ") } @Test("A read-only editor is left alone")