import Foundation import SwiftUI import Testing @testable import OrgSwift @testable import OrgSwiftUI /// Tests for the third renderer over the shared tree: `OrgDocument → OrgLayout`. /// /// The views themselves are layout over this model, so the model is where the org behaviour /// lives and where it is worth asserting. @MainActor struct OrgLayoutTests { private func layout( _ source: String, options: OrgRenderOptions = .init(), styler: OrgCodeStyler = PlainOrgCodeStyler(), keywords: OrgKeywordStyle = .init() ) -> OrgLayout { OrgLayoutBuilder(options: options, styler: styler, keywords: keywords) .layout(OrgParser.parse(source)) } private func plain(_ string: AttributedString) -> String { String(string.characters) } @Test func blocksCoverTheElementTree() { let blocks = layout(""" * Heading A paragraph. - one - two | a | b | |---+---| | 1 | 2 | #+begin_src swift let x = 1 #+end_src #+begin_quote quoted #+end_quote ----- """).blocks func has(_ predicate: (OrgBlock) -> Bool) -> Bool { blocks.contains(where: predicate) } #expect(has { if case .heading = $0 { true } else { false } }) #expect(has { if case .paragraph = $0 { true } else { false } }) #expect(has { if case .list = $0 { true } else { false } }) #expect(has { if case .table = $0 { true } else { false } }) #expect(has { if case .code = $0 { true } else { false } }) #expect(has { if case .quote = $0 { true } else { false } }) #expect(has { if case .rule = $0 { true } else { false } }) } /// Numbering is document-wide and the definitions leave the body, exactly as in HTML. @Test func footnotesAreNumberedAndMovedToTheSection() { let result = layout(""" First[fn:b] and second[fn:a]. [fn:a] note a [fn:b] note b """) #expect(result.footnotes.map(\.number) == [1, 2]) #expect(plain(result.footnotes[0].text) == "note b") #expect(plain(result.footnotes[1].text) == "note a") guard case .paragraph(let text) = result.blocks[0] else { Issue.record("expected a paragraph"); return } #expect(plain(text) == "First[1] and second[2].") // The definitions are not also blocks in the body. #expect(result.blocks.count == 1) } @Test func nestedListsRecurse() { guard case .list(let list) = layout(""" - outer - inner - deepest """).blocks.first else { Issue.record("expected a list"); return } #expect(list.kind == .unordered) #expect(plain(list.items[0].paragraphs[0]) == "outer") let inner = list.items[0].sublist #expect(plain(inner?.items[0].paragraphs[0] ?? "") == "inner") #expect(plain(inner?.items[0].sublist?.items[0].paragraphs[0] ?? "") == "deepest") } @Test func checkboxesAndDescriptionListsSurvive() { guard case .list(let tasks) = layout("- [X] done\n- [ ] todo").blocks.first else { Issue.record("expected a list"); return } #expect(tasks.items.map(\.checkbox) == [.on, .off]) guard case .list(let described) = layout("- term :: definition").blocks.first else { Issue.record("expected a description list"); return } #expect(described.kind == .description) #expect(plain(described.items[0].term ?? "") == "term") #expect(plain(described.items[0].paragraphs[0]) == "definition") } /// Rule rows are structure, not content: they set the header band and then disappear. @Test func tableDropsRuleRowsAndKeepsAlignments() { guard case .table(let table) = layout(""" | name | count | |------+------:| | a | 1 | | b | 2 | """).blocks.first else { Issue.record("expected a table"); return } #expect(table.rows.count == 3) #expect(table.headerRowCount == 1) #expect(table.columnCount == 2) #expect(table.alignment(column: 1) == .right) #expect(plain(table.rows[0].cells[0].text) == "name") #expect(plain(table.rows[2].cells[1].text) == "2") } @Test func imagesResolveRelativeSourcesAndDegradeWhenUnsafe() { let options = OrgRenderOptions( host: "gitbay.org", owner: "krz", repositoryName: "org-swift", ref: "HEAD", readmePath: "README.org", imagePathSegment: "raw" ) guard case .image(let source, _, _) = layout("[[file:docs/logo.png]]", options: options) .blocks.first else { Issue.record("expected an image"); return } #expect(source.absoluteString == "https://gitbay.org/krz/org-swift/raw/HEAD/docs/logo.png") // With no repository context the relative path cannot be made absolute, so it shows // as text rather than as a broken image. guard case .paragraph(let text) = layout("[[file:docs/logo.png]]").blocks.first else { Issue.record("expected a paragraph fallback"); return } #expect(plain(text) == "docs/logo.png") } @Test func codeStylerIsUsedWhenItAnswers() { struct Stub: OrgCodeStyler { func highlighted(code: String, language: String?) -> AttributedString? { language == "swift" ? AttributedString("STYLED:\(code.count)") : nil } } guard case .code(let styled) = layout( "#+begin_src swift\nlet x = 1\n#+end_src", styler: Stub() ).blocks.first else { Issue.record("expected a code block"); return } #expect(plain(styled).hasPrefix("STYLED:")) // An unknown language falls back to plain monospaced text, never to nothing. guard case .code(let plainCode) = layout( "#+begin_src brainfuck\n+++\n#+end_src", styler: Stub() ).blocks.first else { Issue.record("expected a code block"); return } #expect(plain(plainCode) == "+++") } /// HTML export has nowhere to go natively, so it shows as the literal block it is; other /// backends drop, as they do in HTML. @Test func exportBlocksShowHTMLLiterallyAndDropOtherBackends() { #expect(layout("#+begin_export html\nhi\n#+end_export").blocks.count == 1) #expect(layout("#+begin_export latex\n\\emph{hi}\n#+end_export").blocks.isEmpty) } /// Keyword colors come from the caller. The package defaults to semantic colors rather /// than inventing hues that match no app's palette. @Test func todoKeywordsTakeTheirColorFromTheCaller() { let style = OrgKeywordStyle(todo: .pink, done: .brown) guard case .heading(let todo, _) = layout("* TODO Ship it", keywords: style) .blocks.first else { Issue.record("expected a heading"); return } #expect(todo.runs.contains { $0.foregroundColor == .pink }) guard case .heading(let done, _) = layout("* DONE Shipped", keywords: style) .blocks.first else { Issue.record("expected a heading"); return } #expect(done.runs.contains { $0.foregroundColor == .brown }) // The default invents nothing. guard case .heading(let plain, _) = layout("* TODO Ship it").blocks.first else { Issue.record("expected a heading"); return } #expect(plain.runs.allSatisfy { $0.foregroundColor != .green && $0.foregroundColor != .orange }) } /// Every run carries a font, not only the emphasised ones. A plain heading run with no /// font would inherit the surrounding view's and render at body size. @Test func everyRunCarriesItsBlockFont() { guard case .heading(let title, _) = layout("* Plain heading").blocks.first else { Issue.record("expected a heading"); return } #expect(title.runs.allSatisfy { $0.font != nil }) #expect(title.runs.contains { $0.font == Font.system(.title, design: .default).bold() }) // Same for the blocks that render at a non-body style. guard case .table(let table) = layout("| a |\n|---|\n| b |").blocks.first else { Issue.record("expected a table"); return } #expect(table.rows.allSatisfy { $0.cells.allSatisfy { cell in cell.text.runs.allSatisfy { $0.font != nil } } }) let notes = layout("Claim[fn:a]\n\n[fn:a] note").footnotes #expect(notes.first?.text.runs.allSatisfy { $0.font != nil } == true) } /// Emphasis nests, and the styler turns intents into fonts rather than dropping them. @Test func inlineStylingCarriesNestedEmphasis() { guard case .paragraph(let text) = layout("*bold /italic/* and ~code~").blocks.first else { Issue.record("expected a paragraph"); return } #expect(plain(text) == "bold italic and code") // Every emphasised or code run got a concrete font; unstyled runs inherit the view's. let fonts = text.runs.compactMap(\.font) #expect(fonts.count >= 3) } } /// The badge row: a paragraph of nothing but images and image links. @MainActor struct OrgBadgeRowTests { private func blocks(_ source: String) -> [OrgBlock] { OrgLayoutBuilder(options: .init(), styler: PlainOrgCodeStyler(), keywords: .init()) .layout(OrgParser.parse(source)) .blocks } private func badges(_ source: String) -> [OrgBadge]? { guard case .badges(let badges) = blocks(source).first else { return nil } return badges } @Test func aLinkWrappingAnImageIsABadge() throws { let row = try #require(badges( "[[https://gitbay.org/krz/gitbay/builds][file:https://gitbay.org/krz/gitbay/badge/build.svg]]" )) #expect(row.count == 1) #expect(row[0].source.absoluteString == "https://gitbay.org/krz/gitbay/badge/build.svg") #expect(row[0].link?.absoluteString == "https://gitbay.org/krz/gitbay/builds") } @Test func severalBadgesOnOneLineAreOneRow() throws { let row = try #require(badges(""" [[https://example.com/a][file:https://example.com/a.png]] \ [[https://example.com/b][file:https://example.com/b.png]] """)) #expect(row.count == 2) } @Test func anUnlinkedImageIsStillABadgeWhenItSharesTheLine() throws { let row = try #require(badges( "[[https://example.com/a][file:https://example.com/a.png]] [[file:https://example.com/b.png]]" )) #expect(row.count == 2) #expect(row[1].link == nil) } @Test func aParagraphWithProseIsNotABadgeRow() { #expect(badges( "Build: [[https://example.com/a][file:https://example.com/a.png]]" ) == nil) } @Test func aLinkWithATextDescriptionIsNotABadgeRow() { #expect(badges("[[https://example.com][the builds]]") == nil) } /// A lone image on its own line is already a figure, and stays one: a figure carries a /// caption and fills the column, which is right for a diagram and wrong for a badge. @Test func aStandaloneImageStaysAFigure() { if case .image = blocks("[[file:https://example.com/diagram.png]]").first { return } Issue.record("expected a figure block") } /// A relative source with no repository to resolve against cannot be made absolute, and /// a badge has no alt-text fallback to degrade to, so the paragraph stays prose. @Test func anUnresolvableSourceIsNotABadgeRow() { #expect(badges("[[https://example.com][file:badge.png]]") == nil) } }