krz/orgstar

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

Tests/OrgCoreTests/ExportTests.swift

16086b4cf2caff5328774b2cd5ae3ffe1ab65ca4
orgstar/Tests/OrgCoreTests/ExportTests.swift history · blame · raw

359 lines · 13148 bytes

  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 &amp; Things</title>"))
110        #expect(page.contains("<h1 class=\"title\">Notes &amp; 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}