Tests/OrgCoreTests/ClockTests.swift
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}