Tests/OrgEditorAppKitTests/EditorTests.swift
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}