krz/orgstar

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

Tests/OrgEditorAppKitTests/ModalTests.swift

348 lines · 13690 bytes

26 symbols in this file
  1import AppKit
  2import OrgCore
  3import OrgPresentation
  4import Testing
  5@testable import OrgEditorAppKit
  6
  7/// The Doom preset in the editor: keys through the keymap and the modal engine.
  8@MainActor
  9struct ModalTests {
 10    func doom(_ text: String) -> Harness {
 11        let h = Harness(text)
 12        h.editor.keymap = KeymapPreset.doom.keymap
 13        h.editor.modal = true
 14        return h
 15    }
 16
 17    /// Types `keys` as key events: letters, `ESC`, `RET`, `TAB`, `SPC`.
 18    func type(_ h: Harness, _ keys: String) {
 19        let codes: [String: UInt16] = ["ESC": 53, "RET": 36, "TAB": 48, "SPC": 49]
 20        for token in keys.split(separator: " ") {
 21            let key = String(token)
 22            if key.hasPrefix("C-") {
 23                let letter = String(key.dropFirst(2))
 24                let named = codes[letter]
 25                let characters = named == nil ? letter : " "
 26                KeyTests().send(h, characters: characters, ignoring: characters, code: named ?? 0, flags: .control)
 27            } else if let code = codes[key] {
 28                let characters = key == "ESC" ? "\u{1b}" : key == "RET" ? "\r" : key == "TAB" ? "\t" : " "
 29                KeyTests().send(h, characters: characters, ignoring: characters, code: code)
 30            } else {
 31                let shifted = key.first!.isUppercase
 32                KeyTests().send(h, characters: key, ignoring: key, code: 0, flags: shifted ? .shift : [])
 33            }
 34        }
 35    }
 36
 37    @Test func normalStateEditsAndInsertTypes() {
 38        let h = doom("hello world\nsecond\n")
 39        h.caret(at: 0)
 40        type(h, "d w")
 41        #expect(h.editor.document.text == "world\nsecond\n")
 42        type(h, "c i w")
 43        #expect(h.editor.vim.mode == .insert)
 44        h.textView.insertText("planet", replacementRange: h.textView.selectedRange())
 45        type(h, "ESC")
 46        #expect(h.editor.document.text == "planet\nsecond\n")
 47        #expect(h.textView.selectedRange().location == 5)
 48        type(h, "j .")
 49        #expect(h.editor.document.text == "planet\nplanet\n")
 50    }
 51
 52    @Test func undo() {
 53        let h = doom("hello world\n")
 54        h.caret(at: 0)
 55        type(h, "d w")
 56        #expect(h.editor.document.text == "world\n")
 57        type(h, "u")
 58        #expect(h.editor.document.text == "hello world\n")
 59    }
 60
 61    @Test func leaderAndOrgKeys() {
 62        let h = doom("* TODO Task\n")
 63        var app: [String] = []
 64        h.editor.onAppCommand = { app.append($0) }
 65        h.caret(at: 2)
 66        type(h, "SPC f s")
 67        #expect(app == ["app.save"])
 68        type(h, "RET")
 69        #expect(h.editor.document.text == "* DONE Task\n")
 70        type(h, "SPC m t")
 71        #expect(h.editor.document.text == "* KILL Task\n" || h.editor.document.text.hasPrefix("* "))
 72        type(h, "TAB")
 73        #expect(h.editor.vim.mode == .normal)
 74    }
 75
 76    @Test func registersAndMacrosAreShared() {
 77        let a = doom("alpha beta\n")
 78        let b = doom("one\ntwo\n")
 79        a.caret(at: 0)
 80        type(a, "\" r y w")
 81        b.caret(at: 0)
 82        type(b, "\" r P")
 83        #expect(b.editor.document.text == "alpha one\ntwo\n")
 84        type(a, "q m A ! ESC q")
 85        type(b, "j @ m")
 86        #expect(b.editor.document.text == "alpha one\ntwo!\n")
 87    }
 88
 89    @Test func uppercaseMarksAreGlobal() {
 90        let a = doom("first\n  second\n")
 91        let b = doom("other\ntext\n")
 92        var jumps: [(OrgEditor, Int)] = []
 93        b.editor.onJumpToEditor = { jumps.append(($0, $1)) }
 94        a.caret(at: 10)
 95        type(a, "m Q m q")
 96        b.caret(at: 0)
 97        type(b, "' Q")
 98        #expect(jumps.count == 1 && jumps.first?.0 === a.editor && jumps.first?.1 == 8)
 99        type(b, "` Q")
100        #expect(jumps.last?.1 == 10)
101        // Lowercase marks stay in their buffer.
102        type(b, "j ' q")
103        #expect(b.textView.selectedRange().location == 6)
104        // In its own buffer the mark is an ordinary jump, and follows edits.
105        type(a, "g g O")
106        a.textView.insertText("new", replacementRange: a.textView.selectedRange())
107        type(a, "ESC ` Q")
108        #expect(a.textView.selectedRange().location == 14)
109    }
110
111    @Test func foldKeysToggleOpenAndClose() {
112        let h = doom("* A\nbody\n** B\nb body\n* C\n")
113        h.caret(at: 0)
114        type(h, "z c")
115        #expect(h.editor.foldState.folds == [0])
116        type(h, "z o")
117        #expect(h.editor.foldState.folds == [9])
118        type(h, "z o")
119        #expect(h.editor.foldState.folds == [9])
120        type(h, "z a")
121        #expect(h.editor.foldState.folds.contains(0))
122        type(h, "z a")
123        #expect(h.editor.foldState.folds == [9])
124        // za and zo act on a heading line; zc closes the subtree around the caret.
125        h.editor.setFolds(FoldState())
126        h.caret(at: 5)
127        type(h, "z a")
128        type(h, "z o")
129        #expect(h.editor.foldState.folds.isEmpty)
130        h.caret(at: 15)
131        type(h, "z c")
132        #expect(h.editor.foldState.folds == [9])
133    }
134
135    @Test func fillOperatorsInTheEditor() {
136        let h = doom("short\nlines\n\nnext\n")
137        h.caret(at: 7)
138        type(h, "g w a p")
139        #expect(h.editor.document.text == "short lines\n\nnext\n")
140        #expect(h.textView.selectedRange().location == 7)
141        type(h, "u")
142        #expect(h.editor.document.text == "short\nlines\n\nnext\n")
143    }
144
145    /// M-q in visual states fills the region's paragraphs and leaves visual state, as in
146    /// Doom (checked against evil with `org-fill-paragraph`).
147    @Test func fillParagraphFillsTheVisualRegion() {
148        let h = doom("aaa\nbbb\n\nccc\nddd\n\neee\nfff\n")
149        h.caret(at: 0)
150        type(h, "v j j j")
151        KeyTests().press(h, "q", option: KeyEvents.leftOption, typed: "œ")
152        #expect(h.editor.document.text == "aaa bbb\n\nccc ddd\n\neee\nfff\n")
153        #expect(h.textView.selectedRange() == NSRange(location: 9, length: 0))
154        #expect(h.editor.vim.mode == .normal)
155        let lines = doom("aaa\nbbb\nccc\n\nx\ny\n")
156        lines.caret(at: 8)
157        type(lines, "v k")
158        KeyTests().press(lines, "q", option: KeyEvents.leftOption, typed: "œ")
159        #expect(lines.editor.document.text == "aaa bbb ccc\n\nx\ny\n")
160        #expect(lines.textView.selectedRange() == NSRange(location: 4, length: 0))
161        #expect(lines.editor.vim.mode == .normal)
162    }
163
164    /// Evil turns `transient-mark-mode` off in visual block state, so M-q there fills the
165    /// paragraph at the cursor (checked against evil with `org-fill-paragraph`).
166    @Test func fillParagraphInVisualBlockFillsAtTheCursor() {
167        let h = doom("aaa\nbbb\n\nccc\nddd\n\neee\nfff\n")
168        h.caret(at: 0)
169        type(h, "C-v j j j")
170        #expect(h.textView.selectedRanges.first?.rangeValue == NSRange(location: 0, length: 1) && h.textView.selectedRanges.last?.rangeValue == NSRange(location: 9, length: 1))
171        KeyTests().press(h, "q", option: KeyEvents.leftOption, typed: "œ")
172        #expect(h.editor.document.text == "aaa\nbbb\n\nccc ddd\n\neee\nfff\n")
173        #expect(h.textView.selectedRange() == NSRange(location: 9, length: 0))
174        #expect(h.editor.vim.mode == .normal)
175        let up = doom("aaa\nbbb\nccc\n\nx\ny\n")
176        up.caret(at: 8)
177        type(up, "C-v k")
178        KeyTests().press(up, "q", option: KeyEvents.leftOption, typed: "œ")
179        #expect(up.editor.document.text == "aaa bbb ccc\n\nx\ny\n")
180        #expect(up.textView.selectedRange() == NSRange(location: 4, length: 0))
181        #expect(up.editor.vim.mode == .normal)
182        // Nothing to fill: the block stays.
183        let filled = doom("aaa bbb\n\nccc\n")
184        filled.caret(at: 0)
185        type(filled, "C-v l")
186        let block = filled.textView.selectedRanges
187        KeyTests().press(filled, "q", option: KeyEvents.leftOption, typed: "œ")
188        #expect(filled.editor.document.text == "aaa bbb\n\nccc\n")
189        #expect(filled.textView.selectedRanges == block)
190        #expect(filled.editor.vim.mode == .visualBlock)
191    }
192
193    @Test func zzSavesAndClosesAsWq() {
194        let h = doom("text\n")
195        var app: [String] = []
196        h.editor.onAppCommand = { app.append($0) }
197        h.caret(at: 0)
198        type(h, "Z Q")
199        #expect(app.isEmpty)
200        type(h, "Z Z")
201        #expect(app == ["app.save"])
202    }
203
204    @Test func windowLinesInTheEditor() {
205        let h = doom("one\ntwo\nthree\nfour\nfive\n")
206        h.caret(at: 0)
207        type(h, "L")
208        #expect(h.textView.selectedRange().location == 19)
209        type(h, "M")
210        #expect(h.textView.selectedRange().location == 8)
211        type(h, "H")
212        #expect(h.textView.selectedRange().location == 0)
213    }
214
215    @Test func undefinedLeaderSequencesDoNothing() {
216        let h = doom("* Task\nbody text\n")
217        var messages: [String] = []
218        h.editor.onMessage = { messages.append($0) }
219        h.caret(at: 0)
220        type(h, "SPC m z")
221        #expect(messages.last == "SPC m z is undefined")
222        #expect(h.textView.selectedRange().location == 0)
223        // No mark z was set: a jump to it fails and the caret stays.
224        type(h, "j ' z")
225        #expect(h.textView.selectedRange().location == 7)
226        type(h, "C-c z")
227        #expect(messages.last == "C-c z is undefined")
228        #expect(h.editor.document.text == "* Task\nbody text\n")
229        // Evil's own prefixes still reach it.
230        type(h, "g g")
231        #expect(h.textView.selectedRange().location == 0)
232    }
233
234    @Test func keyHintsFollowContext() {
235        let h = doom("* a\n#+begin_src sh\necho\n#+end_src\n")
236        var hints: [(key: String, title: String)] = []
237        h.editor.onKeysPending = { _, next in if !next.isEmpty { hints = next } }
238        func hint() -> String? { hints.first { $0.key == "C-c" }?.title }
239        h.caret(at: 1)
240        type(h, "C-c")
241        #expect(hint() != nil && hint() != "Run Source Block")
242        type(h, "C-g")
243        h.caret(at: 20)
244        type(h, "C-c")
245        #expect(hint() == "Run Source Block")
246        type(h, "C-g")
247    }
248
249    @Test func visualSelection() {
250        let h = doom("one two three\n")
251        h.caret(at: 0)
252        type(h, "v e")
253        #expect(h.textView.selectedRange() == NSRange(location: 0, length: 3))
254        type(h, "d")
255        #expect(h.editor.document.text == " two three\n")
256        #expect(h.textView.selectedRange().length == 0)
257    }
258
259    func shiftTab(_ h: Harness) {
260        KeyTests().send(h, characters: "\u{19}", ignoring: "\u{19}", code: 48, flags: .shift)
261    }
262
263    @Test func insertStateTabFollowsDoom() {
264        let h = doom("* Top\n** Sub\n- one\n- item\n  - child\nbody\n")
265        h.caret(at: 8)
266        type(h, "i")
267        #expect(h.editor.vim.mode == .insert)
268        shiftTab(h)
269        #expect(h.editor.document.text.hasPrefix("* Top\n* Sub\n"))
270        type(h, "TAB")
271        #expect(h.editor.document.text.hasPrefix("* Top\n** Sub\n"))
272        let item = (h.editor.document.text as NSString).range(of: "- item").location
273        h.caret(at: item + 3)
274        type(h, "TAB")
275        #expect(h.editor.document.text.contains("- one\n  - item\n    - child\n"))
276        #expect(h.editor.vim.mode == .insert)
277    }
278
279    @Test func shiftTabCyclesInNormalState() {
280        let h = doom("* Top\nbody\n")
281        h.caret(at: 7)
282        let before = h.editor.document.text
283        shiftTab(h)
284        #expect(h.editor.document.text == before)
285        #expect(h.editor.vim.mode == .normal)
286    }
287
288    @Test func reportsStateAndQuits() {
289        let h = doom("text\n")
290        var states: [String?] = []
291        var app: [String] = []
292        h.editor.onModeChange = { states.append($0) }
293        h.editor.onAppCommand = { app.append($0) }
294        h.caret(at: 0)
295        type(h, "i")
296        type(h, "ESC")
297        type(h, "V")
298        type(h, "ESC")
299        type(h, "SPC q q")
300        #expect(states == ["insert", "normal", "visual-line", "normal"])
301        #expect(app == ["app.quit"])
302        h.editor.modal = false
303        #expect(states.last == .some(nil))
304    }
305
306    @Test func macrosReplayKeys() {
307        let h = doom("one two\nthree four\nfive six\nseven\n")
308        h.caret(at: 0)
309        type(h, "q a d w j q")
310        #expect(h.editor.document.text == "two\nthree four\nfive six\nseven\n")
311        type(h, "@ a")
312        #expect(h.editor.document.text == "two\nfour\nfive six\nseven\n")
313        type(h, "@ @")
314        #expect(h.editor.document.text == "two\nfour\nsix\nseven\n")
315        type(h, "g g q b A ! ESC j q 2 @ b")
316        #expect(h.editor.document.text == "two!\nfour!\nsix!\nseven\n", "\(h.editor.document.text.debugDescription)")
317    }
318
319    @Test func jumpsAndMarks() {
320        let h = doom("one\ntwo\nthree\nfour\n")
321        h.caret(at: 4)
322        type(h, "G")
323        #expect(h.textView.selectedRange().location == 19)
324        type(h, "C-o")
325        #expect(h.textView.selectedRange().location == 4)
326        type(h, "C-i")
327        #expect(h.textView.selectedRange().location == 19)
328        type(h, "g g m a G ' a")
329        #expect(h.textView.selectedRange().location == 0)
330        // Marks follow edits above them.
331        type(h, "G m b g g O")
332        h.textView.insertText("new", replacementRange: h.textView.selectedRange())
333        type(h, "ESC ` b")
334        #expect(h.textView.selectedRange().location == 23)
335    }
336}
337
338struct PresetCommandTests {
339    /// Every command a preset binds is one the editor runs, and every mode is a state.
340    @Test func presetsBindKnownCommands() throws {
341        let known = Set(EditorCommands.all.map(\.id))
342        for preset in KeymapPreset.allCases {
343            var problems: [String] = []
344            _ = try Keymap(toml: preset.toml, problems: &problems, commands: known)
345            #expect(problems.isEmpty, "\(preset): \(problems)")
346        }
347    }
348}