Tests/OrgPresentationTests/WrapperFoldTests.swift
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}