Tests/OrgSwiftUITests/OrgLayoutTests.swift
215 lines · 8159 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 ) -> OrgLayout {
19 OrgLayoutBuilder(options: options, styler: styler).layout(OrgParser.parse(source))
20 }
21
22 private func plain(_ string: AttributedString) -> String { String(string.characters) }
23
24 @Test
25 func blocksCoverTheElementTree() {
26 let blocks = layout("""
27 * Heading
28
29 A paragraph.
30
31 - one
32 - two
33
34 | a | b |
35 |---+---|
36 | 1 | 2 |
37
38 #+begin_src swift
39 let x = 1
40 #+end_src
41
42 #+begin_quote
43 quoted
44 #+end_quote
45
46 -----
47 """).blocks
48
49 func has(_ predicate: (OrgBlock) -> Bool) -> Bool { blocks.contains(where: predicate) }
50 #expect(has { if case .heading = $0 { true } else { false } })
51 #expect(has { if case .paragraph = $0 { true } else { false } })
52 #expect(has { if case .list = $0 { true } else { false } })
53 #expect(has { if case .table = $0 { true } else { false } })
54 #expect(has { if case .code = $0 { true } else { false } })
55 #expect(has { if case .quote = $0 { true } else { false } })
56 #expect(has { if case .rule = $0 { true } else { false } })
57 }
58
59 /// Numbering is document-wide and the definitions leave the body, exactly as in HTML.
60 @Test
61 func footnotesAreNumberedAndMovedToTheSection() {
62 let result = layout("""
63 First[fn:b] and second[fn:a].
64
65 [fn:a] note a
66 [fn:b] note b
67 """)
68
69 #expect(result.footnotes.map(\.number) == [1, 2])
70 #expect(plain(result.footnotes[0].text) == "note b")
71 #expect(plain(result.footnotes[1].text) == "note a")
72
73 guard case .paragraph(let text) = result.blocks[0] else {
74 Issue.record("expected a paragraph"); return
75 }
76 #expect(plain(text) == "First[1] and second[2].")
77 // The definitions are not also blocks in the body.
78 #expect(result.blocks.count == 1)
79 }
80
81 @Test
82 func nestedListsRecurse() {
83 guard case .list(let list) = layout("""
84 - outer
85 - inner
86 - deepest
87 """).blocks.first else {
88 Issue.record("expected a list"); return
89 }
90 #expect(list.kind == .unordered)
91 #expect(plain(list.items[0].paragraphs[0]) == "outer")
92 let inner = list.items[0].sublist
93 #expect(plain(inner?.items[0].paragraphs[0] ?? "") == "inner")
94 #expect(plain(inner?.items[0].sublist?.items[0].paragraphs[0] ?? "") == "deepest")
95 }
96
97 @Test
98 func checkboxesAndDescriptionListsSurvive() {
99 guard case .list(let tasks) = layout("- [X] done\n- [ ] todo").blocks.first else {
100 Issue.record("expected a list"); return
101 }
102 #expect(tasks.items.map(\.checkbox) == [.on, .off])
103
104 guard case .list(let described) = layout("- term :: definition").blocks.first else {
105 Issue.record("expected a description list"); return
106 }
107 #expect(described.kind == .description)
108 #expect(plain(described.items[0].term ?? "") == "term")
109 #expect(plain(described.items[0].paragraphs[0]) == "definition")
110 }
111
112 /// Rule rows are structure, not content: they set the header band and then disappear.
113 @Test
114 func tableDropsRuleRowsAndKeepsAlignments() {
115 guard case .table(let table) = layout("""
116 | name | count |
117 |------+------:|
118 | a | 1 |
119 | b | 2 |
120 """).blocks.first else {
121 Issue.record("expected a table"); return
122 }
123 #expect(table.rows.count == 3)
124 #expect(table.headerRowCount == 1)
125 #expect(table.columnCount == 2)
126 #expect(table.alignment(column: 1) == .right)
127 #expect(plain(table.rows[0].cells[0].text) == "name")
128 #expect(plain(table.rows[2].cells[1].text) == "2")
129 }
130
131 @Test
132 func imagesResolveRelativeSourcesAndDegradeWhenUnsafe() {
133 let options = OrgRenderOptions(
134 host: "gitbay.org", owner: "krz", repositoryName: "org-swift",
135 ref: "HEAD", readmePath: "README.org", imagePathSegment: "raw"
136 )
137 guard case .image(let source, _, _) = layout("[[file:docs/logo.png]]", options: options)
138 .blocks.first else {
139 Issue.record("expected an image"); return
140 }
141 #expect(source.absoluteString == "https://gitbay.org/krz/org-swift/raw/HEAD/docs/logo.png")
142
143 // With no repository context the relative path cannot be made absolute, so it shows
144 // as text rather than as a broken image.
145 guard case .paragraph(let text) = layout("[[file:docs/logo.png]]").blocks.first else {
146 Issue.record("expected a paragraph fallback"); return
147 }
148 #expect(plain(text) == "docs/logo.png")
149 }
150
151 @Test
152 func codeStylerIsUsedWhenItAnswers() {
153 struct Stub: OrgCodeStyler {
154 func highlighted(code: String, language: String?) -> AttributedString? {
155 language == "swift" ? AttributedString("STYLED:\(code.count)") : nil
156 }
157 }
158 guard case .code(let styled) = layout(
159 "#+begin_src swift\nlet x = 1\n#+end_src", styler: Stub()
160 ).blocks.first else {
161 Issue.record("expected a code block"); return
162 }
163 #expect(plain(styled).hasPrefix("STYLED:"))
164
165 // An unknown language falls back to plain monospaced text, never to nothing.
166 guard case .code(let plainCode) = layout(
167 "#+begin_src brainfuck\n+++\n#+end_src", styler: Stub()
168 ).blocks.first else {
169 Issue.record("expected a code block"); return
170 }
171 #expect(plain(plainCode) == "+++")
172 }
173
174 /// HTML export has nowhere to go natively, so it shows as the literal block it is; other
175 /// backends drop, as they do in HTML.
176 @Test
177 func exportBlocksShowHTMLLiterallyAndDropOtherBackends() {
178 #expect(layout("#+begin_export html\n<b>hi</b>\n#+end_export").blocks.count == 1)
179 #expect(layout("#+begin_export latex\n\\emph{hi}\n#+end_export").blocks.isEmpty)
180 }
181
182 /// Every run carries a font, not only the emphasised ones. A plain heading run with no
183 /// font would inherit the surrounding view's and render at body size.
184 @Test
185 func everyRunCarriesItsBlockFont() {
186 guard case .heading(let title, _) = layout("* Plain heading").blocks.first else {
187 Issue.record("expected a heading"); return
188 }
189 #expect(title.runs.allSatisfy { $0.font != nil })
190 #expect(title.runs.contains { $0.font == Font.system(.title, design: .default).bold() })
191
192 // Same for the blocks that render at a non-body style.
193 guard case .table(let table) = layout("| a |\n|---|\n| b |").blocks.first else {
194 Issue.record("expected a table"); return
195 }
196 #expect(table.rows.allSatisfy { $0.cells.allSatisfy { cell in
197 cell.text.runs.allSatisfy { $0.font != nil }
198 } })
199
200 let notes = layout("Claim[fn:a]\n\n[fn:a] note").footnotes
201 #expect(notes.first?.text.runs.allSatisfy { $0.font != nil } == true)
202 }
203
204 /// Emphasis nests, and the styler turns intents into fonts rather than dropping them.
205 @Test
206 func inlineStylingCarriesNestedEmphasis() {
207 guard case .paragraph(let text) = layout("*bold /italic/* and ~code~").blocks.first else {
208 Issue.record("expected a paragraph"); return
209 }
210 #expect(plain(text) == "bold italic and code")
211 // Every emphasised or code run got a concrete font; unstyled runs inherit the view's.
212 let fonts = text.runs.compactMap(\.font)
213 #expect(fonts.count >= 3)
214 }
215}