Tests/OrgSwiftUITests/OrgLayoutTests.swift
379 lines · 14230 bytes
1import Foundation
2import SwiftUI
3import Testing
4@testable import OrgSwift
5@testable import OrgSwiftUI
6
7/// Tests for the third renderer over the shared tree: `OrgDocument → OrgLayout`.
8///
9/// The views themselves are layout over this model, so the model is where the org behaviour
10/// lives and where it is worth asserting.
11@MainActor
12struct OrgLayoutTests {
13
14 private func layout(
15 _ source: String,
16 options: OrgRenderOptions = .init(),
17 styler: OrgCodeStyler = PlainOrgCodeStyler(),
18 keywords: OrgKeywordStyle = .init()
19 ) -> OrgLayout {
20 OrgLayoutBuilder(options: options, styler: styler, keywords: keywords)
21 .layout(OrgParser.parse(source))
22 }
23
24 private func plain(_ string: AttributedString) -> String { String(string.characters) }
25
26 /// An item's first paragraph, as plain text.
27 private func firstParagraph(_ item: OrgListLayout.Item) -> String {
28 for case .paragraph(let text) in item.blocks { return plain(text) }
29 return ""
30 }
31
32 /// The nested list an item carries, if any.
33 private func sublist(_ item: OrgListLayout.Item) -> OrgListLayout? {
34 for case .list(let list) in item.blocks { return list }
35 return nil
36 }
37
38 @Test
39 func blocksCoverTheElementTree() {
40 let blocks = layout("""
41 * Heading
42
43 A paragraph.
44
45 - one
46 - two
47
48 | a | b |
49 |---+---|
50 | 1 | 2 |
51
52 #+begin_src swift
53 let x = 1
54 #+end_src
55
56 #+begin_quote
57 quoted
58 #+end_quote
59
60 -----
61 """).blocks
62
63 func has(_ predicate: (OrgBlock) -> Bool) -> Bool { blocks.contains(where: predicate) }
64 #expect(has { if case .heading = $0 { true } else { false } })
65 #expect(has { if case .paragraph = $0 { true } else { false } })
66 #expect(has { if case .list = $0 { true } else { false } })
67 #expect(has { if case .table = $0 { true } else { false } })
68 #expect(has { if case .code = $0 { true } else { false } })
69 #expect(has { if case .quote = $0 { true } else { false } })
70 #expect(has { if case .rule = $0 { true } else { false } })
71 }
72
73 /// Numbering is document-wide and the definitions leave the body, exactly as in HTML.
74 @Test
75 func footnotesAreNumberedAndMovedToTheSection() {
76 let result = layout("""
77 First[fn:b] and second[fn:a].
78
79 [fn:a] note a
80 [fn:b] note b
81 """)
82
83 #expect(result.footnotes.map(\.number) == [1, 2])
84 #expect(plain(result.footnotes[0].text) == "note b")
85 #expect(plain(result.footnotes[1].text) == "note a")
86
87 guard case .paragraph(let text) = result.blocks[0] else {
88 Issue.record("expected a paragraph"); return
89 }
90 #expect(plain(text) == "First[1] and second[2].")
91 // The definitions are not also blocks in the body.
92 #expect(result.blocks.count == 1)
93 }
94
95 @Test
96 func nestedListsRecurse() {
97 guard case .list(let list) = layout("""
98 - outer
99 - inner
100 - deepest
101 """).blocks.first else {
102 Issue.record("expected a list"); return
103 }
104 #expect(list.kind == .unordered)
105 #expect(firstParagraph(list.items[0]) == "outer")
106 guard let inner = sublist(list.items[0]) else {
107 Issue.record("expected a nested list"); return
108 }
109 #expect(firstParagraph(inner.items[0]) == "inner")
110 #expect(sublist(inner.items[0]).map { firstParagraph($0.items[0]) } == "deepest")
111 }
112
113 /// The native path renders an item's blocks as blocks; before, a `#+begin_src` inside a
114 /// list item came out as literal prose.
115 @Test
116 func listItemBlocksBecomeBlocks() {
117 guard case .list(let list) = layout("""
118 1. Create a new branch:
119 #+begin_src bash
120 git checkout -b my-feature
121 #+end_src
122 """).blocks.first else {
123 Issue.record("expected a list"); return
124 }
125 #expect(firstParagraph(list.items[0]) == "Create a new branch:")
126 guard case .code(let code)? = list.items[0].blocks.last else {
127 Issue.record("expected a code block in the item"); return
128 }
129 #expect(plain(code) == "git checkout -b my-feature")
130 }
131
132 @Test
133 func checkboxesAndDescriptionListsSurvive() {
134 guard case .list(let tasks) = layout("- [X] done\n- [ ] todo").blocks.first else {
135 Issue.record("expected a list"); return
136 }
137 #expect(tasks.items.map(\.checkbox) == [.on, .off])
138
139 guard case .list(let described) = layout("- term :: definition").blocks.first else {
140 Issue.record("expected a description list"); return
141 }
142 #expect(described.kind == .description)
143 #expect(plain(described.items[0].term ?? "") == "term")
144 #expect(firstParagraph(described.items[0]) == "definition")
145 }
146
147 /// Rule rows are structure, not content: they set the header band and then disappear.
148 @Test
149 func tableDropsRuleRowsAndKeepsAlignments() {
150 guard case .table(let table) = layout("""
151 | name | count |
152 |------+------:|
153 | a | 1 |
154 | b | 2 |
155 """).blocks.first else {
156 Issue.record("expected a table"); return
157 }
158 #expect(table.rows.count == 3)
159 #expect(table.headerRowCount == 1)
160 #expect(table.columnCount == 2)
161 #expect(table.alignment(column: 1) == .right)
162 #expect(plain(table.rows[0].cells[0].text) == "name")
163 #expect(plain(table.rows[2].cells[1].text) == "2")
164 }
165
166 @Test
167 func imagesResolveRelativeSourcesAndDegradeWhenUnsafe() {
168 let options = OrgRenderOptions(
169 host: "gitbay.org", owner: "krz", repositoryName: "org-swift",
170 ref: "HEAD", readmePath: "README.org", imagePathSegment: "raw"
171 )
172 guard case .image(let source, _, _) = layout("[[file:docs/logo.png]]", options: options)
173 .blocks.first else {
174 Issue.record("expected an image"); return
175 }
176 #expect(source.absoluteString == "https://gitbay.org/krz/org-swift/raw/HEAD/docs/logo.png")
177
178 // With no repository context the relative path cannot be made absolute, so it shows
179 // as text rather than as a broken image.
180 guard case .paragraph(let text) = layout("[[file:docs/logo.png]]").blocks.first else {
181 Issue.record("expected a paragraph fallback"); return
182 }
183 #expect(plain(text) == "docs/logo.png")
184 }
185
186 @Test
187 func codeStylerIsUsedWhenItAnswers() {
188 struct Stub: OrgCodeStyler {
189 func highlighted(code: String, language: String?) -> AttributedString? {
190 language == "swift" ? AttributedString("STYLED:\(code.count)") : nil
191 }
192 }
193 guard case .code(let styled) = layout(
194 "#+begin_src swift\nlet x = 1\n#+end_src", styler: Stub()
195 ).blocks.first else {
196 Issue.record("expected a code block"); return
197 }
198 #expect(plain(styled).hasPrefix("STYLED:"))
199
200 // An unknown language falls back to plain monospaced text, never to nothing.
201 guard case .code(let plainCode) = layout(
202 "#+begin_src brainfuck\n+++\n#+end_src", styler: Stub()
203 ).blocks.first else {
204 Issue.record("expected a code block"); return
205 }
206 #expect(plain(plainCode) == "+++")
207 }
208
209 /// HTML export has nowhere to go natively, so it shows as the literal block it is; other
210 /// backends drop, as they do in HTML.
211 @Test
212 func exportBlocksShowHTMLLiterallyAndDropOtherBackends() {
213 #expect(layout("#+begin_export html\n<b>hi</b>\n#+end_export").blocks.count == 1)
214 #expect(layout("#+begin_export latex\n\\emph{hi}\n#+end_export").blocks.isEmpty)
215 }
216
217 /// Keyword colors come from the caller. The package defaults to semantic colors rather
218 /// than inventing hues that match no app's palette.
219 @Test
220 func todoKeywordsTakeTheirColorFromTheCaller() {
221 let style = OrgKeywordStyle(todo: .pink, done: .brown)
222
223 guard case .heading(let todo, _) = layout("* TODO Ship it", keywords: style)
224 .blocks.first else {
225 Issue.record("expected a heading"); return
226 }
227 #expect(todo.runs.contains { $0.foregroundColor == .pink })
228
229 guard case .heading(let done, _) = layout("* DONE Shipped", keywords: style)
230 .blocks.first else {
231 Issue.record("expected a heading"); return
232 }
233 #expect(done.runs.contains { $0.foregroundColor == .brown })
234
235 // The default invents nothing.
236 guard case .heading(let plain, _) = layout("* TODO Ship it").blocks.first else {
237 Issue.record("expected a heading"); return
238 }
239 #expect(plain.runs.allSatisfy { $0.foregroundColor != .green && $0.foregroundColor != .orange })
240 }
241
242 /// Every run carries a font, not only the emphasised ones. A plain heading run with no
243 /// font would inherit the surrounding view's and render at body size.
244 @Test
245 func everyRunCarriesItsBlockFont() {
246 guard case .heading(let title, _) = layout("* Plain heading").blocks.first else {
247 Issue.record("expected a heading"); return
248 }
249 #expect(title.runs.allSatisfy { $0.font != nil })
250 #expect(title.runs.contains { $0.font == Font.system(.title, design: .default).bold() })
251
252 // Same for the blocks that render at a non-body style.
253 guard case .table(let table) = layout("| a |\n|---|\n| b |").blocks.first else {
254 Issue.record("expected a table"); return
255 }
256 #expect(table.rows.allSatisfy { $0.cells.allSatisfy { cell in
257 cell.text.runs.allSatisfy { $0.font != nil }
258 } })
259
260 let notes = layout("Claim[fn:a]\n\n[fn:a] note").footnotes
261 #expect(notes.first?.text.runs.allSatisfy { $0.font != nil } == true)
262 }
263
264 /// Emphasis nests, and the styler turns intents into fonts rather than dropping them.
265 @Test
266 func inlineStylingCarriesNestedEmphasis() {
267 guard case .paragraph(let text) = layout("*bold /italic/* and ~code~").blocks.first else {
268 Issue.record("expected a paragraph"); return
269 }
270 #expect(plain(text) == "bold italic and code")
271 // Every emphasised or code run got a concrete font; unstyled runs inherit the view's.
272 let fonts = text.runs.compactMap(\.font)
273 #expect(fonts.count >= 3)
274 }
275}
276
277/// The badge row: a paragraph of nothing but images and image links.
278@MainActor
279struct OrgBadgeRowTests {
280
281 private func blocks(_ source: String) -> [OrgBlock] {
282 OrgLayoutBuilder(options: .init(), styler: PlainOrgCodeStyler(), keywords: .init())
283 .layout(OrgParser.parse(source))
284 .blocks
285 }
286
287 private func badges(_ source: String) -> [OrgBadge]? {
288 guard case .badges(let badges) = blocks(source).first else { return nil }
289 return badges
290 }
291
292 @Test
293 func aLinkWrappingAnImageIsABadge() throws {
294 let row = try #require(badges(
295 "[[https://gitbay.org/krz/gitbay/builds][file:https://gitbay.org/krz/gitbay/badge/build.svg]]"
296 ))
297 #expect(row.count == 1)
298 #expect(row[0].source.absoluteString == "https://gitbay.org/krz/gitbay/badge/build.svg")
299 #expect(row[0].link?.absoluteString == "https://gitbay.org/krz/gitbay/builds")
300 }
301
302 @Test
303 func severalBadgesOnOneLineAreOneRow() throws {
304 let row = try #require(badges("""
305 [[https://example.com/a][file:https://example.com/a.png]] \
306 [[https://example.com/b][file:https://example.com/b.png]]
307 """))
308 #expect(row.count == 2)
309 }
310
311 @Test
312 func anUnlinkedImageIsStillABadgeWhenItSharesTheLine() throws {
313 let row = try #require(badges(
314 "[[https://example.com/a][file:https://example.com/a.png]] [[file:https://example.com/b.png]]"
315 ))
316 #expect(row.count == 2)
317 #expect(row[1].link == nil)
318 }
319
320 @Test
321 func aParagraphWithProseIsNotABadgeRow() {
322 #expect(badges(
323 "Build: [[https://example.com/a][file:https://example.com/a.png]]"
324 ) == nil)
325 }
326
327 @Test
328 func aLinkWithATextDescriptionIsNotABadgeRow() {
329 #expect(badges("[[https://example.com][the builds]]") == nil)
330 }
331
332 /// A lone image on its own line is already a figure, and stays one: a figure carries a
333 /// caption and fills the column, which is right for a diagram and wrong for a badge.
334 @Test
335 func aStandaloneImageStaysAFigure() {
336 if case .image = blocks("[[file:https://example.com/diagram.png]]").first { return }
337 Issue.record("expected a figure block")
338 }
339
340 /// A relative source with no repository to resolve against cannot be made absolute, and
341 /// a badge has no alt-text fallback to degrade to, so the paragraph stays prose.
342 @Test
343 func anUnresolvableSourceIsNotABadgeRow() {
344 #expect(badges("[[https://example.com][file:badge.png]]") == nil)
345 }
346
347 /// The transform sees each run of prose after styling, with code and links still marked,
348 /// and never sees a code block.
349 @Test
350 func textTransformRunsOnProseOnly() {
351 var seen: [String] = []
352 let blocks = OrgLayoutBuilder(
353 options: .init(), styler: PlainOrgCodeStyler(), keywords: .init(),
354 textTransform: { text in
355 seen.append(String(text.characters))
356 for run in text.runs where run.inlinePresentationIntent?.contains(.code) == true {
357 text[run.range].foregroundColor = .red
358 }
359 }
360 ).layout(OrgParser.parse("""
361 * Title
362
363 Some =x= here.
364
365 | cell |
366
367 #+begin_src sh
368 echo hi
369 #+end_src
370 """)).blocks
371
372 #expect(seen == ["Title", "Some x here.", "cell"])
373 guard case .paragraph(let text) = blocks[1] else {
374 Issue.record("expected a paragraph")
375 return
376 }
377 #expect(text.runs.contains { $0.foregroundColor == .red && String(text[$0.range].characters) == "x" })
378 }
379}