Tests/OrgCoreTests/TimestampCommandTests.swift
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}