krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Tests/OrgEditorAppKitTests/GateTests.swift

778058aafc7ddacee4a613b1da87c60aa0c8e113
orgstar/Tests/OrgEditorAppKitTests/GateTests.swift history · blame · raw

116 lines · 5360 bytes

5 symbols in this file
  1import AppKit
  2import Foundation
  3import OrgDocument
  4import 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))
 16struct GateTests {
 17    /// About 1 MB of body text under a single top-level heading.
 18    /// Entries with property and LOGBOOK drawers, all folded when the file opens.
 19    static let drawers: String = {
 20        var text = ""
 21        for n in 0..<3_000 {
 22            text += "* TODO Entry \(n) :tag:\n:PROPERTIES:\n:ID:       \(n)-uuid\n:CREATED:  [2026-10-04 Sun]\n:END:\n"
 23            text += ":LOGBOOK:\n- State \"DONE\"       from \"TODO\"       [2026-10-04 Sun 10:00]\nCLOCK: [2026-10-04 Sun 09:00]--[2026-10-04 Sun 10:00] =>  1:00\n:END:\n"
 24            text += "Body of entry \(n), a paragraph of text.\n\n"
 25        }
 26        return text
 27    }()
 28
 29    static let singleSection: String = {
 30        var text = "* One big section\n"
 31        var n = 0
 32        while text.utf8.count < 1_000_000 {
 33            text += "Paragraph \(n) with *bold*, /italic/, a [[https://example.com/\(n)][link]] and <2026-10-04 Sun>.\n"
 34            text += "It continues on a second line with =code= and ~more~.\n\n"
 35            text += "- item \(n)\n  - nested [ ] task\n\n"
 36            n += 1
 37        }
 38        return text
 39    }()
 40
 41    /// A 5,000-row table.
 42    static let bigTable: String = {
 43        var text = "* Table\n| id | name | note |\n|----+------+------|\n"
 44        for n in 0..<5_000 { text += "| \(n) | row \(n) | *bold* and [[https://example.com][link]] |\n" }
 45        return text
 46    }()
 47
 48    struct Result: CustomStringConvertible {
 49        var name: String
 50        var bytes: Int
 51        var open: Duration
 52        var typingP95: Duration
 53        var newlineP95: Duration
 54        var memoryRatio: Double
 55
 56        var description: String {
 57            "\(name) (\(bytes / 1000) kB): open \(open), typing p95 \(typingP95), blank line p95 \(newlineP95), memory \(String(format: "%.1f", memoryRatio))x"
 58        }
 59    }
 60
 61    /// Heap bytes in use, which unlike resident size isn't hidden by memory freed earlier.
 62    static func heapBytes() -> Int {
 63        var stats = malloc_statistics_t()
 64        malloc_zone_statistics(nil, &stats)
 65        return Int(stats.size_in_use)
 66    }
 67
 68    func measure(_ name: String, _ text: String) -> Result {
 69        let clock = ContinuousClock()
 70        let bytes = Array(text.utf8)
 71        let before = Self.heapBytes()
 72        var harness: Harness!
 73        let open = clock.measure {
 74            harness = Harness(DocumentState(bytes: bytes), layout: false)
 75        }
 76        harness.drainStyling()
 77        let memoryRatio = Double(Self.heapBytes() - before) / Double(bytes.count)
 78
 79        var timings: [Duration] = []
 80        harness.editor.onEditTiming = { timings.append($0) }
 81        let paragraphs = harness.editor.document.tree.root.descendants().filter { $0.kind == .paragraph || $0.kind == .tableCell }
 82        harness.caret(at: paragraphs[paragraphs.count / 2].range.lowerBound + 1)
 83        for _ in 0..<100 { harness.textView.insertText("x", replacementRange: harness.textView.selectedRange()) }
 84        let typing = timings.sorted()[95]
 85
 86        timings = []
 87        for _ in 0..<20 {
 88            harness.textView.insertNewline(nil)
 89            harness.textView.insertNewline(nil)
 90            harness.textView.deleteBackward(nil)
 91            harness.textView.deleteBackward(nil)
 92        }
 93        let sorted = timings.sorted()
 94        return Result(name: name, bytes: bytes.count, open: open, typingP95: typing, newlineP95: sorted[sorted.count * 95 / 100], memoryRatio: memoryRatio)
 95    }
 96
 97    @Test func gates() throws {
 98        // The first text view, and the first text loaded into one, set up the text system once
 99        // per process; an app pays that at launch, not per file.
100        NSTextView(usingTextLayoutManager: true).textStorage?.setAttributedString(NSAttributedString(string: "warm up"))
101        // Memory limits: worst-case dense markup for the synthetic files, typical for real ones.
102        var inputs = [("single 1 MB section", Self.singleSection, 45.0), ("5,000-row table", Self.bigTable, 45.0), ("3,000 entries with drawers", Self.drawers, 45.0)]
103        if let path = ProcessInfo.processInfo.environment["ORGSTAR_GATE_FILE"] {
104            inputs.append((URL(fileURLWithPath: path).lastPathComponent, try String(contentsOfFile: path, encoding: .utf8), 10.0))
105        }
106        for (name, text, memoryLimit) in inputs {
107            let result = measure(name, text)
108            print("GATE \(result)")
109            let perMegabyte = Double(result.bytes) / 1_000_000
110            #expect(result.open < .milliseconds(100) * max(1, perMegabyte), "\(name): open")
111            #expect(result.typingP95 < .milliseconds(16), "\(name): typing")
112            #expect(result.newlineP95 < .milliseconds(16), "\(name): blank line")
113            #expect(result.memoryRatio < memoryLimit, "\(name): memory")
114        }
115    }
116}