Sapling/Tests/SaplingEditorTests/EditorStateTests.swift

320 lines
12 KiB
Swift

import XCTest
import SaplingCore
@testable import SaplingEditor
final class EditorStateTests: XCTestCase {
func testActiveLineFollowsSelection() {
let source = "# Heading\nRendered **line**\nRaw source line"
let document = EditorDocument(
url: URL(fileURLWithPath: "/tmp/EditorStateTests.md"),
title: "EditorStateTests",
source: source
)
var state = EditorState(document: document)
let secondLineLocation = (source as NSString).range(of: "Rendered").location
state.updateSelection(EditorSelection(location: secondLineLocation, length: 0))
XCTAssertEqual(state.activeLineIndex, 1)
XCTAssertEqual(state.lines[0].mode, .rendered)
XCTAssertEqual(state.lines[1].mode, .source)
XCTAssertEqual(state.lines[2].mode, .rendered)
}
func testSelectionIsClampedAfterDeletion() {
let document = EditorDocument(
url: URL(fileURLWithPath: "/tmp/EditorStateTests.md"),
title: "EditorStateTests",
source: "First line\nSecond line\nThird line"
)
var state = EditorState(document: document)
state.updateSelection(EditorSelection(location: 30, length: 20))
state.updateSource("Short")
XCTAssertEqual(state.selection.location, 5)
XCTAssertEqual(state.selection.length, 0)
XCTAssertEqual(state.activeLineIndex, 0)
XCTAssertEqual(state.lines.count, 1)
}
func testLineTrackerPreservesTrailingBlankLine() {
let lines = EditorActiveLineTracker.lines(from: "One\nTwo\n", activeLineIndex: 2)
XCTAssertEqual(lines.count, 3)
XCTAssertEqual(lines[2].source, "")
XCTAssertEqual(lines[2].range.location, 8)
XCTAssertEqual(lines[2].mode, .source)
}
func testUpdatingSourceTracksUnsavedChanges() {
let document = EditorDocument(
url: URL(fileURLWithPath: "/tmp/EditorStateTests.md"),
title: "EditorStateTests",
source: "Initial"
)
var state = EditorState(document: document)
state.updateSource("Initial\nChanged")
XCTAssertTrue(state.hasUnsavedChanges)
XCTAssertEqual(state.lines.count, 2)
state.markSaved()
XCTAssertFalse(state.hasUnsavedChanges)
}
func testActiveColumnFollowsSelectionWithinLine() {
let source = "First line\nSecond line"
let document = EditorDocument(
url: URL(fileURLWithPath: "/tmp/EditorStateTests.md"),
title: "EditorStateTests",
source: source
)
var state = EditorState(document: document)
let secondLineLocation = (source as NSString).range(of: "Second").location
state.updateSelection(EditorSelection(location: secondLineLocation + 3, length: 0))
XCTAssertEqual(state.activeLineIndex, 1)
XCTAssertEqual(state.activeColumnNumber, 4)
}
func testHybridRendererSupportsMilestoneTwoInlineElements() {
let source = "Plain **bold** and *italic* with `code`"
let line = EditorLine(
index: 0,
source: source,
range: NSRange(location: 0, length: source.utf16.count),
mode: .rendered
)
let plan = HybridMarkdownLineRenderer().renderPlan(for: line)
XCTAssertEqual(plan.kind, .paragraph)
XCTAssertTrue(plan.spans.contains { $0.kind == .bold && (source as NSString).substring(with: $0.range) == "bold" })
XCTAssertTrue(plan.spans.contains { $0.kind == .italic && (source as NSString).substring(with: $0.range) == "italic" })
XCTAssertTrue(plan.spans.contains { $0.kind == .inlineCode && (source as NSString).substring(with: $0.range) == "code" })
XCTAssertEqual(plan.spans.filter { $0.kind == .markdownDelimiter }.count, 6)
}
func testHybridRendererSupportsHeadings() {
let source = "## Architecture"
let line = EditorLine(
index: 0,
source: source,
range: NSRange(location: 12, length: source.utf16.count),
mode: .rendered
)
let plan = HybridMarkdownLineRenderer().renderPlan(for: line)
XCTAssertEqual(
plan.kind,
.heading(
level: 2,
markerRange: NSRange(location: 12, length: 2),
textRange: NSRange(location: 15, length: 12)
)
)
}
func testHybridRendererSupportsTaskListsAndLinks() {
let source = "- [ ] Task with [link](https://example.com)"
let line = EditorLine(
index: 0,
source: source,
range: NSRange(location: 0, length: source.utf16.count),
mode: .rendered
)
let plan = HybridMarkdownLineRenderer().renderPlan(for: line)
XCTAssertEqual(
plan.kind,
.taskList(
markerRange: NSRange(location: 0, length: 1),
checkboxRange: NSRange(location: 2, length: 3),
contentRange: NSRange(location: 6, length: 37),
checked: false,
nestingLevel: 0
)
)
XCTAssertTrue(plan.spans.contains { $0.kind == .link && (source as NSString).substring(with: $0.range) == "link" })
XCTAssertEqual(plan.spans.filter { $0.kind == .markdownDelimiter }.count, 4)
}
func testHybridRendererSupportsBlockquotesHorizontalRulesAndLists() {
let blockquoteSource = "> quoted **text**"
let blockquote = EditorLine(
index: 0,
source: blockquoteSource,
range: NSRange(location: 10, length: blockquoteSource.utf16.count),
mode: .rendered
)
let horizontalRule = EditorLine(
index: 1,
source: "---",
range: NSRange(location: 28, length: 3),
mode: .rendered
)
let nestedList = EditorLine(
index: 2,
source: " 1. nested item",
range: NSRange(location: 32, length: 16),
mode: .rendered
)
let renderer = HybridMarkdownLineRenderer()
XCTAssertEqual(
renderer.renderPlan(for: blockquote).kind,
.blockquote(
markerRange: NSRange(location: 10, length: 2),
contentRange: NSRange(location: 12, length: 15)
)
)
XCTAssertEqual(renderer.renderPlan(for: horizontalRule).kind, .horizontalRule(range: horizontalRule.range))
XCTAssertEqual(
renderer.renderPlan(for: nestedList).kind,
.orderedList(
markerRange: NSRange(location: 34, length: 2),
contentRange: NSRange(location: 37, length: 11),
nestingLevel: 1
)
)
}
func testHybridRendererSupportsAutomaticLinks() {
let source = "See https://example.com/docs for details"
let line = EditorLine(
index: 0,
source: source,
range: NSRange(location: 0, length: source.utf16.count),
mode: .rendered
)
let plan = HybridMarkdownLineRenderer().renderPlan(for: line)
XCTAssertTrue(plan.spans.contains {
$0.kind == .automaticLink && (source as NSString).substring(with: $0.range) == "https://example.com/docs"
})
}
func testHybridRendererSupportsFencedCodeBlocksWithLanguage() {
let source = "```swift\nlet value = 42\n```"
let lines = EditorActiveLineTracker.lines(from: source, activeLineIndex: 99)
let plans = HybridMarkdownLineRenderer().renderPlans(for: lines)
XCTAssertEqual(
plans[0].kind,
.fencedCodeFence(
markerRange: NSRange(location: 0, length: 3),
languageRange: NSRange(location: 3, length: 5)
)
)
XCTAssertEqual(plans[1].kind, .codeBlockContent)
XCTAssertEqual(plans[2].kind, .fencedCodeFence(markerRange: NSRange(location: 24, length: 3), languageRange: nil))
}
func testHybridRendererSupportsMarkdownTables() {
let header = "| Name | Value |"
let divider = "| ---- | ----- |"
let row = "| A | B |"
let source = [header, divider, row].joined(separator: "\n")
let lines = EditorActiveLineTracker.lines(from: source, activeLineIndex: 99)
let plans = HybridMarkdownLineRenderer().renderPlans(for: lines)
guard case .tableRow(let headerCells, let headerSeparators, false) = plans[0].kind else {
return XCTFail("Expected header table row.")
}
guard case .tableRow(_, _, true) = plans[1].kind else {
return XCTFail("Expected divider table row.")
}
guard case .tableRow(let rowCells, _, false) = plans[2].kind else {
return XCTFail("Expected body table row.")
}
XCTAssertEqual(headerCells.count, 2)
XCTAssertEqual(headerSeparators.count, 3)
XCTAssertEqual((source as NSString).substring(with: rowCells[0]), "A")
XCTAssertEqual((source as NSString).substring(with: rowCells[1]), "B")
}
@MainActor
func testViewModelTracksActiveLineAndSelectionInstrumentation() {
let document = MarkdownDocument(
url: URL(fileURLWithPath: "/tmp/EditorStateTests.md"),
title: "EditorStateTests",
content: "One\nTwo\nThree"
)
let viewModel = HybridMarkdownEditorViewModel(document: document)
viewModel.updateSelection(EditorSelection(location: 4, length: 0))
viewModel.updateSource("One\nTwo updated\nThree")
viewModel.recordRenderPass(EditorRenderPassMetric(
reason: .activeLineChange,
durationMilliseconds: 1.5,
characterCount: 21,
lineCount: 3,
activeLineIndex: 1
))
XCTAssertEqual(viewModel.instrumentation.selectionChangeCount, 1)
XCTAssertEqual(viewModel.instrumentation.sourceChangeCount, 1)
XCTAssertEqual(viewModel.instrumentation.activeLineChangeCount, 1)
XCTAssertEqual(viewModel.instrumentation.renderPassCount, 1)
XCTAssertEqual(viewModel.instrumentation.lastRenderReason, .activeLineChange)
}
func testHybridRendererHandlesLargePrototypeDocumentShape() {
let source = Self.prototypeDocument(lineCount: 2_100)
let lines = EditorActiveLineTracker.lines(from: source, activeLineIndex: 1_000)
let renderer = HybridMarkdownLineRenderer()
let plans = lines.map(renderer.renderPlan(for:))
XCTAssertEqual(lines.count, 2_100)
XCTAssertEqual(plans.filter {
if case .heading = $0.kind {
return true
}
return false
}.count, 210)
XCTAssertEqual(plans.flatMap(\.spans).filter { $0.kind == .inlineCode }.count, 210)
}
@MainActor
func testViewModelSavesDocumentToDisk() throws {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let url = directory.appendingPathComponent("Note.md")
try "# Note\n".write(to: url, atomically: true, encoding: .utf8)
let document = try HybridMarkdownEditorViewModel.loadDocument(at: url)
let viewModel = HybridMarkdownEditorViewModel(document: document)
viewModel.updateSource("# Note\n\nUpdated")
try viewModel.save()
let saved = try String(contentsOf: url, encoding: .utf8)
XCTAssertEqual(saved, "# Note\n\nUpdated")
XCTAssertFalse(viewModel.hasUnsavedChanges)
}
private static func prototypeDocument(lineCount: Int) -> String {
(1...lineCount).map { index in
if index.isMultiple(of: 10) {
return "## Section \(index / 10)"
}
if index.isMultiple(of: 5) {
return "Line \(index) uses **bold** context and `inline code` for scanning."
}
return "Line \(index) is a realistic paragraph with *light emphasis* and enough text to wrap naturally."
}
.joined(separator: "\n")
}
}