perf(editor): add profiling benchmark harness

This commit is contained in:
Feror 2026-05-30 17:57:37 +02:00
parent 9730f860de
commit be53eddfd2
6 changed files with 52178 additions and 2 deletions

51482
Docs/Benchmarks/5mb.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -10,6 +10,7 @@ let package = Package(
], ],
products: [ products: [
.executable(name: "SaplingApp", targets: ["SaplingApp"]), .executable(name: "SaplingApp", targets: ["SaplingApp"]),
.executable(name: "SaplingEditorBenchmark", targets: ["SaplingEditorBenchmark"]),
.library(name: "SaplingCore", targets: ["SaplingCore"]), .library(name: "SaplingCore", targets: ["SaplingCore"]),
.library(name: "SaplingWorkspace", targets: ["SaplingWorkspace"]), .library(name: "SaplingWorkspace", targets: ["SaplingWorkspace"]),
.library(name: "SaplingGit", targets: ["SaplingGit"]), .library(name: "SaplingGit", targets: ["SaplingGit"]),
@ -33,6 +34,13 @@ let package = Package(
"SaplingUI" "SaplingUI"
] ]
), ),
.executableTarget(
name: "SaplingEditorBenchmark",
dependencies: [
"SaplingCore",
"SaplingEditor"
]
),
.target(name: "SaplingCore"), .target(name: "SaplingCore"),
.target( .target(
name: "SaplingWorkspace", name: "SaplingWorkspace",

View file

@ -0,0 +1,541 @@
import Foundation
import SaplingCore
#if os(macOS)
import AppKit
#endif
public enum EditorBenchmarkCategory: String, Hashable, Sendable {
case documentLoad
case editorUpdate
case rendering
case layout
case interaction
}
public struct EditorBenchmarkMeasurement: Hashable, Sendable {
public var name: String
public var category: EditorBenchmarkCategory
public var durationMilliseconds: Double
public var notes: String
public init(
name: String,
category: EditorBenchmarkCategory,
durationMilliseconds: Double,
notes: String = ""
) {
self.name = name
self.category = category
self.durationMilliseconds = durationMilliseconds
self.notes = notes
}
}
public struct EditorBenchmarkDocumentProfile: Hashable, Sendable {
public var fileName: String
public var byteCount: Int
public var characterCount: Int
public var utf16Count: Int
public var lineCount: Int
public var headingCount: Int
public var unorderedListItemCount: Int
public var orderedListItemCount: Int
public var blockquoteCount: Int
public var fencedCodeFenceCount: Int
public var inlineCodeLineCount: Int
public var boldLineCount: Int
public var italicLineCount: Int
public var inlineLinkCount: Int
public var referenceLinkLikeCount: Int
public var imageCount: Int
public var maxLineUTF16Length: Int
public var averageLineUTF16Length: Double
public var markdownStructureSummary: String {
[
"\(headingCount) ATX headings",
"\(unorderedListItemCount) unordered list items",
"\(orderedListItemCount) ordered list items",
"\(blockquoteCount) blockquotes",
"\(fencedCodeFenceCount) fenced code fences",
"\(inlineCodeLineCount) lines with inline code",
"\(boldLineCount) lines with bold markers",
"\(italicLineCount) lines with italic markers",
"\(inlineLinkCount) inline links",
"\(referenceLinkLikeCount) reference-style link markers",
"\(imageCount) images"
].joined(separator: ", ")
}
}
public struct EditorBenchmarkResult: Sendable {
public var document: EditorBenchmarkDocumentProfile
public var measurements: [EditorBenchmarkMeasurement]
public var renderedModeTrace: EditorRenderedModeTrace
public var measuredTotalMilliseconds: Double {
measurements.reduce(0) { $0 + $1.durationMilliseconds }
}
public var hottestMeasurements: [EditorBenchmarkMeasurement] {
measurements.sorted { $0.durationMilliseconds > $1.durationMilliseconds }
}
}
public struct EditorRenderedModeTrace: Hashable, Sendable {
public var totalLineCount: Int
public var sourceLineCount: Int
public var renderedLineCount: Int
public var headingPlanCount: Int
public var paragraphPlanCount: Int
public var inlineSpanCount: Int
public var markdownDelimiterSpanCount: Int
public var unsupportedReferenceLinkLikeCount: Int
public var renderedModeWasScheduled: Bool {
renderedLineCount > 0
}
}
public enum EditorBenchmarkProfiler {
public static func profileDocument(at url: URL) throws -> EditorBenchmarkResult {
var measurements: [EditorBenchmarkMeasurement] = []
let readResult = try measure {
try String(contentsOf: url, encoding: .utf8)
}
let source = readResult.value
measurements.append(EditorBenchmarkMeasurement(
name: "file_read",
category: .documentLoad,
durationMilliseconds: readResult.durationMilliseconds,
notes: "String(contentsOf:encoding:)"
))
let profile = documentProfile(fileName: url.lastPathComponent, source: source)
let midpoint = source.utf16.count / 2
let activeLineIndex = EditorActiveLineTracker.lineIndex(containing: midpoint, in: source)
let documentResult = measure {
MarkdownDocument(url: url, title: url.deletingPathExtension().lastPathComponent, content: source)
}
let markdownDocument = documentResult.value
measurements.append(EditorBenchmarkMeasurement(
name: "document_model_init",
category: .documentLoad,
durationMilliseconds: documentResult.durationMilliseconds,
notes: "MarkdownDocument construction"
))
let stateResult = measure {
EditorState(
document: EditorDocument(markdownDocument: markdownDocument),
activeLineIndex: activeLineIndex
)
}
var state = stateResult.value
measurements.append(EditorBenchmarkMeasurement(
name: "document_parse_editor_state_init",
category: .documentLoad,
durationMilliseconds: stateResult.durationMilliseconds,
notes: "Builds EditorState.lines for the whole document"
))
let lineGenerationResult = measure {
EditorActiveLineTracker.lines(from: source, activeLineIndex: activeLineIndex)
}
let lines = lineGenerationResult.value
measurements.append(EditorBenchmarkMeasurement(
name: "line_model_generation",
category: .editorUpdate,
durationMilliseconds: lineGenerationResult.durationMilliseconds,
notes: "EditorActiveLineTracker.lines over full source"
))
let renderPlanResult = measure {
let renderer = HybridMarkdownLineRenderer()
return lines.map(renderer.renderPlan(for:))
}
let renderPlans = renderPlanResult.value
measurements.append(EditorBenchmarkMeasurement(
name: "render_plan_generation_all_lines",
category: .rendering,
durationMilliseconds: renderPlanResult.durationMilliseconds,
notes: "HybridMarkdownLineRenderer.renderPlan for every line"
))
let renderedModeTrace = renderedTrace(
lines: lines,
renderPlans: renderPlans,
referenceLinkLikeCount: profile.referenceLinkLikeCount
)
let changedSource = sourceByInsertingProbeText(in: source, at: midpoint)
let changedActiveLineIndex = EditorActiveLineTracker.lineIndex(containing: midpoint + 1, in: changedSource)
let activeLineResult = measure {
EditorActiveLineTracker.lineIndex(containing: midpoint, in: source)
}
measurements.append(EditorBenchmarkMeasurement(
name: "active_line_lookup",
category: .editorUpdate,
durationMilliseconds: activeLineResult.durationMilliseconds,
notes: "Line lookup for midpoint cursor location"
))
let selectionUpdateResult = measure {
state.updateSelection(EditorSelection(location: midpoint, length: 0))
}
measurements.append(EditorBenchmarkMeasurement(
name: "selection_update",
category: .editorUpdate,
durationMilliseconds: selectionUpdateResult.durationMilliseconds,
notes: "EditorState.updateSelection rebuilds line model"
))
let dirtyClickResult = measure {
EditorDirtyLineInvalidator.plan(
previousText: source,
currentText: source,
previousActiveLineIndex: activeLineIndex,
currentActiveLineIndex: changedActiveLineIndex
)
}
let dirtyClickPlan = dirtyClickResult.value
measurements.append(EditorBenchmarkMeasurement(
name: "dirty_line_invalidation_click",
category: .editorUpdate,
durationMilliseconds: dirtyClickResult.durationMilliseconds,
notes: "\(dirtyClickPlan.dirtyLineCount) dirty lines"
))
let sourceUpdateResult = measure {
var updatedState = state
updatedState.updateSource(changedSource)
}
measurements.append(EditorBenchmarkMeasurement(
name: "typing_state_update",
category: .interaction,
durationMilliseconds: sourceUpdateResult.durationMilliseconds,
notes: "EditorState.updateSource after one-character insertion"
))
let dirtyTypingResult = measure {
EditorDirtyLineInvalidator.plan(
previousText: source,
currentText: changedSource,
previousActiveLineIndex: activeLineIndex,
currentActiveLineIndex: changedActiveLineIndex
)
}
let dirtyTypingPlan = dirtyTypingResult.value
measurements.append(EditorBenchmarkMeasurement(
name: "dirty_line_invalidation_typing",
category: .editorUpdate,
durationMilliseconds: dirtyTypingResult.durationMilliseconds,
notes: "\(dirtyTypingPlan.dirtyLineCount) dirty lines"
))
#if os(macOS)
measurements.append(contentsOf: profileTextKit(
source: source,
changedSource: changedSource,
activeLineIndex: activeLineIndex,
changedActiveLineIndex: changedActiveLineIndex,
dirtyClickPlan: dirtyClickPlan,
dirtyTypingPlan: dirtyTypingPlan
))
#endif
return EditorBenchmarkResult(
document: profile,
measurements: measurements,
renderedModeTrace: renderedModeTrace
)
}
public static func documentProfile(fileName: String, source: String) -> EditorBenchmarkDocumentProfile {
let lines = source.components(separatedBy: "\n")
let lineLengths = lines.map { $0.utf16.count }
let lineCount = lines.count
let maxLineLength = lineLengths.max() ?? 0
let totalLineLength = lineLengths.reduce(0, +)
return EditorBenchmarkDocumentProfile(
fileName: fileName,
byteCount: source.lengthOfBytes(using: .utf8),
characterCount: source.count,
utf16Count: source.utf16.count,
lineCount: lineCount,
headingCount: countMatches("(?m)^#{1,6}\\s", in: source),
unorderedListItemCount: countMatches("(?m)^[-*+]\\s", in: source),
orderedListItemCount: countMatches("(?m)^\\d+\\.\\s", in: source),
blockquoteCount: countMatches("(?m)^>\\s", in: source),
fencedCodeFenceCount: countMatches("(?m)^```", in: source),
inlineCodeLineCount: countMatchingLines("`[^`\\n]+`", in: lines),
boldLineCount: countMatchingLines("\\*\\*[^*\\n]+\\*\\*", in: lines),
italicLineCount: countMatchingLines("(?<!\\*)\\*[^*\\n]+\\*(?!\\*)", in: lines),
inlineLinkCount: countMatches("(?<!!)\\[[^\\]]+\\]\\([^\\)]+\\)", in: source),
referenceLinkLikeCount: countMatches("(?<!!)\\[[A-Za-z0-9_-]+\\]", in: source),
imageCount: countMatches("!\\[", in: source),
maxLineUTF16Length: maxLineLength,
averageLineUTF16Length: lineCount == 0 ? 0 : Double(totalLineLength) / Double(lineCount)
)
}
private static func renderedTrace(
lines: [EditorLine],
renderPlans: [HybridMarkdownLineRenderPlan],
referenceLinkLikeCount: Int
) -> EditorRenderedModeTrace {
let headingCount = renderPlans.filter {
if case .heading = $0.kind {
return true
}
return false
}.count
let paragraphCount = renderPlans.count - headingCount
let spans = renderPlans.flatMap(\.spans)
return EditorRenderedModeTrace(
totalLineCount: lines.count,
sourceLineCount: lines.filter { $0.mode == .source }.count,
renderedLineCount: lines.filter { $0.mode == .rendered }.count,
headingPlanCount: headingCount,
paragraphPlanCount: paragraphCount,
inlineSpanCount: spans.count,
markdownDelimiterSpanCount: spans.filter { $0.kind == .markdownDelimiter }.count,
unsupportedReferenceLinkLikeCount: referenceLinkLikeCount
)
}
private static func sourceByInsertingProbeText(in source: String, at utf16Location: Int) -> String {
let nsSource = source as NSString
let location = max(0, min(utf16Location, nsSource.length))
return nsSource.replacingCharacters(in: NSRange(location: location, length: 0), with: "x")
}
private static func countMatches(_ pattern: String, in source: String) -> Int {
guard let regex = try? NSRegularExpression(pattern: pattern) else { return 0 }
return regex.numberOfMatches(in: source, range: NSRange(location: 0, length: source.utf16.count))
}
private static func countMatchingLines(_ pattern: String, in lines: [String]) -> Int {
guard let regex = try? NSRegularExpression(pattern: pattern) else { return 0 }
return lines.reduce(0) { count, line in
let range = NSRange(location: 0, length: line.utf16.count)
return count + (regex.firstMatch(in: line, range: range) == nil ? 0 : 1)
}
}
private static func measure<T>(_ operation: () throws -> T) rethrows -> (value: T, durationMilliseconds: Double) {
let start = DispatchTime.now().uptimeNanoseconds
let value = try operation()
let end = DispatchTime.now().uptimeNanoseconds
return (value, Double(end - start) / 1_000_000)
}
#if os(macOS)
private static func profileTextKit(
source: String,
changedSource: String,
activeLineIndex: Int,
changedActiveLineIndex: Int,
dirtyClickPlan: EditorDirtyLineInvalidationPlan,
dirtyTypingPlan: EditorDirtyLineInvalidationPlan
) -> [EditorBenchmarkMeasurement] {
var measurements: [EditorBenchmarkMeasurement] = []
let textStorage = NSTextStorage()
let layoutManager = NSLayoutManager()
let textContainer = NSTextContainer(size: NSSize(width: 760, height: CGFloat.greatestFiniteMagnitude))
textContainer.widthTracksTextView = false
textContainer.heightTracksTextView = false
layoutManager.addTextContainer(textContainer)
textStorage.addLayoutManager(layoutManager)
let storageUpdateResult = measure {
textStorage.replaceCharacters(in: NSRange(location: 0, length: 0), with: source)
}
measurements.append(EditorBenchmarkMeasurement(
name: "text_storage_initial_update",
category: .layout,
durationMilliseconds: storageUpdateResult.durationMilliseconds,
notes: "NSTextStorage.replaceCharacters full source"
))
let fullRenderPlan = EditorDirtyLineInvalidator.plan(
previousText: nil,
currentText: source,
previousActiveLineIndex: nil,
currentActiveLineIndex: activeLineIndex
)
let attributedStringResult = measure {
MarkdownTextStyler.apply(
to: textStorage,
invalidationPlan: fullRenderPlan,
activeLineIndex: activeLineIndex,
backgroundColor: .textBackgroundColor,
activeLineBackgroundColor: .controlAccentColor.withAlphaComponent(0.10),
textColor: .labelColor,
secondaryTextColor: .secondaryLabelColor,
accentColor: .controlAccentColor
)
}
measurements.append(EditorBenchmarkMeasurement(
name: "attributed_string_generation_initial",
category: .rendering,
durationMilliseconds: attributedStringResult.durationMilliseconds,
notes: "\(attributedStringResult.value.styledLineCount) styled lines"
))
let layoutInvalidationResult = measure {
var actualRange = NSRange(location: 0, length: 0)
layoutManager.invalidateLayout(
forCharacterRange: NSRange(location: 0, length: textStorage.length),
actualCharacterRange: &actualRange
)
}
measurements.append(EditorBenchmarkMeasurement(
name: "layout_invalidation_full_document",
category: .layout,
durationMilliseconds: layoutInvalidationResult.durationMilliseconds,
notes: "NSLayoutManager.invalidateLayout full character range"
))
let glyphGenerationResult = measure {
layoutManager.ensureGlyphs(forCharacterRange: NSRange(location: 0, length: textStorage.length))
}
measurements.append(EditorBenchmarkMeasurement(
name: "glyph_generation_full_document",
category: .layout,
durationMilliseconds: glyphGenerationResult.durationMilliseconds,
notes: "NSLayoutManager.ensureGlyphs full character range"
))
let layoutGenerationResult = measure {
layoutManager.ensureLayout(for: textContainer)
}
measurements.append(EditorBenchmarkMeasurement(
name: "layout_generation_full_document",
category: .layout,
durationMilliseconds: layoutGenerationResult.durationMilliseconds,
notes: "NSLayoutManager.ensureLayout for text container"
))
let firstViewportResult = measure {
layoutManager.glyphRange(
forBoundingRect: NSRect(x: 0, y: 0, width: 760, height: 900),
in: textContainer
)
}
measurements.append(EditorBenchmarkMeasurement(
name: "first_viewport_glyph_range",
category: .layout,
durationMilliseconds: firstViewportResult.durationMilliseconds,
notes: "Visible glyph range at document top"
))
let clickRenderResult = measure {
MarkdownTextStyler.apply(
to: textStorage,
invalidationPlan: dirtyClickPlan,
activeLineIndex: changedActiveLineIndex,
backgroundColor: .textBackgroundColor,
activeLineBackgroundColor: .controlAccentColor.withAlphaComponent(0.10),
textColor: .labelColor,
secondaryTextColor: .secondaryLabelColor,
accentColor: .controlAccentColor
)
}
measurements.append(EditorBenchmarkMeasurement(
name: "render_update_click_dirty",
category: .interaction,
durationMilliseconds: clickRenderResult.durationMilliseconds,
notes: "\(clickRenderResult.value.styledLineCount) styled lines"
))
let typingStorageResult = measure {
let midpoint = textStorage.length / 2
textStorage.replaceCharacters(in: NSRange(location: midpoint, length: 0), with: "x")
}
measurements.append(EditorBenchmarkMeasurement(
name: "text_storage_typing_insert",
category: .interaction,
durationMilliseconds: typingStorageResult.durationMilliseconds,
notes: "NSTextStorage single-character insertion"
))
let typingRenderResult = measure {
MarkdownTextStyler.apply(
to: textStorage,
invalidationPlan: dirtyTypingPlan,
activeLineIndex: changedActiveLineIndex,
backgroundColor: .textBackgroundColor,
activeLineBackgroundColor: .controlAccentColor.withAlphaComponent(0.10),
textColor: .labelColor,
secondaryTextColor: .secondaryLabelColor,
accentColor: .controlAccentColor
)
}
measurements.append(EditorBenchmarkMeasurement(
name: "render_update_typing_dirty",
category: .interaction,
durationMilliseconds: typingRenderResult.durationMilliseconds,
notes: "\(typingRenderResult.value.styledLineCount) styled lines"
))
let layoutAfterTypingResult = measure {
layoutManager.ensureLayout(forCharacterRange: NSRange(location: textStorage.length / 2, length: 1))
}
measurements.append(EditorBenchmarkMeasurement(
name: "layout_after_typing",
category: .interaction,
durationMilliseconds: layoutAfterTypingResult.durationMilliseconds,
notes: "NSLayoutManager.ensureLayout for inserted character"
))
let scrollTopResult = measure {
layoutManager.glyphRange(
forBoundingRect: NSRect(x: 0, y: 0, width: 760, height: 900),
in: textContainer
)
}
measurements.append(EditorBenchmarkMeasurement(
name: "scroll_latency_top_viewport",
category: .interaction,
durationMilliseconds: scrollTopResult.durationMilliseconds,
notes: "glyphRange for top viewport"
))
let scrollMiddleResult = measure {
layoutManager.glyphRange(
forBoundingRect: NSRect(x: 0, y: 50_000, width: 760, height: 900),
in: textContainer
)
}
measurements.append(EditorBenchmarkMeasurement(
name: "scroll_latency_middle_viewport",
category: .interaction,
durationMilliseconds: scrollMiddleResult.durationMilliseconds,
notes: "glyphRange for estimated middle viewport"
))
let scrollDeepResult = measure {
layoutManager.glyphRange(
forBoundingRect: NSRect(x: 0, y: 500_000, width: 760, height: 900),
in: textContainer
)
}
measurements.append(EditorBenchmarkMeasurement(
name: "scroll_latency_deep_viewport",
category: .interaction,
durationMilliseconds: scrollDeepResult.durationMilliseconds,
notes: "glyphRange for estimated deep viewport"
))
_ = changedSource
return measurements
}
#endif
}

View file

@ -552,14 +552,14 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
} }
#endif #endif
private struct MarkdownTextStylingResult { struct MarkdownTextStylingResult {
var totalLineCount: Int var totalLineCount: Int
var styledLineCount: Int var styledLineCount: Int
static let empty = MarkdownTextStylingResult(totalLineCount: 0, styledLineCount: 0) static let empty = MarkdownTextStylingResult(totalLineCount: 0, styledLineCount: 0)
} }
private enum MarkdownTextStyler { enum MarkdownTextStyler {
#if os(macOS) #if os(macOS)
typealias PlatformColor = NSColor typealias PlatformColor = NSColor
#elseif os(iOS) #elseif os(iOS)

View file

@ -0,0 +1,95 @@
import Foundation
import SaplingCore
import SaplingEditor
struct BenchmarkScenario {
var name: String
var url: URL
}
let arguments = Array(CommandLine.arguments.dropFirst())
let repositoryRoot = URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true)
do {
let scenarios = try benchmarkScenarios(arguments: arguments, repositoryRoot: repositoryRoot)
print("# Sapling Editor Benchmark")
print("")
print("Generated: \(ISO8601DateFormatter().string(from: Date()))")
print("")
for scenario in scenarios {
let result = try EditorBenchmarkProfiler.profileDocument(at: scenario.url)
printScenario(name: scenario.name, result: result)
}
} catch {
fputs("SaplingEditorBenchmark failed: \(error)\n", stderr)
exit(1)
}
func benchmarkScenarios(arguments: [String], repositoryRoot: URL) throws -> [BenchmarkScenario] {
if !arguments.isEmpty {
return arguments.map { path in
let url = URL(fileURLWithPath: path)
return BenchmarkScenario(name: url.deletingPathExtension().lastPathComponent, url: url)
}
}
return try [
sampleScenario(),
BenchmarkScenario(
name: "hybrid-large-2100",
url: repositoryRoot.appendingPathComponent("Docs/EditorPrototypes/hybrid-large-2100.md")
),
BenchmarkScenario(
name: "5mb",
url: repositoryRoot.appendingPathComponent("Docs/Benchmarks/5mb.md")
)
]
}
func sampleScenario() throws -> BenchmarkScenario {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("SaplingEditorBenchmark-sample-document.md")
try SaplingSampleData.document.content.write(to: url, atomically: true, encoding: .utf8)
return BenchmarkScenario(name: "sample-document", url: url)
}
func printScenario(name: String, result: EditorBenchmarkResult) {
print("## \(name)")
print("")
print("| Property | Value |")
print("| --- | ---: |")
print("| File | `\(result.document.fileName)` |")
print("| Bytes | \(result.document.byteCount) |")
print("| Characters | \(result.document.characterCount) |")
print("| UTF-16 units | \(result.document.utf16Count) |")
print("| Lines | \(result.document.lineCount) |")
print("| Max line UTF-16 length | \(result.document.maxLineUTF16Length) |")
print("| Average line UTF-16 length | \(format(result.document.averageLineUTF16Length)) |")
print("")
print("Markdown structure: \(result.document.markdownStructureSummary)")
print("")
print("Rendered mode trace: \(result.renderedModeTrace.renderedLineCount) rendered lines, "
+ "\(result.renderedModeTrace.sourceLineCount) source lines, "
+ "\(result.renderedModeTrace.headingPlanCount) heading plans, "
+ "\(result.renderedModeTrace.paragraphPlanCount) paragraph plans, "
+ "\(result.renderedModeTrace.inlineSpanCount) inline spans, "
+ "\(result.renderedModeTrace.unsupportedReferenceLinkLikeCount) reference-style markers.")
print("")
print("| Rank | Operation | Category | Time | Percent | Notes |")
print("| ---: | --- | --- | ---: | ---: | --- |")
for (index, measurement) in result.hottestMeasurements.prefix(20).enumerated() {
let percentage = result.measuredTotalMilliseconds == 0
? 0
: measurement.durationMilliseconds / result.measuredTotalMilliseconds * 100
print("| \(index + 1) | `\(measurement.name)` | \(measurement.category.rawValue) | "
+ "\(format(measurement.durationMilliseconds)) ms | \(format(percentage))% | \(measurement.notes) |")
}
print("")
print("Measured total: \(format(result.measuredTotalMilliseconds)) ms")
print("")
}
func format(_ value: Double) -> String {
String(format: "%.3f", value)
}

View file

@ -0,0 +1,50 @@
import XCTest
@testable import SaplingEditor
final class EditorPerformanceProfilingTests: XCTestCase {
func testDocumentProfileRecordsMarkdownStructure() {
let source = """
# Title
- Item
1. Step
> Quote
Text with **bold**, *italic*, `code`, [ref], and ![image](image.png).
```
code
```
"""
let profile = EditorBenchmarkProfiler.documentProfile(fileName: "Sample.md", source: source)
XCTAssertEqual(profile.fileName, "Sample.md")
XCTAssertEqual(profile.headingCount, 1)
XCTAssertEqual(profile.unorderedListItemCount, 1)
XCTAssertEqual(profile.orderedListItemCount, 1)
XCTAssertEqual(profile.blockquoteCount, 1)
XCTAssertEqual(profile.fencedCodeFenceCount, 2)
XCTAssertEqual(profile.inlineCodeLineCount, 1)
XCTAssertEqual(profile.boldLineCount, 1)
XCTAssertEqual(profile.italicLineCount, 1)
XCTAssertEqual(profile.inlineLinkCount, 0)
XCTAssertEqual(profile.referenceLinkLikeCount, 1)
XCTAssertEqual(profile.imageCount, 1)
}
func testProfilerProducesBenchmarkMeasurements() throws {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let url = directory.appendingPathComponent("Benchmark.md")
try "# Heading\n\nText with **bold** and `code`.\n".write(to: url, atomically: true, encoding: .utf8)
let result = try EditorBenchmarkProfiler.profileDocument(at: url)
XCTAssertEqual(result.document.lineCount, 4)
XCTAssertTrue(result.renderedModeTrace.renderedModeWasScheduled)
XCTAssertTrue(result.measurements.contains { $0.name == "file_read" })
XCTAssertTrue(result.measurements.contains { $0.name == "document_parse_editor_state_init" })
XCTAssertTrue(result.measurements.contains { $0.name == "render_plan_generation_all_lines" })
XCTAssertTrue(result.measurements.contains { $0.name == "dirty_line_invalidation_typing" })
}
}