krz/orgstar

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

Tests/OrgCoreTests/ClockTests.swift

ab6ccec56eca03d0dadf2c9332aab10c4490eb62
orgstar/Tests/OrgCoreTests/ClockTests.swift history · blame · raw

141 lines · 7330 bytes

  1import Foundation
  2import Testing
  3@testable import OrgCore
  4
  5struct ClockTests {
  6    static let texts = [
  7        "* TODO Task\n",
  8        "* TODO Task\nBody text.\n",
  9        "* Task\nSCHEDULED: <2026-10-05 Mon>\n:PROPERTIES:\n:ID: x\n:END:\nBody\n",
 10        "* Task\n:LOGBOOK:\nCLOCK: [2026-10-04 Sun 09:00]--[2026-10-04 Sun 10:00] =>  1:00\n:END:\nNotes\n",
 11        "* Task\nCLOCK: [2026-10-04 Sun 09:00]--[2026-10-04 Sun 10:00] =>  1:00\nNotes\n",
 12        "* Task\n** Child\nChild body\n* Next\n",
 13        "* Task",
 14        "Preamble\n* Task\n- item\n",
 15        "* Task\n:LOGBOOK:\n- State \"DONE\" from \"TODO\" [2026-10-01 Thu 10:00]\n:END:\n",
 16    ]
 17
 18    static var utc: Calendar {
 19        var c = Calendar(identifier: .gregorian)
 20        c.timeZone = TimeZone(identifier: "UTC")!
 21        return c
 22    }
 23
 24    func run(_ command: any OrgCommand, _ text: String, caret: Int, at seconds: Int) -> (text: String, caret: Int, failure: String?) {
 25        let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret], now: Date(timeIntervalSince1970: Double(seconds)), calendar: Self.utc)
 26        switch command.run(in: context) {
 27        case .commit(let result):
 28            var new = text
 29            for edit in result.edits.sorted(by: { $0.range.lowerBound > $1.range.lowerBound }) { new = edit.apply(to: new) }
 30            return (new, result.selection?.first?.lowerBound ?? caret, nil)
 31        case .failed(let message): return (text, caret, message)
 32        default: return (text, caret, "other")
 33        }
 34    }
 35
 36    @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
 37    func matchesEmacs() throws {
 38        let t1 = 1_789_000_000   // Thu 2026-09-10 00:26
 39        let t2 = t1 + 3 * 3600 + 17 * 60 + 42
 40        var cases: [EmacsOracle.Case] = []
 41        var ours: [(String, String)] = []
 42        for text in Self.texts {
 43            let caret = (text as NSString).range(of: "Task").location
 44            for mode in ["out", "cancel"] {
 45                // Clock in at t1, then out (or cancel) at t2.
 46                let first = run(ClockIn(), text, caret: caret, at: t1)
 47                guard let start = ClockIn.runningStart(in: first.text, near: caret) else {
 48                    ours.append(("no running clock: \(first.failure ?? "")", text))
 49                    cases.append(EmacsOracle.Case(text: text, point: 1, form: "nil"))
 50                    continue
 51                }
 52                let second = run(ClockOut(start: start, cancel: mode == "cancel"), first.text, caret: first.caret, at: t2)
 53                ours.append((second.text, first.text))
 54                let out = mode == "cancel" ? "(org-clock-cancel)" : "(org-clock-out)"
 55                cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(caret, in: text), form: """
 56                (progn
 57                  (require 'org-clock)
 58                  (setq org-clock-persist nil org-clock-idle-time nil org-clock-auto-clock-resolution nil org-clock-into-drawer t)
 59                  \(frozenClock(t1, "(org-clock-in)"))
 60                  (setq orgstar-after-in (buffer-substring-no-properties (point-min) (point-max)))
 61                  \(frozenClock(t2, out)))
 62                """))
 63            }
 64        }
 65        let emacs = try EmacsOracle.run(cases)
 66        for (i, (mine, theirs)) in zip(ours, emacs).enumerated() {
 67            #expect(mine.0 == theirs.text, "case \(i) \(cases[i].form.contains("cancel") ? "cancel" : "out")\n\(cases[i].text.debugDescription)\nafter in (ours): \(mine.1.debugDescription)\nours:  \(mine.0.debugDescription)\nemacs: \(theirs.text.debugDescription) \(theirs.error)")
 68        }
 69    }
 70}
 71
 72
 73struct ClockReportTests {
 74    static let config = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".config/doom/config.el")
 75
 76    static let sample = """
 77    * TODO [#A] Audit work :work:
 78    :LOGBOOK:
 79    CLOCK: [2026-02-20 Fri 10:00]--[2026-02-20 Fri 16:00] =>  6:00
 80    CLOCK: [2026-02-17 Tue 16:22]--[2026-02-17 Tue 16:45] =>  0:23
 81    CLOCK: [2026-02-17 Tue 08:15]--[2026-02-17 Tue 08:50] =>  0:35
 82    :END:
 83    * Meetings
 84    :LOGBOOK:
 85    CLOCK: [2026-02-24 Tue 09:30]--[2026-02-24 Tue 10:00] =>  0:30
 86    CLOCK: [2026-03-02 Mon 09:00]
 87    CLOCK: [2026-02-17 Tue 09:28]--[2026-02-17 Tue 09:55] =>  0:27
 88    :END:
 89    """
 90
 91    @Test func totalsPerHeading() {
 92        let entries = ClockReport.entries(Self.sample)
 93        let all = ClockReport.byHeading(entries)
 94        #expect(all.map(\.heading) == ["Audit work", "Meetings"] && all.map(\.minutes) == [418, 57])
 95        #expect(all[1].start == (Self.sample as NSString).range(of: "* Meetings").location)
 96        #expect(ClockReport.byHeading(entries, from: "2026-02-18", to: "2026-02-24").map(\.minutes) == [360, 30])
 97    }
 98
 99    /// Your own `tr/generate-report`, loaded from config.el in batch Emacs.
100    static func emacsReport(_ text: String, start: String?, end: String?) throws -> String {
101        let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-tr-\(UUID().uuidString)")
102        try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
103        defer { try? FileManager.default.removeItem(at: folder) }
104        let input = folder.appendingPathComponent("time.org")
105        let output = folder.appendingPathComponent("report.org")
106        try text.write(to: input, atomically: true, encoding: .utf8)
107        func lisp(_ s: String?) -> String { s.map { "\"\($0)\"" } ?? "nil" }
108        let script = folder.appendingPathComponent("tr.el")
109        try """
110        (require 'org)
111        (require 'cl-lib)
112        (require 'subr-x)
113        (with-temp-buffer
114          (insert-file-contents "\(config.path)")
115          (goto-char (point-min))
116          (condition-case nil
117              (while t
118                (let ((form (read (current-buffer))))
119                  (when (and (eq (car-safe form) 'defun) (string-prefix-p "tr/" (symbol-name (cadr form))))
120                    (eval form t))))
121            (end-of-file nil)))
122        (tr/generate-report "\(input.path)" "\(output.path)" \(lisp(start)) \(lisp(end)))
123        """.write(to: script, atomically: true, encoding: .utf8)
124        _ = try EmacsOracle.run(["-Q", "--batch", "-l", script.path], environment: ["TZ": "UTC"])
125        return try String(contentsOf: output, encoding: .utf8)
126    }
127
128    @Test(.enabled(if: FileManager.default.fileExists(atPath: ClockReportTests.config.path) && ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
129    func matchesYourReport() throws {
130        var texts = [Self.sample]
131        let work = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("git/cmc/work-notes/time.org")
132        if let real = try? String(contentsOf: work, encoding: .utf8) { texts.append(real) }
133        for text in texts {
134            for (start, end) in [(nil, nil), ("2026-02-18", nil), (nil, "2026-02-20"), ("2026-02-17", "2026-02-17")] as [(String?, String?)] {
135                let ours = ClockReport.timeReport(ClockReport.entries(text), start: start, end: end)
136                let theirs = try Self.emacsReport(text, start: start, end: end)
137                #expect(ours == theirs, "\(start ?? "-")..\(end ?? "-")\nours:\n\(ours)\ntheirs:\n\(theirs)")
138            }
139        }
140    }
141}