Tests/OrgCoreTests/AgendaTests.swift
594 lines · 26841 bytes
1import Foundation
2import Testing
3@testable import OrgCore
4
5/// Runs `org-agenda-list` over files in `emacs -Q --batch` with the clock frozen.
6enum AgendaOracle {
7 struct Line: Codable {
8 var line: String
9 var type: String?
10 var category: String?
11 var tod: Int?
12 var extra: String?
13 var urgency: Int?
14 var file: String?
15 var pos: Int?
16 }
17
18 struct Day: Codable {
19 var header: String
20 var items: [Line]
21 }
22
23 static let keywords = "TODO PROJ LOOP STRT WAIT HOLD IDEA | DONE KILL"
24
25 static func script(files: [String], form: String, seconds: Int) -> String {
26 let fileList = files.map { "\"\($0)\"" }.joined(separator: " ")
27 let words = keywords.split(separator: " ").map { "\"\($0)\"" }.joined(separator: " ")
28 return #"""
29 ;;; -*- lexical-binding: t -*-
30 (require 'org)
31 (require 'org-agenda)
32 (require 'org-habit)
33 (require 'json)
34 (setq org-todo-keywords '((sequence \#(words)))
35 org-agenda-files '(\#(fileList)))
36 (add-to-list 'auto-mode-alist '("\\.org_archive\\'" . org-mode))
37 (defun orgstar-dump ()
38 (let (days header items)
39 (goto-char (point-min))
40 (while (not (eobp))
41 (let* ((p (line-beginning-position))
42 (line (buffer-substring-no-properties p (line-end-position)))
43 (hd (get-text-property p 'org-hd-marker)))
44 (cond
45 ((get-text-property p 'org-agenda-date-header)
46 (when header (push `((header . ,header) (items . ,(vconcat (nreverse items)))) days))
47 (setq header line items nil))
48 ((get-text-property p 'org-filter-type))
49 ((or header hd)
50 (push `((line . ,line)
51 (type . ,(get-text-property p 'type))
52 (category . ,(get-text-property p 'org-category))
53 (tod . ,(get-text-property p 'time-of-day))
54 (extra . ,(get-text-property p 'extra))
55 (urgency . ,(get-text-property p 'urgency))
56 (file . ,(and hd (buffer-file-name (marker-buffer hd))))
57 (pos . ,(and hd (marker-position hd))))
58 items))))
59 (forward-line 1))
60 (when (or header items) (push `((header . ,(or header "")) (items . ,(vconcat (nreverse items)))) days))
61 (vconcat (nreverse days))))
62 \#(frozenClock(seconds, """
63 (progn
64 \(form)
65 (let ((result (with-current-buffer org-agenda-buffer-name (orgstar-dump))))
66 (let ((coding-system-for-write 'utf-8-unix))
67 (with-temp-file (getenv "ORACLE_OUTPUT")
68 (insert (json-encode result))))))
69 """))
70 """#
71 }
72
73 static func run(files: [(name: String, text: String)], form: String, today: Int, hour: Int, minute: Int) throws -> [Day] {
74 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-agenda-\(UUID().uuidString)")
75 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
76 defer { try? FileManager.default.removeItem(at: folder) }
77 var paths: [String] = []
78 for file in files {
79 let url = folder.appendingPathComponent(file.name)
80 try file.text.write(to: url, atomically: true, encoding: .utf8)
81 paths.append(url.resolvingSymlinksInPath().path)
82 }
83 let seconds = (today - 719163) * 86400 + hour * 3600 + minute * 60
84 let scriptURL = folder.appendingPathComponent("agenda.el")
85 let output = folder.appendingPathComponent("output.json")
86 try script(files: paths, form: form, seconds: seconds)
87 .write(to: scriptURL, atomically: true, encoding: .utf8)
88 let log = try EmacsOracle.run(["-Q", "--batch", "-l", scriptURL.path], environment: ["ORACLE_OUTPUT": output.path, "TZ": "UTC"])
89 guard let data = try? Data(contentsOf: output) else { throw OracleError(log: log) }
90 let days = try JSONDecoder().decode([Day].self, from: data)
91 // Positions as UTF-16 offsets, files by name.
92 return days.map { day in
93 Day(header: day.header, items: day.items.map { line in
94 var line = line
95 if let file = line.file, let pos = line.pos, let index = paths.firstIndex(of: URL(fileURLWithPath: file).resolvingSymlinksInPath().path) {
96 line.file = files[index].name
97 line.pos = EmacsOracle.offset(pos, in: files[index].text)
98 }
99 return line
100 })
101 }
102 }
103
104 struct OracleError: Error { let log: String }
105
106 /// Tags realigned to one space, as their column depends on the window.
107 static func normalize(_ line: String) -> String {
108 line.replacingOccurrences(of: "[ \\t]+(:[^ \\t]+:)[ \\t]*$", with: " $1", options: .regularExpression)
109 }
110
111 /// `org-agenda-prefix-format` as Lisp.
112 static func prefixLisp(_ formats: [String: String]) -> String {
113 "'(" + formats.sorted { $0.key < $1.key }.map { "(\($0.key) . \"\($0.value)\")" }.joined(separator: " ") + ")"
114 }
115
116 static func compare(
117 files: [(name: String, text: String)], start: Int, span: Int, today: Int, hour: Int = 9, minute: Int = 30,
118 prefix: [String: String]? = nil, logMode: Bool = false, skipIfDone: Bool = false
119 ) throws {
120 let date = Days.date(start)
121 var form = "(org-agenda-list nil \"\(String(format: "%04d-%02d-%02d", date.year, date.month, date.day))\" \(span))"
122 if let prefix { form = "(let ((org-agenda-prefix-format \(prefixLisp(prefix)))) \(form))" }
123 if logMode { form = "(let ((org-agenda-start-with-log-mode t)) \(form))" }
124 if skipIfDone { form = "(let ((org-agenda-skip-scheduled-if-done t) (org-agenda-skip-deadline-if-done t)) \(form))" }
125 let emacs = try run(files: files, form: form, today: today, hour: hour, minute: minute)
126 let defaults = OrgSettings.default.withTodoKeywords(keywords)!
127 let sources = files.map { AgendaSource(path: $0.name, text: $0.text, defaults: defaults) }
128 var options = AgendaOptions.terminal
129 if let prefix { options.prefixFormat = prefix }
130 options.logMode = logMode
131 options.skipScheduledIfDone = skipIfDone
132 options.skipDeadlineIfDone = skipIfDone
133 let ours = Agenda.list(sources, start: start, days: span, today: today, now: (hour, minute), options: options)
134 #expect(ours.count == emacs.count)
135 for (o, e) in zip(ours, emacs) {
136 #expect(o.header == e.header)
137 #expect(describe(o.items, today: today) == describe(e.items), "\(e.header) today \(Days.date(today))")
138 }
139 }
140
141 static func describe(_ items: [Line]) -> [String] {
142 items.map { item in
143 "\(normalize(item.line)) | \(item.type ?? "-") \(item.category ?? "") \(item.tod.map(String.init) ?? "-") [\(item.extra ?? "")] \(item.urgency ?? 0) \(item.file ?? "-")@\(item.pos.map(String.init) ?? "-")"
144 }
145 }
146
147 static func describe(_ items: [AgendaItem], today: Int = 0) -> [String] {
148 items.map { item in
149 let line = item.habit.map { Habit.insertGraph($0.graph(today: today), into: item.line) } ?? item.line
150 let type: String = switch item.kind {
151 case .timeGrid, .currentTime: "-"
152 default: item.kind.rawValue
153 }
154 return "\(normalize(line)) | \(type) \(item.category) \(item.timeOfDay.map(String.init) ?? "-") [\(item.extra)] \(item.urgency) \(item.headingOffset == nil ? "-" : item.path ?? "-")@\(item.headingOffset.map(String.init) ?? "-")"
155 }
156 }
157
158 /// A TODO list or tags view: `form` runs it in Emacs, `ours` builds ours from the sources.
159 static func compareList(files: [(name: String, text: String)], form: String, today: Int, _ ours: ([AgendaSource]) -> [AgendaItem]) throws {
160 let emacs = try run(files: files, form: form, today: today, hour: 9, minute: 30)
161 let defaults = OrgSettings.default.withTodoKeywords(keywords)!
162 let mine = ours(files.map { AgendaSource(path: $0.name, text: $0.text, defaults: defaults) })
163 #expect(describe(mine) == describe(emacs.first?.items ?? []), "\(form)")
164 }
165}
166
167struct AgendaTests {
168 @Test func days() {
169 #expect(Days.absolute(year: 1970, month: 1, day: 1) == 719163)
170 #expect(Days.absolute(year: 2026, month: 10, day: 5) - Days.absolute(year: 2026, month: 9, day: 30) == 5)
171 #expect(Days.weekday(Days.absolute(year: 2026, month: 10, day: 5)) == 1)
172 #expect(Days.isoWeek(Days.absolute(year: 2026, month: 10, day: 5)) == 41)
173 #expect(Days.isoWeek(Days.absolute(year: 2027, month: 1, day: 1)) == 53)
174 let d = Days.date(Days.absolute(year: 2024, month: 2, day: 29))
175 #expect(d.year == 2024 && d.month == 2 && d.day == 29)
176 #expect(Days.date(Days.absolute(year: 2026, month: 2, day: 31)) == (2026, 3, 3))
177 #expect(Days.date(Days.absolute(year: 2026, month: 13, day: 1)) == (2027, 1, 1))
178 }
179
180 static let synthetic = """
181 #+TODO: TODO NEXT | DONE
182 #+FILETAGS: :ft:
183 Before the first heading <2026-10-05 Mon>.
184 * TODO [#A] Pay rent :home:
185 SCHEDULED: <2026-10-01 Thu>
186 * Meeting 10:00 with Bob
187 <2026-10-05 Mon 14:00-15:00>
188 * DONE Old
189 DEADLINE: <2026-10-03 Sat>
190 * NEXT Report :work:
191 DEADLINE: <2026-10-12 Mon -3d>
192 * NEXT Soon
193 DEADLINE: <2026-10-09 Fri>
194 * TODO Overdue
195 DEADLINE: <2026-09-20 Sun 08:15>
196 * Trip
197 <2026-10-04 Sun>--<2026-10-07 Wed>
198 * Same day range
199 <2026-10-06 Tue 10:00>--<2026-10-06 Tue 12:30>
200 * Gym
201 <2026-09-01 Tue 07:00 +1w>
202 ** Child
203 :PROPERTIES:
204 :CATEGORY: kid
205 :END:
206 SCHEDULED: <2026-10-06 Tue>
207 *** Grandchild :x:
208 <2026-10-07 Wed 9am>
209 * TODO Water plants
210 SCHEDULED: <2026-09-28 Mon .+2d>
211 * TODO Monthly
212 SCHEDULED: <2026-08-31 Mon +1m>
213 * TODO Yearly
214 DEADLINE: <2025-10-08 Wed +1y>
215 * TODO Delayed
216 SCHEDULED: <2026-10-02 Fri -2d>
217 * Archived :ARCHIVE:
218 SCHEDULED: <2026-10-05 Mon>
219 <2026-10-05 Mon>
220 ** TODO Under archived
221 DEADLINE: <2026-10-05 Mon>
222 * COMMENT Commented
223 DEADLINE: <2026-10-05 Mon>
224 <2026-10-05 Mon>
225 * Blocks
226 #+begin_src sh
227 echo <2026-10-05 Mon>
228 #+end_src
229 # <2026-10-05 Mon>
230 =<2026-10-06 Tue>= and [[https://example.com][<2026-10-06 Tue>]]
231 * [#C] Low priority <2026-10-06 Tue>
232 * TODO Late meeting 23:30
233 SCHEDULED: <2026-10-05 Mon>
234 * Props
235 :PROPERTIES:
236 :WHEN: <2026-10-06 Tue 16:00>
237 :END:
238 * TODO Spaces :a:b:
239 <2026-10-06 Tue 11:00>
240 * Ünïcode tïtle :tag:
241 <2026-10-05 Mon 08:00>
242 * Range with times <2026-10-05 Mon 10:00-11:00>
243 * Hourly
244 <2026-10-04 Sun 22:00 +12h>
245 """
246
247 static let second = """
248 #+CATEGORY: other
249 * WAIT Second file
250 DEADLINE: <2026-10-05 Mon>
251 * Two stamps
252 <2026-10-05 Mon 13:00> and <2026-10-06 Tue>
253 """
254
255 static let third = """
256 #+FILETAGS: :proj:
257 * TODO Alpha :work:urgent:
258 :PROPERTIES:
259 :EFFORT: 2:00
260 :SIZE: 3
261 :OWNER: alice
262 :DUE: <2026-10-10 Sat>
263 :END:
264 ** NEXT Beta :boss:
265 :PROPERTIES:
266 :SIZE: 10
267 :END:
268 *** Gamma :work:
269 * DONE Delta :home:
270 CLOSED: [2026-10-01 Thu 10:00] SCHEDULED: <2026-09-30 Wed>
271 * WAIT [#A] Epsilon 10:30
272 :PROPERTIES:
273 :OWNER: Bob
274 :END:
275 <2026-10-03 Sat> [2026-10-02 Fri]
276 * HOLD Zeta :x@y:
277 * Eta with category
278 :PROPERTIES:
279 :CATEGORY: cat
280 :END:
281 ** KILL Theta
282 * IDEA [#C] Iota
283 * Archived :ARCHIVE:work:
284 ** TODO Kappa :work:
285 """
286
287 static let matches = [
288 "work", "+work-urgent", "work|home", "proj", "{^w}", "-work", "LEVEL>1", "LEVEL=2+boss", "SIZE>5", "SIZE<5",
289 "OWNER=\"alice\"", "OWNER={^b}", "OWNER<>\"alice\"", "OWNER<\"b\"", "TODO=\"WAIT\"", "PRIORITY=\"A\"",
290 "CATEGORY=\"cat\"", "DUE<\"<2026-10-15>\"", "DUE>\"<today>\"", "SCHEDULED<\"<today>\"", "TIMESTAMP=\"<2026-10-03 Sat>\"",
291 "ITEM={lph}", "work/TODO", "/!", "+work/!TODO|WAIT", "EFFORT>\"1:00\"", "ALLTAGS={proj}", "TAGS=\":work:urgent:\"",
292 "x@y", "/-TODO", "/!{^[HW]}", "work&urgent", "SIZE>=3+SIZE<=3", "LEVEL<>1",
293 // `org-time<>` is `=` in Org 9.8.7.
294 "DUE<>\"<2026-10-10>\"", "DUE<>\"<2026-10-11>\"", "SCHEDULED<>\"<2026-09-30>\"", "TIMESTAMP!=\"<2026-10-03 Sat>\"",
295 "DEADLINE/=\"<2026-10-05>\"", "DEADLINE<>\"<2026-10-07>\"",
296 ]
297
298 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
299 func listsMatchEmacs() throws {
300 let files = [("a.org", Self.synthetic), ("b.org", Self.second), ("c.org", Self.third)]
301 let today = Days.absolute(year: 2026, month: 10, day: 5)
302 var utc = Calendar(identifier: .gregorian)
303 utc.timeZone = TimeZone(identifier: "UTC")!
304 let now = Date(timeIntervalSince1970: Double((today - 719163) * 86400 + 9 * 3600 + 30 * 60))
305 func lisp(_ s: String) -> String { "\"" + s.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + "\"" }
306 try AgendaOracle.compareList(files: files, form: "(org-todo-list nil)", today: today) { Agenda.todoList($0) }
307 for keywords in ["WAIT|HOLD", "DONE", "KILL|TODO"] {
308 try AgendaOracle.compareList(files: files, form: "(org-todo-list \(lisp(keywords)))", today: today) { Agenda.todoList($0, keywords: keywords) }
309 }
310 for match in Self.matches {
311 for todoOnly in [false, true] {
312 try AgendaOracle.compareList(files: files, form: "(org-tags-view \(todoOnly ? "t" : "nil") \(lisp(match)))", today: today) {
313 Agenda.tagsMatch($0, match: match, todoOnly: todoOnly, now: now, calendar: utc)
314 }
315 }
316 }
317 }
318
319 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
320 func matchesEmacs() throws {
321 let files = [("a.org", Self.synthetic), ("b.org", Self.second)]
322 for today in [(2026, 10, 5), (2026, 10, 1), (2026, 10, 8), (2026, 9, 29)] {
323 let day = Days.absolute(year: today.0, month: today.1, day: today.2)
324 try AgendaOracle.compare(files: files, start: day - 3, span: 10, today: day)
325 }
326 let day = Days.absolute(year: 2026, month: 10, day: 6)
327 try AgendaOracle.compare(files: files, start: day, span: 1, today: day)
328 try AgendaOracle.compare(files: files, start: day + 1, span: 1, today: day)
329 }
330
331 static let doneEntries = """
332 * DONE Scheduled today
333 SCHEDULED: <2026-10-05 Mon>
334 * DONE Due today
335 DEADLINE: <2026-10-05 Mon>
336 * KILL Scheduled earlier
337 SCHEDULED: <2026-10-01 Thu>
338 * DONE Due later
339 DEADLINE: <2026-10-07 Wed>
340 * TODO Still open
341 SCHEDULED: <2026-10-05 Mon> DEADLINE: <2026-10-06 Tue>
342 """
343
344 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
345 func skipsDoneEntriesLikeEmacs() throws {
346 let today = Days.absolute(year: 2026, month: 10, day: 5)
347 for skip in [false, true] {
348 try AgendaOracle.compare(files: [("done.org", Self.doneEntries)], start: today - 4, span: 8, today: today, skipIfDone: skip)
349 }
350 }
351
352 /// `ORGSTAR_ORACLE_CORPUS=<folder>`: the top-level files of the folder, ten days around
353 /// two dates.
354 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_ORACLE_CORPUS"] != nil))
355 func matchesEmacsOnACorpus() throws {
356 let root = URL(fileURLWithPath: ProcessInfo.processInfo.environment["ORGSTAR_ORACLE_CORPUS"]!)
357 let files = try FileManager.default.contentsOfDirectory(at: root, includingPropertiesForKeys: nil)
358 .filter { $0.pathExtension == "org" }.sorted { $0.path < $1.path }
359 .compactMap { url in (try? String(contentsOf: url, encoding: .utf8)).map { (url.lastPathComponent, $0) } }
360 for today in [Days.absolute(Date(), calendar: .current), Days.absolute(year: 2025, month: 3, day: 14)] {
361 try AgendaOracle.compare(files: files, start: today - 3, span: 10, today: today)
362 }
363 let today = Days.absolute(Date(), calendar: .current)
364 try AgendaOracle.compareList(files: files, form: "(org-todo-list nil)", today: today) { Agenda.todoList($0) }
365 for match in ["finance|home", "LEVEL=2", "/!", "PRIORITY=\"A\""] {
366 let lisp = "\"" + match.replacingOccurrences(of: "\"", with: "\\\"") + "\""
367 try AgendaOracle.compareList(files: files, form: "(org-tags-view nil \(lisp))", today: today) { Agenda.tagsMatch($0, match: match) }
368 }
369 }
370}
371
372struct ReminderTests {
373 static var utc: Calendar {
374 var calendar = Calendar(identifier: .gregorian)
375 calendar.timeZone = TimeZone(identifier: "UTC")!
376 return calendar
377 }
378
379 static func date(_ day: Int, _ hour: Int, _ minute: Int) -> Date {
380 utc.date(from: DateComponents(year: 2026, month: 10, day: day, hour: hour, minute: minute))!
381 }
382
383 @Test func timedEntriesWithTheirWarnings() {
384 let text = """
385 * TODO Call
386 SCHEDULED: <2026-10-05 Mon 14:00>
387 * Meeting
388 :PROPERTIES:
389 :APPT_WARNTIME: 30
390 :END:
391 <2026-10-06 Tue 10:00-11:00>
392 * DONE Finished
393 SCHEDULED: <2026-10-05 Mon 15:00>
394 * Untimed
395 SCHEDULED: <2026-10-05 Mon>
396 * Earlier
397 <2026-10-05 Mon 08:00>
398 * Soon
399 <2026-10-05 Mon 09:35>
400 * TODO Report due
401 DEADLINE: <2026-10-07 Wed 17:00>
402 """
403 let source = AgendaSource(path: "/n/a.org", text: text)
404 let now = Self.date(5, 9, 30)
405 let reminders = Reminders.upcoming([source], now: now, calendar: Self.utc)
406 #expect(reminders.map(\.title) == ["Soon", "TODO Call", "Meeting", "TODO Report due"])
407 #expect(reminders[0].fireDate == now.addingTimeInterval(1))
408 #expect(reminders[1].fireDate == Self.date(5, 13, 48))
409 #expect(reminders[1].body == "14:00 · Scheduled: · a")
410 #expect(reminders[2].fireDate == Self.date(6, 9, 30))
411 #expect(reminders[3].eventDate == Self.date(7, 17, 0))
412 #expect(Reminders.upcoming([source], now: now, calendar: Self.utc, limit: 2).count == 2)
413 #expect(Reminders.upcoming([source], now: now, calendar: Self.utc, days: 1).map(\.title) == ["Soon", "TODO Call"])
414 }
415
416 @Test func repeatsFillTheWeek() {
417 let source = AgendaSource(path: "/n/a.org", text: "* Standup\n<2026-10-01 Thu 09:00 +1d>\n")
418 let reminders = Reminders.upcoming([source], now: Self.date(5, 9, 30), calendar: Self.utc)
419 #expect(reminders.count == 6)
420 #expect(Set(reminders.map(\.id)).count == 6)
421 }
422}
423
424struct AgendaPrefixFormatTests {
425 static let file = """
426 #+TODO: TODO NEXT | DONE
427 * Projects [1/2] :proj:
428 ** TODO [#A] Write report :work:
429 :PROPERTIES:
430 :Effort: 1:30
431 :END:
432 SCHEDULED: <2026-10-05 Mon 10:00>
433 *** NEXT Outline [[https://x.org][the plan]]
434 DEADLINE: <2026-10-06 Tue>
435 * Errands
436 :PROPERTIES:
437 :CATEGORY: averyverylongcategory
438 :END:
439 ** TODO Groceries 14:00
440 SCHEDULED: <2026-10-05 Mon>
441 """
442
443 static let formats = [
444 "agenda": " %i %-12:c%?-12t%-6e% s%l%b", "todo": " %-8.6c %e %?-4l|", "tags": "%c %?-5:l %b", "search": " %i %-12:c",
445 ]
446
447 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
448 func agendaMatchesEmacs() throws {
449 let today = Days.absolute(year: 2026, month: 10, day: 5)
450 try AgendaOracle.compare(files: [("p.org", Self.file)], start: today, span: 2, today: today, prefix: Self.formats)
451 }
452
453 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
454 func listsMatchEmacs() throws {
455 let today = Days.absolute(year: 2026, month: 10, day: 5)
456 var options = AgendaOptions()
457 options.prefixFormat = Self.formats
458 let lisp = AgendaOracle.prefixLisp(Self.formats)
459 try AgendaOracle.compareList(files: [("p.org", Self.file)], form: "(let ((org-agenda-prefix-format \(lisp))) (org-todo-list))", today: today) {
460 Agenda.todoList($0, options: options)
461 }
462 try AgendaOracle.compareList(files: [("p.org", Self.file)], form: "(let ((org-agenda-prefix-format \(lisp))) (org-tags-view nil \"work|proj\"))", today: today) {
463 Agenda.tagsMatch($0, match: "work|proj", options: options)
464 }
465 }
466
467 /// `%T` is the last tag: Org's field regexp lists only lower-case keys, but matches it
468 /// under `case-fold-search`.
469 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
470 func lastTagMatchesEmacs() throws {
471 let today = Days.absolute(year: 2026, month: 10, day: 5)
472 let formats = ["agenda": " %-10:c%-8T%?-12t% s", "todo": " %?-6T|", "tags": "%c %-6:T", "search": " %i %-12:c"]
473 try AgendaOracle.compare(files: [("p.org", Self.file)], start: today, span: 2, today: today, prefix: formats)
474 var options = AgendaOptions()
475 options.prefixFormat = formats
476 let lisp = AgendaOracle.prefixLisp(formats)
477 try AgendaOracle.compareList(files: [("p.org", Self.file)], form: "(let ((org-agenda-prefix-format \(lisp))) (org-todo-list))", today: today) {
478 Agenda.todoList($0, options: options)
479 }
480 try AgendaOracle.compareList(files: [("p.org", Self.file)], form: "(let ((org-agenda-prefix-format \(lisp))) (org-tags-view nil \"work|proj\"))", today: today) {
481 Agenda.tagsMatch($0, match: "work|proj", options: options)
482 }
483 }
484
485 @Test func compilesFields() {
486 let format = PrefixFormat(" %-8.6c|%?-4t%e")
487 #expect(format.render(["c": "category", "e": "1:00"]) == " categ |1:00")
488 #expect(format.render(["c": "cat", "t": "9:00"]) == " cat |9:00")
489 }
490}
491
492struct AgendaLogModeTests {
493 static let file = """
494 #+TODO: TODO NEXT | DONE
495 * DONE Ship release :work:
496 CLOSED: [2026-10-05 Mon 16:20] SCHEDULED: <2026-10-05 Mon>
497 :LOGBOOK:
498 CLOCK: [2026-10-05 Mon 14:00]--[2026-10-05 Mon 15:30] => 1:30
499 - Fixed the build
500 CLOCK: [2026-10-04 Sun 22:00]--[2026-10-05 Mon 01:15] => 3:15
501 :END:
502 * TODO Writing
503 :LOGBOOK:
504 CLOCK: [2026-10-06 Tue 09:00]--[2026-10-06 Tue 09:45] => 0:45
505 CLOCK: [2026-10-06 Tue 11:00]
506 :END:
507 ** DONE Draft [1/1]
508 CLOSED: [2026-10-06 Tue]
509 * Archived :ARCHIVE:
510 CLOSED: [2026-10-05 Mon 10:00]
511 * Plain
512 Mentions CLOSED: [2026-10-05 Mon 09:00] in text.
513 """
514
515 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
516 func logModeMatchesEmacs() throws {
517 let today = Days.absolute(year: 2026, month: 10, day: 5)
518 try AgendaOracle.compare(files: [("log.org", Self.file)], start: today - 1, span: 3, today: today, logMode: true)
519 try AgendaOracle.compare(files: [("log.org", Self.file)], start: today - 1, span: 3, today: today, prefix: ["agenda": " %-8:c%?-12t%s %l"], logMode: true)
520 }
521}
522
523struct AgendaFilterTests {
524 static let file = """
525 #+TODO: TODO NEXT | DONE
526 #+CATEGORY: home
527 * TODO Groceries :errand:
528 :PROPERTIES:
529 :Effort: 0:20
530 :END:
531 SCHEDULED: <2026-10-05 Mon>
532 * Work stuff
533 :PROPERTIES:
534 :CATEGORY: work
535 :END:
536 ** TODO Write Report :writing:urgent:
537 :PROPERTIES:
538 :Effort: 2:00
539 :END:
540 SCHEDULED: <2026-10-05 Mon 10:00>
541 ** NEXT Call Bob :phone:
542 DEADLINE: <2026-10-06 Tue>
543 ** TODO Review PR :urgent:
544 :PROPERTIES:
545 :Effort: 1h
546 :END:
547 SCHEDULED: <2026-10-05 Mon>
548 """
549
550 static let filters = ["+urgent", "-urgent", "work", "-work", "+home+work", "<0:30", ">1:00", "=1:00", "/report/", "-/call/", "+work-phone<2:00", "nonexistent", "urgent-writing/review/"]
551
552 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
553 func filtersLikeOrgAgendaFilter() throws {
554 let today = Days.absolute(year: 2026, month: 10, day: 5)
555 let defaults = OrgSettings.default.withTodoKeywords(AgendaOracle.keywords)!
556 let sources = [AgendaSource(path: "f.org", text: Self.file, defaults: defaults)]
557 let days = Agenda.list(sources, start: today, days: 2, today: today, now: (9, 30), options: .terminal)
558 let all = days.flatMap(\.items)
559 let tags = Set(all.flatMap(\.tags))
560 let categories = Set(all.filter { $0.path != nil }.map(\.category))
561 for filterText in Self.filters {
562 let emacs = try AgendaOracle.run(files: [("f.org", Self.file)], form: """
563 (progn
564 (org-agenda-list nil "2026-10-05" 2)
565 (with-current-buffer org-agenda-buffer-name
566 (cl-letf (((symbol-function 'completing-read) (lambda (&rest _) \(lispString(filterText)))))
567 (org-agenda-filter))))
568 """, today: today, hour: 9, minute: 30)
569 let filter = AgendaFilter().parse(filterText, tags: tags, categories: categories).filter
570 let ours = days.map { day in day.items.filter(filter.keeps) }
571 #expect(ours.map { AgendaOracle.describe($0, today: today) } == emacs.map { AgendaOracle.describe($0.items) }, "\(filterText)")
572 }
573 }
574
575 @Test func parsesAndDescribes() {
576 let (filter, ignored) = AgendaFilter().parse("+work-phone<2:00/rep/ zzz", tags: ["phone"], categories: ["work"])
577 #expect(filter.categories == ["+work"] && filter.tags == ["-phone"] && filter.efforts == ["+<2:00"] && filter.regexps == ["+rep"])
578 #expect(ignored == ["+zzz"])
579 #expect(filter.description == "+work-phone<2:00/rep/")
580 #expect(AgendaFilter.durationMinutes("1h 30min") == 90 && AgendaFilter.durationMinutes("0:45") == 45)
581 let kept = filter.parse("++urgent", tags: ["urgent"], categories: []).filter
582 #expect(kept.tags == ["-phone", "+urgent"])
583 }
584
585 @Test func descriptionRoundTrips() {
586 let tags: Set<String> = ["phone", "urgent"], categories: Set<String> = ["work", "my-cat"]
587 for text in ["-/call/", "-work+phone-<0:10>1:00/a/-/b/", "\"my-cat\"-urgent", "-\"my-cat\""] {
588 let filter = AgendaFilter().parse(text, tags: tags, categories: categories).filter
589 #expect(AgendaFilter().parse(filter.description, tags: tags, categories: categories).filter == filter, "\(text)")
590 }
591 let negative = AgendaFilter().parse("-/call/", tags: [], categories: []).filter
592 #expect(negative.description == "-/call/")
593 }
594}