krz/orgstar

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

Tests/OrgCoreTests/TimestampCommandTests.swift

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

227 lines · 14082 bytes

  1import Foundation
  2import Testing
  3@testable import OrgCore
  4
  5struct TimestampCommandTests {
  6    static let now = ReadDateTests.now
  7    static let calendar = ReadDateTests.utc
  8
  9    func run(_ command: any OrgCommand, _ text: String, caret: Int, answer: String? = nil, lastCommand: String? = nil) -> (text: String, caret: Int, failure: String?) {
 10        let context = EditContext(
 11            revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret],
 12            now: Self.now, calendar: Self.calendar, answers: answer.map { ["date": $0] } ?? [:], lastCommand: lastCommand
 13        )
 14        switch command.run(in: context) {
 15        case .commit(let result):
 16            let new = result.edits.sorted { $0.range.lowerBound > $1.range.lowerBound }.reduce(text) { $1.apply(to: $0) }
 17            return (new, result.selection?.first?.lowerBound ?? caret, nil)
 18        case .failed(let message): return (text, caret, message)
 19        case .prompt(let prompt): return (text, caret, "prompt \(prompt.key)")
 20        case .external(let request): return (text, caret, "external \(request)")
 21        }
 22    }
 23
 24    @Test func examples() {
 25        #expect(run(InsertTimestamp(), "x\n", caret: 1, answer: "fri").text == "x<2026-09-11 Fri>\n")
 26        #expect(run(SetPlanning(deadline: false), "* a\n", caret: 0, answer: "+1").text == "* a\nSCHEDULED: <2026-09-11 Fri>\n")
 27        #expect(run(ShiftTimestampDay(later: true), "<2026-09-10 Thu>\n", caret: 5).text == "<2026-09-11 Fri>\n")
 28        #expect(run(ShiftTimestamp(up: true), "<2026-09-10 Thu 10:07>\n", caret: 20).text == "<2026-09-10 Thu 10:10>\n")
 29    }
 30
 31    @Test func promptSaysWhetherTheStampIsInactive() {
 32        for inactive in [false, true] {
 33            let context = EditContext(revision: 0, text: "x\n", tree: OrgParser.parse("x\n"), selection: [1..<1], now: Self.now, calendar: Self.calendar)
 34            guard case .prompt(let prompt) = InsertTimestamp(inactive: inactive).run(in: context) else {
 35                Issue.record("no prompt")
 36                continue
 37            }
 38            #expect(prompt.date?.inactive == inactive)
 39        }
 40    }
 41
 42    static let texts = [
 43        "* a\n",
 44        "* a\nbody <2026-09-01 Tue> text\n",
 45        "* a\nSCHEDULED: <2026-09-01 Tue +1w>\n",
 46        "* a\nDEADLINE: <2026-09-20 Sun> SCHEDULED: <2026-09-01 Tue 10:00-11:30>\n",
 47        "* a\nCLOSED: [2026-09-02 Wed 09:00] SCHEDULED: <2026-09-01 Tue>\n",
 48        "* a\n[2026-12-31 Thu 23:58] <2026-01-31 Sat .+2m -3d>\n",
 49        "text <2026-02-28 Sat 07:03>\n",
 50    ]
 51    static let answers = ["", "+1", "fri 9:00", "2027-01-15", "10:00-11:00", "++2d"]
 52
 53    static let commands: [(any OrgCommand, String)] = [
 54        (InsertTimestamp(), "(org-timestamp nil)"),
 55        (InsertTimestamp(inactive: true), "(org-timestamp nil t)"),
 56        (SetPlanning(deadline: false), "(org-schedule nil)"),
 57        (SetPlanning(deadline: true), "(org-deadline nil)"),
 58        (RemovePlanning(deadline: false), "(org-schedule '(4))"),
 59        (ShiftTimestamp(up: true), "(org-timestamp-up)"),
 60        (ShiftTimestamp(up: false), "(org-timestamp-down)"),
 61        (ShiftTimestampDay(later: true), "(org-timestamp-up-day)"),
 62        (ShiftTimestampDay(later: false), "(org-timestamp-down-day)"),
 63    ]
 64
 65    @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
 66    func timestampCommandsMatchEmacs() throws {
 67        try #require(EmacsOracle.isAvailable)
 68        var cases: [EmacsOracle.Case] = []
 69        var ours: [(label: String, text: String, caret: Int, failed: Bool)] = []
 70        for text in Self.texts {
 71            let tree = OrgParser.parse(text)
 72            for caret in EmacsOracle.positions(text) {
 73                for (command, form) in Self.commands {
 74                    let prompts = command is InsertTimestamp || command is SetPlanning
 75                    if command is RemovePlanning || command is SetPlanning, entryHeading(at: caret, in: tree) == nil { continue }
 76                    if command is ShiftTimestamp || command is ShiftTimestampDay, EmacsBuffer(text, point: caret).atTimestamp() == nil { continue }
 77                    for answer in prompts ? Self.answers : [""] {
 78                        let escaped = answer.replacingOccurrences(of: "\"", with: "\\\"")
 79                        let wrapped = frozenClock(1_789_000_000, """
 80                            (cl-letf (((symbol-function 'read-string) (lambda (&rest _) "\(escaped)")))
 81                              (let ((org-read-date-popup-calendar nil)) \(form)))
 82                            """)
 83                        cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(caret, in: text), form: wrapped))
 84                        let result = run(command, text, caret: caret, answer: prompts ? answer : nil)
 85                        ours.append(("\(command.id) \(answer.debugDescription) at \(caret) in \(text.debugDescription)", result.text, result.caret, result.failure != nil))
 86                    }
 87                }
 88            }
 89        }
 90        var mismatches = 0
 91        for (mine, emacs) in zip(ours, try EmacsOracle.run(cases)) {
 92            let failed = !emacs.error.isEmpty
 93            let caret = EmacsOracle.offset(emacs.point, in: emacs.text)
 94            guard mine.failed != failed || (!failed && (mine.text != emacs.text || mine.caret != caret)) else { continue }
 95            mismatches += 1
 96            if mismatches <= 12 {
 97                Issue.record("\(mine.label)\n  ours:  \(mine.failed ? "failed" : "\(mine.text.debugDescription) @\(mine.caret)")\n  emacs: \(failed ? emacs.error : "\(emacs.text.debugDescription) @\(caret)")")
 98            }
 99        }
100        #expect(mismatches == 0, "\(mismatches) of \(ours.count) differ")
101    }
102
103    /// `org-timestamp` in and around date ranges: the start before the dashes, the end from the
104    /// second dash on, and twice in a row a new range wherever there is a stamp to default from.
105    @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
106    func timestampsInRangesMatchEmacs() throws {
107        try #require(EmacsOracle.isAvailable)
108        let texts = [
109            "* a\n:LOGBOOK:\nCLOCK: [2026-09-01 Tue 10:00]--[2026-09-01 Tue 11:30] =>  1:30\n:END:\n",
110            "x <2026-09-01 Tue>--<2026-09-03 Thu> y\n",
111            "[2026-09-01 Tue]-[2026-09-02 Wed]\n",
112            "<2026-09-01 Tue 10:00>---<2026-09-01 Tue 12:00 +1w>\n",
113        ]
114        var cases: [EmacsOracle.Case] = []
115        var ours: [(label: String, text: String, caret: Int, failed: Bool)] = []
116        for text in texts {
117            for caret in EmacsOracle.positions(text) {
118                for (command, form) in [(InsertTimestamp(), "(org-timestamp nil)"), (InsertTimestamp(inactive: true), "(org-timestamp nil t)")] {
119                    for answer in ["", "+1", "10:00", "++2d"] {
120                        for repeated in [false, true] {
121                            let commands = repeated ? "(last-command 'org-timestamp) (this-command 'org-timestamp)" : ""
122                            let wrapped = frozenClock(1_789_000_000, """
123                                (cl-letf (((symbol-function 'read-string) (lambda (&rest _) "\(answer)")))
124                                  (let ((org-read-date-popup-calendar nil) \(commands)) \(form)))
125                                """)
126                            cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(caret, in: text), form: wrapped))
127                            let result = run(command, text, caret: caret, answer: answer, lastCommand: repeated ? "org.timestamp.active" : nil)
128                            ours.append(("\(command.id) \(answer.debugDescription)\(repeated ? " repeated" : "") at \(caret) in \(text.debugDescription)", result.text, result.caret, result.failure != nil))
129                        }
130                    }
131                }
132            }
133        }
134        var mismatches = 0
135        for (mine, emacs) in zip(ours, try EmacsOracle.run(cases)) {
136            let failed = !emacs.error.isEmpty
137            let caret = EmacsOracle.offset(emacs.point, in: emacs.text)
138            guard mine.failed != failed || (!failed && (mine.text != emacs.text || mine.caret != caret)) else { continue }
139            mismatches += 1
140            if mismatches <= 12 {
141                Issue.record("\(mine.label)\n  ours:  \(mine.failed ? "failed" : "\(mine.text.debugDescription) @\(mine.caret)")\n  emacs: \(failed ? emacs.error : "\(emacs.text.debugDescription) @\(caret)")")
142            }
143        }
144        #expect(mismatches == 0, "\(mismatches) of \(ours.count) differ")
145    }
146
147    /// Shifting a timestamp on a CLOCK line rewrites the duration (`org-clock-update-time-maybe`).
148    @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
149    func clockShiftsMatchEmacs() throws {
150        let text = "* a\n:LOGBOOK:\nCLOCK: [2026-09-01 Tue 10:00]--[2026-09-01 Tue 11:30] =>  1:30\n  CLOCK: [2026-09-02 Wed 23:58]--[2026-09-03 Thu 00:02]\nCLOCK: [2026-09-04 Fri 09:00]\n:END:\n"
151        let commands: [(any OrgCommand, String)] = [
152            (ShiftTimestamp(up: true), "(org-timestamp-up)"), (ShiftTimestamp(up: false), "(org-timestamp-down)"),
153            (ShiftTimestampDay(later: true), "(org-timestamp-up-day)"), (ShiftTimestampDay(later: false), "(org-timestamp-down-day)"),
154        ]
155        var cases: [EmacsOracle.Case] = []
156        var ours: [(label: String, text: String, caret: Int, failed: Bool)] = []
157        for caret in EmacsOracle.positions(text) where EmacsBuffer(text, point: caret).atTimestamp() != nil {
158            for (command, form) in commands {
159                cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(caret, in: text), form: frozenClock(1_789_000_000, form)))
160                let result = run(command, text, caret: caret)
161                ours.append(("\(command.id) at \(caret)", result.text, result.caret, result.failure != nil))
162            }
163        }
164        var mismatches = 0
165        for (mine, emacs) in zip(ours, try EmacsOracle.run(cases)) {
166            let failed = !emacs.error.isEmpty
167            let caret = EmacsOracle.offset(emacs.point, in: emacs.text)
168            guard mine.failed != failed || (!failed && (mine.text != emacs.text || mine.caret != caret)) else { continue }
169            mismatches += 1
170            if mismatches <= 12 {
171                Issue.record("\(mine.label)\n  ours:  \(mine.failed ? "failed" : "\(mine.text.debugDescription) @\(mine.caret)")\n  emacs: \(failed ? emacs.error : "\(emacs.text.debugDescription) @\(caret)")")
172            }
173        }
174        #expect(mismatches == 0, "\(mismatches) of \(ours.count) differ")
175        #expect(run(ShiftTimestamp(up: true), text, caret: (text as NSString).range(of: "11:30").location + 4).text.contains("11:35] =>  1:35\n"))
176    }
177
178    static let repeating = [
179        "* TODO a\nSCHEDULED: <2026-09-01 Tue +1w>\n",
180        "* TODO a\nDEADLINE: <2026-09-01 Tue .+1d>\n",
181        "* TODO a\nSCHEDULED: <2026-08-01 Sat ++1m>\nbody\n",
182        "* TODO a :t:\n<2026-09-08 Tue 10:00 +2d>\n",
183        "* TODO a\nDEADLINE: <2026-09-05 Sat +1w> SCHEDULED: <2026-09-01 Tue>\n",
184        "* TODO a\nSCHEDULED: <2026-09-01 Tue +1w>\n:PROPERTIES:\n:LAST_REPEAT: [2026-09-01 Tue 08:00]\n:END:\n- State \"DONE\"       from \"TODO\"       [2026-09-01 Tue 08:00]\nnotes\n",
185        "#+TODO: TODO STRT | DONE\n* STRT a\nSCHEDULED: <2026-09-01 Tue +1d>\n",
186        "* P [0/1]\n** TODO a\nSCHEDULED: <2026-09-01 Tue +1w>\n* Q\n",
187        "* TODO no repeat\nSCHEDULED: <2026-09-01 Tue>\n",
188        "* TODO hourly\nSCHEDULED: <2026-09-10 Thu 00:00 +1h>\n",
189        "* TODO from now\nSCHEDULED: <2026-09-08 Tue 10:00 .+2h>\n",
190        "* TODO from now\n<2026-09-10 Thu 03:15-04:00 .+1h>\n",
191        "* TODO catch up\nSCHEDULED: <2026-09-09 Wed 23:00 ++2h>\n",
192    ]
193
194    @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
195    func repeatsMatchEmacs() throws {
196        try #require(EmacsOracle.isAvailable)
197        var cases: [EmacsOracle.Case] = []
198        var ours: [(label: String, text: String, caret: Int)] = []
199        for text in Self.repeating {
200            let ns = text as NSString
201            let heading = ns.range(of: "\n\\*|^\\*", options: .regularExpression)
202            let start = heading.location == 0 ? 0 : heading.location + 1
203            let lineEnd = ns.range(of: "\n", range: NSRange(location: start, length: ns.length - start)).location
204            for caret in [start, start + 2, lineEnd, min(lineEnd + 3, ns.length)] {
205                cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(caret, in: text), form: frozenClock(1_789_000_000, "(progn (org-todo) (when org-log-setup (org-add-log-note)))")))
206                let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret], now: Self.now, calendar: Self.calendar)
207                guard case .commit(let result) = TodoCycle().run(in: context) else {
208                    ours.append(("failed at \(caret)", "", -1))
209                    continue
210                }
211                let new = result.edits.sorted { $0.range.lowerBound > $1.range.lowerBound }.reduce(text) { $1.apply(to: $0) }
212                ours.append(("at \(caret) in \(text.debugDescription)", new, result.selection?.first?.lowerBound ?? caret))
213            }
214        }
215        var mismatches = 0
216        for (mine, emacs) in zip(ours, try EmacsOracle.run(cases)) {
217            let caret = EmacsOracle.offset(emacs.point, in: emacs.text)
218            guard !emacs.error.isEmpty || mine.text != emacs.text || mine.caret != caret else { continue }
219            mismatches += 1
220            if mismatches <= 10 {
221                let (a, b) = excerpts(mine.text, emacs.text)
222                Issue.record("\(mine.label)\n  ours:  \(a.debugDescription) @\(mine.caret)\n  emacs: \(emacs.error.isEmpty ? "\(b.debugDescription) @\(caret)" : emacs.error)")
223            }
224        }
225        #expect(mismatches == 0, "\(mismatches) of \(ours.count) differ")
226    }
227}