Tests/OrgSwiftTests/OrgTreeTests.swift
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("–"))
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("–"))
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>[ ]</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("–"))
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}