Tests/OrgCoreTests/ClockHistoryTests.swift
191 lines · 10693 bytes
13 symbols in this file
1import Foundation
2import Testing
3@testable import OrgCore
4
5struct ClockResolutionTests {
6 struct Case {
7 var text: String
8 var key: Character
9 var minutes: Int?
10 /// For `t`: when the user got distracted.
11 var date: Int?
12 /// Seconds after the clock-in (active) or the clock's start (dangling) that idling began.
13 var idle: Int
14 var active = true
15 var clockingIn = false
16 }
17
18 static let t1 = 1_789_000_000 // Thu 2026-09-10 00:26:40
19 static let now = t1 + 95 * 60 + 7
20 static let task = "* TODO Task\nBody\n"
21 static let logged = "* Task\n:LOGBOOK:\nCLOCK: [2026-09-09 Wed 09:00]--[2026-09-09 Wed 10:00] => 1:00\n:END:\nNotes\n"
22 static let dangling = "* Task\n:LOGBOOK:\nCLOCK: [2026-09-10 Thu 00:26]\nCLOCK: [2026-09-09 Wed 09:00]--[2026-09-09 Wed 10:00] => 1:00\n:END:\n* Next\n"
23
24 static let cases: [Case] = [
25 Case(text: task, key: "k", minutes: 77, idle: 17 * 60 + 20),
26 Case(text: task, key: "k", minutes: 5, idle: 17 * 60 + 20),
27 Case(text: logged, key: "K", minutes: 5, idle: 17 * 60 + 20),
28 Case(text: task, key: "K", minutes: 77, idle: 17 * 60 + 20),
29 Case(text: task, key: "s", idle: 17 * 60 + 20),
30 Case(text: logged, key: "S", idle: 17 * 60 + 20),
31 Case(text: logged, key: "C", idle: 17 * 60 + 20),
32 Case(text: task, key: "g", minutes: 10, idle: 17 * 60 + 20),
33 Case(text: task, key: "G", minutes: 10, idle: 17 * 60 + 20),
34 Case(text: task, key: "g", minutes: 0, idle: 17 * 60 + 20),
35 Case(text: task, key: "s", idle: 30),
36 Case(text: logged, key: "S", idle: 30),
37 Case(text: task, key: "t", date: t1 + 40 * 60, idle: 17 * 60 + 20),
38 Case(text: task, key: "T", date: t1 + 40 * 60, idle: 17 * 60 + 20),
39 Case(text: task, key: "J", idle: 17 * 60 + 20),
40 Case(text: task, key: "q", idle: 17 * 60 + 20),
41 Case(text: dangling, key: "k", minutes: 95, idle: 0, active: false),
42 Case(text: dangling, key: "k", minutes: 5, idle: 0, active: false),
43 Case(text: dangling, key: "S", idle: 0, active: false),
44 Case(text: dangling, key: "C", idle: 0, active: false),
45 Case(text: dangling, key: "k", minutes: 95, idle: 0, active: false, clockingIn: true),
46 Case(text: dangling, key: "g", minutes: 3, idle: 0, active: false, clockingIn: true),
47 ]
48
49 static func date(_ seconds: Int) -> Date { Date(timeIntervalSince1970: Double(seconds)) }
50
51 /// Clocks in (active cases), then carries out the plan with the clock commands.
52 func ours(_ c: Case) throws -> String {
53 let clock = ClockTests()
54 var text = c.text
55 var start: String
56 if c.active {
57 text = clock.run(ClockIn(), text, caret: 2, at: Self.t1).text
58 start = try #require(ClockIn.runningStart(in: text, near: 0))
59 } else {
60 start = try #require(ClockResolution.openClocks(in: text).first?.start)
61 }
62 let startDate = c.active ? Self.date(Self.t1) : try #require(ClockResolution.date(of: start, calendar: ClockTests.utc))
63 let lastValid = startDate.addingTimeInterval(Double(c.idle))
64 let minutes = c.date.map { ClockResolution.minutes(from: lastValid, to: Self.date($0)) } ?? c.minutes
65 let plan = try ClockResolution.plan(
66 key: c.key, minutes: minutes, start: startDate, lastValid: lastValid, now: Self.date(Self.now), active: c.active, clockingIn: c.clockingIn
67 )
68 let heading = try #require(ClockTask.at(0, in: text, path: "x"))
69 for step in plan.steps {
70 switch step {
71 case .cancel:
72 text = clock.run(ClockOut(start: start, cancel: true), text, caret: 0, at: Self.now).text
73 case .clockOut(let at):
74 text = clock.run(ClockOut(start: start), text, caret: 0, at: Int(at.timeIntervalSince1970)).text
75 case .clockIn(let at):
76 let offset = try #require(heading.locate(in: text))
77 text = clock.run(ClockIn(), text, caret: offset, at: Int(at.timeIntervalSince1970)).text
78 start = try #require(ClockIn.runningStart(in: text, near: offset))
79 case .resume:
80 break
81 }
82 }
83 return text
84 }
85
86 func form(_ c: Case) -> String {
87 let reads = """
88 ((symbol-function 'read-char-exclusive) (lambda (&rest _) ?\(c.key)))
89 ((symbol-function 'read-number) (lambda (&rest _) \(c.minutes ?? 0)))
90 ((symbol-function 'org-read-date) (lambda (&rest _) (seconds-to-time \(c.date ?? 0))))
91 """
92 let find = c.active ? "(cons org-clock-marker org-clock-start-time)" : """
93 (save-excursion
94 (goto-char (point-min))
95 (re-search-forward "CLOCK: \\\\(\\\\[.*?\\\\]\\\\)$")
96 (cons (copy-marker (match-end 1) t) (org-time-string-to-time (match-string 1))))
97 """
98 let resolve = """
99 (cl-letf (\(reads))
100 (let* ((clock \(find))
101 (org-clock-resolving-clocks-due-to-idleness t)
102 (org-clock-clocking-in \(c.clockingIn ? "t" : "nil")))
103 (org-clock-resolve clock (lambda (_) "Idle") (time-add (cdr clock) \(c.idle)))))
104 """
105 return """
106 (progn
107 (require 'org-clock)
108 (setq org-clock-persist nil org-clock-idle-time nil org-clock-auto-clock-resolution nil org-clock-into-drawer t
109 org-clock-leftover-time nil)
110 \(c.active ? frozenClock(Self.t1, "(org-clock-in)") : "")
111 \(frozenClock(Self.now, resolve)))
112 """
113 }
114
115 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
116 func matchesEmacs() throws {
117 let mine = try Self.cases.map(ours)
118 let emacs = try EmacsOracle.run(Self.cases.map { EmacsOracle.Case(text: $0.text, point: 3, form: form($0)) })
119 for (i, (ours, theirs)) in zip(mine, emacs).enumerated() {
120 let c = Self.cases[i]
121 #expect(ours == theirs.text, "case \(i) \(c.key) \(c.minutes.map(String.init) ?? "")\nours: \(ours.debugDescription)\nemacs: \(theirs.text.debugDescription) \(theirs.error)")
122 }
123 }
124
125 @Test func plans() throws {
126 let start = Self.date(Self.t1)
127 let idle = start.addingTimeInterval(600)
128 let now = start.addingTimeInterval(3600)
129 func plan(_ key: Character, _ minutes: Int? = nil, active: Bool = true) throws -> ClockResolution.Plan {
130 try ClockResolution.plan(key: key, minutes: minutes, start: start, lastValid: idle, now: now, active: active, clockingIn: false)
131 }
132 #expect(try plan("k", 50).steps.isEmpty)
133 #expect(try plan("k", 50, active: false).steps == [.resume])
134 #expect(try plan("K", 5) == .init(steps: [.clockOut(idle.addingTimeInterval(300))], leftover: idle.addingTimeInterval(300), setsLeftover: true))
135 #expect(try plan("S") == .init(steps: [.clockOut(idle)], leftover: idle, setsLeftover: true))
136 #expect(try plan("G", 5) == .init(steps: [.clockOut(idle)], leftover: nil, setsLeftover: true))
137 #expect(try plan("j") == .init(jump: true))
138 #expect(throws: ClockResolution.Failure.self) { try plan("k", 70) }
139 #expect(ClockResolution.defaultMinutes(lastValid: idle, now: now.addingTimeInterval(59)) == 50)
140 }
141}
142
143struct ClockHistoryTests {
144 static let text = "* A\n:PROPERTIES:\n:CATEGORY: work\n:END:\n** TODO [#B] Child :tag:\n* B\n:PROPERTIES:\n:ID: b1\n:END:\n* A\n"
145
146 static func offset(_ needle: String, _ text: String = text, last: Bool = false) -> Int {
147 (text as NSString).range(of: needle, options: last ? .backwards : []).location
148 }
149
150 @Test func pushKeepsTheNewestFirstWithoutDuplicates() {
151 let files = ["f.org": Self.text, "g.org": "* G\n"]
152 let read = { (path: String) in files[path] }
153 let a = ClockTask.at(0, in: Self.text, path: "f.org")!
154 let child = ClockTask.at(Self.offset("** TODO"), in: Self.text, path: "f.org")!
155 let b = ClockTask.at(Self.offset("* B"), in: Self.text, path: "f.org")!
156 let g = ClockTask.at(0, in: "* G\n", path: "g.org")!
157 let lastA = ClockTask.at(Self.offset("* A", last: true), in: Self.text, path: "f.org")!
158 #expect(child.title == "Child" && b.id == "b1" && lastA.ordinal == 3)
159 var history: [ClockTask] = []
160 for task in [a, child, b, a, g] { history = ClockHistory.push(task, onto: history, length: 3, text: read) }
161 #expect(history == [g, a, b])
162 // The second heading titled A is another entry.
163 history = ClockHistory.push(lastA, onto: history, length: 5, text: read)
164 #expect(history == [lastA, g, a, b])
165 // A file that is gone takes its entries along.
166 #expect(ClockHistory.push(a, onto: history, length: 5, text: { $0 == "g.org" ? nil : files[$0] }) == [a, lastA, b])
167 }
168
169 @Test func tasksAreFoundAfterEdits() {
170 let b = ClockTask.at(Self.offset("* B"), in: Self.text, path: "f.org")!
171 let a = ClockTask.at(0, in: Self.text, path: "f.org")!
172 let moved = "* New\n" + Self.text.replacingOccurrences(of: "* B\n", with: "* B renamed\n")
173 #expect(b.locate(in: moved) == Self.offset("* B renamed", moved))
174 #expect(a.locate(in: moved) == 6)
175 #expect(a.locate(in: "* Other\n") == nil)
176 }
177
178 @Test func selectionKeysAndLines() throws {
179 let read = { (_: String) in Self.text }
180 let tasks = [0, Self.offset("** TODO"), Self.offset("* B"), Self.offset("* A", last: true)].map { ClockTask.at($0, in: Self.text, path: "/n/f.org")! }
181 let choices = try #require(ClockHistory.selection(defaultTask: tasks[2], interrupted: nil, current: tasks[1], history: [tasks[1], tasks[1], tasks[0], tasks[3]], text: read))
182 #expect(choices.map(\.key) == ["d", "c", "1", "2", "3"])
183 #expect(choices[1].line == "work" + String(repeating: " ", count: 10) + "TODO [#B] Child")
184 #expect(choices[0].line == "f" + String(repeating: " ", count: 13) + "B")
185 #expect(choices.map(\.section) == ["Default Task", "Current Clocking Task", "Recent Tasks", "Recent Tasks", "Recent Tasks"])
186 #expect(ClockHistory.selection(defaultTask: tasks[0], interrupted: nil, current: nil, history: [], text: read) == nil)
187 let many = (0..<12).map { ClockTask(path: "/n/f.org", title: "A", ordinal: 0, id: "\($0)") }
188 let keys = ClockHistory.selection(defaultTask: nil, interrupted: nil, current: nil, history: many, text: read)!.map(\.key)
189 #expect(String(keys) == "123456789ABC")
190 }
191}