Tests/OrgCoreTests/ExportTests.swift
359 lines · 13148 bytes
15 symbols in this file
1import Foundation
2import Testing
3@testable import OrgCore
4
5/// The org-conformance skeleton reduction (SKELETON.md): element opens and closes and text
6/// runs, without `div`/`span` and attributes other than `href` and `src`.
7enum Skeleton {
8 static let ignored: Set<String> = ["div", "span"]
9 static let void: Set<String> = ["br", "hr", "img", "input", "meta", "link", "col", "area", "base", "source", "wbr"]
10
11 static func decode(_ s: String) -> String {
12 var out = ""
13 var i = s.startIndex
14 while i < s.endIndex {
15 if s[i] == "&", let semicolon = s[i...].prefix(13).firstIndex(of: ";") {
16 let name = String(s[s.index(after: i)..<semicolon])
17 var replacement: String?
18 switch name {
19 case "amp": replacement = "&"
20 case "lt": replacement = "<"
21 case "gt": replacement = ">"
22 case "quot": replacement = "\""
23 case "apos": replacement = "'"
24 case "nbsp": replacement = " "
25 default:
26 if name.hasPrefix("#x") || name.hasPrefix("#X"), let v = UInt32(name.dropFirst(2), radix: 16), let u = Unicode.Scalar(v) {
27 replacement = String(Character(u))
28 } else if name.hasPrefix("#"), let v = UInt32(name.dropFirst()), let u = Unicode.Scalar(v) {
29 replacement = String(Character(u))
30 }
31 }
32 if let replacement {
33 out += replacement == "\u{a0}" ? " " : replacement
34 i = s.index(after: semicolon)
35 continue
36 }
37 }
38 out.append(s[i] == "\u{a0}" ? " " : s[i])
39 i = s.index(after: i)
40 }
41 return out
42 }
43
44 static func reduce(_ html: String) -> String {
45 var lines: [String] = []
46 var text = ""
47 func flush() {
48 let collapsed = decode(text).split(whereSeparator: \.isWhitespace).joined(separator: " ")
49 if !collapsed.isEmpty {
50 lines.append("\"" + collapsed.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + "\"")
51 }
52 text = ""
53 }
54 var i = html.startIndex
55 while i < html.endIndex {
56 if html[i] == "<" {
57 if html[i...].hasPrefix("<!--") {
58 let end = html[i...].range(of: "-->")?.upperBound ?? html.endIndex
59 i = end
60 continue
61 }
62 guard let close = html[i...].firstIndex(of: ">") else { text.append(html[i]); i = html.index(after: i); continue }
63 let tag = String(html[html.index(after: i)..<close])
64 i = html.index(after: close)
65 if tag.hasPrefix("!") { continue }
66 let closing = tag.hasPrefix("/")
67 var body = closing ? String(tag.dropFirst()) : tag
68 if body.hasSuffix("/") { body.removeLast() }
69 let name = String(body.prefix { !$0.isWhitespace }).lowercased()
70 if ignored.contains(name) { continue }
71 if closing {
72 if void.contains(name) { continue }
73 flush()
74 lines.append("</\(name)>")
75 continue
76 }
77 flush()
78 var attributes = ""
79 for attribute in ["href", "src"] {
80 if let m = body.firstMatch(of: try! Regex("\\s\(attribute)=\"([^\"]*)\"")) {
81 attributes += " \(attribute)=\"\(decode(String(m.output[1].substring!)))\""
82 }
83 }
84 lines.append("<\(name)\(attributes)>")
85 } else {
86 text.append(html[i])
87 i = html.index(after: i)
88 }
89 }
90 flush()
91 return lines.joined(separator: "\n") + "\n"
92 }
93}
94
95struct ExportTests {
96 static let folder = URL(fileURLWithPath: #filePath).deletingLastPathComponent().appendingPathComponent("Conformance")
97
98 @Test(arguments: ["minimal", "core", "elements", "headings", "listblocks", "lists", "table", "blocks", "timestamps", "footnote", "images", "tblfm"])
99 func conformance(_ name: String) throws {
100 let org = try String(contentsOf: Self.folder.appendingPathComponent("\(name).org"), encoding: .utf8)
101 let expected = try String(contentsOf: Self.folder.appendingPathComponent("\(name).skeleton"), encoding: .utf8)
102 let html = HTMLExport.body(org)
103 let ours = Skeleton.reduce(html)
104 #expect(ours == expected, "\(name)\n--- ours\n\(ours)\n--- html\n\(html)")
105 }
106
107 @Test func page() {
108 let page = HTMLExport.page("#+TITLE: Notes & Things\n#+AUTHOR: Me\n\nHello.\n", fallbackTitle: "x")
109 #expect(page.contains("<title>Notes & Things</title>"))
110 #expect(page.contains("<h1 class=\"title\">Notes & Things</h1>"))
111 #expect(page.contains("<p class=\"byline\">Me</p>"))
112 #expect(page.contains("<p>Hello.</p>"))
113 }
114}
115
116struct MarkdownExportTests {
117 @Test func document() {
118 let org = """
119 #+TITLE: Notes
120
121 Intro with *bold*, /italic/, =code=, a [[https://example.com][link]] and a note.[fn:1]
122
123 * TODO Plans :work:
124 - [ ] first
125 - [X] second
126 1. nested
127 - term :: definition
128
129 | a | b |
130 |---+---|
131 | 1 | 2 |
132
133 #+begin_src swift
134 let x = 1
135 #+end_src
136
137 #+begin_quote
138 Quoted.
139 #+end_quote
140
141 [fn:1] The note.
142 """
143 let expected = """
144 # Notes
145
146 Intro with **bold**, *italic*, `code`, a [link](https://example.com) and a note.[^1]
147
148 ## TODO Plans `work`
149
150 - [ ] first
151 - [x] second
152 1. nested
153 - **term**: definition
154
155 | a | b |
156 |---|---|
157 | 1 | 2 |
158
159 ```swift
160 let x = 1
161 ```
162
163 > Quoted.
164
165 [^1]: The note.
166
167 """
168 let ours = MarkdownExport.document(org)
169 #expect(ours == expected)
170 }
171
172 /// Words that ox-md keeps or drops for `#+OPTIONS`, the tags, COMMENT, `#+INCLUDE` and
173 /// macros, and where links to Org files point.
174 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
175 func documentLevelLikeOrgMd() throws {
176 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-md-\(UUID().uuidString)")
177 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
178 defer { try? FileManager.default.removeItem(at: folder) }
179 try "* Included\nwordincluded\n".write(to: folder.appendingPathComponent("part.org"), atomically: true, encoding: .utf8)
180 let text = """
181 #+TITLE: wordtitle
182 #+OPTIONS: todo:nil tags:nil ^:nil toc:nil num:nil
183 #+EXCLUDE_TAGS: private
184 #+MACRO: greet wordmacro $1
185 {{{greet(wordarg)}}} a_wordsub
186 * TODO wordtodo :wordtag:
187 * Kept :noexport:
188 wordnoexport
189 * Private :private:
190 wordprivate
191 * COMMENT Hidden
192 wordcomment
193 * Links
194 [[file:x.org]] [[file:x.org][X]] [[./y.org]] [[file:sub/z.ORG::*Head][Z]]
195 #+INCLUDE: "part.org"
196 """ + "\n"
197 let path = folder.appendingPathComponent("main.org").path
198 try text.write(toFile: path, atomically: true, encoding: .utf8)
199 let emacs = try EmacsOracle.evaluate(text, """
200 (with-current-buffer (find-file-noselect \(Babel.lispString(path)))
201 (require 'ox-md)
202 (list (org-export-as 'md)))
203 """)[0]
204 let ours = MarkdownExport.document(text, path: path)
205 // ox-md writes no title; this export makes it the top heading.
206 for word in text.matches(of: /\bword[a-z]+\b|\bTODO\b/).map({ String($0.0) }) where word != "wordtitle" {
207 #expect(ours.contains(word) == emacs.contains(word), "\(word)\nours:\n\(ours)\nemacs:\n\(emacs)")
208 }
209 #expect(ours.contains("<sub>") == emacs.contains("<sub>"))
210 let targets = { (s: String) in Set(s.matches(of: /[\w.\/]+\.(?:md|org|ORG)\b/).map { String($0.0) }) }
211 #expect(targets(ours) == targets(emacs), "ours:\n\(ours)\nemacs:\n\(emacs)")
212 }
213}
214
215struct ExportCorpusTests {
216 /// `ORGSTAR_ORACLE_CORPUS=<folder>`: every file exports to HTML and Markdown.
217 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_ORACLE_CORPUS"] != nil))
218 func exportsEveryFile() throws {
219 let root = URL(fileURLWithPath: ProcessInfo.processInfo.environment["ORGSTAR_ORACLE_CORPUS"]!)
220 let files = FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil)!
221 .compactMap { $0 as? URL }.filter { $0.pathExtension == "org" }
222 var slowest = (0.0, "")
223 for file in files {
224 guard let text = try? String(contentsOf: file, encoding: .utf8) else { continue }
225 let start = Date()
226 let html = HTMLExport.page(text, fallbackTitle: file.lastPathComponent)
227 let markdown = MarkdownExport.document(text)
228 let elapsed = Date().timeIntervalSince(start)
229 if elapsed > slowest.0 { slowest = (elapsed, file.lastPathComponent) }
230 #expect(html.contains("</html>") && !markdown.isEmpty, "\(file.path)")
231 }
232 print("exported \(files.count) files; slowest \(slowest.1) in \(String(format: "%.2f", slowest.0)) s")
233 }
234}
235
236struct ExportResultsTests {
237 @Test func resultsFollowExports() {
238 let org = """
239 #+begin_src sh
240 echo a
241 #+end_src
242
243 #+RESULTS:
244 : a
245
246 #+begin_src sh :exports both
247 echo b
248 #+end_src
249
250 #+RESULTS:
251 : b
252
253 #+begin_src sh :exports results
254 echo c
255 #+end_src
256
257 #+RESULTS:
258 : c
259 """
260 let html = HTMLExport.body(org)
261 #expect(html.contains("echo a") && !html.contains(">a</pre>"))
262 #expect(html.contains("echo b") && html.contains(">b</pre>"))
263 #expect(!html.contains("echo c") && html.contains(">c</pre>"))
264 }
265
266 static let exports = """
267 #+PROPERTY: header-args :exports both
268 * Heading
269 :PROPERTIES:
270 :header-args:sh: :exports results
271 :END:
272 #+begin_src sh
273 echo codeprop
274 #+end_src
275
276 #+RESULTS:
277 : resprop
278
279 #+HEADER: :exports code
280 #+begin_src sh
281 echo codeheader
282 #+end_src
283
284 #+RESULTS:
285 : resheader
286
287 #+begin_src python
288 codefile
289 #+end_src
290
291 #+RESULTS:
292 : resfile
293
294 #+NAME: named
295 #+begin_src sh :exports none
296 echo codenamed
297 #+end_src
298
299 #+CALL: named()
300
301 #+RESULTS:
302 : rescall
303
304 #+CALL: named() :exports code
305
306 #+RESULTS:
307 : rescallcode
308
309 #+begin_src dot :file graph.png
310 digraph { codedot }
311 #+end_src
312
313 #+RESULTS:
314 : resdot
315
316 Inline src_sh{echo codeinline} {{{results(=resinline=)}}} one.
317
318 Inline src_sh[:exports code]{echo codeinlinecode} {{{results(=resinlinecode=)}}} two.
319
320 Inline src_sh[:exports both]{echo codeinlineboth} {{{results(=resinlineboth=)}}} three.
321
322 Inline call_named() {{{results(=resinlinecall=)}}} four.
323
324 * Other
325 :PROPERTIES:
326 :header-args: :exports none
327 :END:
328 #+CALL: named()
329
330 #+RESULTS:
331 : rescallother
332
333 Inline call_named() {{{results(=resinlinecallother=)}}} five.
334
335 Inline call_named[:exports code]() {{{results(=resinlinecallcode=)}}} six.
336 """ + "\n"
337
338 /// Which code and results the HTML and Markdown exports keep, against ox-html with nothing
339 /// evaluated.
340 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
341 func exportsLikeOrg() throws {
342 let emacs = try EmacsOracle.evaluate(Self.exports, """
343 (progn
344 (require 'ox-html) (require 'ob-shell) (require 'ob-python) (require 'ob-dot)
345 (dolist (v '(org-babel-default-header-args org-babel-default-inline-header-args org-babel-default-lob-header-args))
346 (set v (cons '(:eval . "never-export") (symbol-value v))))
347 (list (org-export-as 'html nil nil t)))
348 """)[0]
349 let words = Self.exports.matches(of: /\b(?:code|res)[a-z]+\b/).map { String($0.0) }
350 let expected = Set(words.filter { emacs.contains($0) })
351 #expect(expected.count > 4 && expected.count < words.count)
352 let html = HTMLExport.page(Self.exports, fallbackTitle: "t")
353 let markdown = MarkdownExport.document(Self.exports)
354 for word in words {
355 #expect(html.contains(word) == expected.contains(word), "HTML \(word)")
356 #expect(markdown.contains(word) == expected.contains(word), "Markdown \(word)")
357 }
358 }
359}