krz/orgstar

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

Tests/OrgCoreTests/SubtreeCommandTests.swift

16086b4cf2caff5328774b2cd5ae3ffe1ab65ca4
orgstar/Tests/OrgCoreTests/SubtreeCommandTests.swift history · blame · raw

137 lines · 7179 bytes

  1import Foundation
  2import Testing
  3@testable import OrgCore
  4
  5struct SubtreeCommandTests {
  6    static let oracle = ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil
  7
  8    static let text = """
  9        Preamble
 10        * Banana [#B] :fruit:
 11        SCHEDULED: <2026-10-07 Wed>
 12        :PROPERTIES:
 13        :SIZE: 3
 14        :END:
 15        ** Child one
 16        ** Child two
 17        * TODO apple 10
 18        DEADLINE: <2026-10-03 Sat>
 19        :LOGBOOK:
 20        CLOCK: [2026-10-01 Thu 09:00]--[2026-10-01 Thu 10:30] =>  1:30
 21        :END:
 22        * DONE Cherry 2 [#A]
 23        [2026-09-01 Tue]
 24        * *Date* 33
 25        :PROPERTIES:
 26        :SIZE: 1
 27        :END:
 28        <2026-09-20 Sun>
 29        ** NEXT inner
 30
 31        """
 32
 33    static func offsets(_ text: String) -> [Int] {
 34        let ns = text as NSString
 35        var result: [Int] = []
 36        var at = 0
 37        while at < ns.length {
 38            let line = ns.lineRange(for: NSRange(location: at, length: 0))
 39            result.append(line.location)
 40            result.append(min(line.location + 2, NSMaxRange(line) - 1))
 41            at = NSMaxRange(line)
 42        }
 43        return Array(Set(result)).sorted()
 44    }
 45
 46    static func ours(_ command: any OrgCommand, _ text: String, _ caret: Int, answers: [String: String] = [:]) -> (text: String, caret: Int, copied: String?, failed: Bool) {
 47        var utc = Calendar(identifier: .gregorian)
 48        utc.timeZone = TimeZone(identifier: "UTC")!
 49        let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret],
 50                                  now: Date(timeIntervalSince1970: 1_789_000_000), calendar: utc, answers: answers)
 51        guard case .commit(let result) = command.run(in: context) else { return (text, caret, nil, true) }
 52        var new = text
 53        for edit in result.edits.sorted(by: { $0.range.lowerBound > $1.range.lowerBound }) { new = edit.apply(to: new) }
 54        let copied = result.effects.compactMap { if case .copy(let s) = $0 { s } else { nil } }.first
 55        return (new, result.selection?.first?.lowerBound ?? caret, copied, false)
 56    }
 57
 58    @Test(.enabled(if: oracle))
 59    func cutsAndCopiesLikeOrg() throws {
 60        for cut in [true, false] {
 61            let offsets = Self.offsets(Self.text).filter { $0 >= 9 }
 62            let forms = offsets.map { offset in
 63                "(progn (erase-buffer) (insert \(lispString(Self.text))) (goto-char \(EmacsOracle.point(offset, in: Self.text))) (setq kill-ring nil) (org-\(cut ? "cut" : "copy")-subtree) (format \"%s|%d|%s\" (buffer-string) (1- (point)) (car kill-ring)))"
 64            }
 65            let emacs = try EmacsOracle.evaluate("", "(list \(forms.joined(separator: " ")))")
 66            for (offset, expected) in zip(offsets, emacs) {
 67                let mine = Self.ours(CopySubtree(cut: cut), Self.text, offset)
 68                #expect("\(mine.text)|\(mine.caret)|\(mine.copied ?? "nil")" == expected, "cut \(cut) at \(offset)")
 69            }
 70        }
 71    }
 72
 73    static let trees = ["* Pasted\nbody\n** Sub\n", "*** Deep\n**** Deeper", "\n** Two\n* One\n"]
 74
 75    @Test(.enabled(if: oracle))
 76    func pastesLikeOrg() throws {
 77        let offsets = Self.offsets(Self.text) + [(Self.text as NSString).length]
 78        var forms: [String] = []
 79        var ours: [String] = []
 80        for tree in Self.trees {
 81            for offset in offsets {
 82                forms.append("(progn (erase-buffer) (insert \(lispString(Self.text))) (goto-char \(EmacsOracle.point(offset, in: Self.text))) (condition-case e (progn (org-paste-subtree nil \(lispString(tree))) (format \"%s|%d\" (buffer-string) (1- (point)))) (error \"error\")))")
 83                let mine = Self.ours(PasteSubtree(text: tree), Self.text, offset)
 84                ours.append(mine.failed ? "error" : "\(mine.text)|\(EmacsOracle.point(mine.caret, in: mine.text) - 1)")
 85            }
 86        }
 87        let emacs = try EmacsOracle.evaluate("", "(list \(forms.joined(separator: " ")))")
 88        for (i, (mine, theirs)) in zip(ours, emacs).enumerated() {
 89            #expect(mine == theirs, "case \(i)\nours:  \(mine.debugDescription)\nemacs: \(theirs.debugDescription)")
 90        }
 91    }
 92
 93    @Test(.enabled(if: oracle))
 94    func clonesLikeOrg() throws {
 95        let texts = [
 96            "* Task\n:PROPERTIES:\n:ID: old\n:END:\nSCHEDULED: <2026-10-05 Mon>\n:LOGBOOK:\nCLOCK: [2026-10-01 Thu 09:00]--[2026-10-01 Thu 10:00] =>  1:00\n:END:\n* Next\n",
 97            "* Repeat\nSCHEDULED: <2026-10-05 Mon +1w>\n<2026-10-06 Tue 10:00>\n",
 98            "* Plain\nbody\n",
 99        ]
100        let runs: [(Int, String)] = [(2, "+1d"), (1, ""), (3, "-2w"), (0, "+1m")]
101        var forms: [String] = []
102        var ours: [String] = []
103        for text in texts {
104            for (count, shift) in runs {
105                forms.append("(progn (erase-buffer) (insert \(lispString(text))) (goto-char 3) (cl-letf (((symbol-function 'org-id-new) (lambda (&rest _) \"NEW-ID\")) ((symbol-function 'org-id-add-location) #'ignore)) (org-clone-subtree-with-time-shift \(count) \(lispString(shift)))) (buffer-string))")
106                let answers = ["count": String(count), "shift": shift]
107                ours.append(Self.ours(CloneSubtree(newID: { "NEW-ID" }), text, 2, answers: answers).text)
108            }
109        }
110        let emacs = try EmacsOracle.evaluate("", frozenClock(1_789_000_000, "(list \(forms.joined(separator: " ")))"), prelude: "(require 'org-id) (require 'cl-lib)")
111        for (i, (mine, theirs)) in zip(ours, emacs).enumerated() {
112            #expect(mine == theirs, "case \(i)\nours:  \(mine.debugDescription)\nemacs: \(theirs.debugDescription)")
113        }
114    }
115
116    @Test(.enabled(if: oracle))
117    func sortsLikeOrgSortEntries() throws {
118        let keys = ["a", "n", "p", "o", "t", "s", "d", "c", "k", "r", "A", "N", "P", "O", "T", "R"]
119        let starts = [0, (Self.text as NSString).range(of: "* Banana").location, (Self.text as NSString).range(of: "* *Date*").location]
120        var forms: [String] = []
121        var ours: [String] = []
122        for start in starts {
123            for key in keys {
124                let property = key.lowercased() == "r" ? " nil nil \"SIZE\"" : ""
125                forms.append("(progn (erase-buffer) (insert \(lispString(Self.text))) (goto-char \(EmacsOracle.point(start, in: Self.text))) (condition-case e (progn (org-sort-entries nil ?\(key)\(property)) (format \"%s|%d\" (buffer-string) (1- (point)))) (error (error-message-string e))))")
126                var answers = ["sort": key]
127                if key.lowercased() == "r" { answers["property"] = "SIZE" }
128                let mine = Self.ours(SortEntries(), Self.text, start, answers: answers)
129                ours.append(mine.failed ? "failed" : "\(mine.text)|\(EmacsOracle.point(mine.caret, in: mine.text) - 1)")
130            }
131        }
132        let emacs = try EmacsOracle.evaluate("", frozenClock(1_789_000_000, "(list \(forms.joined(separator: " ")))"))
133        for (i, (mine, theirs)) in zip(ours, emacs).enumerated() {
134            #expect(mine == theirs, "case \(i) key \(keys[i % keys.count])\nours:  \(mine.debugDescription)\nemacs: \(theirs.debugDescription)")
135        }
136    }
137}