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.9.0
org-swift/Tests/OrgSwiftUITests/OrgLayoutTests.swift history · blame · raw

379 lines · 14230 bytes

31 symbols in this file
  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}