Tests/OrgCoreTests/HeadingCommandTests.swift
179 lines · 9840 bytes
1import Foundation
2import Testing
3@testable import OrgCore
4
5struct HeadingCommandTests {
6 @Test func todoCycle() {
7 let cycle = TodoCycle()
8 #expect(runCommand(cycle, "* a\n", caret: 2).text == "* TODO a\n")
9 #expect(runCommand(cycle, "* TODO a\n", caret: 2).text == "* DONE a\n")
10 #expect(runCommand(cycle, "* DONE a\n", caret: 2).text == "* a\n")
11 #expect(runCommand(cycle, "* TODO\n", caret: 2).text == "* DONE \n")
12 #expect(runCommand(cycle, "#+TODO: A B | C\n* B x\n", caret: 18).text == "#+TODO: A B | C\n* C x\n")
13 #expect(runCommand(cycle, "text\n", caret: 1).failure != nil)
14 }
15
16 /// `#+TYP_TODO`: a first C-c C-t goes to the done keyword, a repeated one to the next type.
17 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
18 func typeKeywordsMatchEmacs() throws {
19 let texts = [
20 "#+TYP_TODO: Fred Sara Lucy | DONE\n* Fred a\n", "#+TYP_TODO: Fred Sara Lucy | DONE\n* Sara a\n",
21 "#+TYP_TODO: Fred Sara Lucy | DONE\n* Lucy a\n", "#+TYP_TODO: Fred Sara Lucy | DONE\n* DONE a\n",
22 "#+TYP_TODO: Fred Sara | DONE GONE\n* Sara a\n", "#+TYP_TODO: Fred Sara | DONE GONE\n* DONE a\n",
23 "#+TYP_TODO: Fred Sara | DONE\n* a\n", "#+TODO: TODO | DONE\n#+TYP_TODO: Fred Sara | OK\n* Fred a\n",
24 ]
25 var cases: [EmacsOracle.Case] = []
26 var ours: [String] = []
27 for text in texts {
28 let caret = (text as NSString).range(of: "\n*").location + 2
29 for repeated in [false, true] {
30 let last = repeated ? "'org-todo" : "'other"
31 cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(caret, in: text), form: "(let ((this-command 'org-todo) (last-command \(last))) (org-todo))"))
32 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret], lastCommand: repeated ? TodoCycle().id : "org.other")
33 guard case .commit(let result) = TodoCycle().run(in: context) else { ours.append("failed"); continue }
34 ours.append(result.edits.sorted { $0.range.lowerBound > $1.range.lowerBound }.reduce(text) { $1.apply(to: $0) })
35 }
36 }
37 let emacs = try EmacsOracle.run(cases)
38 #expect(emacs.count == ours.count)
39 for (mine, emacs) in zip(ours, emacs) {
40 #expect(mine == emacs.text)
41 }
42 #expect(ours[0].hasSuffix("* DONE a\n") && ours[1].hasSuffix("* Sara a\n"))
43 }
44
45 @Test func caretMovesToTheTitleWhenBeforeIt() {
46 #expect(runCommand(TodoCycle(), "* a\n", caret: 2).caret == 7)
47 #expect(runCommand(TodoCycle(), "* TODO abc\nbody\n", caret: 13).caret == 13)
48 }
49
50 @Test func priorities() {
51 #expect(runCommand(PriorityUp(), "* a\n", caret: 2).text == "* [#B] a\n")
52 #expect(runCommand(PriorityUp(), "* [#A] a\n", caret: 2).text == "* a\n")
53 #expect(runCommand(PriorityDown(), "* TODO a\n", caret: 2).text == "* TODO [#B] a\n")
54 #expect(runCommand(PriorityDown(), "* [#C] a\n", caret: 2).text == "* a\n")
55 }
56
57 @Test func promoteAndDemote() {
58 #expect(runCommand(DemoteHeading(), "* a\nbody\n", caret: 2).text == "** a\nbody\n")
59 #expect(runCommand(PromoteHeading(), "** a\n", caret: 3).text == "* a\n")
60 #expect(runCommand(PromoteHeading(), "* a\n", caret: 0).failure != nil)
61 #expect(runCommand(DemoteHeading(), "* a\nbody\n", caret: 6).failure != nil)
62 }
63
64 @Test func tagsAlignToColumn77() {
65 let result = runCommand(PriorityUp(), "* TODO a :t:\n", caret: 2).text
66 #expect(result == "* TODO [#B] a" + String(repeating: " ", count: 77 - 13 - 3) + ":t:\n")
67 }
68}
69
70/// Every heading variant, every caret position, every heading command, against Emacs.
71/// Fails when Emacs is missing unless `ORGSTAR_SKIP_ORACLE` is set.
72struct HeadingOracleTests {
73 static let variants = [
74 "* a\nbody\n", "* TODO a\n", "* DONE a\n", "** [#B] two words :tag:\n", "* TODO [#A] x :a:b:\nbody\n",
75 "* \n", "* TODO\n", "*** title with 日本 :t:\n", "* a😀 b\n", "* a :t:\n", "text\n* a\n", "* a\n",
76 "* TODO a\n", "* a\r\n",
77 "#+TODO: NEXT WAIT | DONE CANCELED\n* WAIT a :t:\n",
78 "* " + String(repeating: "long ", count: 16) + "title :t:\n",
79 "* TODO\ttab\n",
80 "* P [1/2]\n** TODO a\n** DONE b\n",
81 "* P [50%] :t:\n** TODO a\n*** TODO deep\n** b\n",
82 ]
83
84 /// Commands, their Emacs forms, and whether they only act on heading lines.
85 static let commands: [(command: any OrgCommand, form: String, headingLineOnly: Bool)] = [
86 (TodoCycle(), "(org-todo)", false),
87 (PriorityUp(), "(org-priority-up)", true),
88 (PriorityDown(), "(org-priority-down)", true),
89 (PromoteHeading(), "(org-do-promote)", true),
90 (DemoteHeading(), "(org-do-demote)", true),
91 ]
92
93 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
94 func headingCommandsMatchEmacs() throws {
95 try compareWithEmacs(Self.variants.map { ($0, EmacsOracle.positions($0)) }, Self.commands)
96 }
97
98 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
99 func headingCommandsMatchEmacsWithDoomOptions() throws {
100 try compareWithEmacs(Self.variants.map { ($0, EmacsOracle.positions($0)) }, Self.commands, options: doomOptions)
101 }
102
103 /// Real headings: `ORGSTAR_ORACLE_CORPUS=<folder>` runs every command on up to 400 heading
104 /// lines from the folder's files, with the caret at the line start, the title start and
105 /// the line end.
106 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_ORACLE_CORPUS"] != nil))
107 func headingCommandsMatchEmacsOnACorpus() throws {
108 let root = URL(fileURLWithPath: ProcessInfo.processInfo.environment["ORGSTAR_ORACLE_CORPUS"]!)
109 var samples: [(String, [Int])] = []
110 let files = FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil)!
111 .compactMap { $0 as? URL }.filter { $0.pathExtension == "org" }.sorted { $0.path < $1.path }
112 for file in files where samples.count < 400 {
113 guard let text = try? String(contentsOf: file, encoding: .utf8) else { continue }
114 var settings = ""
115 for line in text.components(separatedBy: "\n") where line.uppercased().hasPrefix("#+TODO:") || line.uppercased().hasPrefix("#+SEQ_TODO:") {
116 settings += line + "\n"
117 }
118 for line in text.components(separatedBy: "\n") where line.hasPrefix("*") && samples.count < 400 {
119 let sample = settings + line + "\n"
120 guard let heading = entryHeading(at: (sample as NSString).length - 1, in: OrgParser.parse(sample)) else { continue }
121 let parts = HeadingLine(heading)
122 samples.append((sample, [parts.start, parts.titleStart, parts.contentEnd]))
123 }
124 }
125 try compareWithEmacs(samples, Self.commands)
126 }
127}
128
129/// Runs every command at every caret of every input here and in Emacs, and records each
130/// difference in text, caret or failure.
131func compareWithEmacs(_ inputs: [(text: String, carets: [Int])], _ commands: [(command: any OrgCommand, form: String, headingLineOnly: Bool)], options: EditingOptions = .org) throws {
132 try #require(EmacsOracle.isAvailable, "Emacs is required for the oracle tests; set ORGSTAR_SKIP_ORACLE to skip")
133 var cases: [EmacsOracle.Case] = []
134 var ours: [(label: String, text: String, caret: Int, failed: Bool)] = []
135 for (text, carets) in inputs {
136 for offset in carets {
137 let onHeadingLine = headingOnLine(at: offset, in: OrgParser.parse(text)) != nil
138 for (command, form, headingLineOnly) in commands where onHeadingLine || !headingLineOnly {
139 cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(offset, in: text), form: withOptions(options, form)))
140 let result = runCommand(command, text, caret: offset, options: options)
141 ours.append(("\(command.id) at \(offset) in \(text.debugDescription)", result.text, result.caret, result.failure != nil))
142 }
143 }
144 }
145 let theirs = try EmacsOracle.run(cases)
146 var mismatches = 0
147 for (mine, emacs) in zip(ours, theirs) {
148 let emacsFailed = !emacs.error.isEmpty
149 let emacsCaret = EmacsOracle.offset(emacs.point, in: emacs.text)
150 let same = mine.failed == emacsFailed && (emacsFailed || (mine.text == emacs.text && mine.caret == emacsCaret))
151 if !same {
152 mismatches += 1
153 if mismatches <= 15 {
154 let (a, b) = excerpts(mine.text, emacs.text)
155 Issue.record("""
156 \(mine.label.prefix(100))
157 ours: \(mine.failed ? "failed" : "\(a.debugDescription) @\(mine.caret)")
158 emacs: \(emacsFailed ? "failed: \(emacs.error)" : "\(b.debugDescription) @\(emacsCaret)")
159 """)
160 }
161 }
162 }
163 #expect(mismatches == 0, "\(mismatches) of \(ours.count) cases differ from Emacs")
164}
165
166/// The parts of two texts around their first difference.
167func excerpts(_ a: String, _ b: String) -> (String, String) {
168 let x = Array(a.utf16), y = Array(b.utf16)
169 var prefix = 0
170 while prefix < min(x.count, y.count), x[prefix] == y[prefix] { prefix += 1 }
171 var suffix = 0
172 while suffix < min(x.count, y.count) - prefix, x[x.count - 1 - suffix] == y[y.count - 1 - suffix] { suffix += 1 }
173 let start = max(0, prefix - 40)
174 func cut(_ s: [UInt16]) -> String {
175 let end = min(s.count, s.count - suffix + 40)
176 return "…" + String(decoding: s[start..<max(start, end)], as: UTF16.self) + "…"
177 }
178 return (cut(x), cut(y))
179}