krz/orgstar

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

Tests/OrgEditorAppKitTests/ModalTests.swift

778058aafc7ddacee4a613b1da87c60aa0c8e113
orgstar/Tests/OrgEditorAppKitTests/ModalTests.swift history · blame · raw

319 lines · 12048 bytes

25 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    @Test func zzSavesAndClosesAsWq() {
165        let h = doom("text\n")
166        var app: [String] = []
167        h.editor.onAppCommand = { app.append($0) }
168        h.caret(at: 0)
169        type(h, "Z Q")
170        #expect(app.isEmpty)
171        type(h, "Z Z")
172        #expect(app == ["app.save"])
173    }
174
175    @Test func windowLinesInTheEditor() {
176        let h = doom("one\ntwo\nthree\nfour\nfive\n")
177        h.caret(at: 0)
178        type(h, "L")
179        #expect(h.textView.selectedRange().location == 19)
180        type(h, "M")
181        #expect(h.textView.selectedRange().location == 8)
182        type(h, "H")
183        #expect(h.textView.selectedRange().location == 0)
184    }
185
186    @Test func undefinedLeaderSequencesDoNothing() {
187        let h = doom("* Task\nbody text\n")
188        var messages: [String] = []
189        h.editor.onMessage = { messages.append($0) }
190        h.caret(at: 0)
191        type(h, "SPC m z")
192        #expect(messages.last == "SPC m z is undefined")
193        #expect(h.textView.selectedRange().location == 0)
194        // No mark z was set: a jump to it fails and the caret stays.
195        type(h, "j ' z")
196        #expect(h.textView.selectedRange().location == 7)
197        type(h, "C-c z")
198        #expect(messages.last == "C-c z is undefined")
199        #expect(h.editor.document.text == "* Task\nbody text\n")
200        // Evil's own prefixes still reach it.
201        type(h, "g g")
202        #expect(h.textView.selectedRange().location == 0)
203    }
204
205    @Test func keyHintsFollowContext() {
206        let h = doom("* a\n#+begin_src sh\necho\n#+end_src\n")
207        var hints: [(key: String, title: String)] = []
208        h.editor.onKeysPending = { _, next in if !next.isEmpty { hints = next } }
209        func hint() -> String? { hints.first { $0.key == "C-c" }?.title }
210        h.caret(at: 1)
211        type(h, "C-c")
212        #expect(hint() != nil && hint() != "Run Source Block")
213        type(h, "C-g")
214        h.caret(at: 20)
215        type(h, "C-c")
216        #expect(hint() == "Run Source Block")
217        type(h, "C-g")
218    }
219
220    @Test func visualSelection() {
221        let h = doom("one two three\n")
222        h.caret(at: 0)
223        type(h, "v e")
224        #expect(h.textView.selectedRange() == NSRange(location: 0, length: 3))
225        type(h, "d")
226        #expect(h.editor.document.text == " two three\n")
227        #expect(h.textView.selectedRange().length == 0)
228    }
229
230    func shiftTab(_ h: Harness) {
231        KeyTests().send(h, characters: "\u{19}", ignoring: "\u{19}", code: 48, flags: .shift)
232    }
233
234    @Test func insertStateTabFollowsDoom() {
235        let h = doom("* Top\n** Sub\n- one\n- item\n  - child\nbody\n")
236        h.caret(at: 8)
237        type(h, "i")
238        #expect(h.editor.vim.mode == .insert)
239        shiftTab(h)
240        #expect(h.editor.document.text.hasPrefix("* Top\n* Sub\n"))
241        type(h, "TAB")
242        #expect(h.editor.document.text.hasPrefix("* Top\n** Sub\n"))
243        let item = (h.editor.document.text as NSString).range(of: "- item").location
244        h.caret(at: item + 3)
245        type(h, "TAB")
246        #expect(h.editor.document.text.contains("- one\n  - item\n    - child\n"))
247        #expect(h.editor.vim.mode == .insert)
248    }
249
250    @Test func shiftTabCyclesInNormalState() {
251        let h = doom("* Top\nbody\n")
252        h.caret(at: 7)
253        let before = h.editor.document.text
254        shiftTab(h)
255        #expect(h.editor.document.text == before)
256        #expect(h.editor.vim.mode == .normal)
257    }
258
259    @Test func reportsStateAndQuits() {
260        let h = doom("text\n")
261        var states: [String?] = []
262        var app: [String] = []
263        h.editor.onModeChange = { states.append($0) }
264        h.editor.onAppCommand = { app.append($0) }
265        h.caret(at: 0)
266        type(h, "i")
267        type(h, "ESC")
268        type(h, "V")
269        type(h, "ESC")
270        type(h, "SPC q q")
271        #expect(states == ["insert", "normal", "visual-line", "normal"])
272        #expect(app == ["app.quit"])
273        h.editor.modal = false
274        #expect(states.last == .some(nil))
275    }
276
277    @Test func macrosReplayKeys() {
278        let h = doom("one two\nthree four\nfive six\nseven\n")
279        h.caret(at: 0)
280        type(h, "q a d w j q")
281        #expect(h.editor.document.text == "two\nthree four\nfive six\nseven\n")
282        type(h, "@ a")
283        #expect(h.editor.document.text == "two\nfour\nfive six\nseven\n")
284        type(h, "@ @")
285        #expect(h.editor.document.text == "two\nfour\nsix\nseven\n")
286        type(h, "g g q b A ! ESC j q 2 @ b")
287        #expect(h.editor.document.text == "two!\nfour!\nsix!\nseven\n", "\(h.editor.document.text.debugDescription)")
288    }
289
290    @Test func jumpsAndMarks() {
291        let h = doom("one\ntwo\nthree\nfour\n")
292        h.caret(at: 4)
293        type(h, "G")
294        #expect(h.textView.selectedRange().location == 19)
295        type(h, "C-o")
296        #expect(h.textView.selectedRange().location == 4)
297        type(h, "C-i")
298        #expect(h.textView.selectedRange().location == 19)
299        type(h, "g g m a G ' a")
300        #expect(h.textView.selectedRange().location == 0)
301        // Marks follow edits above them.
302        type(h, "G m b g g O")
303        h.textView.insertText("new", replacementRange: h.textView.selectedRange())
304        type(h, "ESC ` b")
305        #expect(h.textView.selectedRange().location == 23)
306    }
307}
308
309struct PresetCommandTests {
310    /// Every command a preset binds is one the editor runs, and every mode is a state.
311    @Test func presetsBindKnownCommands() throws {
312        let known = Set(EditorCommands.all.map(\.id))
313        for preset in KeymapPreset.allCases {
314            var problems: [String] = []
315            _ = try Keymap(toml: preset.toml, problems: &problems, commands: known)
316            #expect(problems.isEmpty, "\(preset): \(problems)")
317        }
318    }
319}