krz/orgstar

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

Tests/OrgAppTests/AppTests.swift

ab6ccec56eca03d0dadf2c9332aab10c4490eb62
orgstar/Tests/OrgAppTests/AppTests.swift history · blame · raw

1065 lines · 52328 bytes

   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}