Tests/OrgCoreTests/TodoDependencyTests.swift
209 lines · 11694 bytes
1import Foundation
2import Testing
3@testable import OrgCore
4
5struct TodoDependencyTests {
6 static let oracle = ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil
7
8 /// Point is at the ¶.
9 static let texts = [
10 "* TODO A¶\n** TODO B\n",
11 "* TODO A¶\n** DONE B\n",
12 "* TODO A¶\n** B\n",
13 "* TODO A¶\n** B\n*** TODO C\n",
14 "* TODO A¶\n** DONE B\n*** TODO C\n",
15 "* A¶\n** TODO B\n",
16 "* TODO A\nbody¶\n** TODO [#A] B :x:y:\n",
17 "* TODO A¶\n** TODO\n",
18 "* TODO A¶\n** TODO B :ARCHIVE:\n",
19 "* TODO A¶\n:PROPERTIES:\n:NOBLOCKING: t\n:END:\n** TODO B\n",
20 "* TODO A¶\n:PROPERTIES:\n:NOBLOCKING: nil\n:END:\n** TODO B\n",
21 "* TODO A¶\nSCHEDULED: <2026-10-05 Mon +1d>\n** TODO B\n",
22 "* P\n:PROPERTIES:\n:ORDERED: t\n:END:\n** TODO A :x:\n** TODO B¶\n",
23 "* P\n:PROPERTIES:\n:ORDERED: t\n:END:\n** DONE A\n** TODO B¶\n",
24 "* P\n:PROPERTIES:\n:ORDERED: t\n:END:\n** TODO A¶\n** TODO B\n",
25 "* P\n:PROPERTIES:\n:ORDERED: t\n:END:\n** A\n** TODO B¶\n",
26 "* P\n:PROPERTIES:\n:ORDERED: nil\n:END:\n** TODO A\n** TODO B¶\n",
27 "* P\n:PROPERTIES:\n:ORDERED:\n:END:\n** TODO A\n** TODO B¶\n",
28 "* P\n:PROPERTIES:\n:ordered: t\n:END:\n** TODO A\n** TODO B¶\n",
29 "* P\n:PROPERTIES:\n:ORDERED: t\n:END:\n** DONE A\n*** TODO A1\n** TODO B¶\n",
30 "* G\n:PROPERTIES:\n:ORDERED: t\n:END:\n** P\n*** TODO A\n*** TODO B¶\n",
31 "* G\n:PROPERTIES:\n:ORDERED: t\n:END:\n** TODO A\n** TODO P\n*** TODO B¶\n",
32 "* G\n:PROPERTIES:\n:ORDERED: t\n:END:\n** TODO A\n** P\n*** TODO B¶\n",
33 "* G\n:PROPERTIES:\n:ORDERED: t\n:END:\n** TODO A\n** TODO P\n*** TODO Q\n**** TODO B¶\n",
34 "* G\n:PROPERTIES:\n:ORDERED: t\n:END:\n** DONE A\n** TODO P\n*** TODO B¶\n",
35 "#+PROPERTY: ORDERED t\n* P\n** TODO A\n** TODO B¶\n",
36 "* TODO A\n* TODO B¶\n",
37 "* TODO A¶\n- [ ] x\n",
38 "* TODO A¶\n- [X] x\n",
39 "* TODO A¶\n- [-] x\n - [X] y\n - [ ] z\n",
40 "* TODO A¶\n1. [@3] [ ] x\n",
41 "* TODO A¶\n + [ ] x\n",
42 "* TODO A¶\n * [ ] x\n",
43 "* TODO A¶\n- [ ]\n",
44 "* TODO A¶\nSome [ ] text\n",
45 "* TODO A¶\n** B\n- [ ] x\n",
46 "* TODO A¶\n#+begin_src text\n- [ ] x\n#+end_src\n",
47 "* TODO A¶\n#+begin_example\n- [ ] x\n#+end_example\n",
48 "* TODO A¶\n#+begin_quote\n- [ ] x\n#+end_quote\n",
49 "* TODO A¶\n:NOTES:\n- [ ] x\n:END:\n",
50 "* TODO A¶\n- [ ] x\n** TODO B\n",
51 "* TODO A¶\n- [X] x\n** TODO B\n",
52 "#+TODO: TODO NEXT | DONE CANCELED\n* NEXT A¶\n** TODO B\n",
53 "#+TODO: TODO NEXT | DONE CANCELED\n* DONE A¶\n** TODO B\n",
54 "#+TODO: TODO NEXT | DONE CANCELED\n* TODO A¶\n** NEXT B\n",
55 ]
56
57 static let settings: [(dependencies: Bool, checkboxes: Bool)] = [(true, false), (false, true), (true, true), (false, false)]
58
59 static func place(_ marked: String) -> (text: String, caret: Int) {
60 let caret = (marked as NSString).range(of: "¶").location
61 return (marked.replacingOccurrences(of: "¶", with: ""), caret)
62 }
63
64 static func run(_ command: any OrgCommand, _ text: String, caret: Int, options: EditingOptions) -> (text: String, message: String?) {
65 var utc = Calendar(identifier: .gregorian)
66 utc.timeZone = TimeZone(identifier: "UTC")!
67 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret],
68 now: Date(timeIntervalSince1970: 1_789_000_000), calendar: utc, options: options)
69 switch command.run(in: context) {
70 case .commit(let result):
71 let new = result.edits.sorted { $0.range.lowerBound > $1.range.lowerBound }.reduce(text) { $1.apply(to: $0) }
72 let message = result.effects.compactMap { if case .message(let m) = $0 { m } else { nil } }.last
73 return (new, message)
74 case .failed(let message): return (text, message)
75 default: return (text, "other")
76 }
77 }
78
79 /// Runs `form` with the settings, failing with the last message it shows that `keep` keeps.
80 static func form(_ form: String, dependencies: Bool, checkboxes: Bool, keep: String = "blocked") -> String {
81 frozenClock(1_789_000_000, """
82 (let (msgs)
83 (setq org-blocker-hook nil org-enforce-todo-dependencies \(dependencies ? "t" : "nil")
84 org-enforce-todo-checkbox-dependencies \(checkboxes ? "t" : "nil") org-use-fast-todo-selection nil)
85 (org-mode)
86 (cl-letf (((symbol-function 'message) (lambda (f &rest a) (when f (push (apply #'format-message f a) msgs)) (car msgs))))
87 \(form)
88 (run-hooks 'post-command-hook))
89 (let ((m (seq-find (lambda (s) (string-match-p \(lispString(keep)) s)) msgs))) (when m (error "%s" m))))
90 """)
91 }
92
93 @Test(.enabled(if: oracle))
94 func blocksLikeOrgTodo() throws {
95 let changes: [(String, any OrgCommand, String)] = [
96 ("DONE", SetTodo(keyword: "DONE"), "(org-todo \"DONE\")"),
97 ("CANCELED", SetTodo(keyword: "CANCELED"), "(org-todo \"CANCELED\")"),
98 ("TODO", SetTodo(keyword: "TODO"), "(org-todo \"TODO\")"),
99 ("none", SetTodo(keyword: nil), "(org-todo 'none)"),
100 ("cycle", TodoCycle(), "(org-todo)"),
101 ("right", TodoShift(right: true), "(org-todo 'right)"),
102 ("left", TodoShift(right: false), "(org-todo 'left)"),
103 ]
104 var cases: [EmacsOracle.Case] = []
105 var ours: [(text: String, message: String?)] = []
106 var labels: [String] = []
107 for marked in Self.texts {
108 let (text, caret) = Self.place(marked)
109 let sequences = text.hasPrefix("#+TODO:")
110 for (dependencies, checkboxes) in Self.settings {
111 var options = EditingOptions.org
112 options.enforceTodoDependencies = dependencies
113 options.enforceTodoCheckboxDependencies = checkboxes
114 for (name, command, form) in changes where sequences || name != "CANCELED" {
115 cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(caret, in: text),
116 form: Self.form(form, dependencies: dependencies, checkboxes: checkboxes)))
117 ours.append(Self.run(command, text, caret: caret, options: options))
118 labels.append("\(form) dependencies \(dependencies) checkboxes \(checkboxes)")
119 }
120 }
121 }
122 for (i, theirs) in try EmacsOracle.run(cases).enumerated() {
123 #expect(ours[i].text == theirs.text && (ours[i].message ?? "") == theirs.error,
124 "\(labels[i]) on \(cases[i].text.debugDescription)\nours: \(ours[i].text.debugDescription) \(ours[i].message ?? "")\nemacs: \(theirs.text.debugDescription) \(theirs.error)")
125 }
126 }
127
128 @Test(.enabled(if: oracle))
129 func togglesOrderedLikeOrg() throws {
130 let texts = [
131 "* A¶\n",
132 "* A¶\n:PROPERTIES:\n:ORDERED: t\n:END:\n",
133 "* A\n:PROPERTIES:\n:X: 1\n:ORDERED: t\n:END:\nbody¶\n",
134 "* A¶\n:PROPERTIES:\n:ORDERED: nil\n:END:\n",
135 "* A¶\n:PROPERTIES:\n:ORDERED:\n:END:\n",
136 "* A¶\nSCHEDULED: <2026-10-05 Mon>\nbody\n",
137 "* A\n** TODO B¶ :x:\n** C\n",
138 "* A¶",
139 "text¶\n* A\n",
140 ]
141 var cases: [EmacsOracle.Case] = []
142 var ours: [(text: String, message: String?)] = []
143 for marked in texts {
144 let (text, caret) = Self.place(marked)
145 cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(caret, in: text),
146 form: Self.form("(org-toggle-ordered-property)", dependencies: false, checkboxes: false, keep: "Subtasks")))
147 ours.append(Self.run(ToggleOrderedProperty(), text, caret: caret, options: .org))
148 }
149 for (i, theirs) in try EmacsOracle.run(cases).enumerated() {
150 // Org's message also names the position it reached and the buffer.
151 let before = "Before first headline"
152 let message = theirs.error.hasPrefix(before) && ours[i].message?.hasPrefix(before) == true ? theirs.error : ours[i].message
153 #expect(ours[i].text == theirs.text && (message ?? "") == theirs.error,
154 "on \(cases[i].text.debugDescription)\nours: \(ours[i].text.debugDescription) \(ours[i].message ?? "")\nemacs: \(theirs.text.debugDescription) \(theirs.error)")
155 }
156 }
157
158 /// `org-entry-blocked-p` on every heading, which the agenda dims.
159 @Test(.enabled(if: oracle))
160 func agendaBlockedLikeOrg() throws {
161 var cases: [EmacsOracle.Case] = []
162 var ours: [String] = []
163 for marked in Self.texts {
164 let text = Self.place(marked).text
165 let source = AgendaSource(path: "/tmp/oracle.org", text: text)
166 for dependencies in [true, false] {
167 let form = "(push (mapconcat #'identity (org-map-entries (lambda () (if (org-entry-blocked-p) \"b\" \"-\"))) \"\") msgs)"
168 cases.append(EmacsOracle.Case(text: text, point: 1, form: Self.form(form, dependencies: dependencies, checkboxes: !dependencies, keep: "")))
169 ours.append(source.headings.map { (dependencies ? $0.blockedByHierarchy : $0.blockedByCheckboxes) ? "b" : "-" }.joined())
170 }
171 }
172 for (i, theirs) in try EmacsOracle.run(cases).enumerated() {
173 #expect(ours[i] == theirs.error, "\(i % 2 == 0 ? "dependencies" : "checkboxes") on \(cases[i].text.debugDescription)")
174 }
175 }
176
177 @Test func agendaDimsOrHidesBlockedEntries() {
178 let text = "* TODO A\n** TODO B\n* TODO C\n- [ ] x\n* TODO D\n"
179 let source = AgendaSource(path: "/tmp/a.org", text: text)
180 func blocked(_ dependencies: Bool, _ checkboxes: Bool, _ dim: AgendaOptions.DimBlocked) -> [String] {
181 var options = AgendaOptions()
182 options.enforceTodoDependencies = dependencies
183 options.enforceTodoCheckboxDependencies = checkboxes
184 options.dimBlockedTasks = dim
185 return Agenda.todoList([source], options: options).map { ($0.isBlocked ? "~" : "") + $0.text }
186 }
187 #expect(blocked(false, false, .dim) == ["TODO A", "TODO B", "TODO C", "TODO D"])
188 #expect(blocked(true, true, .dim) == ["~TODO A", "TODO B", "~TODO C", "TODO D"])
189 #expect(blocked(true, false, .dim) == ["~TODO A", "TODO B", "TODO C", "TODO D"])
190 #expect(blocked(true, true, .off) == ["TODO A", "TODO B", "TODO C", "TODO D"])
191 // Entries blocked by checkboxes are dimmed, never hidden.
192 #expect(blocked(true, true, .invisible) == ["TODO B", "~TODO C", "TODO D"])
193 }
194
195 @Test func sourcesCanSkipTheBlockingCheck() {
196 let source = AgendaSource(path: "/tmp/a.org", text: "* TODO A\n** TODO B\n", checksBlocking: false)
197 var options = AgendaOptions()
198 options.enforceTodoDependencies = true
199 #expect(Agenda.todoList([source], options: options).allSatisfy { !$0.isBlocked })
200 }
201
202 @Test func dwimIsBlocked() {
203 let text = "* TODO A\n** TODO B\n"
204 var options = EditingOptions.org
205 options.enforceTodoDependencies = true
206 #expect(Self.run(OrgDwim(), text, caret: 3, options: options) == (text, "TODO state change from TODO to DONE blocked (by \"TODO B\")"))
207 #expect(Self.run(OrgDwim(), text, caret: 3, options: .org).text == "* DONE A\n** TODO B\n")
208 }
209}