A dependency-free Swift library that renders org-mode to sanitized HTML.

html library org-mode swift

Tests/OrgSwiftTests/OrgTreeTests.swift

main
org-swift/Tests/OrgSwiftTests/OrgTreeTests.swift history · blame · raw

300 lines · 12235 bytes

  1import Foundation
  2import Testing
  3@testable import OrgSwift
  4
  5/// Tests for the element tree: `source  OrgDocument  {HTML, AttributedString}`.
  6///
  7/// The parse tests assert the tree carries structure faithfully. The renderer tests assert
  8/// the point of the split  that a second output format is a walk over the same tree rather
  9/// than a second parser. Corpus conformance is gated separately, in `ConformanceTests`.
 10struct ASTParseTests {
 11
 12    @Test
 13    func headingCarriesTodoPriorityAndTags() {
 14        let doc = OrgParser.parse("* TODO [#A] Write the parser :work:rust:")
 15        guard case .heading(let heading) = doc.elements.first else {
 16            Issue.record("expected a heading"); return
 17        }
 18        #expect(heading.level == 1)
 19        #expect(heading.todo == "TODO")
 20        #expect(heading.priority == "A")
 21        #expect(heading.tags == ["work", "rust"])
 22        #expect(OrgParser.plain(heading.title) == "Write the parser")
 23    }
 24
 25    @Test
 26    func emphasisNestsRatherThanFlattening() {
 27        // The point of a tree: bold containing italic is structure, not a markup string.
 28        let objects = OrgParser.parseInline("*bold /inner/ rest*")
 29        guard case .bold(let children) = objects.first else {
 30            Issue.record("expected bold"); return
 31        }
 32        #expect(children.contains { if case .italic = $0 { return true } else { return false } })
 33    }
 34
 35    @Test
 36    func documentKeywordsAreMetadataNotContent() {
 37        let doc = OrgParser.parse("#+TITLE: My Doc\n#+AUTHOR: Someone\n\nBody.")
 38        #expect(doc.keyword("title") == "My Doc")
 39        #expect(doc.keyword("author") == "Someone")
 40        // Metadata does not appear as a body element.
 41        #expect(doc.elements.count == 1)
 42        guard case .paragraph = doc.elements.first else {
 43            Issue.record("expected a single paragraph"); return
 44        }
 45    }
 46
 47    @Test
 48    func nestedListsBecomeNestedItems() {
 49        let doc = OrgParser.parse("""
 50        - outer
 51          - inner
 52            - deepest
 53        - second
 54        """)
 55        guard case .list(let list) = doc.elements.first else {
 56            Issue.record("expected a list"); return
 57        }
 58        #expect(list.items.count == 2)
 59        let inner = list.items[0].sublist
 60        #expect(inner != nil)
 61        #expect(inner?.items.first?.sublist?.items.count == 1)
 62    }
 63
 64    @Test
 65    func tableKeepsRuleRowAndAlignments() {
 66        let doc = OrgParser.parse("""
 67        | Name  | Score |
 68        |:------+------:|
 69        | alpha |    10 |
 70        """)
 71        guard case .table(let table) = doc.elements.first else {
 72            Issue.record("expected a table"); return
 73        }
 74        #expect(table.rows.count == 3)
 75        #expect(table.headerRowCount == 1)
 76        #expect(table.alignments == [.left, .right])
 77        if case .rule = table.rows[1] {} else { Issue.record("row 1 should be the rule") }
 78    }
 79
 80    @Test
 81    func timestampsAndLinksBecomeTypedObjects() {
 82        let objects = OrgParser.parseInline("due <2024-01-15 Mon 10:30> see [[id:abc][the thing]]")
 83        let hasTimestamp = objects.contains {
 84            if case .timestamp(let stamp) = $0 { return stamp.machineValue == "2024-01-15T10:30" }
 85            return false
 86        }
 87        #expect(hasTimestamp)
 88        let hasIDLink = objects.contains {
 89            if case .link(let link) = $0, case .id(let identifier) = link.target { return identifier == "abc" }
 90            return false
 91        }
 92        #expect(hasIDLink)
 93    }
 94}
 95
 96struct ASTRendererTests {
 97
 98    /// The payoff: one parse, two output formats, neither re-deriving the other's work.
 99    @Test
100    func oneParseFeedsTwoRenderers() {
101        let document = OrgParser.parse("A *bold* claim with ~code~ and a [[https://example.com][link]].")
102
103        let html = OrgHTMLTreeRenderer().render(document)
104        #expect(html.contains("<strong>bold</strong>"))
105        #expect(html.contains("<code>code</code>"))
106        #expect(html.contains(#"<a href="https://example.com">link</a>"#))
107
108        let attributed = OrgAttributedStringRenderer().inline({
109            if case .paragraph(let objects) = document.elements[0] { return objects }
110            return []
111        }())
112        // Same content, native representation: no markup, real attributes.
113        let plain = String(attributed.characters)
114        #expect(plain == "A bold claim with code and a link.")
115        #expect(!plain.contains("<"))
116
117        let boldRun = attributed.runs.first { $0.inlinePresentationIntent == .stronglyEmphasized }
118        #expect(boldRun != nil)
119        let codeRun = attributed.runs.first { $0.inlinePresentationIntent == .code }
120        #expect(codeRun != nil)
121        let linkRun = attributed.runs.first { $0.link != nil }
122        #expect(linkRun?.link?.absoluteString == "https://example.com")
123    }
124
125    @Test
126    func attributedStringCarriesRolesForNonStandardIntents() {
127        let objects = OrgParser.parseInline("x^2 and <2024-01-15 Mon>")
128        let attributed = OrgAttributedStringRenderer().inline(objects)
129        let roles: [OrgRole] = attributed.runs.compactMap { $0[OrgRoleAttribute.self] }
130        #expect(roles.contains(.superscript))
131        #expect(roles.contains(.timestamp))
132    }
133
134    @Test
135    func consecutiveListsOfDifferentKindsStaySeparate() {
136        // An ordered list followed by a bullet list is two lists, not one with mixed items.
137        let doc = OrgParser.parse("""
138        1. first
139        2. second
140
141        - [ ] todo
142        - [X] done
143        """)
144        let lists = doc.elements.compactMap { element -> OrgList? in
145            if case .list(let list) = element { return list } else { return nil }
146        }
147        #expect(lists.count == 2)
148        #expect(lists.first?.kind == .ordered)
149        #expect(lists.last?.kind == .unordered)
150        #expect(lists.last?.items.first?.checkbox == .off)
151
152        let html = OrgHTMLTreeRenderer().render(doc)
153        #expect(html.contains("</ol>"))
154        #expect(html.contains("<ul>"))
155    }
156
157    @Test
158    func inlineFootnoteDefinesItsNoteAtTheReference() {
159        let doc = OrgParser.parse("A claim.[fn:x:defined right here]")
160        let html = OrgHTMLTreeRenderer().render(doc)
161        #expect(html.contains(##"href="#fn-1">1</a>"##))
162        // Inline note text sits directly in the item; only reference-style notes get a <p>.
163        #expect(html.contains(#"<li id="fn-1">defined right here "#))
164        #expect(!html.contains(#"<li id="fn-1"><p>"#))
165    }
166
167    @Test
168    func referenceStyleFootnoteKeepsItsParagraph() {
169        let doc = OrgParser.parse("A claim.[fn:1]\n\n[fn:1] The definition.")
170        let html = OrgHTMLTreeRenderer().render(doc)
171        #expect(html.contains(#"<li id="fn-1"><p>The definition.</p>"#))
172    }
173
174    @Test
175    func timestampRangesRenderAsTwoTimeElements() {
176        // Same-day: the end shows only its time, since the start carries the date.
177        let sameDay = OrgHTMLTreeRenderer().render(OrgParser.parse("Range <2024-01-15 Mon 10:00-11:45>."))
178        #expect(sameDay.contains(#"datetime="2024-01-15T10:00">2024-01-15 10:00</time>"#))
179        #expect(sameDay.contains("&#8211;"))
180        #expect(sameDay.contains(#"datetime="2024-01-15T11:45">11:45</time>"#))
181
182        // Multi-day: one timestamp carrying an end date, rendered as two stamps.
183        let multiDay = OrgHTMLTreeRenderer().render(OrgParser.parse("Span <2024-01-15 Mon>--<2024-01-20 Sat>."))
184        #expect(multiDay.contains(#"datetime="2024-01-15">2024-01-15</time>"#))
185        #expect(multiDay.contains("&#8211;"))
186        #expect(multiDay.contains(#"datetime="2024-01-20">2024-01-20</time>"#))
187        // The `--` join is consumed, not left as stray text.
188        #expect(!multiDay.contains("--"))
189    }
190
191    @Test
192    func treeRendererProducesStructuralHTML() {
193        let document = OrgParser.parse("""
194        * Heading
195
196        | a | b |
197        |---+---|
198        | 1 | 2 |
199
200        - [ ] todo
201        - [X] done
202        """)
203        let html = OrgHTMLTreeRenderer().render(document)
204        #expect(html.contains("<h1>Heading</h1>"))
205        #expect(html.contains("<thead>"))
206        #expect(html.contains("<th>a</th>"))
207        #expect(html.contains("<td>1</td>"))
208        #expect(html.contains("<code>[&nbsp;]</code> todo"))
209        #expect(html.contains("<code>[X]</code> done"))
210    }
211}
212
213/// The render options, which the conformance corpus does not exercise because it renders with
214/// no repository context. These were written as shipped-vs-tree equivalence checks during the
215/// migration; now that the tree *is* the renderer, they assert the behaviour directly.
216struct OrgRenderOptionsTests {
217
218    /// gitbay's configuration: images resolve against `raw`, links against `blob`.
219    private static let repositoryOptions = OrgRenderOptions(
220        host: "gitbay.org",
221        owner: "krz",
222        repositoryName: "gitbay",
223        ref: "HEAD",
224        readmePath: "README.org",
225        imagePathSegment: "raw",
226        linkPathSegment: "blob"
227    )
228
229    @Test
230    func resolvesRepositoryRelativeURLsAgainstTheirOwnSegment() {
231        let html = OrgRenderer.renderToHTML("""
232        A relative link to [[docs/DESIGN.org][the design]] and an absolute one to
233        [[https://example.org][elsewhere]].
234
235        [[file:docs/logo.png]]
236        """, options: Self.repositoryOptions)
237
238        #expect(html.contains("https://gitbay.org/krz/gitbay/raw/HEAD/docs/logo.png"))
239        #expect(html.contains("https://gitbay.org/krz/gitbay/blob/HEAD/docs/DESIGN.org"))
240        #expect(html.contains("https://example.org"))
241    }
242
243    @Test
244    func leavesRelativeTargetsAloneWithoutRepositoryContext() {
245        let html = OrgRenderer.renderToHTML("[[docs/DESIGN.org][the design]]\n\n[[file:logo.png]]")
246        #expect(html.contains(#"href="docs/DESIGN.org""#))
247        #expect(html.contains(#"src="logo.png""#))
248    }
249
250    @Test
251    func emitsTheMetadataHeaderOnlyWhenAsked() {
252        let source = "#+TITLE: My Doc\n#+AUTHOR: Someone\n\nBody."
253
254        let withHeader = OrgRenderer.renderToHTML(source)
255        #expect(withHeader.contains(#"<h1 class="org-title">My Doc</h1>"#))
256        #expect(withHeader.contains(#"<p class="org-author">Someone</p>"#))
257
258        let without = OrgRenderer.renderToHTML(source, options: OrgRenderOptions(metadataHeader: false))
259        #expect(!without.contains("org-title"))
260        #expect(!without.contains("My Doc"))
261        #expect(without.contains("<p>Body.</p>"))
262    }
263
264    @Test
265    func rejectsUnsafeSchemes() {
266        let html = OrgRenderer.renderToHTML("[[javascript:alert(1)][click]]")
267        #expect(!html.lowercased().contains("javascript:"))
268        // The link degrades to its text rather than becoming a bad anchor.
269        #expect(html.contains("click"))
270    }
271
272    /// A range whose halves are inactive timestamps is still a range. orgo applies the `--`
273    /// rule to both bracket kinds, requiring only that the halves agree on activeness; the
274    /// renderer this replaced joined active ranges only, so this is the one behaviour the
275    /// swap deliberately changed.
276    @Test
277    func joinsInactiveTimestampRanges() {
278        let html = OrgRenderer.renderToHTML("CLOCK: [2024-01-15 Mon 09:00]--[2024-01-15 Mon 10:00]")
279        #expect(!html.contains("--"))
280        #expect(html.contains("&#8211;"))
281        #expect(html.contains(#"class="timestamp inactive""#))
282    }
283}
284
285private func astCorpusCasesDir() -> URL? {
286    if let env = ProcessInfo.processInfo.environment["ORG_CONFORMANCE_DIR"] {
287        let cases = URL(fileURLWithPath: env).appendingPathComponent("cases")
288        if FileManager.default.fileExists(atPath: cases.path) { return cases }
289    }
290    let pkgRoot = URL(fileURLWithPath: #filePath)
291        .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
292    let sibling = pkgRoot.deletingLastPathComponent()
293        .appendingPathComponent("org-conformance").appendingPathComponent("cases")
294    return FileManager.default.fileExists(atPath: sibling.path) ? sibling : nil
295}
296
297private func astCaseNames(_ dir: URL) -> [String] {
298    let items = (try? FileManager.default.contentsOfDirectory(atPath: dir.path)) ?? []
299    return items.filter { $0.hasSuffix(".org") }.map { String($0.dropLast(4)) }.sorted()
300}