krz/orgstar

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

Tests/OrgCoreTests/AgendaTests.swift

3a3dedba062790e1580c66d38606eda4b02cb501
orgstar/Tests/OrgCoreTests/AgendaTests.swift history · blame · raw

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}