krz/orgstar

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

Tests/OrgAppTests/ClockHistoryModelTests.swift

b98a6509c5ae75e5172dd333d1b6105fd6ceee0e
orgstar/Tests/OrgAppTests/ClockHistoryModelTests.swift history · blame · raw

195 lines · 11428 bytes

12 symbols in this file
  1import Foundation
  2import OrgCore
  3import OrgDocument
  4import Testing
  5@testable import OrgApp
  6
  7/// Answers prompts in order and remembers what they asked.
  8@MainActor
  9final class ScriptedAnswers {
 10    var answers: [String?]
 11    var asked: [String] = []
 12    init(_ answers: [String?]) { self.answers = answers }
 13    var ask: ClockModel.Ask {
 14        { prompt in
 15            self.asked.append(prompt.message)
 16            return self.answers.isEmpty ? nil : self.answers.removeFirst()
 17        }
 18    }
 19}
 20
 21@MainActor
 22struct ClockHistoryModelTests {
 23    let notes: Folder
 24    let work: URL
 25    let session: DocumentSession
 26    let stateFile: URL
 27    let defaults: UserDefaults
 28    let start = Date(timeIntervalSince1970: 1_789_000_000)
 29
 30    init() throws {
 31        notes = try Folder()
 32        work = try notes.write("work.org", "* A\n* B\n:PROPERTIES:\n:ID: b1\n:END:\n* C\n")
 33        session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
 34        session.open(work.path)
 35        stateFile = notes.url.appendingPathComponent("clock.json")
 36        defaults = UserDefaults(suiteName: "orgstar-clock-\(UUID().uuidString)")!
 37    }
 38
 39    func offset(_ heading: String) -> Int {
 40        (session.document!.text as NSString).range(of: heading).location
 41    }
 42
 43    @Test func historyIsKeptAndPersisted() throws {
 44        let clock = ClockModel(file: stateFile, defaults: defaults)
 45        for (i, heading) in ["* A", "* B", "* A", "* C"].enumerated() {
 46            _ = clock.clockIn(session, path: work.path, at: offset(heading), now: start.addingTimeInterval(Double(i * 600)))
 47        }
 48        #expect(clock.history.map(\.title) == ["C", "A", "B"])
 49        #expect(clock.interruptedTask?.title == "A")
 50        let reloaded = ClockModel(file: stateFile, defaults: defaults)
 51        #expect(reloaded.history == clock.history && reloaded.running == clock.running)
 52
 53        // Headings move and are renamed: entries are found by ID, else position and title.
 54        session.reveal(0)
 55        _ = session.perform(PrecomputedEdit(EditResult(baseRevision: session.document!.revision, edits: [TextEdit(range: 0..<0, replacement: "* New\n")])), path: work.path, at: 0, expect: nil)
 56        #expect(clock.locate(clock.history[2], session)?.offset == offset("* B"))
 57
 58        defaults.set(2, forKey: "clockHistoryLength")
 59        _ = clock.clockIn(session, path: work.path, at: offset("* B"), now: start.addingTimeInterval(3000))
 60        #expect(clock.history.map(\.title) == ["B", "C"])
 61    }
 62
 63    @Test func oldClockFilesStillLoad() throws {
 64        let running = ClockModel.Running(path: work.path, start: "2026-09-10 Thu 00:26", heading: "A", startedAt: start)
 65        try JSONEncoder().encode(running).write(to: stateFile)
 66        let clock = ClockModel(file: stateFile, defaults: defaults)
 67        #expect(clock.running == running && clock.history.isEmpty)
 68    }
 69
 70    @Test func selectingRecentEntries() async throws {
 71        let clock = ClockModel(file: stateFile, defaults: defaults)
 72        let none = await clock.clockIn(session, target: .select, paths: [], ask: ScriptedAnswers([]).ask)
 73        #expect(none.messages == ["No recent clock"])
 74        _ = clock.clockIn(session, path: work.path, at: offset("* A"), now: start)
 75        _ = clock.clockIn(session, path: work.path, at: offset("* B"), now: start.addingTimeInterval(600))
 76        _ = clock.markDefault(session, path: work.path, at: offset("* C"))
 77        let choices = try #require(clock.selection(session))
 78        #expect(choices.map(\.key) == ["d", "i", "c", "1", "2"])
 79        #expect(choices.map(\.task.title) == ["C", "A", "B", "B", "A"])
 80
 81        let answers = ScriptedAnswers(["2"])
 82        let outcome = await clock.clockIn(session, target: .select, paths: [work.path], ask: answers.ask, now: { start.addingTimeInterval(1200) })
 83        #expect(answers.asked == ["Clock-in on task: "])
 84        #expect(clock.running?.heading == "A" && outcome.messages.last?.hasPrefix("Clock starts at") == true)
 85        let same = await clock.clockIn(session, target: .last, paths: [], ask: ScriptedAnswers([]).ask)
 86        #expect(same.messages == ["Clock continues in \"A\""])
 87        let aborted = await clock.clockIn(session, target: .select, paths: [], ask: ScriptedAnswers(["q"]).ask)
 88        #expect(aborted.messages == ["Abort"])
 89
 90        _ = clock.clockOut(session, now: start.addingTimeInterval(1800))
 91        let goto = clock.goto(session)
 92        #expect(goto.location?.offset == offset("* A") && goto.message == "No running clock, this is the most recently clocked task")
 93        let recent = await clock.gotoRecent(session, ask: ScriptedAnswers(["2"]).ask)
 94        #expect(recent.location?.offset == offset("* B"))
 95    }
 96
 97    @Test func danglingClocksAreResolvedBeforeClockingIn() async throws {
 98        let other = try notes.write("other.org", "* Old\n:LOGBOOK:\nCLOCK: [2026-09-09 Wed 09:00]\n:END:\n")
 99        let clock = ClockModel(file: stateFile, defaults: defaults)
100        let opened = clock.openClocks(session, paths: [other.path])
101        #expect(opened.map(\.heading) == ["Old"])
102        let answers = ScriptedAnswers(["x", "K", "30"])
103        let outcome = await clock.clockIn(session, target: .caret, paths: [other.path], ask: answers.ask, now: { opened[0].startedAt.addingTimeInterval(7200) })
104        #expect(answers.asked.first == "Dangling clock started 120 mins ago" + ClockResolution.keyHint)
105        #expect(answers.asked.count == 3 && answers.asked[2] == "Keep how many minutes (default 120): ")
106        #expect(outcome.messages.count == 2)
107        #expect(clock.running?.heading == "A")
108        let text = try #require(ClockModel.text(other.path, session))
109        #expect(text.contains("CLOCK: [2026-09-09 Wed 09:00]--[2026-09-09 Wed 09:30] =>  0:30"), "\(text)")
110        // Resolving before a clock-in never leaves time over.
111        #expect(clock.leftover == nil)
112    }
113
114    /// `org-agenda-clock-in` runs `org-clock-in` at the entry, so it resolves first too, and
115    /// finds the entry after resolving moved it.
116    @Test func clockingInAtAnEntryResolvesDanglingClocks() async throws {
117        let other = try notes.write("other.org", "* Old\n:LOGBOOK:\nCLOCK: [2026-09-09 Wed 09:00]\n:END:\n* New\n")
118        let clock = ClockModel(file: stateFile, defaults: defaults)
119        let started = clock.openClocks(session, paths: [other.path])[0].startedAt
120        let text = try #require(ClockModel.text(other.path, session))
121        let entry = (text as NSString).range(of: "* New").location
122        let answers = ScriptedAnswers(["K", "30"])
123        let outcome = await clock.clockIn(
124            session, target: .entry(path: other.path, offset: entry), paths: [other.path], ask: answers.ask, now: { started.addingTimeInterval(7200) }
125        )
126        #expect(answers.asked.first == "Dangling clock started 120 mins ago" + ClockResolution.keyHint)
127        #expect(clock.running?.heading == "New" && outcome.messages.last == "Clock starts at [2026-09-09 Wed 11:00]")
128        let updated = try #require(ClockModel.text(other.path, session))
129        #expect(
130            updated == "* Old\n:LOGBOOK:\nCLOCK: [2026-09-09 Wed 09:00]--[2026-09-09 Wed 09:30] =>  0:30\n:END:\n* New\n:LOGBOOK:\nCLOCK: [2026-09-09 Wed 11:00]\n:END:\n",
131            "\(updated)"
132        )
133    }
134
135    /// A template's `clock-in` is `org-clock-in` when capture began: dangling clocks are
136    /// resolved as of then, and the entry is clocked out when it is filed.
137    @Test func captureClockInResolvesDanglingClocks() async throws {
138        let other = try notes.write("other.org", "* Old\n:LOGBOOK:\nCLOCK: [2026-09-09 Wed 09:00]\n:END:\n")
139        let clock = ClockModel(file: stateFile, defaults: defaults)
140        let opened = clock.openClocks(session, paths: [other.path])[0].startedAt.addingTimeInterval(7200)
141        let answers = ScriptedAnswers(["K", "30"])
142        let outcome = await clock.captured(
143            (work.path, offset("* C")), options: .init(clockIn: true), session: session, paths: [other.path], ask: answers.ask,
144            opened: opened, now: opened.addingTimeInterval(600)
145        )
146        #expect(answers.asked.first == "Dangling clock started 120 mins ago" + ClockResolution.keyHint)
147        #expect(outcome.messages.suffix(2) == ["Clock starts at [2026-09-09 Wed 11:00]", "Clock stopped after 0:10"])
148        #expect(clock.running == nil && clock.history.first?.title == "C")
149        #expect(try #require(ClockModel.text(other.path, session)).contains("=>  0:30"))
150    }
151
152    /// With a clock running the capture clock-in resolves nothing; the `clock-resume` clock-in,
153    /// with no clock running, does, as in Org.
154    @Test func captureClockInResumesTheInterruptedClock() async throws {
155        let other = try notes.write("other.org", "* Old\n:LOGBOOK:\nCLOCK: [2026-09-09 Wed 09:00]\n:END:\n")
156        let clock = ClockModel(file: stateFile, defaults: defaults)
157        _ = clock.clockIn(session, path: work.path, at: offset("* A"), now: start)
158        let answers = ScriptedAnswers([])
159        let outcome = await clock.captured(
160            (work.path, offset("* C")), options: .init(clockIn: true, clockResume: true), session: session, paths: [other.path],
161            ask: answers.ask, opened: start.addingTimeInterval(600), now: start.addingTimeInterval(900)
162        )
163        #expect(answers.asked.count == 1 && answers.asked[0].hasPrefix("Dangling clock started"))
164        #expect(outcome.messages.count == 3 && clock.running?.heading == "A")
165    }
166
167    @Test func jumpingFromADanglingClockShowsItAfterClockingIn() async throws {
168        let other = try notes.write("other.org", "* Old\n:LOGBOOK:\nCLOCK: [2026-09-09 Wed 09:00]\n:END:\n")
169        let clock = ClockModel(file: stateFile, defaults: defaults)
170        let started = clock.openClocks(session, paths: [other.path])[0].startedAt
171        // `c` isn't a key org-clock-resolve reads; `J` clocks out and jumps.
172        let answers = ScriptedAnswers(["c", "J"])
173        let outcome = await clock.clockIn(session, target: .caret, paths: [other.path], ask: answers.ask, now: { started.addingTimeInterval(600) })
174        #expect(answers.asked.count == 2)
175        #expect(outcome.jump?.path == other.path)
176        #expect(clock.running?.heading == "A")
177    }
178
179    @Test func idleTimeIsSubtractedAndOfferedToTheNextClock() async throws {
180        let clock = ClockModel(file: stateFile, defaults: defaults)
181        _ = clock.clockIn(session, path: work.path, at: offset("* A"), now: start)
182        let idle = start.addingTimeInterval(20 * 60)
183        let answers = ScriptedAnswers(["S"])
184        _ = await clock.resolveIdle(session, idleSince: idle, ask: answers.ask, now: { idle.addingTimeInterval(45 * 60) })
185        #expect(answers.asked == ["Clocked in & idle for 45.0 mins" + ClockResolution.keyHint])
186        #expect(clock.running == nil && clock.leftover == idle)
187        #expect(session.document!.text.contains("=>  0:20"))
188
189        let next = ScriptedAnswers(["y"])
190        let c = try #require(ClockTask.at(offset("* C"), in: session.document!.text, path: work.path))
191        _ = await clock.clockIn(session, target: .task(c), paths: [], ask: next.ask, now: { idle.addingTimeInterval(50 * 60) })
192        #expect(next.asked == ["You stopped another clock 50 mins ago; start this one from then? (y or n) "])
193        #expect(clock.running?.heading == "C" && clock.running?.startedAt == idle && clock.leftover == nil)
194    }
195}