Tests/OrgAppTests/AppTests.swift
1065 lines · 52328 bytes
89 symbols in this file
FolderwriteFileTreeTestsfoldersFirstThenFilesfuzzyMatchingWorkspaceModelTestsaddSearchRemoveAndPersistaMovedFolderLeavesNoOldRowslistsDownloadsInTheTreefindsBacklinkscreateRenameAndTrashFilesfoldersAddedLaterAreWatchedchangedFilesAreReindexedDocumentSessionTestsopenShowAndReloadsameFileByAnotherPathSharesItsBufferbuffersOfTheSameNameShowTheirFoldersopenReadsSetupFilessettingsForAFileUseDefaultsAndSetupFileskeywordCtrlCCtrlCRereadsSetupFilesreloadingFromDiskReadsSetupFilesAgainmissingFileReportsAnErrorSessionSavingTeststypesaveWritesAndClearsDirtyautomaticModeSavesAfterTheDelayexplicitModeWaitsForSaveexternalChangeMergesIntoEditsconflictKeepMineconflictUseDiskVersionsearchSeesUnsavedHeadingsKeymapLoaderTestslayersTheUserFileMoveSubtreeTestsrefileWithinAndAcrossFilesarchiveToTheArchiveFileAgendaActionTestsrefileAndArchiveFromAnEntrylocatesMovedHeadingsMoveIntoOpenBufferTestsrefileIntoAnOpenBufferarchiveIntoAnOpenBufferInsertTextappliesrunStateBufferCaretsetAgendaModelTeststopLevelFilesAndTheOpenBufferAgendaCommandTestsloadsViewstodoListModerunsCommandsOnClosedAndOpenFilesFakeReminderCenterauthorizependingIDsdeliveredIDsfireaddremoveReminderSchedulerTestsreminderreconcilesEmacsFallbackTestsrecalculatesTablesNativeCodeCannotcancelingARecalculationLeavesTheTableBabelSessionTestsrunsBlocksAndWritesResultsBabelErrorTestsjobreportsErrorsAndStillInsertsResultscancelsARunningBlockEmacsExportTestsexportsBesideTheFileexportUsesBabelPathcancellingStopsTheExportCaptureAppTestsloadsTemplatescapturesIntoClosedMissingAndOpenFilesClockModelTestsclocksInAndOutAcrossFilesBufferTestsbuffersKeepEditsWhileOthersShowbackgroundBuffersFollowTheDiskLinkFollowTestsfollowsLinksOfEachKindfollowfollowsAttachmentAndRadioLinksfollow
1import Foundation
2import OrgCore
3import OrgDocument
4import OrgIndex
5import Testing
6@testable import OrgApp
7
8/// A temporary folder that removes itself.
9final class Folder {
10 let url: URL
11
12 init() throws {
13 url = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-\(UUID().uuidString)").standardizedFileURL
14 try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
15 }
16
17 deinit {
18 try? FileManager.default.removeItem(at: url)
19 }
20
21 @discardableResult
22 func write(_ path: String, _ text: String) throws -> URL {
23 let file = url.appendingPathComponent(path)
24 try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true)
25 try Data(text.utf8).write(to: file)
26 return file.standardizedFileURL
27 }
28}
29
30struct FileTreeTests {
31 @Test func foldersFirstThenFiles() {
32 let files = ["b.org", "a/z.org", "a/y.org", "c.sync-conflict-1.org", "a2.org"].map {
33 FileEntry(path: "/r/" + $0, root: "/r", kind: $0.contains("conflict") ? .conflict : .org)
34 }
35 let tree = FileTreeNode.build(root: "/r", files: files)
36 #expect(tree.children?.map(\.name) == ["a", "a2.org", "b.org", "c.sync-conflict-1.org"])
37 #expect(tree.children?[0].children?.map(\.name) == ["y.org", "z.org"])
38 #expect(tree.children?[1].children == nil)
39 }
40
41 @Test func fuzzyMatching() {
42 #expect(FuzzyMatch.score("wn", "work/notes.org") != nil)
43 #expect(FuzzyMatch.score("xyz", "work/notes.org") == nil)
44 let files = ["work/notes.org", "notes.org", "personal/wine.org"].map { FileEntry(path: "/r/" + $0, root: "/r", kind: .org) }
45 #expect(FuzzyMatch.rank("notes", files).map(\.relativePath) == ["notes.org", "work/notes.org"])
46 #expect(FuzzyMatch.rank("", files).count == 3)
47 }
48}
49
50@MainActor
51struct WorkspaceModelTests {
52 @Test func addSearchRemoveAndPersist() async throws {
53 let notes = try Folder()
54 let support = try Folder()
55 try notes.write("a.org", "* Groceries\nbuy apples\n")
56 try notes.write("sub/b.org", "* Taxes\n")
57 try notes.write("c.sync-conflict-1.org", "* Taxes copy\n")
58
59 let model = try WorkspaceModel(directory: support.url)
60 try await model.addRoot(notes.url)
61 #expect(model.files.count == 3)
62 #expect(model.search("apples").map(\.title) == ["Groceries"])
63 #expect(model.quickOpen("bo").map(\.name) == ["b.org"])
64 let root = try #require(model.rootPaths.first)
65 #expect(model.tree(forRoot: root).children?.map(\.name) == ["sub", "a.org", "c.sync-conflict-1.org"])
66
67 let reopened = try WorkspaceModel(directory: support.url)
68 #expect(reopened.roots.count == 1)
69 #expect(reopened.files.count == 3)
70
71 try reopened.removeRoot(path: root)
72 #expect(reopened.roots.isEmpty)
73 #expect(reopened.files.isEmpty)
74 #expect(reopened.search("apples").isEmpty)
75 }
76
77 @Test func aMovedFolderLeavesNoOldRows() async throws {
78 let parent = try Folder()
79 let original = parent.url.appendingPathComponent("notes")
80 try FileManager.default.createDirectory(at: original, withIntermediateDirectories: true)
81 try "* Plumber\n".write(to: original.appendingPathComponent("a.org"), atomically: true, encoding: .utf8)
82 let data = parent.url.appendingPathComponent("data")
83 let model = try WorkspaceModel(directory: data)
84 try await model.addRoot(original)
85 #expect(model.search("plumber").count == 1)
86
87 let moved = parent.url.appendingPathComponent("moved")
88 try FileManager.default.moveItem(at: original, to: moved)
89 let reopened = try WorkspaceModel(directory: data)
90 await reopened.refresh()
91 let found = reopened.search("plumber").map(\.path)
92 #expect(found.count == 1 && found[0].hasSuffix("/moved/a.org"), "\(found)")
93 // Adding it again, under its new path, adds nothing.
94 try await reopened.addRoot(moved)
95 #expect(reopened.roots.count == 1)
96 }
97
98 @Test func listsDownloadsInTheTree() {
99 let files = [FileEntry(path: "/r/a.org", root: "/r", kind: .org)]
100 let tree = FileTreeNode.build(root: "/r", files: files, downloading: ["/r/b.org", "/r/sub/c.org"])
101 #expect(tree.children?.map(\.name) == ["sub", "a.org", "b.org"])
102 #expect(tree.children?[2].isDownloading == true && tree.children?[2].file == nil)
103 #expect(tree.children?[0].children?.map(\.name) == ["c.org"])
104 }
105
106 @Test func findsBacklinks() async throws {
107 let notes = try Folder()
108 try notes.write("a.org", "* Sink\n:PROPERTIES:\n:ID: s-1\n:CUSTOM_ID: sink\n:END:\nsee [[*Taps]]\n* Taps\n")
109 try notes.write("b.org", "* Plumbing\n[[id:s-1][the sink]] and [[file:a.org]]\n* Other\n[[file:a.org::#sink]] [[https://x.org]]\n")
110 // Links before the first heading come from the file itself.
111 try notes.write("c.org", "#+TITLE: C\nAbout [[file:a.org::*Sink]] and [[file:a.org]].\n* Later\n")
112 let model = try WorkspaceModel(directory: nil)
113 try await model.addRoot(notes.url)
114 let a = try #require(model.files.first { $0.path.hasSuffix("/a.org") }).path
115 let c = try #require(model.files.first { $0.path.hasSuffix("/c.org") }).path
116 let sink = Backlinks.Target(path: a, title: "Sink", id: "s-1", customID: "sink")
117 #expect(model.backlinks(to: sink).map(\.title) == ["Plumbing", "Other", "c.org"])
118 #expect(model.backlinks(to: Backlinks.Target(path: a)).map(\.title) == ["Plumbing", "c.org"])
119 #expect(model.backlinks(to: Backlinks.Target(path: a, title: "Taps")).map(\.title) == ["Sink"])
120 #expect(model.backlinks(to: sink, excluding: 0).map(\.title) == ["Plumbing", "Other", "c.org"])
121 let fromC = try #require(model.backlinks(to: sink).last)
122 #expect(fromC == .file(c, contentHash: fromC.contentHash))
123 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
124 session.show(fromC)
125 #expect(session.path == c && session.revealRequest?.offset == 0)
126 }
127
128 @Test func createRenameAndTrashFiles() async throws {
129 let notes = try Folder()
130 try notes.write("a.org", "* Alpha\n")
131 let model = try WorkspaceModel(directory: nil)
132 model.trasher = { try FileManager.default.removeItem(at: $0) }
133 try await model.addRoot(notes.url)
134 let root = try #require(model.rootPaths.first)
135
136 let created = try await model.createFile(named: "projects/plan", in: root)
137 #expect(created == root + "/projects/plan.org")
138 #expect(model.files.map(\.path).contains(created))
139 await #expect(throws: WorkspaceModel.FileError.self) { try await model.createFile(named: "projects/plan.org", in: root) }
140 await #expect(throws: WorkspaceModel.FileError.self) { try await model.createFile(named: "../escape", in: root) }
141
142 let renamed = try await model.rename(created, to: "roadmap.org")
143 #expect(model.files.map(\.path).contains(renamed) && !model.files.map(\.path).contains(created))
144 let folder = try await model.rename(root + "/projects", to: "work")
145 #expect(model.files.map(\.path).contains(folder + "/roadmap.org"))
146
147 try await model.trash(folder + "/roadmap.org")
148 #expect(!FileManager.default.fileExists(atPath: folder + "/roadmap.org"))
149 #expect(!model.files.map(\.path).contains(folder + "/roadmap.org"))
150 #expect(model.search("alpha").count == 1)
151 }
152
153 /// Watching starts with no folders (a first launch); a folder added afterwards is
154 /// watched.
155 @Test func foldersAddedLaterAreWatched() async throws {
156 let notes = try Folder()
157 let model = try WorkspaceModel(directory: nil)
158 model.startWatching()
159 try await model.addRoot(notes.url)
160 let file = try notes.write("new.org", "* Fresh heading\n")
161 var found = false
162 for _ in 0..<40 where !found {
163 try await Task.sleep(for: .milliseconds(100))
164 found = model.files.contains { URL(fileURLWithPath: $0.path).resolvingSymlinksInPath() == file.resolvingSymlinksInPath() }
165 }
166 #expect(found)
167 }
168
169 @Test func changedFilesAreReindexed() async throws {
170 let notes = try Folder()
171 let file = try notes.write("a.org", "* Old\n")
172 let model = try WorkspaceModel(directory: nil)
173 try await model.addRoot(notes.url)
174 try notes.write("a.org", "* New\n")
175 await model.filesChanged([file], rescan: [])
176 #expect(model.search("new").count == 1)
177 #expect(model.changedFile?.path == file.path)
178 }
179}
180
181@MainActor
182struct DocumentSessionTests {
183 @Test func openShowAndReload() throws {
184 let notes = try Folder()
185 let file = try notes.write("a.org", "* One\n* Two\n")
186 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
187 session.open(file.path)
188 #expect(session.outline.map(\.title) == ["One", "Two"])
189
190 let location = HeadingLocation(path: file.path, ordinal: 1, title: "Two", start: 6, contentHash: "stale")
191 session.show(location)
192 #expect(session.revealRequest?.offset == 6)
193
194 try notes.write("a.org", "intro\n* One\n* Two\n")
195 session.fileChanged(file.path)
196 #expect(session.outline.map(\.start) == [6, 12])
197 session.show(location)
198 #expect(session.revealRequest?.offset == 12)
199 }
200
201 @Test func sameFileByAnotherPathSharesItsBuffer() throws {
202 let notes = try Folder()
203 let file = try notes.write("a.org", "* One\n")
204 let link = notes.url.appendingPathComponent("link")
205 try FileManager.default.createSymbolicLink(at: link, withDestinationURL: notes.url)
206 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
207 session.open(file.path)
208 session.open(link.appendingPathComponent("a.org").path)
209 #expect(session.openPaths == [file.path])
210 #expect(session.path == file.path)
211 }
212
213 @Test func buffersOfTheSameNameShowTheirFolders() throws {
214 let notes = try Folder()
215 let a = try notes.write("x/blog/index.org", "a\n")
216 let b = try notes.write("y/blog/index.org", "b\n")
217 let c = try notes.write("y/other.org", "c\n")
218 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
219 session.open(a.path)
220 #expect(session.bufferName(a.path) == "index.org")
221 session.open(b.path)
222 session.open(c.path)
223 #expect(session.bufferName(a.path) == "index.org<x/blog>")
224 #expect(session.bufferName(b.path) == "index.org<y/blog>")
225 #expect(session.bufferName(c.path) == "other.org")
226 }
227
228 @Test func openReadsSetupFiles() throws {
229 let notes = try Folder()
230 try notes.write("setup.org", "#+TODO: NEXT | DONE\n")
231 let file = try notes.write("a.org", "#+SETUPFILE: setup.org\n* NEXT One\n")
232 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
233 session.open(file.path)
234 #expect(session.document?.tree.settings.todoKeywordNames.contains("NEXT") == true)
235 }
236
237 @Test func settingsForAFileUseDefaultsAndSetupFiles() throws {
238 let notes = try Folder()
239 try notes.write("setup.org", "#+TODO: NEXT | FINISHED\n")
240 let viaSetup = try notes.write("a.org", "#+SETUPFILE: setup.org\n* NEXT One\n")
241 let plain = try notes.write("b.org", "* WAIT Two\n")
242 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
243 session.defaults = try #require(OrgSettings.default.withTodoKeywords("WAIT | OK"))
244 #expect(session.settings(for: viaSetup.path)?.todoSequences.first?.done.first?.name == "FINISHED")
245 #expect(session.settings(for: plain.path)?.todoSequences.first?.done.first?.name == "OK")
246 session.open(plain.path)
247 #expect(session.settings(for: plain.path)?.todoSequences.first?.done.first?.name == "OK")
248 #expect(session.settings(for: notes.url.appendingPathComponent("missing.org").path) == nil)
249 }
250
251 @Test func keywordCtrlCCtrlCRereadsSetupFiles() throws {
252 let notes = try Folder()
253 try notes.write("setup.org", "#+TODO: NEXT | DONE\n")
254 let file = try notes.write("a.org", "#+SETUPFILE: setup.org\n* WAIT One\n")
255 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
256 session.open(file.path)
257 #expect(session.document?.tree.settings.todoKeywordNames.contains("WAIT") == false)
258 try notes.write("setup.org", "#+TODO: NEXT WAIT | DONE\n")
259 let text = session.document!.text
260 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [2..<2])
261 guard case .commit(let result) = CtrlCCtrlC().run(in: context) else { Issue.record("no commit"); return }
262 #expect(result.effects.contains(.refreshSetup))
263 session.refreshSetup()
264 #expect(session.document?.tree.settings.todoKeywordNames.contains("WAIT") == true)
265 #expect(session.document?.isDirty == false)
266 }
267
268 @Test func reloadingFromDiskReadsSetupFilesAgain() throws {
269 let notes = try Folder()
270 try notes.write("setup.org", "#+TODO: NEXT | DONE\n")
271 let file = try notes.write("a.org", "* WAIT One\n")
272 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
273 session.open(file.path)
274 try notes.write("a.org", "#+SETUPFILE: setup.org\n* WAIT One\n")
275 try notes.write("setup.org", "#+TODO: WAIT | DONE\n")
276 session.fileChanged(file.path)
277 #expect(session.document?.tree.settings.todoKeywordNames.contains("WAIT") == true)
278 }
279
280 @Test func missingFileReportsAnError() {
281 let session = DocumentSession()
282 session.open("/nonexistent/a.org")
283 #expect(session.lastError != nil)
284 #expect(session.document == nil)
285 }
286}
287
288@MainActor
289struct SessionSavingTests {
290 let notes: Folder
291 let file: URL
292 let recovery: FileRecoveryStore
293 let session: DocumentSession
294
295 init() throws {
296 notes = try Folder()
297 file = try notes.write("a.org", "a\nb\nc\n")
298 recovery = FileRecoveryStore(directory: notes.url.appendingPathComponent(".recovery"))
299 session = DocumentSession(recovery: recovery)
300 session.saveMode = .explicit
301 session.open(file.path)
302 }
303
304 /// Types `text` at `offset`, as the editor would.
305 func type(_ text: String, at offset: Int) throws {
306 let buffer = StateBuffer(session.document!)
307 session.attach(buffer)
308 try buffer.update { try $0.apply([TextEdit(range: offset..<offset, replacement: text)], baseRevision: $0.revision) }
309 session.bufferChanged()
310 }
311
312 var disk: String { (try? String(contentsOf: file, encoding: .utf8)) ?? "" }
313
314 @Test func saveWritesAndClearsDirty() throws {
315 try type("x", at: 0)
316 #expect(session.isDirty)
317 #expect(session.save())
318 #expect(!session.isDirty)
319 #expect(disk == "xa\nb\nc\n")
320 }
321
322 @Test func automaticModeSavesAfterTheDelay() async throws {
323 session.saveMode = .automatic
324 session.autosaveDelay = .milliseconds(10)
325 try type("x", at: 0)
326 #expect(disk == "a\nb\nc\n")
327 await session.autosave?.value
328 #expect(disk == "xa\nb\nc\n")
329 #expect(!session.isDirty)
330 }
331
332 @Test func explicitModeWaitsForSave() throws {
333 try type("x", at: 0)
334 #expect(session.autosave == nil)
335 }
336
337 @Test func externalChangeMergesIntoEdits() throws {
338 try type("x", at: 0)
339 try notes.write("a.org", "a\nb\nC\n")
340 session.fileChanged(file.path)
341 #expect(session.document?.text == "xa\nb\nC\n")
342 #expect(session.conflict == nil)
343 #expect(session.save())
344 #expect(disk == "xa\nb\nC\n")
345 }
346
347 @Test func conflictKeepMine() throws {
348 try type("x", at: 0)
349 try notes.write("a.org", "z\nb\nc\n")
350 session.fileChanged(file.path)
351 #expect(session.conflict != nil)
352 #expect(!session.save())
353 #expect(disk == "z\nb\nc\n")
354 session.keepMine()
355 #expect(session.conflict == nil)
356 #expect(disk == "xa\nb\nc\n")
357 let kept = try recovery.versions(for: file).map { try String(contentsOf: $0, encoding: .utf8) }
358 #expect(kept.contains("z\nb\nc\n"))
359 }
360
361 @Test func conflictUseDiskVersion() throws {
362 try type("x", at: 0)
363 try notes.write("a.org", "z\nb\nc\n")
364 #expect(!session.save())
365 #expect(session.conflict != nil)
366 session.useDiskVersion()
367 #expect(session.conflict == nil)
368 #expect(!session.isDirty)
369 #expect(session.document?.text == "z\nb\nc\n")
370 let kept = try recovery.versions(for: file).map { try String(contentsOf: $0, encoding: .utf8) }
371 #expect(kept.contains("xa\nb\nc\n"))
372 }
373
374 @Test func searchSeesUnsavedHeadings() async throws {
375 let workspace = try WorkspaceModel(directory: nil)
376 try await workspace.addRoot(notes.url)
377 try type("* Unsaved heading\n", at: 0)
378 let overlay = workspace.overlay(path: session.path, document: session.document)
379 #expect(workspace.search("unsaved").isEmpty)
380 #expect(workspace.search("unsaved", overlay: overlay).map(\.title) == ["Unsaved heading"])
381 }
382}
383
384@MainActor
385struct KeymapLoaderTests {
386 @Test func layersTheUserFile() throws {
387 let folder = try Folder()
388 let missing = KeymapLoader.load(.emacs, userFile: folder.url.appendingPathComponent("none.toml"))
389 #expect(missing.keymap == KeymapPreset.emacs.keymap && missing.problems.isEmpty)
390
391 let file = try folder.write("keymap.toml", "[[bind]]\nkeys = \"C-c a\"\ncommand = \"app.palette\"\n[[bind]]\nkeys = \"Q-q\"\ncommand = \"x\"\n")
392 let loaded = KeymapLoader.load(.emacs, userFile: file)
393 #expect(loaded.keymap.candidates(for: KeySequence.parse("C-c a")!).first?.command == "app.palette")
394 #expect(loaded.problems == ["line 4: bad or missing keys"])
395
396 let broken = try folder.write("broken.toml", "keys = \"x")
397 #expect(KeymapLoader.load(.mac, userFile: broken).problems == ["line 1: unclosed string"])
398 }
399}
400
401@MainActor
402struct MoveSubtreeTests {
403 @Test func refileWithinAndAcrossFiles() async throws {
404 let notes = try Folder()
405 let file = try notes.write("a.org", "* one\nbody\n* two\n")
406 let other = try notes.write("b.org", "* inbox\n")
407 let workspace = try WorkspaceModel(directory: nil)
408 try await workspace.addRoot(notes.url)
409 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
410 session.saveMode = .explicit
411 session.open(file.path)
412 let targets = workspace.refileTargets(openPath: session.path, document: session.document)
413 #expect(targets.map(\.label) == ["a.org", "a.org/one", "a.org/two", "b.org", "b.org/inbox"])
414
415 #expect(session.refile(to: targets[2]) == "Refiled to a.org/two")
416 #expect(session.document?.text == "* two\n** one\nbody\n")
417
418 let inbox = try #require(workspace.refileTargets(openPath: session.path, document: session.document).first { $0.label == "b.org/inbox" })
419 StateBufferCaret.set(session, 6)
420 #expect(session.refile(to: inbox) == "Refiled to b.org/inbox")
421 #expect(try String(contentsOf: other, encoding: .utf8) == "* inbox\n** one\nbody\n")
422 #expect(session.document?.text == "* two\n")
423 }
424
425 @Test func archiveToTheArchiveFile() throws {
426 let notes = try Folder()
427 let file = try notes.write("a.org", "* done\n* keep\n")
428 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
429 session.saveMode = .explicit
430 session.open(file.path)
431 #expect(session.archive(now: Date(timeIntervalSince1970: 0)) == "Archived to a.org_archive")
432 #expect(session.document?.text == "* keep\n")
433 let archived = try String(contentsOf: notes.url.appendingPathComponent("a.org_archive"), encoding: .utf8)
434 #expect(archived.contains("* done\n:PROPERTIES:\n:ARCHIVE_TIME:"))
435 }
436}
437
438@MainActor
439struct AgendaActionTests {
440 /// From the agenda: entries in files that aren't open move and archive on disk, and the
441 /// current buffer stays as it was.
442 @Test func refileAndArchiveFromAnEntry() async throws {
443 let notes = try Folder()
444 let file = try notes.write("a.org", "* one\nbody\n* two\n* three\n")
445 let other = try notes.write("b.org", "* inbox\n")
446 let shown = try notes.write("c.org", "* shown\n")
447 let workspace = try WorkspaceModel(directory: nil)
448 try await workspace.addRoot(notes.url)
449 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
450 session.saveMode = .explicit
451 session.open(shown.path)
452
453 let inbox = try #require(workspace.refileTargets(openPath: nil, document: nil).first { $0.label == "b.org/inbox" })
454 #expect(session.refile(to: inbox, from: (file.path, 0)) == "Refiled to b.org/inbox")
455 #expect(try String(contentsOf: other, encoding: .utf8) == "* inbox\n** one\nbody\n")
456 #expect(try String(contentsOf: file, encoding: .utf8) == "* two\n* three\n")
457
458 let text = try String(contentsOf: file, encoding: .utf8)
459 let two = try #require(workspace.refileTargets(openPath: file.path, document: DocumentState(bytes: [UInt8](text.utf8))).first { $0.label == "a.org/two" })
460 #expect(session.refile(to: two, from: (file.path, 6)) == "Refiled to a.org/two")
461 #expect(try String(contentsOf: file, encoding: .utf8) == "* two\n** three\n")
462
463 #expect(session.archive(from: (file.path, 0), now: Date(timeIntervalSince1970: 0)) == "Archived to a.org_archive")
464 #expect(try String(contentsOf: file, encoding: .utf8) == "")
465 #expect(session.path == shown.path && session.document?.text == "* shown\n")
466 }
467
468 @Test func locatesMovedHeadings() throws {
469 let notes = try Folder()
470 let file = try notes.write("a.org", "* new\n* task\n* other\n* task\n")
471 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
472 #expect(session.locateHeading(path: file.path, line: "* task", near: 0) == 6)
473 #expect(session.locateHeading(path: file.path, line: "* task", near: 20) == 21)
474 #expect(session.locateHeading(path: file.path, line: "* other", near: 14) == 13)
475 #expect(session.locateHeading(path: file.path, line: "* gone", near: 0) == nil)
476 }
477}
478
479@MainActor
480struct MoveIntoOpenBufferTests {
481 /// A target open with unsaved edits above its heading takes the subtree in its buffer,
482 /// under the right heading, and the disk keeps the saved text.
483 @Test func refileIntoAnOpenBuffer() async throws {
484 let notes = try Folder()
485 let file = try notes.write("a.org", "* task\nbody\n")
486 let other = try notes.write("b.org", "* inbox\n* projects\n")
487 let workspace = try WorkspaceModel(directory: nil)
488 try await workspace.addRoot(notes.url)
489 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
490 session.saveMode = .explicit
491 session.open(other.path)
492 let projects = try #require(workspace.refileTargets(openPath: nil, document: nil).first { $0.label == "b.org/projects" })
493 _ = session.perform(InsertText(text: "* new\n"), path: other.path, at: 0, expect: nil)
494 session.open(file.path)
495 #expect(session.refile(to: projects) == "Refiled to b.org/projects")
496 #expect(session.document?.text == "")
497 #expect(session.openDocuments.first { $0.path == other.path }?.document.text == "* new\n* inbox\n* projects\n** task\nbody\n")
498 #expect(try String(contentsOf: other, encoding: .utf8) == "* inbox\n* projects\n")
499 }
500
501 @Test func archiveIntoAnOpenBuffer() throws {
502 let notes = try Folder()
503 let file = try notes.write("a.org", "* done\n* keep\n")
504 let archive = try notes.write("a.org_archive", "* old\n")
505 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
506 session.saveMode = .explicit
507 session.open(archive.path)
508 session.open(file.path)
509 #expect(session.archive(now: Date(timeIntervalSince1970: 0)) == "Archived to a.org_archive")
510 #expect(session.document?.text == "* keep\n")
511 let text = try #require(session.openDocuments.first { $0.path == archive.path }?.document.text)
512 #expect(text.hasPrefix("* old\n") && text.contains("* done\n:PROPERTIES:\n:ARCHIVE_TIME:"))
513 #expect(try String(contentsOf: archive, encoding: .utf8) == "* old\n")
514 }
515}
516
517/// Inserts text at the caret, for tests.
518struct InsertText: OrgCommand {
519 let text: String
520 var id: String { "test.insert" }
521 var title: String { "Insert" }
522 func applies(in context: EditContext) -> Bool { true }
523 func run(in context: EditContext) -> CommandStep {
524 .commit(EditResult(baseRevision: context.revision, edits: [TextEdit(range: context.caret..<context.caret, replacement: text)]))
525 }
526}
527
528/// Moves the caret of a session's `StateBuffer`, as clicking in the editor would.
529@MainActor
530enum StateBufferCaret {
531 static func set(_ session: DocumentSession, _ offset: Int) {
532 let buffer = StateBuffer(session.document!)
533 buffer.selection = [offset..<offset]
534 session.attach(buffer)
535 }
536}
537
538@MainActor
539struct AgendaModelTests {
540 @Test func topLevelFilesAndTheOpenBuffer() async throws {
541 let notes = try Folder()
542 try notes.write("a.org", "* TODO Pay rent\nSCHEDULED: <2026-10-05 Mon>\n")
543 try notes.write("sub/b.org", "* Hidden <2026-10-05 Mon>\n")
544 try notes.write("old.org_archive", "* Archived <2026-10-05 Mon>\n")
545 let model = try WorkspaceModel(directory: nil)
546 try await model.addRoot(notes.url)
547 let agenda = AgendaModel(workspace: model)
548 var utc = Calendar(identifier: .gregorian)
549 utc.timeZone = TimeZone(identifier: "UTC")!
550 let now = try #require(utc.date(from: DateComponents(year: 2026, month: 10, day: 5, hour: 9)))
551
552 await agenda.refresh(open: [:], now: now, calendar: utc)
553 #expect(agenda.days.count == 10)
554 #expect(agenda.days[3].isToday)
555 #expect(agenda.days[3].items.map(\.text) == ["TODO Pay rent"])
556
557 agenda.includeSubfolders = true
558 await agenda.refresh(open: [:], now: now, calendar: utc)
559 #expect(agenda.days[3].items.map(\.text) == ["TODO Pay rent", "Hidden "])
560
561 let path = try #require(agenda.files.first { $0.hasSuffix("/a.org") })
562 await agenda.refresh(open: [path: "* DONE Pay rent\nSCHEDULED: <2026-10-05 Mon>\n"], now: now, calendar: utc)
563 #expect(agenda.days[3].items.first?.text == "DONE Pay rent")
564
565 agenda.shift(forward: true, now: now, calendar: utc)
566 await agenda.refresh(open: [:], now: now, calendar: utc)
567 #expect(agenda.days.first?.day == Days.absolute(year: 2026, month: 10, day: 12))
568 }
569}
570
571@MainActor
572struct AgendaCommandTests {
573 @Test func loadsViews() throws {
574 let folder = try Folder()
575 let file = try folder.write("views.toml", """
576 [[view]]
577 name = "Work"
578 type = "match"
579 match = "+work"
580 [[view]]
581 name = "Week"
582 span = 7
583 start = -1
584 [[view]]
585 name = "Waiting"
586 type = "todo"
587 keywords = "WAIT|HOLD"
588 [[view]]
589 name = "Broken"
590 type = "match"
591 """)
592 let loaded = AgendaViewLoader.load(userFile: file)
593 #expect(loaded.views.map(\.name) == ["Agenda", "TODO List", "Work", "Week", "Waiting"])
594 #expect(loaded.views[2].mode == .match("+work", todoOnly: false))
595 #expect(loaded.views[3].mode == .agenda(span: 7, startOffset: -1))
596 #expect(loaded.views[4].mode == .todo(keywords: "WAIT|HOLD"))
597 #expect(loaded.problems == ["views.toml line 13: Broken needs a match"])
598 }
599
600 @Test func todoListMode() async throws {
601 let notes = try Folder()
602 try notes.write("a.org", "* TODO One :work:\n* DONE Two\n* TODO Three\n")
603 let model = try WorkspaceModel(directory: nil)
604 try await model.addRoot(notes.url)
605 let agenda = AgendaModel(workspace: model)
606 agenda.mode = .todo(keywords: nil)
607 await agenda.refresh(open: [:])
608 #expect(agenda.list.map(\.text) == ["TODO One", "TODO Three"])
609 agenda.mode = .match("work", todoOnly: false)
610 await agenda.refresh(open: [:])
611 #expect(agenda.list.map(\.text) == ["TODO One"])
612 }
613
614 @Test func runsCommandsOnClosedAndOpenFiles() throws {
615 let notes = try Folder()
616 let a = try notes.write("a.org", "* TODO One\n* TODO Two\n")
617 let b = try notes.write("b.org", "* TODO Three\n")
618 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
619 session.open(a.path)
620
621 // A closed file is changed on disk.
622 let closed = session.perform(TodoCycle(), path: b.path, at: 0, expect: (0, "* TODO Three"))
623 guard case .commit = closed else { Issue.record("\(closed)"); return }
624 #expect(try String(contentsOf: b, encoding: .utf8) == "* DONE Three\n")
625
626 // The open file changes in its buffer.
627 let open = session.perform(TodoCycle(), path: a.path, at: 11, expect: (11, "* TODO Two"))
628 guard case .commit = open else { Issue.record("\(open)"); return }
629 #expect(session.document?.text == "* TODO One\n* DONE Two\n")
630
631 // A heading that moved is refused.
632 let stale = session.perform(TodoCycle(), path: b.path, at: 0, expect: (0, "* TODO Three"))
633 guard case .failed = stale else { Issue.record("\(stale)"); return }
634 #expect(try String(contentsOf: b, encoding: .utf8) == "* DONE Three\n")
635 }
636}
637
638final class FakeReminderCenter: ReminderCenter, @unchecked Sendable {
639 private let lock = NSLock()
640 private var pending: [String] = ["someone.else"]
641 var authorized = true
642 private(set) var added: [String] = []
643
644 func authorize() async -> Bool { authorized }
645 func pendingIDs() async -> [String] { lock.withLock { pending } }
646 var delivered: [String] = []
647 func deliveredIDs() async -> [String] { lock.withLock { delivered } }
648 /// The system showing a notification: no longer pending.
649 func fire(_ id: String, keep: Bool) {
650 lock.withLock {
651 pending.removeAll { $0 == id }
652 if keep { delivered.append(id) }
653 }
654 }
655 func add(_ reminder: Reminder) async throws {
656 lock.withLock {
657 pending.append(reminder.id)
658 added.append(reminder.id)
659 }
660 }
661 func remove(_ ids: [String]) { lock.withLock { pending.removeAll { ids.contains($0) } } }
662 var ids: [String] { lock.withLock { pending } }
663}
664
665@MainActor
666struct ReminderSchedulerTests {
667 func reminder(_ id: String, _ minutes: Double) -> Reminder {
668 let date = Date(timeIntervalSince1970: 1_000_000 + minutes * 60)
669 return Reminder(id: "orgstar.\(id)", fireDate: date, eventDate: date.addingTimeInterval(720), title: id, body: "", path: "/a.org", offset: 0)
670 }
671
672 @Test func reconciles() async {
673 let center = FakeReminderCenter()
674 let scheduler = ReminderScheduler(center: center)
675 await scheduler.reconcile([reminder("a", 1), reminder("b", 2)])
676 #expect(center.ids == ["someone.else", "orgstar.a", "orgstar.b"])
677 #expect(scheduler.scheduledThrough == reminder("b", 2).eventDate)
678
679 await scheduler.reconcile([reminder("b", 2), reminder("c", 3)])
680 #expect(center.ids == ["someone.else", "orgstar.b", "orgstar.c"])
681 #expect(center.added == ["orgstar.a", "orgstar.b", "orgstar.c"])
682
683 await scheduler.clear()
684 #expect(center.ids == ["someone.else"])
685 #expect(scheduler.scheduledThrough == nil)
686
687 // Fired reminders aren't set again, whether or not they're still in Notification Center.
688 await scheduler.reconcile([reminder("e", 5), reminder("f", 6)])
689 center.fire("orgstar.e", keep: false)
690 await scheduler.reconcile([reminder("e", 5), reminder("f", 6)])
691 #expect(center.added.filter { $0 == "orgstar.e" }.count == 1)
692 let relaunched = ReminderScheduler(center: center)
693 center.fire("orgstar.f", keep: true)
694 await relaunched.reconcile([reminder("f", 6)])
695 #expect(center.added.filter { $0 == "orgstar.f" }.count == 1)
696 await scheduler.clear()
697
698 center.authorized = false
699 await scheduler.reconcile([reminder("d", 4)])
700 #expect(!scheduler.isAuthorized)
701 #expect(center.ids == ["someone.else"])
702 }
703}
704
705@MainActor
706struct EmacsFallbackTests {
707 @Test(.enabled(if: EmacsRunner.executable() != nil && ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
708 func recalculatesTablesNativeCodeCannot() async throws {
709 let notes = try Folder()
710 let file = try notes.write("t.org", "* Prices\n| item | n | twice |\n|------+---+-------|\n| x | a | |\n| y | 2 | |\n#+TBLFM: $3=$2*2\n")
711 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
712 session.open(file.path)
713 let step = session.perform(TableRecalculate(all: true), path: file.path, at: 50, expect: nil)
714 guard case .external(.emacsTable(let table, let point, let form, let reason, let lisp)) = step else {
715 Issue.record("\(step)")
716 return
717 }
718 #expect(reason == "non-numeric field a")
719 #expect(!lisp)
720 let message = await session.recalculateInEmacs(table: table, point: point, form: form)
721 #expect(message == "Recalculated in Emacs")
722 #expect(session.document?.text == "* Prices\n| item | n | twice |\n|------+---+-------|\n| x | a | 2 a |\n| y | 2 | 4 |\n#+TBLFM: $3=$2*2\n")
723 }
724
725 @Test(.enabled(if: EmacsRunner.executable() != nil && ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
726 func cancelingARecalculationLeavesTheTable() async throws {
727 let notes = try Folder()
728 let text = "| n | twice |\n|---+-------|\n| a | |\n#+TBLFM: $2=$1*2\n"
729 let file = try notes.write("t.org", text)
730 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
731 session.open(file.path)
732 let step = session.perform(TableRecalculate(all: true), path: file.path, at: 2, expect: nil)
733 guard case .external(.emacsTable(let table, let point, let form, _, _)) = step else {
734 Issue.record("\(step)")
735 return
736 }
737 let task = Task { await session.recalculateInEmacs(table: table, point: point, form: form) }
738 task.cancel()
739 #expect(await task.value == "Recalculation canceled")
740 #expect(session.document?.text == text)
741 }
742}
743
744@MainActor
745struct BabelSessionTests {
746 @Test func runsBlocksAndWritesResults() async throws {
747 let notes = try Folder()
748 let file = try notes.write("b.org", "#+begin_src sh\necho answer\necho \"$((6 * 7))\"\n#+end_src\n")
749 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
750 session.open(file.path)
751 let step = session.perform(ExecuteSrcBlock(), path: file.path, at: 3, expect: nil)
752 guard case .external(.babel(let job)) = step else {
753 Issue.record("\(step)")
754 return
755 }
756 #expect(await session.runBabel(job) == "Code block evaluation complete.")
757 // A column that is half numbers is right-aligned, as org-table-align does.
758 #expect(session.document?.text == "#+begin_src sh\necho answer\necho \"$((6 * 7))\"\n#+end_src\n\n#+RESULTS:\n| answer |\n| 42 |\n")
759 }
760}
761
762@MainActor
763struct BabelErrorTests {
764 func job(_ session: DocumentSession, _ path: String) throws -> BabelJob {
765 guard case .external(.babel(let job)) = session.perform(ExecuteSrcBlock(), path: path, at: 3, expect: nil) else {
766 throw CancellationError()
767 }
768 return job
769 }
770
771 /// Like `org-babel-eval`: the results go in, and the error output and status are shown.
772 @Test func reportsErrorsAndStillInsertsResults() async throws {
773 let notes = try Folder()
774 let file = try notes.write("e.org", "#+begin_src sh\necho out\necho oops >&2\nexit 3\n#+end_src\n")
775 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
776 session.open(file.path)
777 #expect(await session.runBabel(try job(session, file.path)) == "Exited with status 3: oops")
778 #expect(session.document?.text.hasSuffix("#+RESULTS:\n: out\n") == true)
779 }
780
781 @Test func cancelsARunningBlock() async throws {
782 let notes = try Folder()
783 let file = try notes.write("s.org", "#+begin_src sh\nsleep 20\necho late\n#+end_src\n")
784 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
785 session.open(file.path)
786 let start = ContinuousClock.now
787 var message: String?
788 session.startBabel(try job(session, file.path)) { message = $0 }
789 #expect(session.isRunningBabel)
790 try await Task.sleep(for: .milliseconds(200))
791 session.cancelBabel()
792 while message == nil, ContinuousClock.now - start < .seconds(5) { try await Task.sleep(for: .milliseconds(20)) }
793 #expect(message == "Code block canceled.")
794 #expect(!session.isRunningBabel)
795 #expect(ContinuousClock.now - start < .seconds(5))
796 #expect(session.document?.text.contains("#+RESULTS:") == false)
797 }
798}
799
800struct EmacsExportTests {
801 @Test(.enabled(if: EmacsRunner.executable() != nil && ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
802 func exportsBesideTheFile() async throws {
803 let notes = try Folder()
804 let file = try notes.write("doc.org", "#+TITLE: On disk\n")
805 // The buffer's text is exported, not the file's.
806 let written = try await EmacsRunner.export(text: "#+TITLE: In the buffer\n\n* Heading\nBody.\n", path: file.path, function: "org-ascii-export-to-ascii")
807 #expect(URL(fileURLWithPath: written).lastPathComponent == "doc.txt")
808 let text = try String(contentsOfFile: written, encoding: .utf8)
809 #expect(text.uppercased().contains("IN THE BUFFER") && text.contains("Body."), "\(text)")
810 #expect(try String(contentsOf: file, encoding: .utf8) == "#+TITLE: On disk\n")
811 }
812
813 @Test(.enabled(if: EmacsRunner.executable() != nil && ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
814 func exportUsesBabelPath() async throws {
815 let notes = try Folder()
816 let file = try notes.write("doc.org", "")
817 let function = #"(lambda () (with-temp-file "path.txt" (insert (getenv "PATH"))) "path.txt")"#
818 let written = try await EmacsRunner.export(text: "", path: file.path, function: function)
819 #expect(try String(contentsOfFile: written, encoding: .utf8) == BabelRunner.environment["PATH"])
820 }
821
822 @Test(.enabled(if: EmacsRunner.executable() != nil && ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
823 func cancellingStopsTheExport() async throws {
824 let notes = try Folder()
825 let file = try notes.write("doc.org", "")
826 let pidFile = notes.url.appendingPathComponent("pid")
827 let function = #"(lambda () (with-temp-file "pid" (insert (number-to-string (emacs-pid)))) (sleep-for 60) "x")"#
828 let export = Task { try await EmacsRunner.export(text: "", path: file.path, function: function) }
829 var pid: pid_t?
830 for _ in 0..<200 where pid == nil {
831 try await Task.sleep(for: .milliseconds(50))
832 pid = (try? String(contentsOf: pidFile, encoding: .utf8)).flatMap { pid_t($0) }
833 }
834 let started = try #require(pid)
835 export.cancel()
836 await #expect(throws: CancellationError.self) { try await export.value }
837 var alive = true
838 for _ in 0..<100 where alive {
839 alive = kill(started, 0) == 0
840 if alive { try await Task.sleep(for: .milliseconds(50)) }
841 }
842 #expect(!alive)
843 }
844}
845
846@MainActor
847struct CaptureAppTests {
848 @Test func loadsTemplates() throws {
849 let folder = try Folder()
850 let file = try folder.write("capture.toml", """
851 [[template]]
852 key = "w"
853 name = "Work"
854 file = "work.org"
855 olp = "Projects/Work"
856 template = "* TODO %?\\n%a"
857 [[template]]
858 key = "j"
859 type = "plain"
860 file = "~/journal.org"
861 template = "%U %?"
862 prepend = true
863 """)
864 let loaded = CaptureTemplates.load(userFile: file)
865 #expect(loaded.problems.isEmpty)
866 #expect(loaded.templates.map(\.key) == ["w", "j"])
867 #expect(loaded.templates[0].target == .outline(file: "work.org", path: ["Projects", "Work"]))
868 #expect(loaded.templates[0].template == "* TODO %?\n%a")
869 #expect(loaded.templates[1].kind == .plain && loaded.templates[1].prepend)
870 try """
871 [[template]]
872 key = "d"
873 file = "journal.org"
874 datetree = true
875 olp = "Journal"
876 tree-type = "month"
877 template = "* %?"
878 empty-lines-after = 1
879 jump-to-captured = true
880 [[template]]
881 key = "i"
882 id = "abc"
883 template = "* %?"
884 clock-in = true
885 clock-resume = true
886 [[template]]
887 key = "k"
888 clock = true
889 type = "item"
890 template = "%?"
891 immediate-finish = true
892 """.write(to: file, atomically: true, encoding: .utf8)
893 let targets = CaptureTemplates.load(userFile: file)
894 #expect(targets.problems.isEmpty)
895 #expect(targets.templates.map(\.target) == [.datetree(file: "journal.org", path: ["Journal"], tree: .month), .id("abc"), .clock])
896 #expect(targets.templates[0].options == .init(emptyLinesAfter: 1, jumpToCaptured: true))
897 #expect(targets.templates[1].options == .init(clockIn: true, clockResume: true))
898 #expect(targets.templates[2].options.immediateFinish)
899 #expect(CaptureTemplates.load(userFile: folder.url.appendingPathComponent("none.toml")).templates == CaptureTemplates.defaults)
900 }
901
902 @Test func capturesIntoClosedMissingAndOpenFiles() throws {
903 let notes = try Folder()
904 let todo = try notes.write("todo.org", "* Inbox\n** TODO Old\n* Later\n")
905 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
906 let template = CaptureTemplates.defaults[0]
907 let placed = try session.capture("* TODO New\n", template: template, root: notes.url.path).get()
908 #expect(try String(contentsOf: todo, encoding: .utf8) == "* Inbox\n** TODO Old\n** TODO New\n* Later\n")
909 #expect(placed.path == todo.path && placed.offset == ("* Inbox\n** TODO Old\n" as NSString).length)
910
911 _ = try session.capture("* Note\n", template: CaptureTemplates.defaults[1], root: notes.url.path).get()
912 #expect(try String(contentsOf: notes.url.appendingPathComponent("notes.org"), encoding: .utf8) == "* Inbox\n** Note\n")
913
914 session.open(todo.path)
915 _ = try session.capture("* TODO Third\n", template: template, root: notes.url.path).get()
916 #expect(session.document?.text == "* Inbox\n** TODO Old\n** TODO New\n** TODO Third\n* Later\n")
917
918 // An ID's file is resolved by the caller.
919 let id = CaptureTemplate(key: "i", name: "", target: .id("x1"), template: "")
920 let ided = try notes.write("ided.org", "* Target\n:PROPERTIES:\n:ID: x1\n:END:\n")
921 _ = try session.capture("* Child\n", template: id, root: nil, file: ided.path).get()
922 #expect(try String(contentsOf: ided, encoding: .utf8) == "* Target\n:PROPERTIES:\n:ID: x1\n:END:\n** Child\n")
923 }
924}
925
926@MainActor
927struct ClockModelTests {
928 @Test func clocksInAndOutAcrossFiles() throws {
929 let notes = try Folder()
930 let work = try notes.write("work.org", "* Task\nBody\n")
931 let other = try notes.write("other.org", "* Other\n")
932 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
933 session.open(work.path)
934 session.reveal(2)
935 let stateFile = notes.url.appendingPathComponent("clock.json")
936 let clock = ClockModel(file: stateFile)
937 var utc = Calendar(identifier: .gregorian)
938 utc.timeZone = TimeZone(identifier: "UTC")!
939 let start = Date(timeIntervalSince1970: 1_789_000_000)
940 _ = clock.clockIn(session, now: start)
941 #expect(clock.running?.heading == "Task")
942 #expect(ClockModel(file: stateFile).running == clock.running)
943
944 // Out from another file: the clocked file's buffer is still open behind it and takes
945 // the change.
946 session.open(other.path)
947 let message = clock.clockOut(session, now: start.addingTimeInterval(95 * 60))
948 #expect(message == "Clock stopped after 1:35")
949 #expect(clock.running == nil)
950 #expect(session.dirtyPaths == [work.path])
951 #expect(session.saveAll())
952 let text = try String(contentsOf: work, encoding: .utf8)
953 #expect(text.hasPrefix("* Task\n:LOGBOOK:\nCLOCK: [") && text.contains("=> 1:35\n:END:\nBody\n"), "\(text)")
954 }
955}
956
957@MainActor
958struct BufferTests {
959 @Test func buffersKeepEditsWhileOthersShow() throws {
960 let notes = try Folder()
961 let a = try notes.write("a.org", "* A\n")
962 let b = try notes.write("b.org", "* B\n")
963 let c = try notes.write("c.org", "* C\n")
964 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
965 session.saveMode = .explicit
966 session.open(a.path)
967 _ = session.perform(SetTodo(keyword: "TODO"), path: a.path, at: 0, expect: nil)
968 session.open(b.path)
969 #expect(session.path == b.path)
970 #expect(session.openPaths == [a.path, b.path])
971 #expect(session.dirtyPaths == [a.path])
972 // A background buffer takes commands for its file, not the disk.
973 _ = session.perform(SetTodo(keyword: "DONE"), path: a.path, at: 0, expect: nil)
974 #expect(try String(contentsOf: a, encoding: .utf8) == "* A\n")
975 #expect(session.openDocuments.first { $0.path == a.path }?.document.text.hasPrefix("* DONE A") == true)
976
977 session.open(a.path)
978 #expect(session.document?.text.hasPrefix("* DONE A") == true)
979 session.open(c.path)
980 // Opened after the current buffer.
981 #expect(session.openPaths == [a.path, c.path, b.path])
982 session.cycle(1)
983 #expect(session.path == b.path)
984 session.cycle(1)
985 #expect(session.path == a.path)
986 session.showPrevious()
987 #expect(session.path == b.path)
988 #expect(session.recentPaths == [b.path, a.path, c.path])
989
990 session.kill(b.path)
991 #expect(session.path == a.path)
992 #expect(session.openPaths == [a.path, c.path])
993 #expect(session.saveAll())
994 #expect(try String(contentsOf: a, encoding: .utf8).hasPrefix("* DONE A"))
995 session.kill(a.path)
996 session.kill(c.path)
997 #expect(session.path == nil && session.document == nil && session.openPaths.isEmpty)
998 }
999
1000 @Test func backgroundBuffersFollowTheDisk() throws {
1001 let notes = try Folder()
1002 let a = try notes.write("a.org", "* A\n")
1003 let b = try notes.write("b.org", "* B\n")
1004 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
1005 session.open(a.path)
1006 session.open(b.path)
1007 try notes.write("a.org", "* A changed\n")
1008 session.fileChanged(a.path)
1009 #expect(session.openDocuments.first { $0.path == a.path }?.document.text == "* A changed\n")
1010 }
1011}
1012
1013@MainActor
1014struct LinkFollowTests {
1015 @Test func followsLinksOfEachKind() async throws {
1016 let notes = try Folder()
1017 let a = try notes.write("a.org", "* Start\nSee <<here>>.\n* Second [1/2]\n:PROPERTIES:\n:CUSTOM_ID: two\n:END:\n")
1018 let b = try notes.write("sub/b.org", "* Target heading\n:PROPERTIES:\n:ID: 1234-ABCD\n:END:\nline 5\nline 6\n")
1019 _ = try notes.write("pic.png", "not really")
1020 let workspace = try WorkspaceModel(directory: nil)
1021 try await workspace.addRoot(notes.url)
1022 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
1023 session.open(a.path)
1024 let text = { (path: String) in try? String(contentsOfFile: path, encoding: .utf8) }
1025 func follow(_ raw: String) -> LinkFollow { session.follow(Links.parse(bracket: raw), workspace: workspace, isText: { $0.hasSuffix(".org") }) }
1026
1027 #expect(follow("here") == .shown)
1028 #expect(session.revealRequest?.offset == (text(a.path)! as NSString).range(of: "<<here>>").location)
1029 #expect(follow("#two") == .shown)
1030 #expect(follow("Second") == .shown)
1031 #expect(follow("Nowhere") == .message("No match for fuzzy expression: Nowhere"))
1032
1033 #expect(follow("file:sub/b.org::5") == .shown)
1034 #expect(session.path == b.path)
1035 #expect(session.revealRequest?.offset == (text(b.path)! as NSString).range(of: "line 5").location)
1036 session.open(a.path)
1037 #expect(follow("file:sub/b.org::*Target heading") == .shown && session.path == b.path)
1038 session.open(a.path)
1039 #expect(follow("id:1234-ABCD") == .shown && session.path == b.path)
1040 #expect(follow("id:none") == .message("No heading has the ID none"))
1041 // Relative to the open file (now sub/b.org); files Orgstar doesn't edit go to macOS.
1042 #expect(follow("file:../pic.png") == .openExternally(URL(fileURLWithPath: (notes.url.appendingPathComponent("pic.png").path as NSString).standardizingPath)))
1043 #expect(follow("file:pic.png") == .message("No file " + (notes.url.appendingPathComponent("sub/pic.png").path as NSString).standardizingPath))
1044 #expect(follow("https://orgmode.org/a b") == .openExternally(URL(string: "https://orgmode.org/a%20b")!))
1045 #expect(follow("doi:10.1000/182") == .openExternally(URL(string: "https://doi.org/10.1000/182")!))
1046 #expect(follow("shell:rm -rf x") == .message("Orgstar doesn't run shell links"))
1047 }
1048
1049 @Test func followsAttachmentAndRadioLinks() throws {
1050 let notes = try Folder()
1051 let text = "* Task\n:PROPERTIES:\n:ID: ab12-cd\n:END:\nSee [[attachment:scan.pdf]] and <<<the plan>>>.\n* Later\nAs in The Plan.\n"
1052 let a = try notes.write("a.org", text)
1053 let scan = try notes.write("data/ab/12-cd/scan.pdf", "pdf")
1054 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
1055 session.open(a.path)
1056 let tree = try #require(session.document?.tree)
1057 func follow(_ needle: String) throws -> LinkFollow {
1058 let link = try #require(Links.link(at: (text as NSString).range(of: needle).location + 1, text: text, tree: tree)?.link)
1059 return session.follow(link, workspace: nil, isText: { $0.hasSuffix(".org") })
1060 }
1061 #expect(try follow("attachment:") == .openExternally(URL(fileURLWithPath: (scan.path as NSString).standardizingPath)))
1062 #expect(try follow("The Plan") == .shown)
1063 #expect(session.revealRequest?.offset == (text as NSString).range(of: "<<<the plan>>>").location)
1064 }
1065}