krz/orgstar

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

Tests/OrgCoreTests/AgendaTests.swift

570 lines · 25809 bytes

33 symbols in this file
  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}