krz/orgstar

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

Tests/OrgCoreTests/LinkStoreTests.swift

b98a6509c5ae75e5172dd333d1b6105fd6ceee0e
orgstar/Tests/OrgCoreTests/LinkStoreTests.swift history · blame · raw

233 lines · 13820 bytes

11 symbols in this file
  1import Foundation
  2import Testing
  3@testable import OrgCore
  4
  5struct LinkStoreTests {
  6    static let oracle = ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil
  7
  8    static let text = """
  9        #+TITLE: Store
 10        intro (line) here
 11        * TODO [#A] First [1/2] heading :tag:
 12        body <<my target>> here
 13        text with [[https://x.org][a link]] in it
 14        * Second
 15        :PROPERTIES:
 16        :CUSTOM_ID: sec
 17        :END:
 18        #+NAME: tbl
 19        | a |
 20
 21        #+name: code
 22        #+begin_src sh
 23        echo hi
 24        #+end_src
 25        * COMMENT Third [[https://y.org][linked]] title
 26        *\u{20}
 27
 28        """
 29
 30    /// Carets as text before them, and selections as `start…end` pairs of such text.
 31    static let cases: [(String, String?)] = [
 32        ("#+TITLE", nil), ("intro", nil), ("* TODO", nil), ("First", nil), ("<<my", nil), ("y target", nil), ("here\ntext", nil),
 33        ("body", nil), ("text with", nil), (":CUSTOM_ID", nil), ("#+NAME", nil), ("| a", nil), ("#+name", nil), ("echo", nil),
 34        ("#+end_src", nil), ("Third", nil), ("* \n", nil), ("intro", "First"), ("(line)", "here"),
 35    ]
 36
 37    static func offset(_ s: String) -> Int { (text as NSString).range(of: s).location }
 38
 39    @Test(.enabled(if: oracle))
 40    func storesLikeOrgStoreLink() throws {
 41        let points = Self.cases.map { c -> String in
 42            let start = EmacsOracle.point(Self.offset(c.0), in: Self.text)
 43            let end = c.1.map { EmacsOracle.point(Self.offset($0), in: Self.text) }
 44            return "(\(start) \(end.map(String.init) ?? "nil"))"
 45        }
 46        let emacs = try EmacsOracle.evaluate(Self.text, """
 47            (let ((dir (file-name-directory buffer-file-name)) out)
 48              (dolist (c '(\(points.joined(separator: " "))))
 49                (deactivate-mark)
 50                (goto-char (car c))
 51                (when (cadr c) (set-mark (car c)) (goto-char (cadr c)) (activate-mark))
 52                (setq org-stored-links nil)
 53                (call-interactively #'org-store-link)
 54                (push (format "%S" (mapcar (lambda (l) (list (replace-regexp-in-string (regexp-quote dir) "/d/" (car l) t t) (cadr l))) org-stored-links)) out))
 55              (nreverse out))
 56            """, prelude: "(transient-mark-mode 1)")
 57        var ours: [String] = []
 58        let tree = OrgParser.parse(Self.text)
 59        for c in Self.cases {
 60            let start = Self.offset(c.0)
 61            let selection = c.1.map { start..<Self.offset($0) } ?? start..<start
 62            let context = EditContext(revision: 0, text: Self.text, tree: tree, selection: [selection])
 63            let stored = Links.store(in: context, path: "/d/oracle.org")
 64            ours.append("((\(lisp(stored.link)) \(stored.description.map(lisp) ?? "nil")))")
 65        }
 66        #expect(ours == emacs)
 67    }
 68
 69    func lisp(_ s: String) -> String { "\"" + s.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + "\"" }
 70
 71    struct InsertCase {
 72        var text: String
 73        /// Where the caret goes: before this text, or with `|` marking it.
 74        var caret: Int
 75        var region: Range<Int>?
 76        var stored: [StoredLink]
 77        var link: String
 78        /// nil accepts the offered description.
 79        var description: String?
 80        var special: String? = nil
 81    }
 82
 83    static func insertCase(_ marked: String, region: String? = nil, stored: [StoredLink] = [], link: String, description: String? = nil, special: String? = nil) -> InsertCase {
 84        let caret = (marked as NSString).range(of: "|").location
 85        let text = marked.replacingOccurrences(of: "|", with: "")
 86        let range = region.map { r -> Range<Int> in
 87            let start = (text as NSString).range(of: r).location
 88            return start..<(start + (r as NSString).length)
 89        }
 90        return InsertCase(text: text, caret: range?.upperBound ?? caret, region: range, stored: stored, link: link, description: description, special: special)
 91    }
 92
 93    static let insertCases: [InsertCase] = [
 94        insertCase("* A\n|\n* Heading\n", stored: [StoredLink(link: "file:/d/oracle.org::*Heading", description: "Heading")], link: ""),
 95        insertCase("* A\n|\n", stored: [StoredLink(link: "file:/d/sub/b.org::*X", description: "X"), StoredLink(link: "file:/d/oracle.org::#c", description: "C")], link: "file:/d/sub/b.org::*X"),
 96        insertCase("* A\n|\n", stored: [StoredLink(link: "file:/elsewhere/c.org", description: nil)], link: "", description: "Elsewhere"),
 97        insertCase("* A\n|\n", stored: [StoredLink(link: "file:~/notes/x.org::42", description: nil)], link: ""),
 98        insertCase("Some |text\n", link: "https://orgmode.org", description: "Org"),
 99        insertCase("Some text here\n", region: "text", link: "https://orgmode.org"),
100        insertCase("See [[https://a.org][the [a] site]] |\n", link: "https://b.org"),
101        insertCase("See [[https://a.org][the site]]\n", region: nil, link: "https://b.org", description: "B"),
102        insertCase("Visit https://a.org/x| now\n", link: "https://c.org"),
103        insertCase("x |\n", link: "a [b] c\\", description: "ends]] with]"),
104        insertCase("x |\n", link: "   "),
105        insertCase("x |\n", stored: [StoredLink(link: "file:/d/oracle.org::*H", description: "Heading H")], link: "Heading H"),
106        insertCase("x |\n", link: "https:", special: "https://typed.org"),
107        insertCase("x |\n", link: "<https://angle.org>", description: ""),
108        insertCase("x |\n", stored: [StoredLink(link: "file:/d/sub/b.org", description: "/d/sub/b.org")], link: ""),
109    ]
110
111    @Test(.enabled(if: oracle))
112    func insertsLikeOrgInsertLink() throws {
113        var cases = Self.insertCases
114        // The edit-link case puts its caret inside the link.
115        cases[7].caret = (cases[7].text as NSString).range(of: "the site").location
116        var forms: [String] = []
117        for c in cases {
118            let stored = c.stored.map { "(list (funcall fix \(lisp($0.link))) \($0.description.map { "(funcall fix \(lisp($0)))" } ?? "nil"))" }.joined(separator: " ")
119            let region = c.region.map { "(set-mark \(EmacsOracle.point($0.lowerBound, in: c.text))) (goto-char \(EmacsOracle.point($0.upperBound, in: c.text))) (activate-mark)" } ?? ""
120            forms.append("""
121                (progn (erase-buffer) (insert \(lisp(c.text))) (deactivate-mark) (goto-char \(EmacsOracle.point(c.caret, in: c.text))) \(region)
122                  (setq org-stored-links (list \(stored)))
123                  (let ((answers (list (cons "link" (funcall fix \(lisp(c.link)))) (cons "description" \(c.description.map(lisp) ?? "nil")) (cons "special" \(c.special.map(lisp) ?? "nil")))))
124                    (let ((err (condition-case e
125                                   (cl-letf (((symbol-function 'org-completing-read)
126                                              (lambda (_prompt _coll &optional _p _r _i _h def &rest _)
127                                                (let ((a (cdr (assoc "link" answers)))) (if (equal a "") (or def "") a))))
128                                             ((symbol-function 'read-string)
129                                              (lambda (prompt &optional initial &rest _)
130                                                (cond ((string-prefix-p "Description" prompt) (or (cdr (assoc "description" answers)) initial ""))
131                                                      ((string-prefix-p "Link (no" prompt) (cdr (assoc "special" answers)))
132                                                      (t (cdr (assoc "link" answers)))))))
133                                     (call-interactively #'org-insert-link) "")
134                                 (error (error-message-string e)))))
135                      (format "%s|%d|%d|%s" (replace-regexp-in-string (regexp-quote dir) "/d/" (buffer-string) t t) (1- (point)) (length org-stored-links) err))))
136                """)
137        }
138        let emacs = try EmacsOracle.evaluate("", """
139            (let* ((dir (file-name-directory buffer-file-name))
140                   (fix (lambda (s) (replace-regexp-in-string "/d/" dir s t t))))
141              (list \(forms.joined(separator: "\n"))))
142            """, prelude: "(require 'cl-lib) (transient-mark-mode 1)")
143        var ours: [String] = []
144        for c in cases {
145            let tree = OrgParser.parse(c.text)
146            var answers: [String: String] = [:]
147            let command = InsertLink(stored: c.stored, file: "/d/oracle.org", home: NSHomeDirectory())
148            var line = ""
149            loop: while true {
150                let context = EditContext(revision: 0, text: c.text, tree: tree, selection: [c.region ?? c.caret..<c.caret], answers: answers)
151                switch command.run(in: context) {
152                case .prompt(let prompt):
153                    switch prompt.key {
154                    case "link": answers["link"] = c.link
155                    case "special": answers["special"] = c.special ?? ""
156                    default: answers["description"] = c.description ?? prompt.initial
157                    }
158                case .commit(let result):
159                    var new = c.text
160                    for edit in result.edits { new = edit.apply(to: new) }
161                    let used = result.effects.contains { if case .storedLinkUsed = $0 { true } else { false } }
162                    let point = EmacsOracle.point(result.selection!.first!.lowerBound, in: new) - 1
163                    line = "\(new)|\(point)|\(c.stored.count - (used ? 1 : 0))|"
164                    break loop
165                case .failed(let message):
166                    line = "\(c.text)|\(EmacsOracle.point(c.caret, in: c.text) - 1)|\(c.stored.count)|\(message)"
167                    break loop
168                case .external:
169                    break loop
170                }
171            }
172            ours.append(line)
173        }
174        for (o, e) in zip(ours, emacs) { #expect(o == e) }
175        #expect(ours.count == emacs.count)
176    }
177
178    @Test(.enabled(if: oracle))
179    func createsIDsLikeOrgIDGetCreate() throws {
180        let cases = [
181            ("* A\nbody\n", "body"), ("* A\nSCHEDULED: <2026-10-05 Mon>\n", "A"),
182            ("* A\n:PROPERTIES:\n:ID:       old-id\n:END:\n", "A"), ("* A\n:PROPERTIES:\n:CUSTOM_ID: x\n:END:\n", "A"),
183            ("#+TITLE: T\ntext\n* A\n", "text"), ("* A\n:PROPERTIES:\n:ID:\n:END:\n", "A"),
184        ]
185        let forms = cases.map { c in
186            "(progn (erase-buffer) (insert \(lisp(c.0))) (goto-char \(EmacsOracle.point((c.0 as NSString).range(of: c.1).location, in: c.0))) (cl-letf (((symbol-function 'org-id-new) (lambda (&rest _) \"NEW-ID\")) ((symbol-function 'org-id-add-location) #'ignore)) (call-interactively #'org-id-get-create)) (format \"%s|%d\" (buffer-string) (1- (point))))"
187        }
188        let emacs = try EmacsOracle.evaluate("", "(list \(forms.joined(separator: " ")))", prelude: "(require 'org-id) (require 'cl-lib)")
189        let ours = cases.map { c -> String in
190            let result = runCommand(CreateID { "NEW-ID" }, c.0, caret: (c.0 as NSString).range(of: c.1).location)
191            return "\(result.text)|\(EmacsOracle.point(result.caret, in: result.text) - 1)"
192        }
193        #expect(ours == emacs)
194    }
195
196    @Test(.enabled(if: oracle))
197    func storesIDLinksLikeOrgIDStoreLink() throws {
198        let cases = [("* TODO [#A] First [1/2] :tag:\nbody\n", "body"), ("#+TITLE: The Title\ntext\n* A\n", "text"), ("text\n* A\n", "text"), ("* A\n:PROPERTIES:\n:ID: kept\n:END:\n", "A")]
199        let forms = cases.map { c in
200            "(progn (erase-buffer) (insert \(lisp(c.0))) (goto-char \(EmacsOracle.point((c.0 as NSString).range(of: c.1).location, in: c.0))) (cl-letf (((symbol-function 'org-id-new) (lambda (&rest _) \"NEW-ID\")) ((symbol-function 'org-id-add-location) #'ignore)) (call-interactively #'org-id-store-link)) (format \"%s|%s\" (plist-get org-store-link-plist :link) (plist-get org-store-link-plist :description)))"
201        }
202        let emacs = try EmacsOracle.evaluate("", "(list \(forms.joined(separator: " ")))", prelude: "(require 'org-id) (require 'cl-lib)")
203        let ours = cases.map { c -> String in
204            let caret = (c.0 as NSString).range(of: c.1).location
205            let created = runCommand(CreateID { "NEW-ID" }, c.0, caret: caret)
206            let context = EditContext(revision: 0, text: created.text, tree: OrgParser.parse(created.text), selection: [created.caret..<created.caret])
207            let stored = Links.storeID(in: context, fileName: "oracle.org")
208            return "\(stored?.link ?? "nil")|\(stored?.description ?? "nil")"
209        }
210        #expect(ours == emacs)
211    }
212
213    @Test(.enabled(if: oracle))
214    func createdIDsAreLowercaseUUIDsLikeOrgID() throws {
215        let uuid = "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
216        let emacs = try EmacsOracle.evaluate("* A\n", "(list (progn (require 'org-id) (org-id-uuid)))")
217        #expect(emacs.first?.range(of: uuid, options: .regularExpression) != nil, "\(emacs)")
218        let text = runCommand(CreateID(), "* A\n", caret: 0).text
219        let id = try #require(text.firstMatch(of: /:ID: +(\S+)/)?.1)
220        #expect(String(id).range(of: uuid, options: .regularExpression) != nil, "\(text)")
221    }
222
223    @Test func addingMovesALinkToTheFront() {
224        let a = StoredLink(link: "file:a.org", description: "A")
225        let b = StoredLink(link: "file:b.org", description: nil)
226        var links: [StoredLink] = []
227        #expect(StoredLink.add(a, to: &links) == "Stored: A")
228        #expect(StoredLink.add(b, to: &links) == "Stored: file:b.org")
229        #expect(StoredLink.add(b, to: &links) == "This link has already been stored")
230        #expect(StoredLink.add(a, to: &links) == "Link moved to front: A")
231        #expect(links == [a, b])
232    }
233}