Commit 7af09a499f
Verified · cmc
Layout: unified · split
Tests/OrgEditorAppKitTests/GateTests.swift added +105
| @@ -0,0 +1,105 @@ | ||
| 1 | import AppKit | |
| 2 | import Foundation | |
| 3 | import OrgDocument | |
| 4 | import Testing | |
| 5 | @testable import OrgEditorAppKit | |
| 6 | ||
| 7 | /// The phase 1 performance gates (design, "Phases"). Skipped unless `ORGSTAR_GATES` is set; | |
| 8 | /// run in a release build on the reference machine: | |
| 9 | /// | |
| 10 | /// ORGSTAR_GATES=1 swift test -c release --filter GateTests | |
| 11 | /// | |
| 12 | /// `ORGSTAR_GATE_FILE` adds a real file to the synthetic ones. Memory limits follow the design: | |
| 13 | /// 10x the file size for typical documents, 45x for the synthetic worst cases. | |
| 14 | @MainActor | |
| 15 | @Suite(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_GATES"] != nil)) | |
| 16 | struct GateTests { | |
| 17 | /// About 1 MB of body text under a single top-level heading. | |
| 18 | static let singleSection: String = { | |
| 19 | var text = "* One big section\n" | |
| 20 | var n = 0 | |
| 21 | while text.utf8.count < 1_000_000 { | |
| 22 | text += "Paragraph \(n) with *bold*, /italic/, a [[https://example.com/\(n)][link]] and <2026-10-04 Sun>.\n" | |
| 23 | text += "It continues on a second line with =code= and ~more~.\n\n" | |
| 24 | text += "- item \(n)\n - nested [ ] task\n\n" | |
| 25 | n += 1 | |
| 26 | } | |
| 27 | return text | |
| 28 | }() | |
| 29 | ||
| 30 | /// A 5,000-row table. | |
| 31 | static let bigTable: String = { | |
| 32 | var text = "* Table\n| id | name | note |\n|----+------+------|\n" | |
| 33 | for n in 0..<5_000 { text += "| \(n) | row \(n) | *bold* and [[https://example.com][link]] |\n" } | |
| 34 | return text | |
| 35 | }() | |
| 36 | ||
| 37 | struct Result: CustomStringConvertible { | |
| 38 | var name: String | |
| 39 | var bytes: Int | |
| 40 | var open: Duration | |
| 41 | var typingP95: Duration | |
| 42 | var newlineP95: Duration | |
| 43 | var memoryRatio: Double | |
| 44 | ||
| 45 | var description: String { | |
| 46 | "\(name) (\(bytes / 1000) kB): open \(open), typing p95 \(typingP95), blank line p95 \(newlineP95), memory \(String(format: "%.1f", memoryRatio))x" | |
| 47 | } | |
| 48 | } | |
| 49 | ||
| 50 | /// Heap bytes in use, which unlike resident size isn't hidden by memory freed earlier. | |
| 51 | static func heapBytes() -> Int { | |
| 52 | var stats = malloc_statistics_t() | |
| 53 | malloc_zone_statistics(nil, &stats) | |
| 54 | return Int(stats.size_in_use) | |
| 55 | } | |
| 56 | ||
| 57 | func measure(_ name: String, _ text: String) -> Result { | |
| 58 | let clock = ContinuousClock() | |
| 59 | let bytes = Array(text.utf8) | |
| 60 | let before = Self.heapBytes() | |
| 61 | var harness: Harness! | |
| 62 | let open = clock.measure { | |
| 63 | harness = Harness(DocumentState(bytes: bytes), layout: false) | |
| 64 | } | |
| 65 | harness.drainStyling() | |
| 66 | let memoryRatio = Double(Self.heapBytes() - before) / Double(bytes.count) | |
| 67 | ||
| 68 | var timings: [Duration] = [] | |
| 69 | harness.editor.onEditTiming = { timings.append($0) } | |
| 70 | let paragraphs = harness.editor.document.tree.root.descendants().filter { $0.kind == .paragraph || $0.kind == .tableCell } | |
| 71 | harness.caret(at: paragraphs[paragraphs.count / 2].range.lowerBound + 1) | |
| 72 | for _ in 0..<100 { harness.textView.insertText("x", replacementRange: harness.textView.selectedRange()) } | |
| 73 | let typing = timings.sorted()[95] | |
| 74 | ||
| 75 | timings = [] | |
| 76 | for _ in 0..<20 { | |
| 77 | harness.textView.insertNewline(nil) | |
| 78 | harness.textView.insertNewline(nil) | |
| 79 | harness.textView.deleteBackward(nil) | |
| 80 | harness.textView.deleteBackward(nil) | |
| 81 | } | |
| 82 | let sorted = timings.sorted() | |
| 83 | return Result(name: name, bytes: bytes.count, open: open, typingP95: typing, newlineP95: sorted[sorted.count * 95 / 100], memoryRatio: memoryRatio) | |
| 84 | } | |
| 85 | ||
| 86 | @Test func gates() throws { | |
| 87 | // The first text view, and the first text loaded into one, set up the text system once | |
| 88 | // per process; an app pays that at launch, not per file. | |
| 89 | NSTextView(usingTextLayoutManager: true).textStorage?.setAttributedString(NSAttributedString(string: "warm up")) | |
| 90 | // Memory limits: worst-case dense markup for the synthetic files, typical for real ones. | |
| 91 | var inputs = [("single 1 MB section", Self.singleSection, 45.0), ("5,000-row table", Self.bigTable, 45.0)] | |
| 92 | if let path = ProcessInfo.processInfo.environment["ORGSTAR_GATE_FILE"] { | |
| 93 | inputs.append((URL(fileURLWithPath: path).lastPathComponent, try String(contentsOfFile: path, encoding: .utf8), 10.0)) | |
| 94 | } | |
| 95 | for (name, text, memoryLimit) in inputs { | |
| 96 | let result = measure(name, text) | |
| 97 | print("GATE \(result)") | |
| 98 | let perMegabyte = Double(result.bytes) / 1_000_000 | |
| 99 | #expect(result.open < .milliseconds(100) * max(1, perMegabyte), "\(name): open") | |
| 100 | #expect(result.typingP95 < .milliseconds(16), "\(name): typing") | |
| 101 | #expect(result.newlineP95 < .milliseconds(16), "\(name): blank line") | |
| 102 | #expect(result.memoryRatio < memoryLimit, "\(name): memory") | |
| 103 | } | |
| 104 | } | |
| 105 | } | |