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

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}