import Foundation
import Testing
@testable import OrgSwift
/// Tests for the element tree: `source → OrgDocument → {HTML, AttributedString}`.
///
/// The parse tests assert the tree carries structure faithfully. The renderer tests assert
/// the point of the split — that a second output format is a walk over the same tree rather
/// than a second parser. Corpus conformance is gated separately, in `ConformanceTests`.
struct ASTParseTests {
@Test
func headingCarriesTodoPriorityAndTags() {
let doc = OrgParser.parse("* TODO [#A] Write the parser :work:rust:")
guard case .heading(let heading) = doc.elements.first else {
Issue.record("expected a heading"); return
}
#expect(heading.level == 1)
#expect(heading.todo == "TODO")
#expect(heading.priority == "A")
#expect(heading.tags == ["work", "rust"])
#expect(OrgParser.plain(heading.title) == "Write the parser")
}
@Test
func emphasisNestsRatherThanFlattening() {
// The point of a tree: bold containing italic is structure, not a markup string.
let objects = OrgParser.parseInline("*bold /inner/ rest*")
guard case .bold(let children) = objects.first else {
Issue.record("expected bold"); return
}
#expect(children.contains { if case .italic = $0 { return true } else { return false } })
}
@Test
func documentKeywordsAreMetadataNotContent() {
let doc = OrgParser.parse("#+TITLE: My Doc\n#+AUTHOR: Someone\n\nBody.")
#expect(doc.keyword("title") == "My Doc")
#expect(doc.keyword("author") == "Someone")
// Metadata does not appear as a body element.
#expect(doc.elements.count == 1)
guard case .paragraph = doc.elements.first else {
Issue.record("expected a single paragraph"); return
}
}
@Test
func nestedListsBecomeNestedItems() {
let doc = OrgParser.parse("""
- outer
- inner
- deepest
- second
""")
guard case .list(let list) = doc.elements.first else {
Issue.record("expected a list"); return
}
#expect(list.items.count == 2)
let inner = list.items[0].sublist
#expect(inner != nil)
#expect(inner?.items.first?.sublist?.items.count == 1)
}
@Test
func tableKeepsRuleRowAndAlignments() {
let doc = OrgParser.parse("""
| Name | Score |
|:------+------:|
| alpha | 10 |
""")
guard case .table(let table) = doc.elements.first else {
Issue.record("expected a table"); return
}
#expect(table.rows.count == 3)
#expect(table.headerRowCount == 1)
#expect(table.alignments == [.left, .right])
if case .rule = table.rows[1] {} else { Issue.record("row 1 should be the rule") }
}
@Test
func timestampsAndLinksBecomeTypedObjects() {
let objects = OrgParser.parseInline("due <2024-01-15 Mon 10:30> see [[id:abc][the thing]]")
let hasTimestamp = objects.contains {
if case .timestamp(let stamp) = $0 { return stamp.machineValue == "2024-01-15T10:30" }
return false
}
#expect(hasTimestamp)
let hasIDLink = objects.contains {
if case .link(let link) = $0, case .id(let identifier) = link.target { return identifier == "abc" }
return false
}
#expect(hasIDLink)
}
}
struct ASTRendererTests {
/// The payoff: one parse, two output formats, neither re-deriving the other's work.
@Test
func oneParseFeedsTwoRenderers() {
let document = OrgParser.parse("A *bold* claim with ~code~ and a [[https://example.com][link]].")
let html = OrgHTMLTreeRenderer().render(document)
#expect(html.contains("bold"))
#expect(html.contains("code"))
#expect(html.contains(#"link"#))
let attributed = OrgAttributedStringRenderer().inline({
if case .paragraph(let objects) = document.elements[0] { return objects }
return []
}())
// Same content, native representation: no markup, real attributes.
let plain = String(attributed.characters)
#expect(plain == "A bold claim with code and a link.")
#expect(!plain.contains("<"))
let boldRun = attributed.runs.first { $0.inlinePresentationIntent == .stronglyEmphasized }
#expect(boldRun != nil)
let codeRun = attributed.runs.first { $0.inlinePresentationIntent == .code }
#expect(codeRun != nil)
let linkRun = attributed.runs.first { $0.link != nil }
#expect(linkRun?.link?.absoluteString == "https://example.com")
}
@Test
func attributedStringCarriesRolesForNonStandardIntents() {
let objects = OrgParser.parseInline("x^2 and <2024-01-15 Mon>")
let attributed = OrgAttributedStringRenderer().inline(objects)
let roles: [OrgRole] = attributed.runs.compactMap { $0[OrgRoleAttribute.self] }
#expect(roles.contains(.superscript))
#expect(roles.contains(.timestamp))
}
@Test
func consecutiveListsOfDifferentKindsStaySeparate() {
// An ordered list followed by a bullet list is two lists, not one with mixed items.
let doc = OrgParser.parse("""
1. first
2. second
- [ ] todo
- [X] done
""")
let lists = doc.elements.compactMap { element -> OrgList? in
if case .list(let list) = element { return list } else { return nil }
}
#expect(lists.count == 2)
#expect(lists.first?.kind == .ordered)
#expect(lists.last?.kind == .unordered)
#expect(lists.last?.items.first?.checkbox == .off)
let html = OrgHTMLTreeRenderer().render(doc)
#expect(html.contains(""))
#expect(html.contains("
. #expect(html.contains(#"
"#)) } @Test func referenceStyleFootnoteKeepsItsParagraph() { let doc = OrgParser.parse("A claim.[fn:1]\n\n[fn:1] The definition.") let html = OrgHTMLTreeRenderer().render(doc) #expect(html.contains(#"
The definition.
"#)) } @Test func timestampRangesRenderAsTwoTimeElements() { // Same-day: the end shows only its time, since the start carries the date. let sameDay = OrgHTMLTreeRenderer().render(OrgParser.parse("Range <2024-01-15 Mon 10:00-11:45>.")) #expect(sameDay.contains(#"datetime="2024-01-15T10:00">2024-01-15 10:00"#)) #expect(sameDay.contains("–")) #expect(sameDay.contains(#"datetime="2024-01-15T11:45">11:45"#)) // Multi-day: one timestamp carrying an end date, rendered as two stamps. let multiDay = OrgHTMLTreeRenderer().render(OrgParser.parse("Span <2024-01-15 Mon>--<2024-01-20 Sat>.")) #expect(multiDay.contains(#"datetime="2024-01-15">2024-01-15"#)) #expect(multiDay.contains("–")) #expect(multiDay.contains(#"datetime="2024-01-20">2024-01-20"#)) // The `--` join is consumed, not left as stray text. #expect(!multiDay.contains("--")) } @Test func treeRendererProducesStructuralHTML() { let document = OrgParser.parse(""" * Heading | a | b | |---+---| | 1 | 2 | - [ ] todo - [X] done """) let html = OrgHTMLTreeRenderer().render(document) #expect(html.contains("[ ] todo"))
#expect(html.contains("[X] done"))
}
}
/// The render options, which the conformance corpus does not exercise because it renders with
/// no repository context. These were written as shipped-vs-tree equivalence checks during the
/// migration; now that the tree *is* the renderer, they assert the behaviour directly.
struct OrgRenderOptionsTests {
/// gitbay's configuration: images resolve against `raw`, links against `blob`.
private static let repositoryOptions = OrgRenderOptions(
host: "gitbay.org",
owner: "krz",
repositoryName: "gitbay",
ref: "HEAD",
readmePath: "README.org",
imagePathSegment: "raw",
linkPathSegment: "blob"
)
@Test
func resolvesRepositoryRelativeURLsAgainstTheirOwnSegment() {
let html = OrgRenderer.renderToHTML("""
A relative link to [[docs/DESIGN.org][the design]] and an absolute one to
[[https://example.org][elsewhere]].
[[file:docs/logo.png]]
""", options: Self.repositoryOptions)
#expect(html.contains("https://gitbay.org/krz/gitbay/raw/HEAD/docs/logo.png"))
#expect(html.contains("https://gitbay.org/krz/gitbay/blob/HEAD/docs/DESIGN.org"))
#expect(html.contains("https://example.org"))
}
@Test
func leavesRelativeTargetsAloneWithoutRepositoryContext() {
let html = OrgRenderer.renderToHTML("[[docs/DESIGN.org][the design]]\n\n[[file:logo.png]]")
#expect(html.contains(#"href="docs/DESIGN.org""#))
#expect(html.contains(#"src="logo.png""#))
}
@Test
func emitsTheMetadataHeaderOnlyWhenAsked() {
let source = "#+TITLE: My Doc\n#+AUTHOR: Someone\n\nBody."
let withHeader = OrgRenderer.renderToHTML(source)
#expect(withHeader.contains(#"Body.
")) } @Test func rejectsUnsafeSchemes() { let html = OrgRenderer.renderToHTML("[[javascript:alert(1)][click]]") #expect(!html.lowercased().contains("javascript:")) // The link degrades to its text rather than becoming a bad anchor. #expect(html.contains("click")) } /// A range whose halves are inactive timestamps is still a range. orgo applies the `--` /// rule to both bracket kinds, requiring only that the halves agree on activeness; the /// renderer this replaced joined active ranges only, so this is the one behaviour the /// swap deliberately changed. @Test func joinsInactiveTimestampRanges() { let html = OrgRenderer.renderToHTML("CLOCK: [2024-01-15 Mon 09:00]--[2024-01-15 Mon 10:00]") #expect(!html.contains("--")) #expect(html.contains("–")) #expect(html.contains(#"class="timestamp inactive""#)) } } private func astCorpusCasesDir() -> URL? { if let env = ProcessInfo.processInfo.environment["ORG_CONFORMANCE_DIR"] { let cases = URL(fileURLWithPath: env).appendingPathComponent("cases") if FileManager.default.fileExists(atPath: cases.path) { return cases } } let pkgRoot = URL(fileURLWithPath: #filePath) .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() let sibling = pkgRoot.deletingLastPathComponent() .appendingPathComponent("org-conformance").appendingPathComponent("cases") return FileManager.default.fileExists(atPath: sibling.path) ? sibling : nil } private func astCaseNames(_ dir: URL) -> [String] { let items = (try? FileManager.default.contentsOfDirectory(atPath: dir.path)) ?? [] return items.filter { $0.hasSuffix(".org") }.map { String($0.dropLast(4)) }.sorted() }