2026-05-29 20:57:03 +02:00
|
|
|
import XCTest
|
|
|
|
|
@testable import SaplingEditor
|
|
|
|
|
|
|
|
|
|
final class EditorLargeDocumentValidationTests: XCTestCase {
|
|
|
|
|
func testLargeDocumentOpenAndDirtyRenderPlanning() {
|
|
|
|
|
for lineCount in [1_000, 5_000, 10_000] {
|
|
|
|
|
let source = Self.prototypeDocument(lineCount: lineCount)
|
|
|
|
|
let activeLineIndex = lineCount / 2
|
|
|
|
|
|
|
|
|
|
let openMeasurement = elapsedTime {
|
|
|
|
|
_ = EditorState(
|
|
|
|
|
document: document(source: source, lineCount: lineCount),
|
|
|
|
|
activeLineIndex: activeLineIndex
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let updatedSource = Self.replacingLine(
|
|
|
|
|
activeLineIndex,
|
|
|
|
|
in: source,
|
|
|
|
|
with: "Line \(activeLineIndex + 1) edited with **bold** and `inline code`."
|
|
|
|
|
)
|
|
|
|
|
let updateMeasurement = elapsedTime {
|
|
|
|
|
var state = EditorState(
|
|
|
|
|
document: document(source: source, lineCount: lineCount),
|
|
|
|
|
activeLineIndex: activeLineIndex
|
|
|
|
|
)
|
|
|
|
|
state.updateSource(updatedSource)
|
|
|
|
|
_ = EditorDirtyLineInvalidator.plan(
|
|
|
|
|
previousText: source,
|
|
|
|
|
currentText: updatedSource,
|
|
|
|
|
previousActiveLineIndex: activeLineIndex,
|
|
|
|
|
currentActiveLineIndex: activeLineIndex
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
let plan = EditorDirtyLineInvalidator.plan(
|
|
|
|
|
previousText: source,
|
|
|
|
|
currentText: updatedSource,
|
|
|
|
|
previousActiveLineIndex: activeLineIndex,
|
|
|
|
|
currentActiveLineIndex: activeLineIndex
|
|
|
|
|
)
|
|
|
|
|
let dirtyRenderMeasurement = elapsedTime {
|
|
|
|
|
let lines = EditorActiveLineTracker.lines(from: updatedSource, activeLineIndex: activeLineIndex)
|
|
|
|
|
let dirty = Set(plan.dirtyLineIndexes)
|
|
|
|
|
let renderer = HybridMarkdownLineRenderer()
|
|
|
|
|
_ = lines
|
|
|
|
|
.filter { dirty.contains($0.index) }
|
|
|
|
|
.map(renderer.renderPlan(for:))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
XCTAssertEqual(EditorActiveLineTracker.lines(from: source, activeLineIndex: activeLineIndex).count, lineCount)
|
|
|
|
|
XCTAssertFalse(plan.isFullRender)
|
|
|
|
|
XCTAssertLessThanOrEqual(plan.dirtyLineCount, 3)
|
|
|
|
|
XCTAssertLessThan(openMeasurement, 0.25, "Opening \(lineCount) generated lines should remain interactive.")
|
|
|
|
|
XCTAssertLessThan(updateMeasurement, 0.50, "State update for \(lineCount) lines should remain bounded.")
|
|
|
|
|
XCTAssertLessThan(dirtyRenderMeasurement, 0.05, "Dirty render planning should not scale with the full document.")
|
2026-05-29 20:59:38 +02:00
|
|
|
|
|
|
|
|
if ProcessInfo.processInfo.environment["SAPLING_EDITOR_PRINT_METRICS"] == "1" {
|
|
|
|
|
print(
|
|
|
|
|
"SaplingEditorMetrics lines=\(lineCount) "
|
|
|
|
|
+ "openMs=\(milliseconds(openMeasurement)) "
|
|
|
|
|
+ "typingMs=\(milliseconds(updateMeasurement)) "
|
|
|
|
|
+ "dirtyRenderMs=\(milliseconds(dirtyRenderMeasurement)) "
|
|
|
|
|
+ "dirtyLines=\(plan.dirtyLineCount) "
|
|
|
|
|
+ "fullRender=\(plan.isFullRender)"
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-05-29 20:57:03 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testLargeDocumentActiveLineNavigationDoesNotScheduleFullRender() {
|
|
|
|
|
let source = Self.prototypeDocument(lineCount: 10_000)
|
|
|
|
|
|
|
|
|
|
let plan = EditorDirtyLineInvalidator.plan(
|
|
|
|
|
previousText: source,
|
|
|
|
|
currentText: source,
|
|
|
|
|
previousActiveLineIndex: 250,
|
|
|
|
|
currentActiveLineIndex: 9_750
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
XCTAssertEqual(plan.reason, .activeLineChange)
|
|
|
|
|
XCTAssertFalse(plan.isFullRender)
|
|
|
|
|
XCTAssertEqual(plan.dirtyLineIndexes, [250, 9_750])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func document(source: String, lineCount: Int) -> EditorDocument {
|
|
|
|
|
EditorDocument(
|
|
|
|
|
url: URL(fileURLWithPath: "/tmp/EditorLargeDocument-\(lineCount).md"),
|
|
|
|
|
title: "EditorLargeDocument-\(lineCount)",
|
|
|
|
|
source: source
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func elapsedTime(_ operation: () -> Void) -> TimeInterval {
|
|
|
|
|
let start = Date()
|
|
|
|
|
operation()
|
|
|
|
|
return Date().timeIntervalSince(start)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 20:59:38 +02:00
|
|
|
private func milliseconds(_ interval: TimeInterval) -> String {
|
|
|
|
|
String(format: "%.3f", interval * 1000)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 20:57:03 +02:00
|
|
|
private static func prototypeDocument(lineCount: Int) -> String {
|
|
|
|
|
(1...lineCount).map { index in
|
|
|
|
|
if index.isMultiple(of: 25) {
|
|
|
|
|
return "## Section \(index / 25)"
|
|
|
|
|
}
|
|
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static func replacingLine(_ lineIndex: Int, in source: String, with replacement: String) -> String {
|
|
|
|
|
var lines = source.components(separatedBy: "\n")
|
|
|
|
|
lines[lineIndex] = replacement
|
|
|
|
|
return lines.joined(separator: "\n")
|
|
|
|
|
}
|
|
|
|
|
}
|