krz/orgstar

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

Tests/OrgPresentationTests/WrapperFoldTests.swift

15f6b0709d88971fb62ed432c3e5b8032643670a
orgstar/Tests/OrgPresentationTests/WrapperFoldTests.swift history · blame · raw

119 lines · 6501 bytes

  1import Foundation
  2import Testing
  3import OrgCore
  4@testable import OrgPresentation
  5
  6/// Drawer and block folding against Emacs: the visible text after opening a file and after
  7/// each TAB or Shift-TAB, with Doom's `org-startup-folded` nil.
  8struct WrapperFoldTests {
  9    static let texts = [
 10        "* A\n:PROPERTIES:\n:ID: x\n:END:\nbody\n#+begin_src sh\necho\n#+end_src\n* B\n:LOGBOOK:\n- State \"DONE\" from \"TODO\"\n:END:\ntext\n",
 11        "#+STARTUP: hideblocks\n:PROPERTIES:\n:CATEGORY: c\n:END:\n* A\n#+begin_quote\nq\n#+end_quote\n\n#+begin_example\ne\n#+end_example\n",
 12        "#+STARTUP: nohidedrawers\n* A\n:NOTES:\nn\n:END:\n#+begin_src sh\n#+end_src\n",
 13        "#+STARTUP: overview\n* A\n:PROPERTIES:\n:X: 1\n:END:\n** B\n:LOGBOOK:\nl\n:END:\n",
 14        "* A\n:DRAWER:\n#+begin_src sh\necho\n#+end_src\n:END:\n",
 15    ]
 16
 17    /// `T:text` presses TAB at the start of `text`; `G` presses Shift-TAB. Emacs cycles a heading
 18    /// by the previous command and Orgstar by its fold state; they agree on repeated TABs on
 19    /// one heading, so steps don't go back to a heading after other steps.
 20    static let steps = [
 21        ["T::PROPERTIES:", "T::PROPERTIES:", "T:#+begin_src", "T:#+end_src", "T:* A", "T:* A", "G", "G", "G", "G"],
 22        ["T::END:", "T:#+begin_quote", "T:#+end_quote", "T:q\n", "G", "G", "G"],
 23        ["T::NOTES:", "T::END:", "T:#+begin_src", "G", "G", "G"],
 24        ["T:* A", "T:* A", "T::LOGBOOK:", "G", "G", "G"],
 25        ["T::DRAWER:", "T::DRAWER:", "T:#+begin_src", "T::DRAWER:", "T::DRAWER:"],
 26    ]
 27
 28    static func visible(_ text: String, _ hidden: [Range<Int>]) -> String {
 29        let units = Array(text.utf16)
 30        var out: [UInt16] = []
 31        for (i, unit) in units.enumerated() where !hidden.contains(where: { $0.contains(i) }) { out.append(unit) }
 32        return String(decoding: out, as: UTF16.self)
 33    }
 34
 35    static func ours(_ text: String, _ steps: [String]) -> [String] {
 36        let tree = OrgParser.parse(text)
 37        var state = Presentation.startupFoldState(tree) ?? FoldState()
 38        state.wrapperFolds = Presentation.startupWrapperFolds(tree)
 39        var global: GlobalVisibility?
 40        func show() -> String {
 41            visible(text, Presentation.hiddenRanges(tree, folds: state.folds, bodyFolds: state.bodyFolds, wrapperFolds: state.wrapperFolds))
 42        }
 43        var out = [show()]
 44        for step in steps {
 45            if step == "G" {
 46                let (next, folds) = Presentation.cycleGlobal(from: global, in: tree, wrapperFolds: state.wrapperFolds)
 47                global = next
 48                state = folds
 49            } else {
 50                global = nil
 51                let offset = (text as NSString).range(of: String(step.dropFirst(2))).location
 52                state = Presentation.cycle(at: offset, in: tree, state) ?? state
 53            }
 54            out.append(show())
 55        }
 56        return out
 57    }
 58
 59    static func lisp(_ s: String) -> String {
 60        "\"" + s.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + "\""
 61    }
 62
 63    static func emacs(_ text: String, _ steps: [String]) throws -> [String] {
 64        let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-fold-\(UUID().uuidString)")
 65        try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
 66        defer { try? FileManager.default.removeItem(at: folder) }
 67        let file = folder.appendingPathComponent("f.org")
 68        try text.write(to: file, atomically: true, encoding: .utf8)
 69        let output = folder.appendingPathComponent("out.json")
 70        let actions = steps.enumerated().map { i, step in
 71            let repeated = i > 0 && steps[i - 1] == step
 72            return step == "G"
 73                ? "(progn (setq this-command 'org-shifttab) (org-cycle '(4)) (setq last-command 'org-shifttab))"
 74                : "(progn (setq last-command \(repeated ? "'org-cycle" : "nil") this-command 'org-cycle) (goto-char (point-min)) (search-forward \(lisp(String(step.dropFirst(2))))) (goto-char (match-beginning 0)) (org-cycle))"
 75        }
 76        let script = folder.appendingPathComponent("fold.el")
 77        try """
 78            ;;; -*- lexical-binding: t -*-
 79            (require 'org)
 80            (require 'json)
 81            (setq org-startup-folded nil)
 82            (defun vis () (let (s) (dotimes (i (1- (point-max))) (unless (invisible-p (1+ i)) (push (char-after (1+ i)) s))) (concat (nreverse s))))
 83            (with-current-buffer (find-file-noselect "\(file.path)")
 84              (let ((out (list (vis))))
 85                \(actions.map { "(progn \($0) (push (vis) out))" }.joined(separator: "\n    "))
 86                (with-temp-file (getenv "ORACLE_OUTPUT") (insert (json-encode (vconcat (nreverse out)))))))
 87            """.write(to: script, atomically: true, encoding: .utf8)
 88        let process = Process()
 89        process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
 90        process.arguments = [ProcessInfo.processInfo.environment["ORGSTAR_EMACS"] ?? "emacs", "-Q", "--batch", "-l", script.path]
 91        process.environment = ProcessInfo.processInfo.environment.merging(["ORACLE_OUTPUT": output.path]) { $1 }
 92        process.standardOutput = FileHandle.nullDevice
 93        process.standardError = FileHandle.nullDevice
 94        try process.run()
 95        process.waitUntilExit()
 96        return try JSONDecoder().decode([String].self, from: Data(contentsOf: output))
 97    }
 98
 99    @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
100    func foldsLikeOrg() throws {
101        for (text, steps) in zip(Self.texts, Self.steps) {
102            let emacs = try Self.emacs(text, steps)
103            let ours = Self.ours(text, steps)
104            for (i, (mine, theirs)) in zip(ours, emacs).enumerated() {
105                #expect(mine == theirs, "after \(i == 0 ? "opening" : steps[i - 1]) in \(text.debugDescription)\nours:  \(mine.debugDescription)\nemacs: \(theirs.debugDescription)")
106            }
107            #expect(ours.count == emacs.count)
108        }
109    }
110
111    @Test func togglesFromOpeningAndClosingLines() {
112        let text = "* A\n:PROPERTIES:\n:ID: x\n:END:\n\n"
113        let tree = OrgParser.parse(text)
114        #expect(Wrappers.toggleable(at: 4, in: tree)?.start == 4)
115        #expect(Wrappers.toggleable(at: 18, in: tree) == nil)
116        #expect(Wrappers.toggleable(at: 25, in: tree)?.hidden == 17..<30)
117        #expect(Wrappers.toggleable(at: 30, in: tree) == nil)
118    }
119}