krz/orgstar

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

Tests/OrgEditorAppKitTests/EditorTests.swift

15f6b0709d88971fb62ed432c3e5b8032643670a
orgstar/Tests/OrgEditorAppKitTests/EditorTests.swift history · blame · raw

465 lines · 20680 bytes

  1import AppKit
  2import OrgCore
  3import OrgDocument
  4import OrgPresentation
  5@testable import OrgApp
  6import Testing
  7@testable import OrgEditorAppKit
  8
  9/// An editor in an offscreen window, laid out.
 10@MainActor
 11final class Harness {
 12    let editor: OrgEditor
 13    let window: NSWindow
 14
 15    convenience init(_ text: String) {
 16        self.init(DocumentState(bytes: Array(text.utf8)))
 17        drainStyling()
 18        layout()
 19    }
 20
 21    /// The editor as a window would show it first: background styling not yet run.
 22    init(_ document: DocumentState, layout: Bool = true, mode: OrgEditor.Mode = .org) {
 23        _ = NSApplication.shared
 24        editor = OrgEditor(document: document, mode: mode)
 25        window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 600, height: 800), styleMask: [.titled], backing: .buffered, defer: false)
 26        window.contentView = editor.makeScrollView()
 27        window.makeFirstResponder(editor.textView)
 28        if layout { self.layout() }
 29    }
 30
 31    func drainStyling() {
 32        editor.finishStyling()
 33    }
 34
 35    var textView: NSTextView { editor.textView }
 36    var string: String { textView.string }
 37
 38    func layout() {
 39        let manager = editor.textLayoutManager
 40        manager.ensureLayout(for: manager.documentRange)
 41    }
 42
 43    /// Text of each laid-out paragraph.
 44    var visibleLines: [String] {
 45        layout()
 46        let manager = editor.textLayoutManager
 47        var lines: [String] = []
 48        manager.enumerateTextLayoutFragments(from: manager.documentRange.location, options: [.ensuresLayout]) { fragment in
 49            let content = manager.textContentManager!
 50            let start = content.offset(from: content.documentRange.location, to: fragment.rangeInElement.location)
 51            let end = content.offset(from: content.documentRange.location, to: fragment.rangeInElement.endLocation)
 52            lines.append((self.string as NSString).substring(with: NSRange(location: start, length: end - start)))
 53            return true
 54        }
 55        return lines.filter { !$0.isEmpty }
 56    }
 57
 58    func caret(at offset: Int) {
 59        textView.setSelectedRange(NSRange(location: offset, length: 0))
 60    }
 61
 62    var caret: Int { textView.selectedRange().location }
 63
 64    func offset(of line: String) -> Int {
 65        (string as NSString).range(of: line).location
 66    }
 67
 68    func checkInSync() {
 69        #expect(editor.document.text == string)
 70        #expect(editor.document.tree.green == OrgParser.parse(string).green)
 71    }
 72}
 73
 74let outline = "* a\nbody\n** b\nmore\n* c\ntext\n"
 75
 76@MainActor
 77struct FoldingTests {
 78    @Test func foldingHidesTheSubtree() {
 79        let h = Harness(outline)
 80        #expect(h.visibleLines == ["* a\n", "body\n", "** b\n", "more\n", "* c\n", "text\n"])
 81        h.caret(at: 1)
 82        #expect(h.editor.cycleAtCaret())
 83        #expect(h.visibleLines == ["* a\n", "* c\n", "text\n"])
 84        h.editor.cycleAtCaret()
 85        #expect(h.visibleLines == ["* a\n", "body\n", "** b\n", "* c\n", "text\n"])
 86        h.editor.cycleAtCaret()
 87        #expect(h.visibleLines.count == 6)
 88    }
 89
 90    @Test func globalCycleAndStartup() {
 91        let h = Harness(outline)
 92        h.editor.run("org.cycle-global")
 93        h.editor.run("org.cycle-global")
 94        #expect(h.visibleLines == ["* a\n", "** b\n", "* c\n"])
 95        h.editor.run("org.cycle-global")
 96        #expect(h.visibleLines.count == 6)
 97        #expect(Harness("#+STARTUP: overview\n" + outline).visibleLines == ["#+STARTUP: overview\n", "* a\n", "* c\n"])
 98    }
 99
100    @Test func revealUnfoldsWhatHidesTheTarget() {
101        let h = Harness("#+STARTUP: overview\n" + outline)
102        let target = h.offset(of: "more")
103        h.editor.reveal(target)
104        #expect(h.visibleLines.contains("more\n"))
105        #expect(h.caret == target)
106    }
107
108    @Test func readOnlyAndReloadKeepFolds() {
109        let editor = OrgEditor(document: DocumentState(bytes: Array(outline.utf8)), editable: false)
110        #expect(!editor.textView.isEditable)
111        editor.setFolds(FoldState(folds: [19]))
112        editor.diskChanged(to: Array(("new line\n" + outline).utf8))
113        #expect(editor.document.text == "new line\n" + outline)
114        #expect(editor.foldState.folds == [28])
115    }
116
117    @Test func tabFoldsOnlyOnHeadings() {
118        let h = Harness(outline)
119        h.caret(at: 5)
120        #expect(!h.editor.cycleAtCaret())
121        h.editor.run("org.cycle-global")
122        #expect(h.visibleLines == ["* a\n", "* c\n"])
123    }
124
125    @Test func caretMovesOverFoldedText() {
126        let h = Harness(outline)
127        h.caret(at: 1)
128        h.editor.cycleAtCaret()
129        h.caret(at: 0)
130        h.textView.moveDown(nil)
131        #expect(h.caret >= h.offset(of: "* c") && h.caret < h.offset(of: "text"))
132        h.caret(at: 3)
133        h.textView.moveRight(nil)
134        #expect(h.caret == h.offset(of: "* c"))
135        h.textView.moveLeft(nil)
136        #expect(h.caret == 3)
137    }
138
139    @Test func copyingAcrossAFoldIncludesHiddenText() {
140        let h = Harness(outline)
141        h.caret(at: 1)
142        h.editor.cycleAtCaret()
143        h.textView.setSelectedRange(NSRange(location: 0, length: h.offset(of: "text")))
144        let pasteboard = NSPasteboard(name: NSPasteboard.Name("orgstar-test-\(UUID().uuidString)"))
145        defer { pasteboard.releaseGlobally() }
146        #expect(h.textView.writeSelection(to: pasteboard, types: h.textView.writablePasteboardTypes))
147        #expect(pasteboard.string(forType: .string) == "* a\nbody\n** b\nmore\n* c\n")
148    }
149
150    @Test func opensWithTheCaretAtTheStart() {
151        let h = Harness("* a\nbody\n* b\n")
152        #expect(h.textView.selectedRange() == NSRange(location: 0, length: 0))
153    }
154
155    @Test func drawersFoldAtOpenAndToggleWithTab() {
156        let text = "* a\n:PROPERTIES:\n:ID: x\n:END:\nbody\n#+begin_src sh\necho\n#+end_src\n* c\n"
157        let h = Harness(text)
158        #expect(h.visibleLines == ["* a\n", ":PROPERTIES:\n", "body\n", "#+begin_src sh\n", "echo\n", "#+end_src\n", "* c\n"])
159        h.caret(at: h.offset(of: ":PROPERTIES:"))
160        #expect(h.editor.cycleAtCaret())
161        #expect(h.visibleLines.count == 9)
162        h.caret(at: h.offset(of: "#+end_src"))
163        #expect(h.editor.cycleAtCaret())
164        #expect(h.visibleLines == ["* a\n", ":PROPERTIES:\n", ":ID: x\n", ":END:\n", "body\n", "#+begin_src sh\n", "* c\n"])
165        #expect(h.textView.selectedRange().location == h.offset(of: "#+begin_src"))
166        // Heading cycling leaves the block folded.
167        h.caret(at: 1)
168        h.editor.cycleAtCaret()
169        h.editor.cycleAtCaret()
170        #expect(h.visibleLines.contains("#+begin_src sh\n") && !h.visibleLines.contains("echo\n"))
171        #expect(Harness("#+STARTUP: nohidedrawers\n" + text).visibleLines.contains(":ID: x\n"))
172        let folded = Harness(text)
173        folded.editor.reveal(folded.offset(of: ":ID: x") + 1)
174        #expect(folded.visibleLines.contains(":ID: x\n"))
175    }
176
177    @Test func drawerFoldsFollowEdits() {
178        let h = Harness("* a\n:PROPERTIES:\n:ID: x\n:END:\nbody\n* b\n:LOGBOOK:\nl\n:END:\n")
179        h.caret(at: h.offset(of: "body") + 4)
180        h.textView.insertText(" more", replacementRange: h.textView.selectedRange())
181        h.textView.insertNewline(nil)
182        h.caret(at: 2)
183        h.textView.insertText("new ", replacementRange: h.textView.selectedRange())
184        #expect(h.visibleLines == ["* new a\n", ":PROPERTIES:\n", "body more\n", "\n", "* b\n", ":LOGBOOK:\n"])
185        // Renaming the drawer's opening line keeps a drawer, and its fold.
186        h.caret(at: h.offset(of: ":LOGBOOK:") + 1)
187        h.textView.insertText("X", replacementRange: h.textView.selectedRange())
188        #expect(h.visibleLines.last == ":XLOGBOOK:\n")
189        h.checkInSync()
190    }
191
192    @Test func foldsFollowEdits() {
193        let h = Harness(outline)
194        h.caret(at: h.offset(of: "* c") + 1)
195        h.editor.cycleAtCaret()
196        h.caret(at: 0)
197        h.textView.insertText("new ", replacementRange: NSRange(location: 2, length: 0))
198        #expect(h.visibleLines == ["* new a\n", "body\n", "** b\n", "more\n", "* c\n"])
199        h.checkInSync()
200    }
201}
202
203@MainActor
204struct EditingTests {
205    @Test func typingKeepsTheDocumentInSync() {
206        let h = Harness(outline)
207        h.caret(at: h.offset(of: "body") + 4)
208        h.textView.insertText(" *bold*", replacementRange: h.textView.selectedRange())
209        h.textView.insertNewline(nil)
210        h.textView.insertText("* new heading", replacementRange: h.textView.selectedRange())
211        h.textView.deleteBackward(nil)
212        h.checkInSync()
213        #expect(h.editor.document.isDirty)
214    }
215
216    @Test func inputMethodComposition() {
217        let h = Harness("* a\n")
218        h.caret(at: 3)
219        h.textView.setMarkedText("か", selectedRange: NSRange(location: 1, length: 0), replacementRange: NSRange(location: NSNotFound, length: 0))
220        #expect(h.textView.hasMarkedText())
221        h.textView.setMarkedText("かん", selectedRange: NSRange(location: 2, length: 0), replacementRange: NSRange(location: NSNotFound, length: 0))
222        h.textView.insertText("漢", replacementRange: NSRange(location: NSNotFound, length: 0))
223        #expect(!h.textView.hasMarkedText())
224        #expect(h.string == "* a漢\n")
225        h.checkInSync()
226    }
227
228    @Test func undoKeepsTheDocumentInSync() {
229        let h = Harness(outline)
230        h.caret(at: 3)
231        h.textView.insertText("xyz", replacementRange: h.textView.selectedRange())
232        h.textView.breakUndoCoalescing()
233        h.textView.undoManager?.undo()
234        #expect(h.string == outline)
235        h.checkInSync()
236    }
237
238    @Test func styling() throws {
239        let h = Harness("** b\nbody\n")
240        let storage = try #require(h.textView.textStorage)
241        #expect(storage.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor == .clear)
242        let style = try #require(storage.attribute(.paragraphStyle, at: 6, effectiveRange: nil) as? NSParagraphStyle)
243        #expect(style.firstLineHeadIndent == 3 * h.editor.theme.column)
244        h.caret(at: 0)
245        h.textView.insertText("*", replacementRange: NSRange(location: 0, length: 0))
246        let deeper = try #require(storage.attribute(.paragraphStyle, at: 7, effectiveRange: nil) as? NSParagraphStyle)
247        #expect(deeper.firstLineHeadIndent == 4 * h.editor.theme.column)
248    }
249
250    @Test func accessibilityReadsTheWholeText() {
251        let h = Harness(outline)
252        h.caret(at: 1)
253        h.editor.cycleAtCaret()
254        #expect(h.textView.accessibilityValue() as? String == outline)
255        h.textView.setSelectedRange(NSRange(location: 0, length: 3))
256        #expect(h.textView.accessibilitySelectedText() == "* a")
257        #expect(h.textView.accessibilityLine(for: h.offset(of: "* c")) >= 1)
258    }
259
260    @Test func saveWritesAndClearsDirty() throws {
261        let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-\(UUID().uuidString)")
262        try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
263        defer { try? FileManager.default.removeItem(at: folder) }
264        let file = folder.appendingPathComponent("a.org")
265        try Data(outline.utf8).write(to: file)
266        let h = Harness(outline)
267        h.textView.insertText("x", replacementRange: NSRange(location: 0, length: 0))
268        let saver = Saver(fileSystem: CoordinatedFileSystem(), recovery: FileRecoveryStore(directory: folder.appendingPathComponent("r")))
269        #expect(try h.editor.save(using: saver, to: file) == .saved)
270        #expect(!h.editor.document.isDirty)
271        #expect(try String(contentsOf: file, encoding: .utf8) == "x" + outline)
272    }
273}
274
275@MainActor
276struct BufferTests {
277    @Test func updateMapsCaretAndFoldsAndClearsUndo() {
278        let h = Harness(outline)
279        var changes = 0
280        h.editor.onChange = { changes += 1 }
281        h.caret(at: 1)
282        h.textView.insertText("x", replacementRange: NSRange(location: 4, length: 0))
283        #expect(changes == 1)
284        RunLoop.current.run(until: Date())
285        #expect(h.textView.undoManager?.canUndo == true)
286        h.editor.setFolds(FoldState(folds: [h.offset(of: "** b")]))
287        h.caret(at: h.offset(of: "* c") + 2)
288        h.editor.update { $0 = DocumentState(bytes: Array(("new\n" + outline).utf8)) }
289        #expect(changes == 2)
290        #expect(h.string == "new\n" + outline)
291        #expect(h.caret == h.offset(of: "* c") + 2)
292        #expect(h.editor.foldState.folds == [h.offset(of: "** b")])
293        #expect(h.textView.undoManager?.canUndo != true)
294        h.checkInSync()
295    }
296
297    /// Each buffer's editor reports its own changes: a background buffer's edit saves its
298    /// file, not the shown one's.
299    @Test func backgroundEditorsSaveTheirOwnFiles() async throws {
300        let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-\(UUID().uuidString)")
301        try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
302        defer { try? FileManager.default.removeItem(at: folder) }
303        let a = folder.appendingPathComponent("a.org"), b = folder.appendingPathComponent("b.org")
304        try Data("* A\n".utf8).write(to: a)
305        try Data("* B\n".utf8).write(to: b)
306        let session = DocumentSession(recovery: FileRecoveryStore(directory: folder.appendingPathComponent("r")))
307        session.autosaveDelay = .milliseconds(10)
308        var harnesses: [String: Harness] = [:]
309        for file in [a, b] {
310            session.open(file.path)
311            let h = Harness(try #require(session.document))
312            let path = file.path
313            h.editor.onChange = { [weak session] in session?.bufferChanged(path) }
314            session.attach(h.editor)
315            harnesses[path] = h
316        }
317        #expect(session.path == b.path)
318        harnesses[a.path]!.textView.insertText("x", replacementRange: NSRange(location: 0, length: 0))
319        #expect(session.dirtyPaths == [a.path])
320        #expect(!session.isDirty)
321        for _ in 0..<50 where (try? String(contentsOf: a, encoding: .utf8)) != "x* A\n" {
322            try await Task.sleep(for: .milliseconds(20))
323        }
324        #expect(try String(contentsOf: a, encoding: .utf8) == "x* A\n")
325    }
326
327    /// The wiring the app uses: the editor is the session's buffer and reports typing.
328    @Test func sessionSavesWhatIsTyped() async throws {
329        let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-\(UUID().uuidString)")
330        try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
331        defer { try? FileManager.default.removeItem(at: folder) }
332        let file = folder.appendingPathComponent("a.org")
333        try Data(outline.utf8).write(to: file)
334        let session = DocumentSession(recovery: FileRecoveryStore(directory: folder.appendingPathComponent("r")))
335        session.autosaveDelay = .milliseconds(10)
336        session.open(file.path)
337        let h = Harness(try #require(session.document))
338        h.editor.onChange = { [weak session] in session?.bufferChanged() }
339        session.attach(h.editor)
340        h.textView.insertText("x", replacementRange: NSRange(location: 0, length: 0))
341        #expect(session.isDirty)
342        await session.autosave?.value
343        #expect(!session.isDirty)
344        #expect(try String(contentsOf: file, encoding: .utf8) == "x" + outline)
345
346        try Data(("x" + outline + "end\n").utf8).write(to: file)
347        session.fileChanged(file.path)
348        #expect(h.string == "x" + outline + "end\n")
349    }
350}
351
352@MainActor
353struct RestyleTests {
354    func indent(_ h: Harness, at line: String) -> CGFloat? {
355        let style = h.textView.textStorage?.attribute(.paragraphStyle, at: h.offset(of: line), effectiveRange: nil) as? NSParagraphStyle
356        return style.map { $0.firstLineHeadIndent / h.editor.theme.column }
357    }
358
359    @Test func newHeadingReindentsTheLinesItTakesOver() {
360        let h = Harness("* a\none\ntwo\nthree\n")
361        h.textView.insertText("** b\n", replacementRange: NSRange(location: h.offset(of: "two"), length: 0))
362        #expect(indent(h, at: "one") == 2)
363        #expect(indent(h, at: "three") == 3)
364        h.checkInSync()
365    }
366
367    @Test func joiningAwayAHeadingReindentsItsBody() {
368        let h = Harness("* a\none\n** b\ntwo\nthree\n")
369        h.textView.insertText("", replacementRange: NSRange(location: h.offset(of: "** b") - 1, length: 1))
370        #expect(indent(h, at: "three") == 2)
371        h.checkInSync()
372    }
373
374    @Test func typingASpaceAfterAStarMakesAHeading() {
375        let h = Harness("* a\n*x\nbody\nmore\n")
376        h.textView.insertText(" ", replacementRange: NSRange(location: h.offset(of: "*x") + 1, length: 0))
377        #expect(indent(h, at: "more") == 2)
378        h.checkInSync()
379    }
380}
381
382@MainActor
383struct RestyleFuzzTests {
384    /// After each random edit, the restyled text must equal a fresh editor's full styling. Set
385    /// `ORGSTAR_RESTYLE_EDITS` and `ORGSTAR_FUZZ_SEED` for longer runs.
386    @Test func incrementalRestyleEqualsFullRestyle() throws {
387        let environment = ProcessInfo.processInfo.environment
388        var state = UInt64(environment["ORGSTAR_FUZZ_SEED"] ?? "") ?? 20261006
389        let count = Int(environment["ORGSTAR_RESTYLE_EDITS"] ?? "") ?? 300
390        func next(_ bound: Int) -> Int {
391            state = state &* 6364136223846793005 &+ 1442695040888963407
392            return Int((state >> 33) % UInt64(bound))
393        }
394        let pieces = ["* ", "** ", "*", " ", "\n", "x", "*b*", "TODO ", "- ", "| a |", "#+begin_src\n", "#+end_src\n", ":PROPERTIES:\n", ":END:\n", "[[l][d]]", "<2026-10-04 Sun>"]
395        let h = Harness("* a\nbody *b*\n** b\n- item\nmore\n* c\ntext\n")
396        for n in 0..<count {
397            let length = (h.string as NSString).length
398            let start = next(length + 1)
399            let end = min(length, start + next(4))
400            let replacement = next(3) == 0 ? "" : pieces[next(pieces.count)]
401            h.textView.insertText(replacement, replacementRange: NSRange(location: start, length: end - start))
402            let fresh = Harness(h.string)
403            let same = h.textView.textStorage!.isEqual(to: fresh.textView.textStorage!)
404            #expect(same, "edit \(n): \(start)..<\(end) \(replacement.debugDescription) -> \(h.string.debugDescription)")
405            if !same { break }
406        }
407        h.checkInSync()
408    }
409}
410
411@MainActor
412struct InitialStylingTests {
413    @Test func stylesTheFirstScreenThenTheRest() throws {
414        let line = "* heading *bold*\nbody text\n"
415        let text = String(repeating: line, count: 4_000)
416        let h = Harness(DocumentState(bytes: Array(text.utf8)), layout: false)
417        let length = (text as NSString).length
418        #expect(h.editor.styledUpTo >= OrgEditor.firstStyleChunk && h.editor.styledUpTo < length)
419        h.textView.insertText("x", replacementRange: NSRange(location: length - 3, length: 0))
420        h.drainStyling()
421        #expect(h.editor.styledUpTo == length + 1)
422        #expect(h.textView.textStorage!.isEqual(to: Harness(h.string).textView.textStorage!))
423    }
424
425    @Test func editsBeforeTheWatermarkShiftIt() {
426        let text = String(repeating: "line of text\n", count: 5_000)
427        let h = Harness(DocumentState(bytes: Array(text.utf8)), layout: false)
428        let before = h.editor.styledUpTo
429        h.textView.insertText("abc", replacementRange: NSRange(location: 0, length: 0))
430        #expect(h.editor.styledUpTo == before + 3)
431    }
432}
433
434@MainActor
435struct CommandTests {
436    @Test func performEditsThroughTheTextView() {
437        let h = Harness("* a :t:\nbody\n")
438        var messages: [String] = []
439        h.editor.onMessage = { messages.append($0) }
440        h.caret(at: 2)
441        h.editor.perform(PriorityUp())
442        #expect(h.string.hasPrefix("* [#B] a "))
443        #expect(h.caret == 2)
444        h.checkInSync()
445        // Undo groups by event: let the run loop close the first command's group, as a key
446        // press would.
447        RunLoop.current.run(until: Date())
448        h.editor.perform(TodoCycle())
449        #expect(h.string.hasPrefix("* TODO [#B] a "))
450        RunLoop.current.run(until: Date())
451        h.textView.undoManager?.undo()
452        #expect(h.string.hasPrefix("* [#B] a "))
453        h.checkInSync()
454        h.caret(at: h.offset(of: "body"))
455        h.editor.perform(PromoteHeading())
456        #expect(messages == ["Not on a heading"])
457    }
458
459    @Test func readOnlyEditorsRefuseEdits() {
460        let editor = OrgEditor(document: DocumentState(bytes: Array("* a\n".utf8)), editable: false)
461        editor.textView.setSelectedRange(NSRange(location: 2, length: 0))
462        #expect(editor.perform(TodoCycle()) == .failed("read-only"))
463        #expect(editor.document.text == "* a\n")
464    }
465}