krz/orgstar

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

Tests/OrgCoreTests/CaptureTests.swift

main
orgstar/Tests/OrgCoreTests/CaptureTests.swift history · blame · raw

331 lines · 20252 bytes

12 symbols in this file
  1import Foundation
  2import Testing
  3@testable import OrgCore
  4
  5struct CaptureTests {
  6    struct Case {
  7        var file: String
  8        var template: CaptureTemplate
  9        var typed: String
 10    }
 11
 12    static func lisp(_ s: String) -> String {
 13        "\"" + s.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + "\""
 14    }
 15
 16    /// Captures each case in `emacs --batch`: the target file afterwards.
 17    static func emacs(_ cases: [Case], seconds: Int) throws -> [String] {
 18        let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-capture-\(UUID().uuidString)")
 19        try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
 20        defer { try? FileManager.default.removeItem(at: folder) }
 21        var forms: [String] = []
 22        for (i, c) in cases.enumerated() {
 23            let path = folder.appendingPathComponent("target\(i).org").path
 24            try c.file.write(toFile: path, atomically: true, encoding: .utf8)
 25            let target: String
 26            var properties = c.template.prepend ? " :prepend t" : ""
 27            let o = c.template.options
 28            if let n = o.emptyLines { properties += " :empty-lines \(n)" }
 29            if let n = o.emptyLinesBefore { properties += " :empty-lines-before \(n)" }
 30            if let n = o.emptyLinesAfter { properties += " :empty-lines-after \(n)" }
 31            if let pos = o.tableLinePos { properties += " :table-line-pos \(lisp(pos))" }
 32            switch c.template.target {
 33            case .file: target = "(file \(lisp(path)))"
 34            case .headline(_, let h): target = "(file+headline \(lisp(path)) \(lisp(h)))"
 35            case .outline(_, let p): target = "(file+olp \(lisp(path)) \(p.map(lisp).joined(separator: " ")))"
 36            case .datetree(_, let p, let tree):
 37                target = "(file+olp+datetree \(lisp(path)) \(p.map(lisp).joined(separator: " ")))"
 38                properties += " :tree-type \(tree.rawValue)"
 39            case .id(let id): target = "(id \(lisp(id)))"
 40            case .clock, .heading: target = "(clock)"
 41            }
 42            forms.append("""
 43            (let ((org-capture-templates '(("x" "case" \(c.template.kind.rawValue) \(target) \(lisp(c.template.template))\(properties))))
 44                  (path \(lisp(path))))
 45              (cl-letf (((symbol-function 'org-id-find)
 46                         (lambda (id &rest _)
 47                           (with-current-buffer (find-file-noselect path)
 48                             (save-excursion
 49                               (goto-char (point-min))
 50                               (when (re-search-forward (concat "^[ \t]*:ID:[ \t]+" (regexp-quote id) "[ \t]*$") nil t)
 51                                 (org-back-to-heading t)
 52                                 (cons path (point))))))))
 53                (org-capture nil "x"))
 54              (insert \(lisp(c.typed)))
 55              (org-capture-finalize)
 56              (with-current-buffer (find-buffer-visiting \(lisp(path)))
 57                (push (buffer-substring-no-properties (point-min) (point-max)) results)))
 58            """)
 59        }
 60        let output = folder.appendingPathComponent("out.json")
 61        let script = folder.appendingPathComponent("capture.el")
 62        try """
 63        ;;; -*- lexical-binding: t -*-
 64        (require 'org)
 65        (require 'org-capture)
 66        (require 'org-id)
 67        (require 'json)
 68        (setq org-adapt-indentation nil)
 69        (defvar results nil)
 70        \(frozenClock(seconds, "(progn " + forms.joined(separator: "\n") + ")"))
 71        (with-temp-file \(lisp(output.path)) (insert (json-encode (vconcat (nreverse results)))))
 72        """.write(to: script, atomically: true, encoding: .utf8)
 73        let log = try EmacsOracle.run(["-Q", "--batch", "-l", script.path], environment: ["TZ": "UTC"])
 74        guard FileManager.default.fileExists(atPath: output.path) else { throw EmacsOracle.EvaluateError(log: log) }
 75        return try JSONDecoder().decode([String].self, from: Data(contentsOf: output))
 76    }
 77
 78    static func ours(_ c: Case, now: Date) -> String {
 79        var utcCalendar = Calendar(identifier: .gregorian)
 80        utcCalendar.timeZone = TimeZone(identifier: "UTC")!
 81        var utc = Calendar(identifier: .gregorian)
 82        utc.timeZone = TimeZone(identifier: "UTC")!
 83        guard case .success(let filled) = Capture.fill(c.template.template, context: CaptureContext(now: now, calendar: utc)) else { return "fill failed" }
 84        var text = filled.text
 85        let index = String.Index(utf16Offset: filled.cursor, in: text)
 86        text.insert(contentsOf: c.typed, at: index)
 87        switch Capture.place(text, template: c.template, in: c.file, now: now, calendar: utcCalendar) {
 88        case .success(let result): return result
 89        case .failure(.message(let message)): return "failed: \(message)"
 90        }
 91    }
 92
 93    static let files = [
 94        "#+TITLE: Todo\n* Inbox\n** TODO Old\n* Later\nText\n",
 95        "* Inbox\n\n** TODO Old\n\n* Later\n",
 96        "* Inbox\n",
 97        "* Inbox",
 98        "",
 99        "Preamble\n* Other\n",
100        "* Inbox\nSome notes.\n- one\n- two\n\n** Child\n",
101        "* Projects\n** Work\n*** Active\n** Home\n",
102        "* Inbox\n:PROPERTIES:\n:ID: x\n:END:\nBody\n",
103    ]
104
105    static let templates: [CaptureTemplate] = [
106        CaptureTemplate(key: "x", name: "", target: .headline(file: "", headline: "Inbox"), template: "* TODO %?\n%i\n%a"),
107        CaptureTemplate(key: "x", name: "", target: .headline(file: "", headline: "Inbox"), template: "* %u %?\n%i\n%a"),
108        CaptureTemplate(key: "x", name: "", target: .headline(file: "", headline: "Inbox"), template: "* TODO %?", prepend: true),
109        CaptureTemplate(key: "x", name: "", target: .headline(file: "", headline: "Missing"), template: "* %?"),
110        CaptureTemplate(key: "x", name: "", target: .file(""), template: "* Note %U\n%?"),
111        CaptureTemplate(key: "x", name: "", target: .file(""), template: "** Deep %?", prepend: true),
112        CaptureTemplate(key: "x", name: "", target: .headline(file: "", headline: "Inbox"), template: "Plain %? text", ),
113        CaptureTemplate(key: "x", name: "", kind: .plain, target: .headline(file: "", headline: "Inbox"), template: "Plain line %?"),
114        CaptureTemplate(key: "x", name: "", kind: .plain, target: .file(""), template: "%<%Y-%m-%d>: %?"),
115        CaptureTemplate(key: "x", name: "", kind: .item, target: .headline(file: "", headline: "Inbox"), template: "%?"),
116        CaptureTemplate(key: "x", name: "", kind: .checkitem, target: .headline(file: "", headline: "Inbox"), template: "[ ] %?"),
117        CaptureTemplate(key: "x", name: "", target: .outline(file: "", path: ["Projects", "Work"]), template: "* TODO %?\n  Added %T"),
118    ]
119
120    @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
121    func matchesEmacs() throws {
122        let seconds = 1_789_000_000
123        var cases: [Case] = []
124        for template in Self.templates {
125            for file in Self.files {
126                if case .outline = template.target, !file.contains("Projects") { continue }
127                if template.kind == .item || template.kind == .checkitem, !file.contains("Inbox") { continue }
128                cases.append(Case(file: file, template: template, typed: "Buy milk"))
129            }
130        }
131        let emacs = try Self.emacs(cases, seconds: seconds)
132        let now = Date(timeIntervalSince1970: Double(seconds))
133        for (c, expected) in zip(cases, emacs) {
134            let mine = Self.ours(c, now: now)
135            #expect(mine == expected, "\(c.template.template.debugDescription) \(c.template.kind) prepend \(c.template.prepend) into \(c.file.debugDescription)\nours:\n\(mine.debugDescription)\nemacs:\n\(expected.debugDescription)")
136        }
137    }
138
139    static let tableFiles = [
140        "* Inbox\n| a | b |\n|---+---|\n| 1 | 2 |\n* Later\n",
141        "* Inbox\nText first.\n\n| a | b |\n|---+---|\n| 1 | 2 |\n|---+---|\n| total | |\n\nAfter.\n* Later\n",
142        "* Inbox\nNo table here.\n* Later\n",
143        "* Inbox\n",
144        "| x |\n| 1 |\n",
145        "* Inbox\n| n | sq |\n|---+----|\n| 2 |  4 |\n| 3 | 9 |\n#+TBLFM: $2=$1^2::@2$1=@3$1-1\n* Later\n",
146    ]
147
148    static let tableTemplates: [CaptureTemplate] = [
149        CaptureTemplate(key: "x", name: "", kind: .tableLine, target: .headline(file: "", headline: "Inbox"), template: "| %? | new |"),
150        CaptureTemplate(key: "x", name: "", kind: .tableLine, target: .headline(file: "", headline: "Inbox"), template: "| %? |", prepend: true),
151        CaptureTemplate(key: "x", name: "", kind: .tableLine, target: .headline(file: "", headline: "Inbox"), template: "%? | plain",
152                        options: .init(tableLinePos: "II-1")),
153        CaptureTemplate(key: "x", name: "", kind: .tableLine, target: .headline(file: "", headline: "Inbox"), template: "| first %? |\n| second |",
154                        options: .init(tableLinePos: "I+1")),
155        CaptureTemplate(key: "x", name: "", kind: .tableLine, target: .file(""), template: "| %? |"),
156        CaptureTemplate(key: "x", name: "", kind: .tableLine, target: .headline(file: "", headline: "Inbox"), template: "| far %? |",
157                        options: .init(tableLinePos: "I-9")),
158    ]
159
160    @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
161    func tableLinesMatchEmacs() throws {
162        let seconds = 1_789_000_000
163        var cases: [Case] = []
164        for template in Self.tableTemplates {
165            for file in Self.tableFiles {
166                if case .headline = template.target, !file.contains("Inbox") { continue }
167                if template.options.tableLinePos == "II-1", !file.contains("total") { continue }
168                // Text in a computed column: Calc's symbolic result goes to Emacs.
169                if template.template.contains("first"), file.contains("TBLFM") { continue }
170                cases.append(Case(file: file, template: template, typed: "7"))
171            }
172        }
173        let emacs = try Self.emacs(cases, seconds: seconds)
174        let now = Date(timeIntervalSince1970: Double(seconds))
175        for (c, expected) in zip(cases, emacs) {
176            let mine = Self.ours(c, now: now)
177            #expect(mine == expected, "\(c.template.template.debugDescription) prepend \(c.template.prepend) pos \(c.template.options.tableLinePos ?? "-") into \(c.file.debugDescription)\nours:\n\(mine)\nemacs:\n\(expected)")
178        }
179    }
180
181    static let datetreeFiles = [
182        "",
183        "#+TITLE: Journal\n",
184        "* 2026\n** 2026-09 September\n*** 2026-09-01 Tuesday\nOld\n*** 2026-09-30 Wednesday\n** 2026-11 November\n* 2027\n",
185        "* 2025\n\n** 2025-01 January\n\n*** 2025-01-01 Wednesday\n\nNew year\n\n* Other\n",
186        "Notes before.\n\n* Journal\n** 2026\n*** 2026-09 September\n* Tasks\n** TODO Thing\n",
187        "* Journal\n:PROPERTIES:\n:ID: journal-id\n:END:\n* Tasks\n",
188        "* 2026\n** 2026-W36\n*** 2026-09-01 Tuesday\n** 2026-W38\n",
189    ]
190
191    static let datetreeTemplates: [CaptureTemplate] = [
192        CaptureTemplate(key: "x", name: "", target: .datetree(file: "", path: [], tree: .day), template: "* %?"),
193        CaptureTemplate(key: "x", name: "", target: .datetree(file: "", path: [], tree: .week), template: "* %U %?"),
194        CaptureTemplate(key: "x", name: "", target: .datetree(file: "", path: [], tree: .month), template: "* %?"),
195        CaptureTemplate(key: "x", name: "", target: .datetree(file: "", path: ["Journal"], tree: .day), template: "* %?"),
196        CaptureTemplate(key: "x", name: "", kind: .plain, target: .datetree(file: "", path: [], tree: .day), template: "%U %?"),
197        CaptureTemplate(key: "x", name: "", kind: .item, target: .datetree(file: "", path: [], tree: .day), template: "%?"),
198        CaptureTemplate(key: "x", name: "", target: .datetree(file: "", path: [], tree: .day), template: "* %?", options: .init(prepend: true)),
199    ]
200
201    @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil), arguments: [1_789_000_000, 1_798_804_800])
202    func datetreesMatchEmacs(seconds: Int) throws {
203        var cases: [Case] = []
204        for template in Self.datetreeTemplates {
205            for file in Self.datetreeFiles {
206                if case .datetree(_, let path, _) = template.target, !path.isEmpty, !file.contains("* Journal") { continue }
207                cases.append(Case(file: file, template: template, typed: "Entry"))
208            }
209        }
210        let emacs = try Self.emacs(cases, seconds: seconds)
211        let now = Date(timeIntervalSince1970: Double(seconds))
212        for (c, expected) in zip(cases, emacs) {
213            let mine = Self.ours(c, now: now)
214            #expect(mine == expected, "\(c.template.target) \(c.template.kind) into \(c.file.debugDescription)\nours:\n\(mine.debugDescription)\nemacs:\n\(expected.debugDescription)")
215        }
216    }
217
218    static let optionTemplates: [CaptureTemplate] = [
219        CaptureTemplate(key: "x", name: "", target: .id("journal-id"), template: "* %?"),
220        CaptureTemplate(key: "x", name: "", target: .headline(file: "", headline: "Inbox"), template: "* %?", options: .init(emptyLines: 1)),
221        CaptureTemplate(key: "x", name: "", target: .headline(file: "", headline: "Inbox"), template: "* %?", options: .init(emptyLinesBefore: 2, emptyLinesAfter: 1)),
222        CaptureTemplate(key: "x", name: "", target: .file(""), template: "* %?", options: .init(prepend: true, emptyLinesAfter: 1)),
223        CaptureTemplate(key: "x", name: "", kind: .plain, target: .headline(file: "", headline: "Inbox"), template: "%?", options: .init(emptyLines: 1)),
224        CaptureTemplate(key: "x", name: "", kind: .item, target: .file(""), template: "%?", options: .init(emptyLines: 1)),
225    ]
226
227    @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
228    func targetsAndEmptyLinesMatchEmacs() throws {
229        var cases: [Case] = []
230        let files = ["* Journal\n:PROPERTIES:\n:ID: journal-id\n:END:\nText\n** Old\n* Inbox\n", "* Inbox\n\n** Old\n\n* Later\n", "Preamble\n* Inbox\nBody\n"]
231        for template in Self.optionTemplates {
232            for file in files {
233                if case .id = template.target, !file.contains("journal-id") { continue }
234                cases.append(Case(file: file, template: template, typed: "Entry"))
235            }
236        }
237        let seconds = 1_789_000_000
238        let emacs = try Self.emacs(cases, seconds: seconds)
239        for (c, expected) in zip(cases, emacs) {
240            let mine = Self.ours(c, now: Date(timeIntervalSince1970: Double(seconds)))
241            #expect(mine == expected, "\(c.template.target) \(c.template.kind) \(c.template.options) into \(c.file.debugDescription)\nours:\n\(mine.debugDescription)\nemacs:\n\(expected.debugDescription)")
242        }
243    }
244
245    @Test func fillsPrompts() {
246        let template = "* %^{Title|untitled} for %^{Who}\n%\\1 again"
247        #expect(Capture.questions(template) == [
248            Capture.Question(prompt: "Title", defaultValue: "untitled", choices: []),
249            Capture.Question(prompt: "Who", defaultValue: "", choices: []),
250        ])
251        guard case .success(let filled) = Capture.fill(template, context: CaptureContext(), answers: ["", "me"]) else {
252            Issue.record("fill failed")
253            return
254        }
255        #expect(filled.text == "* untitled for me\nuntitled again\n")
256    }
257}
258
259struct CaptureEscapeTests {
260    /// Templates and the answers to their prompts, in order.
261    static let cases: [(template: String, answers: [String])] = [
262        ("* TODO %^{Task} %^g\n%^{Effort}p%?", ["Call bank", "work:phone", "1:00"]),
263        ("* Meeting %^T\n%^{Who|Ann|Bob} said %\\1, tags %\\*2", ["fri 14:00", ""]),
264        ("* %^{Title} :work:\nSCHEDULED: %^t\n%^u %^U", ["Plan", "+2d", "", "2026-10-01 9:30"]),
265        ("* Link %^L and %^C", ["https://orgmode.org", ""]),
266        ("* %A\n%:annotation %:initial %k%?", ["the page"]),
267        ("\\%a literal and \\\\%a doubled %c", []),
268        ("* Heading %^{Due}t and %^{Seen}U", ["tue", ""]),
269        ("%^{Name}\n- item %^g", ["Bob", "a:b"]),
270        ("* A rather long heading title that goes on %^G", ["x:y"]),
271        ("* Empty tags %^g done", [""]),
272        ("* Prop %^{CATEGORY|home}p\nbody", [""]),
273    ]
274
275    @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
276    func fillsLikeOrgCaptureFillTemplate() throws {
277        let seconds = 1_789_000_000
278        let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-fill-\(UUID().uuidString)")
279        try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
280        defer { try? FileManager.default.removeItem(at: folder) }
281        let forms = Self.cases.map { c in
282            """
283            (let ((answers (list \(c.answers.map(CaptureTests.lisp).joined(separator: " ")))))
284              (cl-letf* ((next (lambda (default) (let ((a (pop answers))) (if (or (null a) (equal a "")) (or default "") a))))
285                         (ord (symbol-function 'org-read-date))
286                         ((symbol-function 'org-completing-read) (lambda (_p _c &optional _a _b _i _h def &rest _) (funcall next def)))
287                         ((symbol-function 'completing-read-multiple) (lambda (&rest _) (split-string (funcall next "") ":" t)))
288                         ((symbol-function 'read-string) (lambda (_p &optional _i _h def &rest _) (funcall next def)))
289                         ((symbol-function 'org-read-property-value) (lambda (_p &optional _e def) (funcall next def)))
290                         ((symbol-function 'org-read-date)
291                          (lambda (&optional with-time to-time _from prompt &rest _)
292                            (let ((a (pop answers))) (funcall ord with-time to-time (if (equal a "") "." a) prompt)))))
293                (with-temp-buffer
294                  (org-mode)
295                  (setq org-capture-plist (list :buffer (current-buffer) :pos 1))
296                  (push (org-capture-fill-template \(CaptureTests.lisp(c.template)) "Selected" "[[file:~/n.org::*Task][Task]]") results))))
297            """
298        }
299        let output = folder.appendingPathComponent("out.json")
300        let script = folder.appendingPathComponent("fill.el")
301        try """
302        ;;; -*- lexical-binding: t -*-
303        (require 'org)
304        (require 'org-capture)
305        (require 'json)
306        (require 'cl-lib)
307        (setq org-adapt-indentation nil)
308        (kill-new "Clip")
309        (defvar results nil)
310        \(frozenClock(seconds, "(progn " + forms.joined(separator: "\n") + ")"))
311        (with-temp-file \(CaptureTests.lisp(output.path)) (insert (json-encode (vconcat (nreverse results)))))
312        """.write(to: script, atomically: true, encoding: .utf8)
313        let log = try EmacsOracle.run(["-Q", "--batch", "-l", script.path], environment: ["TZ": "UTC"])
314        guard FileManager.default.fileExists(atPath: output.path) else { throw EmacsOracle.EvaluateError(log: log) }
315        let emacs = try JSONDecoder().decode([String].self, from: Data(contentsOf: output))
316        var utc = Calendar(identifier: .gregorian)
317        utc.timeZone = TimeZone(identifier: "UTC")!
318        let context = CaptureContext(now: Date(timeIntervalSince1970: Double(seconds)), calendar: utc, initial: "Selected",
319                                     annotation: "[[file:~/n.org::*Task][Task]]", clipboard: "Clip")
320        for (c, expected) in zip(Self.cases, emacs) {
321            #expect(Capture.questions(c.template, context: context).count == c.answers.count, "\(c.template.debugDescription)")
322            guard case .success(let filled) = Capture.fill(c.template, context: context, answers: c.answers) else {
323                Issue.record("fill failed for \(c.template.debugDescription)")
324                continue
325            }
326            var mine = filled.text
327            if c.template.contains("%?") { mine.insert(contentsOf: "%?", at: String.Index(utf16Offset: filled.cursor, in: mine)) }
328            #expect(mine == expected, "\(c.template.debugDescription)\nours:  \(mine.debugDescription)\nemacs: \(expected.debugDescription)")
329        }
330    }
331}