320 lines
12 KiB
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(language: "swift"))
|
|
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")
|
|
}
|
|
}
|