Sapling/Tests/SaplingEditorTests/HybridMarkdownLiveEditorHarnessTests.swift

290 lines
13 KiB
Swift
Raw Normal View History

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())
}
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)
}
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)
}
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)
}
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"
)
}
}
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))
}
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)
}
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"))
harness.setSelectionByMouse(NSRange(location: nsSource.range(of: "Navigation").location, length: 0))
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"))
harness.setSelectionByMouse(NSRange(location: nsSource.range(of: "value").location, length: 0))
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)
}
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")
})
}
}
#endif
private extension NSRange {
var upperBound: Int {
location + length
}
}