krz/org-swift

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

Tests/OrgSwiftUITests/OrgLayoutTests.swift

v0.3.0
org-swift/Tests/OrgSwiftUITests/OrgLayoutTests.swift history · blame · raw

242 lines · 9324 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}