krz/orgstar

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

Tests/OrgAppTests/ClockHistoryModelTests.swift

778058aafc7ddacee4a613b1da87c60aa0c8e113
orgstar/Tests/OrgAppTests/ClockHistoryModelTests.swift history · blame · raw

142 lines · 7828 bytes

  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    @Test func jumpingFromADanglingClockShowsItAfterClockingIn() async throws {
115        let other = try notes.write("other.org", "* Old\n:LOGBOOK:\nCLOCK: [2026-09-09 Wed 09:00]\n:END:\n")
116        let clock = ClockModel(file: stateFile, defaults: defaults)
117        let started = clock.openClocks(session, paths: [other.path])[0].startedAt
118        // `c` isn't a key org-clock-resolve reads; `J` clocks out and jumps.
119        let answers = ScriptedAnswers(["c", "J"])
120        let outcome = await clock.clockIn(session, target: .caret, paths: [other.path], ask: answers.ask, now: { started.addingTimeInterval(600) })
121        #expect(answers.asked.count == 2)
122        #expect(outcome.jump?.path == other.path)
123        #expect(clock.running?.heading == "A")
124    }
125
126    @Test func idleTimeIsSubtractedAndOfferedToTheNextClock() async throws {
127        let clock = ClockModel(file: stateFile, defaults: defaults)
128        _ = clock.clockIn(session, path: work.path, at: offset("* A"), now: start)
129        let idle = start.addingTimeInterval(20 * 60)
130        let answers = ScriptedAnswers(["S"])
131        _ = await clock.resolveIdle(session, idleSince: idle, ask: answers.ask, now: { idle.addingTimeInterval(45 * 60) })
132        #expect(answers.asked == ["Clocked in & idle for 45.0 mins" + ClockResolution.keyHint])
133        #expect(clock.running == nil && clock.leftover == idle)
134        #expect(session.document!.text.contains("=>  0:20"))
135
136        let next = ScriptedAnswers(["y"])
137        let c = try #require(ClockTask.at(offset("* C"), in: session.document!.text, path: work.path))
138        _ = await clock.clockIn(session, target: .task(c), paths: [], ask: next.ask, now: { idle.addingTimeInterval(50 * 60) })
139        #expect(next.asked == ["You stopped another clock 50 mins ago; start this one from then? (y or n) "])
140        #expect(clock.running?.heading == "C" && clock.running?.startedAt == idle && clock.leftover == nil)
141    }
142}