krz/orgstar

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

Tests/OrgCoreTests/ClockHistoryTests.swift

322a87d47526d505d903e05d14e65a5464e1468f
orgstar/Tests/OrgCoreTests/ClockHistoryTests.swift history · blame · raw

191 lines · 10693 bytes

  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}