2026-05-31 23:01:11 +02:00
|
|
|
import XCTest
|
|
|
|
|
@testable import SaplingEditor
|
|
|
|
|
|
|
|
|
|
final class DocumentPresentationStateTests: XCTestCase {
|
|
|
|
|
func testEveryLineHasExactlyOnePresentationState() {
|
|
|
|
|
let source = "# Heading\n* [x] Done\nParagraph"
|
|
|
|
|
let lineIndex = DocumentLineIndex(source: source)
|
|
|
|
|
|
|
|
|
|
let presentation = DocumentPresentationState(lineIndex: lineIndex, activeLineIndex: 1)
|
|
|
|
|
|
|
|
|
|
XCTAssertEqual(presentation.lines.count, 3)
|
|
|
|
|
XCTAssertEqual(presentation.lineState(at: 0), .rendered)
|
|
|
|
|
XCTAssertEqual(presentation.lineState(at: 1), .source)
|
|
|
|
|
XCTAssertEqual(presentation.lineState(at: 2), .rendered)
|
|
|
|
|
XCTAssertEqual(presentation.lines.filter { $0.state == .source }.map(\.line.index), [1])
|
|
|
|
|
XCTAssertEqual(presentation.lines.filter { $0.state == .rendered }.map(\.line.index), [0, 2])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testPresentationStateIsDeterministicForSameDocumentAndActiveLine() {
|
|
|
|
|
let source = "# Heading\nThis has **bold** and `code`.\n* [ ] Todo"
|
|
|
|
|
let lineIndex = DocumentLineIndex(source: source)
|
|
|
|
|
|
|
|
|
|
let first = DocumentPresentationState(lineIndex: lineIndex, activeLineIndex: 2)
|
|
|
|
|
let second = DocumentPresentationState(lineIndex: lineIndex, activeLineIndex: 2)
|
|
|
|
|
|
|
|
|
|
XCTAssertEqual(first, second)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 09:13:09 +02:00
|
|
|
func testDocumentRenderModelIsIndependentOfActiveLinePresentation() {
|
|
|
|
|
let source = "# Heading\nThis has **bold** and `code`.\n* [ ] Todo"
|
|
|
|
|
let lineIndex = DocumentLineIndex(source: source)
|
|
|
|
|
|
|
|
|
|
let renderModel = DocumentRenderModel(lineIndex: lineIndex)
|
|
|
|
|
let firstPresentation = DocumentPresentationState(lineIndex: lineIndex, activeLineIndex: 0)
|
|
|
|
|
let secondPresentation = DocumentPresentationState(lineIndex: lineIndex, activeLineIndex: 2)
|
|
|
|
|
|
|
|
|
|
XCTAssertEqual(renderModel.snapshot, DocumentRenderModel(lineIndex: lineIndex).snapshot)
|
|
|
|
|
XCTAssertNotEqual(firstPresentation, secondPresentation)
|
|
|
|
|
XCTAssertTrue(renderModel.nodes.contains {
|
|
|
|
|
guard case .heading(let heading) = $0.element else { return false }
|
|
|
|
|
return heading.lineIndex == 0
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testInitialRenderModelContainsHeadingsWithoutInteraction() {
|
|
|
|
|
let source = "# Title\n\n## Section\nParagraph"
|
|
|
|
|
let renderModel = DocumentRenderModel(lineIndex: DocumentLineIndex(source: source))
|
|
|
|
|
|
|
|
|
|
let headingLevels = renderModel.nodes.compactMap { node -> Int? in
|
|
|
|
|
guard case .heading(let heading) = node.element else { return nil }
|
|
|
|
|
return heading.level
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
XCTAssertEqual(headingLevels, [1, 2])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testRenderSnapshotChangesOnlyWhenMarkdownChanges() {
|
|
|
|
|
let source = "# Heading\n* [ ] Move with arrow keys."
|
|
|
|
|
let originalModel = DocumentRenderModel(lineIndex: DocumentLineIndex(source: source))
|
|
|
|
|
let repeatedModel = DocumentRenderModel(lineIndex: DocumentLineIndex(source: source))
|
|
|
|
|
let toggledSource = (source as NSString).replacingCharacters(
|
|
|
|
|
in: NSRange(location: (source as NSString).range(of: "[ ]").location, length: 3),
|
|
|
|
|
with: "[x]"
|
|
|
|
|
)
|
|
|
|
|
let toggledModel = DocumentRenderModel(lineIndex: DocumentLineIndex(source: toggledSource))
|
|
|
|
|
|
|
|
|
|
XCTAssertEqual(originalModel.snapshot, repeatedModel.snapshot)
|
|
|
|
|
XCTAssertNotEqual(originalModel.snapshot, toggledModel.snapshot)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testRenderedTaskToggleReplacementDoesNotMoveSourceRanges() {
|
|
|
|
|
let source = "Intro\n* [ ] Move with arrow keys.\nOutro"
|
|
|
|
|
let lineIndex = DocumentLineIndex(source: source)
|
|
|
|
|
let task = DocumentRenderModel(lineIndex: lineIndex).nodes.compactMap { node -> RenderedTaskElement? in
|
|
|
|
|
guard case .task(let task) = node.element else { return nil }
|
|
|
|
|
return task
|
|
|
|
|
}.first
|
|
|
|
|
|
|
|
|
|
XCTAssertEqual(task?.toggledMarkdownCheckbox, "[x]")
|
|
|
|
|
XCTAssertEqual(task?.checkboxRange.length, ((task?.toggledMarkdownCheckbox ?? "") as NSString).length)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 23:01:11 +02:00
|
|
|
func testRenderedElementsAreSemantic() {
|
|
|
|
|
let source = "# Heading\n* [x] Done\nSee [Docs](https://example.com)"
|
|
|
|
|
let lineIndex = DocumentLineIndex(source: source)
|
|
|
|
|
|
|
|
|
|
let presentation = DocumentPresentationState(lineIndex: lineIndex, activeLineIndex: 99)
|
|
|
|
|
|
|
|
|
|
XCTAssertTrue(presentation.elements.contains {
|
|
|
|
|
guard case .heading(let heading) = $0 else { return false }
|
|
|
|
|
return heading.lineIndex == 0 && heading.level == 1
|
|
|
|
|
})
|
|
|
|
|
XCTAssertEqual(presentation.renderedTasks.map(\.checked), [true])
|
|
|
|
|
XCTAssertTrue(presentation.elements.contains {
|
|
|
|
|
guard case .link(let link) = $0 else { return false }
|
|
|
|
|
return (source as NSString).substring(with: link.titleRange) == "Docs"
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testCodeBlockIsRepresentedAsSemanticBlock() {
|
|
|
|
|
let source = "Intro\n```swift\nlet value = 42\n```"
|
|
|
|
|
let lineIndex = DocumentLineIndex(source: source)
|
|
|
|
|
|
|
|
|
|
let presentation = DocumentPresentationState(lineIndex: lineIndex, activeLineIndex: 0)
|
|
|
|
|
|
|
|
|
|
XCTAssertEqual(presentation.renderedCodeBlocks.count, 1)
|
|
|
|
|
XCTAssertEqual(presentation.renderedCodeBlocks[0].lineIndexes, [1, 2, 3])
|
|
|
|
|
XCTAssertEqual(
|
|
|
|
|
(source as NSString).substring(with: presentation.renderedCodeBlocks[0].languageRange!),
|
|
|
|
|
"swift"
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testDirtyPresentationResolvesNearbyCodeBlockContext() {
|
|
|
|
|
let source = "Intro\n```swift\nlet value = 42\n```"
|
|
|
|
|
let lineIndex = DocumentLineIndex(source: source)
|
|
|
|
|
|
|
|
|
|
let presentation = DocumentPresentationState(
|
|
|
|
|
lineIndex: lineIndex,
|
|
|
|
|
activeLineIndex: 0,
|
|
|
|
|
lineIndexes: [2]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
XCTAssertEqual(presentation.lines.count, 1)
|
|
|
|
|
XCTAssertEqual(presentation.lines[0].state, .rendered)
|
2026-06-01 10:17:17 +02:00
|
|
|
XCTAssertEqual(presentation.lines[0].renderPlan.kind, .codeBlockContent(language: "swift"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testEditableRegionMakesMultipleSelectedLinesSource() {
|
|
|
|
|
let source = "# Heading\nParagraph\n* [ ] Task\nOutro"
|
|
|
|
|
let lineIndex = DocumentLineIndex(source: source)
|
|
|
|
|
let selectionStart = (source as NSString).range(of: "Paragraph").location
|
|
|
|
|
let selectionEnd = (source as NSString).range(of: "Task").upperBound
|
|
|
|
|
let region = EditableRegion.selection(
|
|
|
|
|
NSRange(location: selectionStart, length: selectionEnd - selectionStart),
|
|
|
|
|
in: lineIndex
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
let presentation = DocumentPresentationState(lineIndex: lineIndex, editableRegion: region)
|
|
|
|
|
|
|
|
|
|
XCTAssertEqual(presentation.lines.filter { $0.state == .source }.map(\.line.index), [1, 2])
|
|
|
|
|
XCTAssertEqual(presentation.lines.filter { $0.state == .rendered }.map(\.line.index), [0, 3])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testEditableRegionExpandsToEntireCodeBlock() {
|
|
|
|
|
let source = "Intro\n```swift\nlet value = 42\n```\nOutro"
|
|
|
|
|
let lineIndex = DocumentLineIndex(source: source)
|
|
|
|
|
let cursorLocation = (source as NSString).range(of: "value").location
|
|
|
|
|
|
|
|
|
|
let region = EditableRegion.selection(NSRange(location: cursorLocation, length: 0), in: lineIndex)
|
|
|
|
|
let presentation = DocumentPresentationState(lineIndex: lineIndex, editableRegion: region)
|
|
|
|
|
|
|
|
|
|
XCTAssertEqual(region.lineIndexes, [1, 2, 3])
|
|
|
|
|
XCTAssertEqual(presentation.lines.filter { $0.state == .source }.map(\.line.index), [1, 2, 3])
|
|
|
|
|
XCTAssertEqual(presentation.lines.filter { $0.state == .rendered }.map(\.line.index), [0, 4])
|
2026-06-01 14:22:27 +02:00
|
|
|
XCTAssertTrue(presentation.renderedCodeBlocks.isEmpty)
|
2026-06-01 10:17:17 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private extension NSRange {
|
|
|
|
|
var upperBound: Int {
|
|
|
|
|
location + length
|
2026-05-31 23:01:11 +02:00
|
|
|
}
|
|
|
|
|
}
|