Tests/OrgCoreTests/ArchiveTests.swift
116 lines · 7407 bytes
1import Foundation
2import Testing
3@testable import OrgCore
4
5struct ArchiveTests {
6 static let now = Date(timeIntervalSince1970: 1_789_000_000)
7 static var utc: Calendar {
8 var calendar = Calendar(identifier: .gregorian)
9 calendar.timeZone = TimeZone(identifier: "UTC")!
10 return calendar
11 }
12
13 func context(_ text: String, _ caret: Int) -> EditContext {
14 EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret], now: Self.now, calendar: Self.utc)
15 }
16
17 @Test func destinations() throws {
18 let plain = try Archive.destination(context("* a\n", 0), sourcePath: "/n/a.org").get()
19 #expect(plain == Archive.Destination(path: "/n/a.org_archive", heading: nil, isSameFile: false))
20 let keyword = try Archive.destination(context("#+ARCHIVE: done.org::* %s\n* a\n", 27), sourcePath: "/n/a.org").get()
21 #expect(keyword == Archive.Destination(path: "/n/done.org", heading: "* a.org", isSameFile: false))
22 let local = try Archive.destination(context("* a\n:PROPERTIES:\n:ARCHIVE: ::* Old\n:END:\n", 0), sourcePath: "/n/a.org").get()
23 #expect(local.isSameFile && local.heading == "* Old")
24 }
25
26 @Test func archivesToANewFile() throws {
27 let text = "* P\n** TODO a :t:\nbody\n** b\n"
28 let ctx = context(text, 6)
29 let destination = try Archive.destination(ctx, sourcePath: "/n/a.org").get()
30 let edits = try Archive.run(ctx, sourcePath: "/n/a.org", destination: destination, archiveText: nil).get()
31 #expect(edits.source.edits.reduce(text) { $1.apply(to: $0) } == "* P\n** b\n")
32 let archived = try #require(edits.archive).apply(to: "")
33 #expect(archived.hasPrefix("\nArchived entries from file /n/a.org\n\n\n* TODO a"))
34 #expect(archived.contains(":ARCHIVE_OLPATH: P\n"))
35 #expect(archived.contains(":ARCHIVE_TIME: 2026-09-10 Thu 00:26\n"))
36 }
37
38 static let sources = [
39 "* a\nbody\n* b\n",
40 "#+FILETAGS: :f:\n* P :p:\n** TODO a :t:\nbody\n** DONE b\n",
41 "* P [1/2]\n** TODO a\n** DONE b\n",
42 "#+CATEGORY: work\n* a [[https://x.org][link]]\n* b\n",
43 "#+ARCHIVE: ::* Archive\n* a :x:\n* Archive\n** old\n",
44 "* P\n:PROPERTIES:\n:ARCHIVE: ::* Old :t:\n:CATEGORY: c\n:END:\n** a\n* Old :t:\n",
45 "* a\nSCHEDULED: <2026-01-01 Thu>\n:PROPERTIES:\n:ID: 1\n:END:\n* b",
46 "#+ARCHIVE: ::datetree/\n* a\nbody\n* b\n",
47 "#+ARCHIVE: %s_archive::datetree/\n* DONE a\nCLOSED: [2026-08-15 Sat 09:00]\n* b\n",
48 "#+ARCHIVE: ::datetree/* Notes\n* a\n** sub\n* b\n",
49 ]
50 static let archives: [String?] = [nil, "", "* existing\n", "\n#+TITLE: arch\n\n* x\n:PROPERTIES:\n:ARCHIVE_TIME: t\n:END:\n\n",
51 "* 2026\n** 2026-09 September\n*** 2026-09-10 Thursday\n**** prior\n*** 2026-09-12 Saturday\n"]
52
53 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
54 func archiveMatchesEmacs() throws {
55 try #require(EmacsOracle.isAvailable)
56 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-archive-\(UUID().uuidString)")
57 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
58 defer { try? FileManager.default.removeItem(at: folder) }
59 var cases: [EmacsOracle.Case] = []
60 var ours: [(label: String, text: String, caret: Int, failed: Bool)] = []
61 var index = 0
62 for source in Self.sources {
63 let tree = OrgParser.parse(source)
64 let carets = tree.root.descendants().filter { $0.kind == .heading }.flatMap { [$0.range.lowerBound, $0.range.lowerBound + 2] }
65 for caret in carets {
66 for archiveText in Self.archives {
67 index += 1
68 let path = folder.appendingPathComponent("s\(index).org").path
69 let ctx = context(source, caret)
70 guard case .success(let destination) = Archive.destination(ctx, sourcePath: path) else { continue }
71 if destination.isSameFile, archiveText != nil { continue }
72 let archivePath = destination.path
73 let escaped = { (s: String) in s.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") }
74 let writeArchive = archiveText.map { "(with-temp-file \"\(escaped(archivePath))\" (insert \"\(escaped($0))\"))" } ?? "(when (file-exists-p \"\(escaped(archivePath))\") (delete-file \"\(escaped(archivePath))\"))"
75 let form = """
76 (let ((p (point)) (src (buffer-string)) s1 s2)
77 \(writeArchive)
78 (with-temp-file "\(escaped(path))" (insert src))
79 (with-current-buffer (find-file-noselect "\(escaped(path))")
80 (org-fold-show-all) (goto-char p)
81 (cl-letf* ((orig (symbol-function 'format-time-string))
82 ((symbol-function 'format-time-string) (lambda (f &optional time zone) (funcall orig f (or time 1789000000) zone))))
83 (org-archive-subtree))
84 (setq p (point) s1 (buffer-string))
85 (set-buffer-modified-p nil) (kill-buffer))
86 (let ((b (get-file-buffer "\(escaped(archivePath))")))
87 (when b (with-current-buffer b (setq s2 (buffer-string)) (set-buffer-modified-p nil) (kill-buffer))))
88 (erase-buffer) (insert s1 "\\n@@@@\\n" (or s2 "")) (goto-char p))
89 """
90 cases.append(EmacsOracle.Case(text: source, point: EmacsOracle.point(caret, in: source), form: form))
91 let label = "\(caret) in \(source.debugDescription) to \(archiveText.debugDescription)"
92 switch Archive.run(ctx, sourcePath: path, destination: destination, archiveText: archiveText) {
93 case .success(let edits):
94 let newSource = edits.source.edits.reduce(source) { $1.apply(to: $0) }
95 let newArchive = destination.isSameFile ? "" : (edits.archive.map { $0.apply(to: archiveText ?? "") } ?? (archiveText ?? ""))
96 ours.append((label, newSource + "\n@@@@\n" + newArchive, edits.source.selection?.first?.lowerBound ?? caret, false))
97 case .failure:
98 ours.append((label, "", 0, true))
99 }
100 }
101 }
102 }
103 var mismatches = 0
104 for (mine, emacs) in zip(ours, try EmacsOracle.run(cases)) {
105 let failed = !emacs.error.isEmpty
106 let caret = EmacsOracle.offset(emacs.point, in: emacs.text)
107 guard mine.failed != failed || (!failed && (mine.text != emacs.text || mine.caret != caret)) else { continue }
108 mismatches += 1
109 if mismatches <= 8 {
110 let (a, b) = excerpts(mine.text, emacs.text)
111 Issue.record("\(mine.label)\n ours: \(mine.failed ? "failed" : "\(a.debugDescription) @\(mine.caret)")\n emacs: \(failed ? emacs.error : "\(b.debugDescription) @\(caret)")")
112 }
113 }
114 #expect(mismatches == 0, "\(mismatches) of \(ours.count) differ")
115 }
116}