2026-06-01 09:40:19 +02:00
|
|
|
import XCTest
|
|
|
|
|
@testable import SaplingEditor
|
|
|
|
|
|
|
|
|
|
#if os(macOS)
|
|
|
|
|
import AppKit
|
|
|
|
|
|
|
|
|
|
@MainActor
|
|
|
|
|
final class HybridMarkdownLiveEditorHarnessTests: XCTestCase {
|
|
|
|
|
func testLiveInitialFirstResponderDoesNotShowHeadingSourceBeforeUserInteraction() {
|
|
|
|
|
let harness = HybridMarkdownLiveEditorHarness(source: "# Heading\nParagraph")
|
|
|
|
|
|
|
|
|
|
harness.simulateLaunchFirstResponder()
|
|
|
|
|
|
|
|
|
|
XCTAssertTrue(harness.headingMarkerIsHidden())
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 15:15:09 +02:00
|
|
|
func testClickAtTopOfDocumentDoesNotScrollViewportDown() {
|
|
|
|
|
let source = (["# Heading", "Opening paragraph"] + (1...80).map { "Line \($0)" })
|
|
|
|
|
.joined(separator: "\n")
|
|
|
|
|
let harness = HybridMarkdownLiveEditorHarness(source: source)
|
|
|
|
|
harness.simulateLaunchFirstResponder()
|
|
|
|
|
harness.scrollViewport(toY: 0)
|
|
|
|
|
|
|
|
|
|
let paragraphLocation = (source as NSString).range(of: "Opening").location
|
|
|
|
|
harness.setSelectionByMouse(NSRange(location: paragraphLocation, length: 0))
|
|
|
|
|
|
|
|
|
|
XCTAssertEqual(harness.viewportOrigin().y, 0, accuracy: 0.001)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 08:52:20 +02:00
|
|
|
func testNativeMouseScrollDuringTopClickIsRestored() {
|
|
|
|
|
let source = (["# Heading", "Opening paragraph"] + (1...80).map { "Line \($0)" })
|
|
|
|
|
.joined(separator: "\n")
|
|
|
|
|
let harness = HybridMarkdownLiveEditorHarness(source: source)
|
|
|
|
|
harness.simulateLaunchFirstResponder()
|
|
|
|
|
harness.scrollViewport(toY: 0)
|
|
|
|
|
|
|
|
|
|
let paragraphLocation = (source as NSString).range(of: "Opening").location
|
|
|
|
|
harness.simulateMouseSelectionAfterNativeScroll(
|
|
|
|
|
NSRange(location: paragraphLocation, length: 0),
|
|
|
|
|
nativeScrollY: 48
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
XCTAssertEqual(harness.viewportOrigin().y, 0, accuracy: 0.001)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 09:40:19 +02:00
|
|
|
func testLiveParagraphGeometryReturnsAfterClickAndFocusAway() throws {
|
|
|
|
|
let source = """
|
|
|
|
|
# Heading
|
|
|
|
|
Paragraph with **bold**, *italic*, `code`, and [Link](https://example.com) markers.
|
|
|
|
|
Outro
|
|
|
|
|
"""
|
|
|
|
|
let harness = HybridMarkdownLiveEditorHarness(source: source)
|
|
|
|
|
harness.simulateLaunchFirstResponder()
|
|
|
|
|
let initialPoint = try XCTUnwrap(harness.point(for: "Paragraph"))
|
|
|
|
|
|
|
|
|
|
let paragraphLocation = (source as NSString).range(of: "bold").location
|
|
|
|
|
harness.setSelection(NSRange(location: paragraphLocation, length: 0))
|
|
|
|
|
harness.simulateFocusAway()
|
|
|
|
|
let finalPoint = try XCTUnwrap(harness.point(for: "Paragraph"))
|
|
|
|
|
|
|
|
|
|
XCTAssertEqual(initialPoint.x, finalPoint.x, accuracy: 0.001)
|
|
|
|
|
XCTAssertEqual(initialPoint.y, finalPoint.y, accuracy: 0.001)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testLiveCheckboxClickPreservesSelectionActiveLineAndGeometry() throws {
|
|
|
|
|
let source = "Editing here\n* [ ] Move with arrow keys."
|
|
|
|
|
let editingRange = (source as NSString).range(of: "Editing")
|
|
|
|
|
let harness = HybridMarkdownLiveEditorHarness(
|
|
|
|
|
source: source,
|
|
|
|
|
selectedRange: NSRange(location: editingRange.location, length: 0)
|
|
|
|
|
)
|
|
|
|
|
harness.simulateLaunchFirstResponder()
|
|
|
|
|
harness.setSelection(NSRange(location: editingRange.location, length: 0))
|
|
|
|
|
|
|
|
|
|
let selectionBefore = harness.selectedRange()
|
|
|
|
|
let activeLineBefore = harness.effectiveActiveLineIndex()
|
|
|
|
|
let labelPointBefore = try XCTUnwrap(harness.point(for: "Move"))
|
|
|
|
|
let checkboxFrameBefore = try XCTUnwrap(harness.checklistButtonFrame(lineIndex: 1))
|
|
|
|
|
|
|
|
|
|
harness.clickRenderedCheckbox(lineIndex: 1)
|
|
|
|
|
|
|
|
|
|
let labelPointAfter = try XCTUnwrap(harness.point(for: "Move"))
|
|
|
|
|
let checkboxFrameAfter = try XCTUnwrap(harness.checklistButtonFrame(lineIndex: 1))
|
|
|
|
|
XCTAssertEqual(harness.selectedRange(), selectionBefore)
|
|
|
|
|
XCTAssertEqual(harness.effectiveActiveLineIndex(), activeLineBefore)
|
|
|
|
|
XCTAssertTrue(harness.source().contains("* [x] Move with arrow keys."))
|
|
|
|
|
XCTAssertEqual(labelPointBefore.x, labelPointAfter.x, accuracy: 0.001)
|
|
|
|
|
XCTAssertEqual(labelPointBefore.y, labelPointAfter.y, accuracy: 0.001)
|
|
|
|
|
XCTAssertEqual(checkboxFrameBefore.origin.x, checkboxFrameAfter.origin.x, accuracy: 0.001)
|
|
|
|
|
XCTAssertEqual(checkboxFrameBefore.origin.y, checkboxFrameAfter.origin.y, accuracy: 0.001)
|
|
|
|
|
XCTAssertEqual(checkboxFrameBefore.size.width, checkboxFrameAfter.size.width, accuracy: 0.001)
|
|
|
|
|
XCTAssertEqual(checkboxFrameBefore.size.height, checkboxFrameAfter.size.height, accuracy: 0.001)
|
|
|
|
|
}
|
2026-06-01 09:56:36 +02:00
|
|
|
|
|
|
|
|
func testInitialChecklistOverlayTracksFirstLiveLayoutPass() throws {
|
|
|
|
|
let source = """
|
|
|
|
|
## Navigation Checklist
|
|
|
|
|
|
|
|
|
|
Use this section for quick keyboard testing.
|
|
|
|
|
|
|
|
|
|
* [ ] Move with arrow keys.
|
|
|
|
|
* [ ] Jump by word with Option + Arrow.
|
|
|
|
|
* [ ] Extend selection with Shift + Arrow.
|
|
|
|
|
* [ ] Select across multiple lines.
|
|
|
|
|
* [ ] Use Home, End, Page Up, and Page Down.
|
|
|
|
|
* [ ] Type into the active line after moving quickly.
|
|
|
|
|
"""
|
|
|
|
|
let harness = HybridMarkdownLiveEditorHarness(source: source, initialWidth: 640)
|
|
|
|
|
let initialGaps = try Dictionary(uniqueKeysWithValues: (4...9).map { lineIndex in
|
|
|
|
|
(lineIndex, try XCTUnwrap(harness.checklistLabelGap(lineIndex: lineIndex)))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
harness.simulateLayout(width: 900)
|
|
|
|
|
|
|
|
|
|
for lineIndex in 4...9 {
|
|
|
|
|
let alignmentDelta = try XCTUnwrap(harness.checklistAlignmentDelta(lineIndex: lineIndex))
|
|
|
|
|
let labelGap = try XCTUnwrap(harness.checklistLabelGap(lineIndex: lineIndex))
|
|
|
|
|
XCTAssertLessThan(alignmentDelta, 8, "Checklist overlay for line \(lineIndex) is not aligned with its label")
|
|
|
|
|
XCTAssertEqual(
|
|
|
|
|
labelGap,
|
|
|
|
|
initialGaps[lineIndex] ?? labelGap,
|
|
|
|
|
accuracy: 0.001,
|
|
|
|
|
"Checklist overlay for line \(lineIndex) did not track the label through the first layout pass"
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-01 10:17:17 +02:00
|
|
|
|
|
|
|
|
func testLiveMultiLineSelectionUsesEditableRegion() throws {
|
|
|
|
|
let source = "# Heading\nParagraph\n* [ ] Task\nOutro"
|
|
|
|
|
let nsSource = source as NSString
|
|
|
|
|
let selectionStart = nsSource.range(of: "#").location
|
|
|
|
|
let selectionEnd = nsSource.range(of: "Task").upperBound
|
|
|
|
|
let taskLineIndex = 2
|
|
|
|
|
let harness = HybridMarkdownLiveEditorHarness(source: source)
|
|
|
|
|
|
|
|
|
|
harness.simulateLaunchFirstResponder()
|
|
|
|
|
harness.setSelection(NSRange(location: selectionStart, length: selectionEnd - selectionStart))
|
|
|
|
|
|
|
|
|
|
XCTAssertFalse(harness.characterIsHidden(at: nsSource.range(of: "#").location))
|
|
|
|
|
XCTAssertNil(harness.checklistButtonFrame(lineIndex: taskLineIndex))
|
|
|
|
|
|
|
|
|
|
harness.setSelection(NSRange(location: nsSource.range(of: "Outro").location, length: 0))
|
|
|
|
|
|
|
|
|
|
XCTAssertTrue(harness.characterIsHidden(at: nsSource.range(of: "#").location))
|
|
|
|
|
XCTAssertNotNil(harness.checklistButtonFrame(lineIndex: taskLineIndex))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testLiveCodeBlockSelectionUsesWholeBlockEditableRegion() {
|
|
|
|
|
let source = "Intro\n```swift\nlet value = 42\n```"
|
|
|
|
|
let nsSource = source as NSString
|
|
|
|
|
let harness = HybridMarkdownLiveEditorHarness(source: source)
|
|
|
|
|
let cursorLocation = nsSource.range(of: "value").location
|
|
|
|
|
|
|
|
|
|
harness.simulateLaunchFirstResponder()
|
|
|
|
|
harness.setSelection(NSRange(location: cursorLocation, length: 0))
|
|
|
|
|
|
|
|
|
|
XCTAssertFalse(harness.characterIsHidden(at: nsSource.range(of: "```swift").location))
|
|
|
|
|
XCTAssertFalse(harness.characterIsHidden(at: nsSource.range(of: "```", options: .backwards).location))
|
|
|
|
|
|
|
|
|
|
harness.setSelection(NSRange(location: nsSource.range(of: "Intro").location, length: 0))
|
|
|
|
|
|
|
|
|
|
XCTAssertTrue(harness.characterIsHidden(at: nsSource.range(of: "```swift").location))
|
|
|
|
|
XCTAssertTrue(harness.characterIsHidden(at: nsSource.range(of: "```", options: .backwards).location))
|
|
|
|
|
}
|
2026-06-01 14:22:27 +02:00
|
|
|
|
|
|
|
|
func testLiveRenderedCodeBlockUsesSingleContainerPresentation() throws {
|
|
|
|
|
let source = """
|
|
|
|
|
Intro
|
|
|
|
|
```swift
|
|
|
|
|
struct Example {
|
|
|
|
|
let value = 42
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
Outro
|
|
|
|
|
"""
|
|
|
|
|
let nsSource = source as NSString
|
|
|
|
|
let harness = HybridMarkdownLiveEditorHarness(source: source, initialWidth: 700)
|
|
|
|
|
|
|
|
|
|
harness.simulateLaunchFirstResponder()
|
|
|
|
|
|
|
|
|
|
XCTAssertEqual(harness.codeBlockContainerCount(), 1)
|
|
|
|
|
XCTAssertEqual(harness.codeBlockContainerLabel(containing: 2), "Swift")
|
|
|
|
|
let initialFrame = try XCTUnwrap(harness.codeBlockContainerFrame(containing: 2))
|
|
|
|
|
XCTAssertGreaterThan(initialFrame.height, 80)
|
|
|
|
|
XCTAssertGreaterThan(initialFrame.width, 500)
|
|
|
|
|
XCTAssertTrue(harness.characterIsHidden(at: nsSource.range(of: "```swift").location))
|
|
|
|
|
XCTAssertTrue(harness.characterIsHidden(at: nsSource.range(of: "```", options: .backwards).location))
|
|
|
|
|
|
|
|
|
|
harness.setSelection(NSRange(location: nsSource.range(of: "value").location, length: 0))
|
|
|
|
|
|
|
|
|
|
XCTAssertEqual(harness.codeBlockContainerCount(), 0)
|
|
|
|
|
XCTAssertFalse(harness.characterIsHidden(at: nsSource.range(of: "```swift").location))
|
|
|
|
|
|
|
|
|
|
harness.setSelection(NSRange(location: nsSource.range(of: "Intro").location, length: 0))
|
|
|
|
|
|
|
|
|
|
XCTAssertEqual(harness.codeBlockContainerCount(), 1)
|
|
|
|
|
let restoredFrame = try XCTUnwrap(harness.codeBlockContainerFrame(containing: 2))
|
|
|
|
|
XCTAssertEqual(initialFrame.origin.x, restoredFrame.origin.x, accuracy: 0.001)
|
|
|
|
|
XCTAssertEqual(initialFrame.size.width, restoredFrame.size.width, accuracy: 0.001)
|
|
|
|
|
}
|
2026-06-01 14:44:14 +02:00
|
|
|
|
|
|
|
|
func testHeadingTransitionKeepsEditedContentVisuallyAnchored() throws {
|
|
|
|
|
let intro = (0..<24).map { "Intro paragraph \($0)" }.joined(separator: "\n")
|
|
|
|
|
let outro = (0..<24).map { "Outro paragraph \($0)" }.joined(separator: "\n")
|
|
|
|
|
let source = """
|
|
|
|
|
\(intro)
|
|
|
|
|
# Navigation Checklist
|
|
|
|
|
Body text below the heading.
|
|
|
|
|
\(outro)
|
|
|
|
|
"""
|
|
|
|
|
let nsSource = source as NSString
|
|
|
|
|
let harness = HybridMarkdownLiveEditorHarness(source: source, initialWidth: 700)
|
|
|
|
|
harness.simulateLaunchFirstResponder()
|
|
|
|
|
let headingPoint = try XCTUnwrap(harness.point(for: "Navigation"))
|
|
|
|
|
harness.scrollViewport(toY: headingPoint.y - 160)
|
|
|
|
|
let renderedViewportPoint = try XCTUnwrap(harness.viewportPoint(for: "Navigation"))
|
|
|
|
|
|
2026-06-01 15:00:55 +02:00
|
|
|
harness.setSelectionByMouse(NSRange(location: nsSource.range(of: "Navigation").location, length: 0))
|
2026-06-01 14:44:14 +02:00
|
|
|
|
|
|
|
|
let sourceViewportPoint = try XCTUnwrap(harness.viewportPoint(for: "Navigation"))
|
|
|
|
|
XCTAssertEqual(sourceViewportPoint.y, renderedViewportPoint.y, accuracy: 0.5)
|
|
|
|
|
|
|
|
|
|
harness.simulateFocusAway()
|
|
|
|
|
|
|
|
|
|
let restoredViewportPoint = try XCTUnwrap(harness.viewportPoint(for: "Navigation"))
|
|
|
|
|
XCTAssertEqual(restoredViewportPoint.y, renderedViewportPoint.y, accuracy: 0.5)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testCodeBlockTransitionKeepsEditedContentVisuallyAnchored() throws {
|
|
|
|
|
let intro = (0..<24).map { "Intro paragraph \($0)" }.joined(separator: "\n")
|
|
|
|
|
let outro = (0..<24).map { "Outro paragraph \($0)" }.joined(separator: "\n")
|
|
|
|
|
let source = """
|
|
|
|
|
\(intro)
|
|
|
|
|
```swift
|
|
|
|
|
struct Example {
|
|
|
|
|
let value = 42
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
\(outro)
|
|
|
|
|
"""
|
|
|
|
|
let nsSource = source as NSString
|
|
|
|
|
let harness = HybridMarkdownLiveEditorHarness(source: source, initialWidth: 700)
|
|
|
|
|
harness.simulateLaunchFirstResponder()
|
|
|
|
|
let codePoint = try XCTUnwrap(harness.point(for: "value"))
|
|
|
|
|
harness.scrollViewport(toY: codePoint.y - 180)
|
|
|
|
|
let renderedViewportPoint = try XCTUnwrap(harness.viewportPoint(for: "value"))
|
|
|
|
|
|
2026-06-01 15:00:55 +02:00
|
|
|
harness.setSelectionByMouse(NSRange(location: nsSource.range(of: "value").location, length: 0))
|
2026-06-01 14:44:14 +02:00
|
|
|
|
|
|
|
|
let sourceViewportPoint = try XCTUnwrap(harness.viewportPoint(for: "value"))
|
|
|
|
|
XCTAssertEqual(sourceViewportPoint.y, renderedViewportPoint.y, accuracy: 0.5)
|
|
|
|
|
|
|
|
|
|
harness.simulateFocusAway()
|
|
|
|
|
|
|
|
|
|
let restoredViewportPoint = try XCTUnwrap(harness.viewportPoint(for: "value"))
|
|
|
|
|
XCTAssertEqual(restoredViewportPoint.y, renderedViewportPoint.y, accuracy: 0.5)
|
|
|
|
|
}
|
2026-06-01 15:00:55 +02:00
|
|
|
|
|
|
|
|
func testKeyboardNavigationDoesNotRunViewportStabilization() throws {
|
|
|
|
|
let intro = (0..<24).map { "Intro paragraph \($0)" }.joined(separator: "\n")
|
|
|
|
|
let outro = (0..<24).map { "Outro paragraph \($0)" }.joined(separator: "\n")
|
|
|
|
|
let source = """
|
|
|
|
|
\(intro)
|
|
|
|
|
# Navigation Checklist
|
|
|
|
|
Body text below the heading.
|
|
|
|
|
\(outro)
|
|
|
|
|
"""
|
|
|
|
|
let nsSource = source as NSString
|
|
|
|
|
let harness = HybridMarkdownLiveEditorHarness(source: source, initialWidth: 700)
|
|
|
|
|
harness.simulateLaunchFirstResponder()
|
|
|
|
|
let headingPoint = try XCTUnwrap(harness.point(for: "Navigation"))
|
|
|
|
|
harness.scrollViewport(toY: headingPoint.y - 160)
|
|
|
|
|
|
|
|
|
|
harness.setSelectionByKeyboard(NSRange(location: nsSource.range(of: "Navigation").location, length: 0))
|
|
|
|
|
|
|
|
|
|
XCTAssertTrue(harness.viewportStabilityEventDescriptions().contains {
|
|
|
|
|
$0.contains("cause=selectionChange:keyboard")
|
|
|
|
|
&& $0.contains("decision=native-keyboard-navigation")
|
|
|
|
|
&& $0.contains("restored=false")
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-06-01 09:40:19 +02:00
|
|
|
}
|
|
|
|
|
#endif
|
2026-06-01 10:17:17 +02:00
|
|
|
|
|
|
|
|
private extension NSRange {
|
|
|
|
|
var upperBound: Int {
|
|
|
|
|
location + length
|
|
|
|
|
}
|
|
|
|
|
}
|