krz/orgstar

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

Tests/OrgCoreTests/TodoDependencyTests.swift

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

209 lines · 11694 bytes

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