Tests/OrgCoreTests/LinkStoreTests.swift
222 lines · 13273 bytes
10 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}