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") } }