Tests/OrgCoreTests/AgendaTests.swift
570 lines · 25809 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
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 let emacs = try run(files: files, form: form, today: today, hour: hour, minute: minute)
125 let defaults = OrgSettings.default.withTodoKeywords(keywords)!
126 let sources = files.map { AgendaSource(path: $0.name, text: $0.text, defaults: defaults) }
127 var options = AgendaOptions.terminal
128 if let prefix { options.prefixFormat = prefix }
129 options.logMode = logMode
130 let ours = Agenda.list(sources, start: start, days: span, today: today, now: (hour, minute), options: options)
131 #expect(ours.count == emacs.count)
132 for (o, e) in zip(ours, emacs) {
133 #expect(o.header == e.header)
134 #expect(describe(o.items, today: today) == describe(e.items), "\(e.header) today \(Days.date(today))")
135 }
136 }
137
138 static func describe(_ items: [Line]) -> [String] {
139 items.map { item in
140 "\(normalize(item.line)) | \(item.type ?? "-") \(item.category ?? "") \(item.tod.map(String.init) ?? "-") [\(item.extra ?? "")] \(item.urgency ?? 0) \(item.file ?? "-")@\(item.pos.map(String.init) ?? "-")"
141 }
142 }
143
144 static func describe(_ items: [AgendaItem], today: Int = 0) -> [String] {
145 items.map { item in
146 let line = item.habit.map { Habit.insertGraph($0.graph(today: today), into: item.line) } ?? item.line
147 let type: String = switch item.kind {
148 case .timeGrid, .currentTime: "-"
149 default: item.kind.rawValue
150 }
151 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) ?? "-")"
152 }
153 }
154
155 /// A TODO list or tags view: `form` runs it in Emacs, `ours` builds ours from the sources.
156 static func compareList(files: [(name: String, text: String)], form: String, today: Int, _ ours: ([AgendaSource]) -> [AgendaItem]) throws {
157 let emacs = try run(files: files, form: form, today: today, hour: 9, minute: 30)
158 let defaults = OrgSettings.default.withTodoKeywords(keywords)!
159 let mine = ours(files.map { AgendaSource(path: $0.name, text: $0.text, defaults: defaults) })
160 #expect(describe(mine) == describe(emacs.first?.items ?? []), "\(form)")
161 }
162}
163
164struct AgendaTests {
165 @Test func days() {
166 #expect(Days.absolute(year: 1970, month: 1, day: 1) == 719163)
167 #expect(Days.absolute(year: 2026, month: 10, day: 5) - Days.absolute(year: 2026, month: 9, day: 30) == 5)
168 #expect(Days.weekday(Days.absolute(year: 2026, month: 10, day: 5)) == 1)
169 #expect(Days.isoWeek(Days.absolute(year: 2026, month: 10, day: 5)) == 41)
170 #expect(Days.isoWeek(Days.absolute(year: 2027, month: 1, day: 1)) == 53)
171 let d = Days.date(Days.absolute(year: 2024, month: 2, day: 29))
172 #expect(d.year == 2024 && d.month == 2 && d.day == 29)
173 #expect(Days.date(Days.absolute(year: 2026, month: 2, day: 31)) == (2026, 3, 3))
174 #expect(Days.date(Days.absolute(year: 2026, month: 13, day: 1)) == (2027, 1, 1))
175 }
176
177 static let synthetic = """
178 #+TODO: TODO NEXT | DONE
179 #+FILETAGS: :ft:
180 Before the first heading <2026-10-05 Mon>.
181 * TODO [#A] Pay rent :home:
182 SCHEDULED: <2026-10-01 Thu>
183 * Meeting 10:00 with Bob
184 <2026-10-05 Mon 14:00-15:00>
185 * DONE Old
186 DEADLINE: <2026-10-03 Sat>
187 * NEXT Report :work:
188 DEADLINE: <2026-10-12 Mon -3d>
189 * NEXT Soon
190 DEADLINE: <2026-10-09 Fri>
191 * TODO Overdue
192 DEADLINE: <2026-09-20 Sun 08:15>
193 * Trip
194 <2026-10-04 Sun>--<2026-10-07 Wed>
195 * Same day range
196 <2026-10-06 Tue 10:00>--<2026-10-06 Tue 12:30>
197 * Gym
198 <2026-09-01 Tue 07:00 +1w>
199 ** Child
200 :PROPERTIES:
201 :CATEGORY: kid
202 :END:
203 SCHEDULED: <2026-10-06 Tue>
204 *** Grandchild :x:
205 <2026-10-07 Wed 9am>
206 * TODO Water plants
207 SCHEDULED: <2026-09-28 Mon .+2d>
208 * TODO Monthly
209 SCHEDULED: <2026-08-31 Mon +1m>
210 * TODO Yearly
211 DEADLINE: <2025-10-08 Wed +1y>
212 * TODO Delayed
213 SCHEDULED: <2026-10-02 Fri -2d>
214 * Archived :ARCHIVE:
215 SCHEDULED: <2026-10-05 Mon>
216 <2026-10-05 Mon>
217 ** TODO Under archived
218 DEADLINE: <2026-10-05 Mon>
219 * COMMENT Commented
220 DEADLINE: <2026-10-05 Mon>
221 <2026-10-05 Mon>
222 * Blocks
223 #+begin_src sh
224 echo <2026-10-05 Mon>
225 #+end_src
226 # <2026-10-05 Mon>
227 =<2026-10-06 Tue>= and [[https://example.com][<2026-10-06 Tue>]]
228 * [#C] Low priority <2026-10-06 Tue>
229 * TODO Late meeting 23:30
230 SCHEDULED: <2026-10-05 Mon>
231 * Props
232 :PROPERTIES:
233 :WHEN: <2026-10-06 Tue 16:00>
234 :END:
235 * TODO Spaces :a:b:
236 <2026-10-06 Tue 11:00>
237 * Ünïcode tïtle :tag:
238 <2026-10-05 Mon 08:00>
239 * Range with times <2026-10-05 Mon 10:00-11:00>
240 * Hourly
241 <2026-10-04 Sun 22:00 +12h>
242 """
243
244 static let second = """
245 #+CATEGORY: other
246 * WAIT Second file
247 DEADLINE: <2026-10-05 Mon>
248 * Two stamps
249 <2026-10-05 Mon 13:00> and <2026-10-06 Tue>
250 """
251
252 static let third = """
253 #+FILETAGS: :proj:
254 * TODO Alpha :work:urgent:
255 :PROPERTIES:
256 :EFFORT: 2:00
257 :SIZE: 3
258 :OWNER: alice
259 :DUE: <2026-10-10 Sat>
260 :END:
261 ** NEXT Beta :boss:
262 :PROPERTIES:
263 :SIZE: 10
264 :END:
265 *** Gamma :work:
266 * DONE Delta :home:
267 CLOSED: [2026-10-01 Thu 10:00] SCHEDULED: <2026-09-30 Wed>
268 * WAIT [#A] Epsilon 10:30
269 :PROPERTIES:
270 :OWNER: Bob
271 :END:
272 <2026-10-03 Sat> [2026-10-02 Fri]
273 * HOLD Zeta :x@y:
274 * Eta with category
275 :PROPERTIES:
276 :CATEGORY: cat
277 :END:
278 ** KILL Theta
279 * IDEA [#C] Iota
280 * Archived :ARCHIVE:work:
281 ** TODO Kappa :work:
282 """
283
284 static let matches = [
285 "work", "+work-urgent", "work|home", "proj", "{^w}", "-work", "LEVEL>1", "LEVEL=2+boss", "SIZE>5", "SIZE<5",
286 "OWNER=\"alice\"", "OWNER={^b}", "OWNER<>\"alice\"", "OWNER<\"b\"", "TODO=\"WAIT\"", "PRIORITY=\"A\"",
287 "CATEGORY=\"cat\"", "DUE<\"<2026-10-15>\"", "DUE>\"<today>\"", "SCHEDULED<\"<today>\"", "TIMESTAMP=\"<2026-10-03 Sat>\"",
288 "ITEM={lph}", "work/TODO", "/!", "+work/!TODO|WAIT", "EFFORT>\"1:00\"", "ALLTAGS={proj}", "TAGS=\":work:urgent:\"",
289 "x@y", "/-TODO", "/!{^[HW]}", "work&urgent", "SIZE>=3+SIZE<=3", "LEVEL<>1",
290 // `org-time<>` is `=` in Org 9.8.7.
291 "DUE<>\"<2026-10-10>\"", "DUE<>\"<2026-10-11>\"", "SCHEDULED<>\"<2026-09-30>\"", "TIMESTAMP!=\"<2026-10-03 Sat>\"",
292 "DEADLINE/=\"<2026-10-05>\"", "DEADLINE<>\"<2026-10-07>\"",
293 ]
294
295 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
296 func listsMatchEmacs() throws {
297 let files = [("a.org", Self.synthetic), ("b.org", Self.second), ("c.org", Self.third)]
298 let today = Days.absolute(year: 2026, month: 10, day: 5)
299 var utc = Calendar(identifier: .gregorian)
300 utc.timeZone = TimeZone(identifier: "UTC")!
301 let now = Date(timeIntervalSince1970: Double((today - 719163) * 86400 + 9 * 3600 + 30 * 60))
302 func lisp(_ s: String) -> String { "\"" + s.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + "\"" }
303 try AgendaOracle.compareList(files: files, form: "(org-todo-list nil)", today: today) { Agenda.todoList($0) }
304 for keywords in ["WAIT|HOLD", "DONE", "KILL|TODO"] {
305 try AgendaOracle.compareList(files: files, form: "(org-todo-list \(lisp(keywords)))", today: today) { Agenda.todoList($0, keywords: keywords) }
306 }
307 for match in Self.matches {
308 for todoOnly in [false, true] {
309 try AgendaOracle.compareList(files: files, form: "(org-tags-view \(todoOnly ? "t" : "nil") \(lisp(match)))", today: today) {
310 Agenda.tagsMatch($0, match: match, todoOnly: todoOnly, now: now, calendar: utc)
311 }
312 }
313 }
314 }
315
316 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
317 func matchesEmacs() throws {
318 let files = [("a.org", Self.synthetic), ("b.org", Self.second)]
319 for today in [(2026, 10, 5), (2026, 10, 1), (2026, 10, 8), (2026, 9, 29)] {
320 let day = Days.absolute(year: today.0, month: today.1, day: today.2)
321 try AgendaOracle.compare(files: files, start: day - 3, span: 10, today: day)
322 }
323 let day = Days.absolute(year: 2026, month: 10, day: 6)
324 try AgendaOracle.compare(files: files, start: day, span: 1, today: day)
325 try AgendaOracle.compare(files: files, start: day + 1, span: 1, today: day)
326 }
327
328 /// `ORGSTAR_ORACLE_CORPUS=<folder>`: the top-level files of the folder, ten days around
329 /// two dates.
330 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_ORACLE_CORPUS"] != nil))
331 func matchesEmacsOnACorpus() throws {
332 let root = URL(fileURLWithPath: ProcessInfo.processInfo.environment["ORGSTAR_ORACLE_CORPUS"]!)
333 let files = try FileManager.default.contentsOfDirectory(at: root, includingPropertiesForKeys: nil)
334 .filter { $0.pathExtension == "org" }.sorted { $0.path < $1.path }
335 .compactMap { url in (try? String(contentsOf: url, encoding: .utf8)).map { (url.lastPathComponent, $0) } }
336 for today in [Days.absolute(Date(), calendar: .current), Days.absolute(year: 2025, month: 3, day: 14)] {
337 try AgendaOracle.compare(files: files, start: today - 3, span: 10, today: today)
338 }
339 let today = Days.absolute(Date(), calendar: .current)
340 try AgendaOracle.compareList(files: files, form: "(org-todo-list nil)", today: today) { Agenda.todoList($0) }
341 for match in ["finance|home", "LEVEL=2", "/!", "PRIORITY=\"A\""] {
342 let lisp = "\"" + match.replacingOccurrences(of: "\"", with: "\\\"") + "\""
343 try AgendaOracle.compareList(files: files, form: "(org-tags-view nil \(lisp))", today: today) { Agenda.tagsMatch($0, match: match) }
344 }
345 }
346}
347
348struct ReminderTests {
349 static var utc: Calendar {
350 var calendar = Calendar(identifier: .gregorian)
351 calendar.timeZone = TimeZone(identifier: "UTC")!
352 return calendar
353 }
354
355 static func date(_ day: Int, _ hour: Int, _ minute: Int) -> Date {
356 utc.date(from: DateComponents(year: 2026, month: 10, day: day, hour: hour, minute: minute))!
357 }
358
359 @Test func timedEntriesWithTheirWarnings() {
360 let text = """
361 * TODO Call
362 SCHEDULED: <2026-10-05 Mon 14:00>
363 * Meeting
364 :PROPERTIES:
365 :APPT_WARNTIME: 30
366 :END:
367 <2026-10-06 Tue 10:00-11:00>
368 * DONE Finished
369 SCHEDULED: <2026-10-05 Mon 15:00>
370 * Untimed
371 SCHEDULED: <2026-10-05 Mon>
372 * Earlier
373 <2026-10-05 Mon 08:00>
374 * Soon
375 <2026-10-05 Mon 09:35>
376 * TODO Report due
377 DEADLINE: <2026-10-07 Wed 17:00>
378 """
379 let source = AgendaSource(path: "/n/a.org", text: text)
380 let now = Self.date(5, 9, 30)
381 let reminders = Reminders.upcoming([source], now: now, calendar: Self.utc)
382 #expect(reminders.map(\.title) == ["Soon", "TODO Call", "Meeting", "TODO Report due"])
383 #expect(reminders[0].fireDate == now.addingTimeInterval(1))
384 #expect(reminders[1].fireDate == Self.date(5, 13, 48))
385 #expect(reminders[1].body == "14:00 · Scheduled: · a")
386 #expect(reminders[2].fireDate == Self.date(6, 9, 30))
387 #expect(reminders[3].eventDate == Self.date(7, 17, 0))
388 #expect(Reminders.upcoming([source], now: now, calendar: Self.utc, limit: 2).count == 2)
389 #expect(Reminders.upcoming([source], now: now, calendar: Self.utc, days: 1).map(\.title) == ["Soon", "TODO Call"])
390 }
391
392 @Test func repeatsFillTheWeek() {
393 let source = AgendaSource(path: "/n/a.org", text: "* Standup\n<2026-10-01 Thu 09:00 +1d>\n")
394 let reminders = Reminders.upcoming([source], now: Self.date(5, 9, 30), calendar: Self.utc)
395 #expect(reminders.count == 6)
396 #expect(Set(reminders.map(\.id)).count == 6)
397 }
398}
399
400struct AgendaPrefixFormatTests {
401 static let file = """
402 #+TODO: TODO NEXT | DONE
403 * Projects [1/2] :proj:
404 ** TODO [#A] Write report :work:
405 :PROPERTIES:
406 :Effort: 1:30
407 :END:
408 SCHEDULED: <2026-10-05 Mon 10:00>
409 *** NEXT Outline [[https://x.org][the plan]]
410 DEADLINE: <2026-10-06 Tue>
411 * Errands
412 :PROPERTIES:
413 :CATEGORY: averyverylongcategory
414 :END:
415 ** TODO Groceries 14:00
416 SCHEDULED: <2026-10-05 Mon>
417 """
418
419 static let formats = [
420 "agenda": " %i %-12:c%?-12t%-6e% s%l%b", "todo": " %-8.6c %e %?-4l|", "tags": "%c %?-5:l %b", "search": " %i %-12:c",
421 ]
422
423 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
424 func agendaMatchesEmacs() throws {
425 let today = Days.absolute(year: 2026, month: 10, day: 5)
426 try AgendaOracle.compare(files: [("p.org", Self.file)], start: today, span: 2, today: today, prefix: Self.formats)
427 }
428
429 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
430 func listsMatchEmacs() throws {
431 let today = Days.absolute(year: 2026, month: 10, day: 5)
432 var options = AgendaOptions()
433 options.prefixFormat = Self.formats
434 let lisp = AgendaOracle.prefixLisp(Self.formats)
435 try AgendaOracle.compareList(files: [("p.org", Self.file)], form: "(let ((org-agenda-prefix-format \(lisp))) (org-todo-list))", today: today) {
436 Agenda.todoList($0, options: options)
437 }
438 try AgendaOracle.compareList(files: [("p.org", Self.file)], form: "(let ((org-agenda-prefix-format \(lisp))) (org-tags-view nil \"work|proj\"))", today: today) {
439 Agenda.tagsMatch($0, match: "work|proj", options: options)
440 }
441 }
442
443 /// `%T` is the last tag: Org's field regexp lists only lower-case keys, but matches it
444 /// under `case-fold-search`.
445 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
446 func lastTagMatchesEmacs() throws {
447 let today = Days.absolute(year: 2026, month: 10, day: 5)
448 let formats = ["agenda": " %-10:c%-8T%?-12t% s", "todo": " %?-6T|", "tags": "%c %-6:T", "search": " %i %-12:c"]
449 try AgendaOracle.compare(files: [("p.org", Self.file)], start: today, span: 2, today: today, prefix: formats)
450 var options = AgendaOptions()
451 options.prefixFormat = formats
452 let lisp = AgendaOracle.prefixLisp(formats)
453 try AgendaOracle.compareList(files: [("p.org", Self.file)], form: "(let ((org-agenda-prefix-format \(lisp))) (org-todo-list))", today: today) {
454 Agenda.todoList($0, options: options)
455 }
456 try AgendaOracle.compareList(files: [("p.org", Self.file)], form: "(let ((org-agenda-prefix-format \(lisp))) (org-tags-view nil \"work|proj\"))", today: today) {
457 Agenda.tagsMatch($0, match: "work|proj", options: options)
458 }
459 }
460
461 @Test func compilesFields() {
462 let format = PrefixFormat(" %-8.6c|%?-4t%e")
463 #expect(format.render(["c": "category", "e": "1:00"]) == " categ |1:00")
464 #expect(format.render(["c": "cat", "t": "9:00"]) == " cat |9:00")
465 }
466}
467
468struct AgendaLogModeTests {
469 static let file = """
470 #+TODO: TODO NEXT | DONE
471 * DONE Ship release :work:
472 CLOSED: [2026-10-05 Mon 16:20] SCHEDULED: <2026-10-05 Mon>
473 :LOGBOOK:
474 CLOCK: [2026-10-05 Mon 14:00]--[2026-10-05 Mon 15:30] => 1:30
475 - Fixed the build
476 CLOCK: [2026-10-04 Sun 22:00]--[2026-10-05 Mon 01:15] => 3:15
477 :END:
478 * TODO Writing
479 :LOGBOOK:
480 CLOCK: [2026-10-06 Tue 09:00]--[2026-10-06 Tue 09:45] => 0:45
481 CLOCK: [2026-10-06 Tue 11:00]
482 :END:
483 ** DONE Draft [1/1]
484 CLOSED: [2026-10-06 Tue]
485 * Archived :ARCHIVE:
486 CLOSED: [2026-10-05 Mon 10:00]
487 * Plain
488 Mentions CLOSED: [2026-10-05 Mon 09:00] in text.
489 """
490
491 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
492 func logModeMatchesEmacs() throws {
493 let today = Days.absolute(year: 2026, month: 10, day: 5)
494 try AgendaOracle.compare(files: [("log.org", Self.file)], start: today - 1, span: 3, today: today, logMode: true)
495 try AgendaOracle.compare(files: [("log.org", Self.file)], start: today - 1, span: 3, today: today, prefix: ["agenda": " %-8:c%?-12t%s %l"], logMode: true)
496 }
497}
498
499struct AgendaFilterTests {
500 static let file = """
501 #+TODO: TODO NEXT | DONE
502 #+CATEGORY: home
503 * TODO Groceries :errand:
504 :PROPERTIES:
505 :Effort: 0:20
506 :END:
507 SCHEDULED: <2026-10-05 Mon>
508 * Work stuff
509 :PROPERTIES:
510 :CATEGORY: work
511 :END:
512 ** TODO Write Report :writing:urgent:
513 :PROPERTIES:
514 :Effort: 2:00
515 :END:
516 SCHEDULED: <2026-10-05 Mon 10:00>
517 ** NEXT Call Bob :phone:
518 DEADLINE: <2026-10-06 Tue>
519 ** TODO Review PR :urgent:
520 :PROPERTIES:
521 :Effort: 1h
522 :END:
523 SCHEDULED: <2026-10-05 Mon>
524 """
525
526 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/"]
527
528 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
529 func filtersLikeOrgAgendaFilter() throws {
530 let today = Days.absolute(year: 2026, month: 10, day: 5)
531 let defaults = OrgSettings.default.withTodoKeywords(AgendaOracle.keywords)!
532 let sources = [AgendaSource(path: "f.org", text: Self.file, defaults: defaults)]
533 let days = Agenda.list(sources, start: today, days: 2, today: today, now: (9, 30), options: .terminal)
534 let all = days.flatMap(\.items)
535 let tags = Set(all.flatMap(\.tags))
536 let categories = Set(all.filter { $0.path != nil }.map(\.category))
537 for filterText in Self.filters {
538 let emacs = try AgendaOracle.run(files: [("f.org", Self.file)], form: """
539 (progn
540 (org-agenda-list nil "2026-10-05" 2)
541 (with-current-buffer org-agenda-buffer-name
542 (cl-letf (((symbol-function 'completing-read) (lambda (&rest _) \(lispString(filterText)))))
543 (org-agenda-filter))))
544 """, today: today, hour: 9, minute: 30)
545 let filter = AgendaFilter().parse(filterText, tags: tags, categories: categories).filter
546 let ours = days.map { day in day.items.filter(filter.keeps) }
547 #expect(ours.map { AgendaOracle.describe($0, today: today) } == emacs.map { AgendaOracle.describe($0.items) }, "\(filterText)")
548 }
549 }
550
551 @Test func parsesAndDescribes() {
552 let (filter, ignored) = AgendaFilter().parse("+work-phone<2:00/rep/ zzz", tags: ["phone"], categories: ["work"])
553 #expect(filter.categories == ["+work"] && filter.tags == ["-phone"] && filter.efforts == ["+<2:00"] && filter.regexps == ["+rep"])
554 #expect(ignored == ["+zzz"])
555 #expect(filter.description == "+work-phone<2:00/rep/")
556 #expect(AgendaFilter.durationMinutes("1h 30min") == 90 && AgendaFilter.durationMinutes("0:45") == 45)
557 let kept = filter.parse("++urgent", tags: ["urgent"], categories: []).filter
558 #expect(kept.tags == ["-phone", "+urgent"])
559 }
560
561 @Test func descriptionRoundTrips() {
562 let tags: Set<String> = ["phone", "urgent"], categories: Set<String> = ["work", "my-cat"]
563 for text in ["-/call/", "-work+phone-<0:10>1:00/a/-/b/", "\"my-cat\"-urgent", "-\"my-cat\""] {
564 let filter = AgendaFilter().parse(text, tags: tags, categories: categories).filter
565 #expect(AgendaFilter().parse(filter.description, tags: tags, categories: categories).filter == filter, "\(text)")
566 }
567 let negative = AgendaFilter().parse("-/call/", tags: [], categories: []).filter
568 #expect(negative.description == "-/call/")
569 }
570}