krz/orgstar

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

Tests/OrgCoreTests/ArchiveTests.swift

c1295824f55c7bba681fca8338bf08167e110105
orgstar/Tests/OrgCoreTests/ArchiveTests.swift history · blame · raw

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}