Tests/OrgSwiftUITests/OrgLayoutTests.swift
313 lines · 11891 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 @Test
27 func blocksCoverTheElementTree() {
28 let blocks = layout("""
29 * Heading
30
31 A paragraph.
32
33 - one
34 - two
35
36 | a | b |
37 |---+---|
38 | 1 | 2 |
39
40 #+begin_src swift
41 let x = 1
42 #+end_src
43
44 #+begin_quote
45 quoted
46 #+end_quote
47
48 -----
49 """).blocks
50
51 func has(_ predicate: (OrgBlock) -> Bool) -> Bool { blocks.contains(where: predicate) }
52 #expect(has { if case .heading = $0 { true } else { false } })
53 #expect(has { if case .paragraph = $0 { true } else { false } })
54 #expect(has { if case .list = $0 { true } else { false } })
55 #expect(has { if case .table = $0 { true } else { false } })
56 #expect(has { if case .code = $0 { true } else { false } })
57 #expect(has { if case .quote = $0 { true } else { false } })
58 #expect(has { if case .rule = $0 { true } else { false } })
59 }
60
61 /// Numbering is document-wide and the definitions leave the body, exactly as in HTML.
62 @Test
63 func footnotesAreNumberedAndMovedToTheSection() {
64 let result = layout("""
65 First[fn:b] and second[fn:a].
66
67 [fn:a] note a
68 [fn:b] note b
69 """)
70
71 #expect(result.footnotes.map(\.number) == [1, 2])
72 #expect(plain(result.footnotes[0].text) == "note b")
73 #expect(plain(result.footnotes[1].text) == "note a")
74
75 guard case .paragraph(let text) = result.blocks[0] else {
76 Issue.record("expected a paragraph"); return
77 }
78 #expect(plain(text) == "First[1] and second[2].")
79 // The definitions are not also blocks in the body.
80 #expect(result.blocks.count == 1)
81 }
82
83 @Test
84 func nestedListsRecurse() {
85 guard case .list(let list) = layout("""
86 - outer
87 - inner
88 - deepest
89 """).blocks.first else {
90 Issue.record("expected a list"); return
91 }
92 #expect(list.kind == .unordered)
93 #expect(plain(list.items[0].paragraphs[0]) == "outer")
94 let inner = list.items[0].sublist
95 #expect(plain(inner?.items[0].paragraphs[0] ?? "") == "inner")
96 #expect(plain(inner?.items[0].sublist?.items[0].paragraphs[0] ?? "") == "deepest")
97 }
98
99 @Test
100 func checkboxesAndDescriptionListsSurvive() {
101 guard case .list(let tasks) = layout("- [X] done\n- [ ] todo").blocks.first else {
102 Issue.record("expected a list"); return
103 }
104 #expect(tasks.items.map(\.checkbox) == [.on, .off])
105
106 guard case .list(let described) = layout("- term :: definition").blocks.first else {
107 Issue.record("expected a description list"); return
108 }
109 #expect(described.kind == .description)
110 #expect(plain(described.items[0].term ?? "") == "term")
111 #expect(plain(described.items[0].paragraphs[0]) == "definition")
112 }
113
114 /// Rule rows are structure, not content: they set the header band and then disappear.
115 @Test
116 func tableDropsRuleRowsAndKeepsAlignments() {
117 guard case .table(let table) = layout("""
118 | name | count |
119 |------+------:|
120 | a | 1 |
121 | b | 2 |
122 """).blocks.first else {
123 Issue.record("expected a table"); return
124 }
125 #expect(table.rows.count == 3)
126 #expect(table.headerRowCount == 1)
127 #expect(table.columnCount == 2)
128 #expect(table.alignment(column: 1) == .right)
129 #expect(plain(table.rows[0].cells[0].text) == "name")
130 #expect(plain(table.rows[2].cells[1].text) == "2")
131 }
132
133 @Test
134 func imagesResolveRelativeSourcesAndDegradeWhenUnsafe() {
135 let options = OrgRenderOptions(
136 host: "gitbay.org", owner: "krz", repositoryName: "org-swift",
137 ref: "HEAD", readmePath: "README.org", imagePathSegment: "raw"
138 )
139 guard case .image(let source, _, _) = layout("[[file:docs/logo.png]]", options: options)
140 .blocks.first else {
141 Issue.record("expected an image"); return
142 }
143 #expect(source.absoluteString == "https://gitbay.org/krz/org-swift/raw/HEAD/docs/logo.png")
144
145 // With no repository context the relative path cannot be made absolute, so it shows
146 // as text rather than as a broken image.
147 guard case .paragraph(let text) = layout("[[file:docs/logo.png]]").blocks.first else {
148 Issue.record("expected a paragraph fallback"); return
149 }
150 #expect(plain(text) == "docs/logo.png")
151 }
152
153 @Test
154 func codeStylerIsUsedWhenItAnswers() {
155 struct Stub: OrgCodeStyler {
156 func highlighted(code: String, language: String?) -> AttributedString? {
157 language == "swift" ? AttributedString("STYLED:\(code.count)") : nil
158 }
159 }
160 guard case .code(let styled) = layout(
161 "#+begin_src swift\nlet x = 1\n#+end_src", styler: Stub()
162 ).blocks.first else {
163 Issue.record("expected a code block"); return
164 }
165 #expect(plain(styled).hasPrefix("STYLED:"))
166
167 // An unknown language falls back to plain monospaced text, never to nothing.
168 guard case .code(let plainCode) = layout(
169 "#+begin_src brainfuck\n+++\n#+end_src", styler: Stub()
170 ).blocks.first else {
171 Issue.record("expected a code block"); return
172 }
173 #expect(plain(plainCode) == "+++")
174 }
175
176 /// HTML export has nowhere to go natively, so it shows as the literal block it is; other
177 /// backends drop, as they do in HTML.
178 @Test
179 func exportBlocksShowHTMLLiterallyAndDropOtherBackends() {
180 #expect(layout("#+begin_export html\n<b>hi</b>\n#+end_export").blocks.count == 1)
181 #expect(layout("#+begin_export latex\n\\emph{hi}\n#+end_export").blocks.isEmpty)
182 }
183
184 /// Keyword colors come from the caller. The package defaults to semantic colors rather
185 /// than inventing hues that match no app's palette.
186 @Test
187 func todoKeywordsTakeTheirColorFromTheCaller() {
188 let style = OrgKeywordStyle(todo: .pink, done: .brown)
189
190 guard case .heading(let todo, _) = layout("* TODO Ship it", keywords: style)
191 .blocks.first else {
192 Issue.record("expected a heading"); return
193 }
194 #expect(todo.runs.contains { $0.foregroundColor == .pink })
195
196 guard case .heading(let done, _) = layout("* DONE Shipped", keywords: style)
197 .blocks.first else {
198 Issue.record("expected a heading"); return
199 }
200 #expect(done.runs.contains { $0.foregroundColor == .brown })
201
202 // The default invents nothing.
203 guard case .heading(let plain, _) = layout("* TODO Ship it").blocks.first else {
204 Issue.record("expected a heading"); return
205 }
206 #expect(plain.runs.allSatisfy { $0.foregroundColor != .green && $0.foregroundColor != .orange })
207 }
208
209 /// Every run carries a font, not only the emphasised ones. A plain heading run with no
210 /// font would inherit the surrounding view's and render at body size.
211 @Test
212 func everyRunCarriesItsBlockFont() {
213 guard case .heading(let title, _) = layout("* Plain heading").blocks.first else {
214 Issue.record("expected a heading"); return
215 }
216 #expect(title.runs.allSatisfy { $0.font != nil })
217 #expect(title.runs.contains { $0.font == Font.system(.title, design: .default).bold() })
218
219 // Same for the blocks that render at a non-body style.
220 guard case .table(let table) = layout("| a |\n|---|\n| b |").blocks.first else {
221 Issue.record("expected a table"); return
222 }
223 #expect(table.rows.allSatisfy { $0.cells.allSatisfy { cell in
224 cell.text.runs.allSatisfy { $0.font != nil }
225 } })
226
227 let notes = layout("Claim[fn:a]\n\n[fn:a] note").footnotes
228 #expect(notes.first?.text.runs.allSatisfy { $0.font != nil } == true)
229 }
230
231 /// Emphasis nests, and the styler turns intents into fonts rather than dropping them.
232 @Test
233 func inlineStylingCarriesNestedEmphasis() {
234 guard case .paragraph(let text) = layout("*bold /italic/* and ~code~").blocks.first else {
235 Issue.record("expected a paragraph"); return
236 }
237 #expect(plain(text) == "bold italic and code")
238 // Every emphasised or code run got a concrete font; unstyled runs inherit the view's.
239 let fonts = text.runs.compactMap(\.font)
240 #expect(fonts.count >= 3)
241 }
242}
243
244/// The badge row: a paragraph of nothing but images and image links.
245@MainActor
246struct OrgBadgeRowTests {
247
248 private func blocks(_ source: String) -> [OrgBlock] {
249 OrgLayoutBuilder(options: .init(), styler: PlainOrgCodeStyler(), keywords: .init())
250 .layout(OrgParser.parse(source))
251 .blocks
252 }
253
254 private func badges(_ source: String) -> [OrgBadge]? {
255 guard case .badges(let badges) = blocks(source).first else { return nil }
256 return badges
257 }
258
259 @Test
260 func aLinkWrappingAnImageIsABadge() throws {
261 let row = try #require(badges(
262 "[[https://gitbay.org/krz/gitbay/builds][file:https://gitbay.org/krz/gitbay/badge/build.svg]]"
263 ))
264 #expect(row.count == 1)
265 #expect(row[0].source.absoluteString == "https://gitbay.org/krz/gitbay/badge/build.svg")
266 #expect(row[0].link?.absoluteString == "https://gitbay.org/krz/gitbay/builds")
267 }
268
269 @Test
270 func severalBadgesOnOneLineAreOneRow() throws {
271 let row = try #require(badges("""
272 [[https://example.com/a][file:https://example.com/a.png]] \
273 [[https://example.com/b][file:https://example.com/b.png]]
274 """))
275 #expect(row.count == 2)
276 }
277
278 @Test
279 func anUnlinkedImageIsStillABadgeWhenItSharesTheLine() throws {
280 let row = try #require(badges(
281 "[[https://example.com/a][file:https://example.com/a.png]] [[file:https://example.com/b.png]]"
282 ))
283 #expect(row.count == 2)
284 #expect(row[1].link == nil)
285 }
286
287 @Test
288 func aParagraphWithProseIsNotABadgeRow() {
289 #expect(badges(
290 "Build: [[https://example.com/a][file:https://example.com/a.png]]"
291 ) == nil)
292 }
293
294 @Test
295 func aLinkWithATextDescriptionIsNotABadgeRow() {
296 #expect(badges("[[https://example.com][the builds]]") == nil)
297 }
298
299 /// A lone image on its own line is already a figure, and stays one: a figure carries a
300 /// caption and fills the column, which is right for a diagram and wrong for a badge.
301 @Test
302 func aStandaloneImageStaysAFigure() {
303 if case .image = blocks("[[file:https://example.com/diagram.png]]").first { return }
304 Issue.record("expected a figure block")
305 }
306
307 /// A relative source with no repository to resolve against cannot be made absolute, and
308 /// a badge has no alt-text fallback to degrade to, so the paragraph stays prose.
309 @Test
310 func anUnresolvableSourceIsNotABadgeRow() {
311 #expect(badges("[[https://example.com][file:badge.png]]") == nil)
312 }
313}