Tests/OrgCoreTests/CaptureTests.swift
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}