diff options
Diffstat (limited to 'Tests/NorgKitTests')
17 files changed, 1352 insertions, 0 deletions
diff --git a/Tests/NorgKitTests/Extensions/Array+InlineSpanTests.swift b/Tests/NorgKitTests/Extensions/Array+InlineSpanTests.swift new file mode 100644 index 0000000..4f23feb --- /dev/null +++ b/Tests/NorgKitTests/Extensions/Array+InlineSpanTests.swift @@ -0,0 +1,19 @@ +import Testing + +@testable import NorgKit + +struct ArrayInlineSpanTests { + + @Test func plainTextConcatenatesSpanText() { + let spans = [ + InlineSpan(text: "Hello, ", styles: .bold), + InlineSpan(text: "world", styles: .italic), + InlineSpan(text: "!"), + ] + #expect(spans.plainText == "Hello, world!") + } + + @Test func plainTextOfEmptyArrayIsEmpty() { + #expect([InlineSpan]().plainText.isEmpty) + } +} diff --git a/Tests/NorgKitTests/Helpers/ASCIIByteSetTests.swift b/Tests/NorgKitTests/Helpers/ASCIIByteSetTests.swift new file mode 100644 index 0000000..eb29ecb --- /dev/null +++ b/Tests/NorgKitTests/Helpers/ASCIIByteSetTests.swift @@ -0,0 +1,42 @@ +import Testing + +@testable import NorgKit + +struct ASCIIByteSetTests { + + @Test func containsLowBytes() { + // `*` (0x2A) and `-` (0x2D) live in the low 64-bit word. + let set = ASCIIByteSet("*-") + #expect(set.contains(UInt8(ascii: "*"))) + #expect(set.contains(UInt8(ascii: "-"))) + #expect(!set.contains(UInt8(ascii: "/"))) + } + + @Test func containsHighBytes() { + // `~` (0x7E) and `_` (0x5F) live in the high 64-bit word. + let set = ASCIIByteSet("~_") + #expect(set.contains(UInt8(ascii: "~"))) + #expect(set.contains(UInt8(ascii: "_"))) + #expect(!set.contains(UInt8(ascii: "x"))) + } + + @Test func spansBothWords() { + let set = ASCIIByteSet("*~") + #expect(set.contains(UInt8(ascii: "*"))) + #expect(set.contains(UInt8(ascii: "~"))) + } + + @Test func rejectsNonAsciiAndOutOfSetBytes() { + let set = ASCIIByteSet("*") + #expect(!set.contains(0)) // NUL, low word, unset + #expect(!set.contains(200)) // beyond ASCII, never stored + #expect(!set.contains(127)) // high word, unset + } + + @Test func emptySetContainsNothing() { + let set = ASCIIByteSet("") + #expect(!set.contains(UInt8(ascii: "*"))) + #expect(!set.contains(0)) + #expect(!set.contains(127)) + } +} diff --git a/Tests/NorgKitTests/Helpers/ASCIIHelperTests.swift b/Tests/NorgKitTests/Helpers/ASCIIHelperTests.swift new file mode 100644 index 0000000..8a807fe --- /dev/null +++ b/Tests/NorgKitTests/Helpers/ASCIIHelperTests.swift @@ -0,0 +1,22 @@ +import Testing + +@testable import NorgKit + +struct ASCIIHelperTests { + @Test func detectsSpaces() { + #expect(ASCIIHelper.isWhitespace(0x20) == true) + #expect(ASCIIHelper.isWhitespace(0x09) == true) + #expect(ASCIIHelper.isWhitespace(0x0A) == true) + #expect(ASCIIHelper.isWhitespace(0x0B) == true) + #expect(ASCIIHelper.isWhitespace(0x0C) == true) + #expect(ASCIIHelper.isWhitespace(0x0D) == true) + } + + @Test func doesNotDetectNonSpaces() { + #expect(ASCIIHelper.isWhitespace(0x19) == false) + #expect(ASCIIHelper.isWhitespace(0x21) == false) + #expect(ASCIIHelper.isWhitespace(0x0E) == false) + #expect(ASCIIHelper.isWhitespace(0x08) == false) + #expect(ASCIIHelper.isWhitespace(0x67) == false) + } +} diff --git a/Tests/NorgKitTests/Helpers/TextHelperTests.swift b/Tests/NorgKitTests/Helpers/TextHelperTests.swift new file mode 100644 index 0000000..9e236ff --- /dev/null +++ b/Tests/NorgKitTests/Helpers/TextHelperTests.swift @@ -0,0 +1,71 @@ +import Testing + +@testable import NorgKit + +struct TextHelperTests { + + @Test func lineSlicesSplitsOnNewlines() { + #expect(TextHelper.lineSlices("a\nb\nc").map(String.init) == ["a", "b", "c"]) + } + + @Test func lineSlicesKeepsTrailingEmptyLine() { + #expect(TextHelper.lineSlices("a\n").map(String.init) == ["a", ""]) + } + + @Test func lineSlicesHandlesEmptyString() { + #expect(TextHelper.lineSlices("").map(String.init) == [""]) + } + + @Test func enumerateLinesProvidesIndices() { + var seen: [(String, Int)] = [] + TextHelper.enumerateLines(in: "one\ntwo") { line, index in + seen.append((String(line), index)) + } + #expect(seen.map(\.0) == ["one", "two"]) + #expect(seen.map(\.1) == [0, 1]) + } + + @Test func enumerateLinesStripsCarriageReturns() { + var lines: [String] = [] + TextHelper.enumerateLines(in: "a\r\nb\r\n") { line, _ in lines.append(String(line)) } + #expect(lines == ["a", "b", ""]) + } + + @Test func lineReturnsRequestedLine() { + #expect(TextHelper.line(in: "a\nb\nc", at: 1).map(String.init) == "b") + } + + @Test func lineReturnsLastLineWithoutTrailingNewline() { + #expect(TextHelper.line(in: "a\nb", at: 1).map(String.init) == "b") + } + + @Test func lineStripsCarriageReturn() { + #expect(TextHelper.line(in: "a\r\nb", at: 0).map(String.init) == "a") + } + + @Test func lineReturnsNilForOutOfRange() { + #expect(TextHelper.line(in: "a\nb", at: 5) == nil) + } + + @Test func lineReturnsNilForNegativeIndex() { + #expect(TextHelper.line(in: "a", at: -1) == nil) + } + + @Test func whitespaceTrimmedSubstringTrimsBothEnds() { + let trimmed = TextHelper.whitespaceTrimmed(" \t hi \t "[...]) + #expect(trimmed == "hi") + } + + @Test func whitespaceTrimmedSubstringOfAllWhitespaceIsEmpty() { + #expect(TextHelper.whitespaceTrimmed(" \t "[...]).isEmpty) + } + + @Test func whitespaceTrimmedStringTrimsWhenNeeded() { + #expect(TextHelper.whitespaceTrimmed(" hi ") == "hi") + } + + @Test func whitespaceTrimmedStringReturnsSameValueWhenAlreadyTrimmed() { + let input = "already-clean" + #expect(TextHelper.whitespaceTrimmed(input) == input) + } +} diff --git a/Tests/NorgKitTests/Models/DetachedModifierTests.swift b/Tests/NorgKitTests/Models/DetachedModifierTests.swift new file mode 100644 index 0000000..7ef1479 --- /dev/null +++ b/Tests/NorgKitTests/Models/DetachedModifierTests.swift @@ -0,0 +1,61 @@ +import Testing + +@testable import NorgKit + +struct DetachedModifierTests { + + private let markers = ASCIIByteSet("*-~>$^:") + + @Test func parsesMarkerLevelAndContent() { + let m = DetachedModifier.parse("** heading"[...], accepting: markers) + #expect(m?.marker == "*") + #expect(m?.level == 2) + #expect(m?.content == "heading") + #expect(m?.status == nil) + } + + @Test func skipsLeadingWhitespace() { + let m = DetachedModifier.parse(" - item"[...], accepting: markers) + #expect(m?.marker == "-") + #expect(m?.level == 1) + #expect(m?.content == "item") + } + + @Test func parsesStatusAndStatusIndex() { + let line = "- (x) done" + let m = DetachedModifier.parse(line[...], accepting: markers) + #expect(m?.status == .done) + #expect(m?.content == "done") + + if let index = m?.statusIndex { + #expect(line[index] == "x") + } else { + Issue.record("Expected a status index") + } + } + + @Test func returnsNilForUnacceptedMarker() { + #expect(DetachedModifier.parse("+ item"[...], accepting: markers) == nil) + } + + @Test func returnsNilWithoutWhitespaceAfterMarker() { + #expect(DetachedModifier.parse("*bold*"[...], accepting: markers) == nil) + } + + @Test func returnsNilForEmptyAndBlankLines() { + #expect(DetachedModifier.parse(""[...], accepting: markers) == nil) + #expect(DetachedModifier.parse(" "[...], accepting: markers) == nil) + } + + @Test func unterminatedStatusParenIsTreatedAsContent() { + let m = DetachedModifier.parse("- (x oops"[...], accepting: markers) + #expect(m?.status == nil) + #expect(m?.content == "(x oops") + } + + @Test func unknownStatusCharInParensIsContent() { + let m = DetachedModifier.parse("- (z) text"[...], accepting: markers) + #expect(m?.status == nil) + #expect(m?.content == "(z) text") + } +} diff --git a/Tests/NorgKitTests/Models/InlineLinkTests.swift b/Tests/NorgKitTests/Models/InlineLinkTests.swift new file mode 100644 index 0000000..9cf2cce --- /dev/null +++ b/Tests/NorgKitTests/Models/InlineLinkTests.swift @@ -0,0 +1,32 @@ +import Foundation +import Testing + +@testable import NorgKit + +struct InlineLinkTests { + + @Test func storesKindAndTarget() { + let link = InlineLink(kind: .link, target: "https://example.com") + #expect(link.kind == .link) + #expect(link.target == "https://example.com") + } + + @Test func anchorMayHaveNoTarget() { + let anchor = InlineLink(kind: .anchor, target: nil) + #expect(anchor.kind == .anchor) + #expect(anchor.target == nil) + } + + @Test func equatableDistinguishesKindAndTarget() { + #expect(InlineLink(kind: .link, target: "a") == InlineLink(kind: .link, target: "a")) + #expect(InlineLink(kind: .link, target: "a") != InlineLink(kind: .anchor, target: "a")) + #expect(InlineLink(kind: .link, target: "a") != InlineLink(kind: .link, target: "b")) + } + + @Test func roundTripsThroughJSON() throws { + for link in [InlineLink(kind: .link, target: "x"), InlineLink(kind: .anchor, target: nil)] { + let data = try JSONEncoder().encode(link) + #expect(try JSONDecoder().decode(InlineLink.self, from: data) == link) + } + } +} diff --git a/Tests/NorgKitTests/Models/InlineSpanTests.swift b/Tests/NorgKitTests/Models/InlineSpanTests.swift new file mode 100644 index 0000000..24531e4 --- /dev/null +++ b/Tests/NorgKitTests/Models/InlineSpanTests.swift @@ -0,0 +1,35 @@ +import Foundation +import Testing + +@testable import NorgKit + +struct InlineSpanTests { + + @Test func defaultsToNoStyleAndNoLink() { + let span = InlineSpan(text: "hi") + #expect(span.styles.isEmpty) + #expect(span.link == nil) + } + + @Test func storesStyleAndLink() { + let link = InlineLink(kind: .link, target: "https://example.com") + let span = InlineSpan(text: "hi", styles: .bold, link: link) + #expect(span.styles.contains(.bold)) + #expect(span.link == link) + } + + @Test func equatableDistinguishesFields() { + #expect(InlineSpan(text: "a") == InlineSpan(text: "a")) + #expect(InlineSpan(text: "a") != InlineSpan(text: "b")) + #expect(InlineSpan(text: "a", styles: .bold) != InlineSpan(text: "a")) + } + + @Test func roundTripsThroughJSON() throws { + let span = InlineSpan( + text: "hi", styles: [.bold, .italic], + link: InlineLink(kind: .anchor, target: nil) + ) + let data = try JSONEncoder().encode(span) + #expect(try JSONDecoder().decode(InlineSpan.self, from: data) == span) + } +} diff --git a/Tests/NorgKitTests/Models/InlineStyleTests.swift b/Tests/NorgKitTests/Models/InlineStyleTests.swift new file mode 100644 index 0000000..4eafc12 --- /dev/null +++ b/Tests/NorgKitTests/Models/InlineStyleTests.swift @@ -0,0 +1,35 @@ +import Foundation +import Testing + +@testable import NorgKit + +struct InlineStyleTests { + + @Test func combinesAndContains() { + let style: InlineStyle = [.bold, .italic] + #expect(style.contains(.bold)) + #expect(style.contains(.italic)) + #expect(!style.contains(.underline)) + } + + @Test func distinctRawValues() { + let all: [InlineStyle] = [ + .bold, .italic, .underline, .strikethrough, + .verbatim, .superscript, .subscript, .spoiler, .math, + ] + #expect(Set(all.map(\.rawValue)).count == all.count) + } + + @Test func encodesAsSingleInteger() throws { + let style: InlineStyle = [.bold, .verbatim] + let data = try JSONEncoder().encode(style) + #expect(String(data: data, encoding: .utf8) == "\(style.rawValue)") + } + + @Test func roundTripsThroughJSON() throws { + let style: InlineStyle = [.italic, .math, .spoiler] + let data = try JSONEncoder().encode(style) + let decoded = try JSONDecoder().decode(InlineStyle.self, from: data) + #expect(decoded == style) + } +} diff --git a/Tests/NorgKitTests/Models/NorgBlockTests.swift b/Tests/NorgKitTests/Models/NorgBlockTests.swift new file mode 100644 index 0000000..d9d18c2 --- /dev/null +++ b/Tests/NorgKitTests/Models/NorgBlockTests.swift @@ -0,0 +1,127 @@ +import Foundation +import Testing + +@testable import NorgKit + +struct NorgBlockTests { + + private let span = [InlineSpan(text: "x")] + private let title = [InlineSpan(text: "t")] + private let body: [NorgBlock] = [.paragraph(content: [InlineSpan(text: "b")], line: 1)] + + private var allBlocks: [NorgBlock] { + [ + .heading(level: 1, status: .done, content: span, line: 0), + .paragraph(content: span, line: 1), + .unorderedListItem(level: 1, status: .undone, content: span, line: 2), + .orderedListItem(level: 1, status: .pending, content: span, line: 3), + .quote(level: 1, status: .urgent, content: span, line: 4), + .codeBlock(language: "swift", code: "c", line: 5), + .definition(title: title, status: .needsInput, body: body, line: 6), + .footnote(title: title, status: .onHold, body: body, line: 7), + .tableCell(title: title, status: .recurring, body: body, line: 8), + .rangedTag(name: ["img"], parameters: ["a"], content: "c", line: 9), + .horizontalRule(line: 10), + .weakDelimiter(line: 11), + .strongDelimiter(line: 12), + ] + } + + @Test func lineReportsSourceLineForEveryCase() { + for (expected, block) in allBlocks.enumerated() { + #expect(block.line == expected) + } + } + + @Test func statusReturnsValueForStatusBearingBlocks() { + #expect(NorgBlock.heading(level: 1, status: .done, content: span, line: 0).status == .done) + #expect( + NorgBlock.unorderedListItem(level: 1, status: .undone, content: span, line: 0).status + == .undone) + #expect( + NorgBlock.orderedListItem(level: 1, status: .pending, content: span, line: 0).status + == .pending) + #expect(NorgBlock.quote(level: 1, status: .urgent, content: span, line: 0).status == .urgent) + #expect( + NorgBlock.definition(title: title, status: .needsInput, body: body, line: 0).status + == .needsInput) + #expect( + NorgBlock.footnote(title: title, status: .onHold, body: body, line: 0).status == .onHold) + #expect( + NorgBlock.tableCell(title: title, status: .recurring, body: body, line: 0).status + == .recurring) + } + + @Test func statusIsNilForBlocksThatCannotCarryOne() { + #expect(NorgBlock.paragraph(content: span, line: 0).status == nil) + #expect(NorgBlock.codeBlock(language: nil, code: "c", line: 0).status == nil) + #expect(NorgBlock.rangedTag(name: ["x"], parameters: [], content: "c", line: 0).status == nil) + #expect(NorgBlock.horizontalRule(line: 0).status == nil) + #expect(NorgBlock.weakDelimiter(line: 0).status == nil) + #expect(NorgBlock.strongDelimiter(line: 0).status == nil) + } + + @Test func contentReturnsInlineSpansOrTitle() { + #expect(NorgBlock.heading(level: 1, status: nil, content: span, line: 0).content == span) + #expect(NorgBlock.paragraph(content: span, line: 0).content == span) + #expect( + NorgBlock.unorderedListItem(level: 1, status: nil, content: span, line: 0).content == span) + #expect( + NorgBlock.orderedListItem(level: 1, status: nil, content: span, line: 0).content == span) + #expect(NorgBlock.quote(level: 1, status: nil, content: span, line: 0).content == span) + #expect(NorgBlock.definition(title: title, status: nil, body: body, line: 0).content == title) + #expect(NorgBlock.footnote(title: title, status: nil, body: body, line: 0).content == title) + #expect(NorgBlock.tableCell(title: title, status: nil, body: body, line: 0).content == title) + } + + @Test func contentIsNilForContentlessBlocks() { + #expect(NorgBlock.codeBlock(language: nil, code: "c", line: 0).content == nil) + #expect(NorgBlock.rangedTag(name: ["x"], parameters: [], content: "c", line: 0).content == nil) + #expect(NorgBlock.horizontalRule(line: 0).content == nil) + #expect(NorgBlock.weakDelimiter(line: 0).content == nil) + #expect(NorgBlock.strongDelimiter(line: 0).content == nil) + } + + @Test func bodyReturnsNestedBlocksForRangeableBlocks() { + #expect(NorgBlock.definition(title: title, status: nil, body: body, line: 0).body == body) + #expect(NorgBlock.footnote(title: title, status: nil, body: body, line: 0).body == body) + #expect(NorgBlock.tableCell(title: title, status: nil, body: body, line: 0).body == body) + } + + @Test func bodyIsNilForNonRangeableBlocks() { + #expect(NorgBlock.heading(level: 1, status: nil, content: span, line: 0).body == nil) + #expect(NorgBlock.paragraph(content: span, line: 0).body == nil) + #expect(NorgBlock.codeBlock(language: nil, code: "c", line: 0).body == nil) + #expect(NorgBlock.horizontalRule(line: 0).body == nil) + } + + @Test func settingStatusReplacesStatusOnSupportingBlocks() { + let cases: [NorgBlock] = [ + .heading(level: 2, status: nil, content: span, line: 0), + .unorderedListItem(level: 1, status: nil, content: span, line: 0), + .orderedListItem(level: 1, status: nil, content: span, line: 0), + .quote(level: 1, status: nil, content: span, line: 0), + .definition(title: title, status: nil, body: body, line: 0), + .footnote(title: title, status: nil, body: body, line: 0), + .tableCell(title: title, status: nil, body: body, line: 0), + ] + for block in cases { + #expect(block.status == nil) + let set = block.settingStatus(.done) + #expect(set.status == .done) + #expect(set.line == block.line) + #expect(set.content == block.content) + #expect(set.body == block.body) + #expect(set.settingStatus(nil).status == nil) + } + } + + @Test func settingStatusLeavesUnsupportedBlocksUnchanged() { + let paragraph = NorgBlock.paragraph(content: span, line: 0) + #expect(paragraph.settingStatus(.done) == paragraph) + let rule = NorgBlock.horizontalRule(line: 0) + #expect(rule.settingStatus(.done) == rule) + let code = NorgBlock.codeBlock(language: "swift", code: "c", line: 0) + #expect(code.settingStatus(.urgent) == code) + } +} diff --git a/Tests/NorgKitTests/Models/NorgDocumentTests.swift b/Tests/NorgKitTests/Models/NorgDocumentTests.swift new file mode 100644 index 0000000..13bad67 --- /dev/null +++ b/Tests/NorgKitTests/Models/NorgDocumentTests.swift @@ -0,0 +1,32 @@ +import Foundation +import Testing + +@testable import NorgKit + +struct NorgDocumentTests { + + @Test func defaultsToEmptyBlocks() { + #expect(NorgDocument().blocks.isEmpty) + } + + @Test func treeFoldsBlocksIntoNodes() { + let doc = NorgDocument(blocks: [ + .heading(level: 1, status: nil, content: [InlineSpan(text: "H")], line: 0), + .paragraph(content: [InlineSpan(text: "body")], line: 1), + ]) + let tree = doc.tree() + #expect(tree.count == 1) + #expect(tree.first?.children.count == 1) + } + + @Test func treeMatchesTreeFolderOutput() { + let doc = NorgParser.parse("* H\n- a\n-- b") + #expect(doc.tree() == TreeFolder(doc.blocks).fold()) + } + + @Test func roundTripsThroughJSON() throws { + let doc = NorgParser.parse("* H\nbody\n- ( ) task") + let data = try JSONEncoder().encode(doc) + #expect(try JSONDecoder().decode(NorgDocument.self, from: data) == doc) + } +} diff --git a/Tests/NorgKitTests/Models/NorgNodeTests.swift b/Tests/NorgKitTests/Models/NorgNodeTests.swift new file mode 100644 index 0000000..5e60014 --- /dev/null +++ b/Tests/NorgKitTests/Models/NorgNodeTests.swift @@ -0,0 +1,31 @@ +import Foundation +import Testing + +@testable import NorgKit + +struct NorgNodeTests { + + private let block = NorgBlock.paragraph(content: [InlineSpan(text: "x")], line: 0) + + @Test func defaultsToNoChildren() { + #expect(NorgNode(block: block).children.isEmpty) + } + + @Test func storesBlockAndChildren() { + let child = NorgNode(block: block) + let node = NorgNode(block: block, children: [child]) + #expect(node.block == block) + #expect(node.children == [child]) + } + + @Test func equatableComparesBlockAndChildren() { + #expect(NorgNode(block: block) == NorgNode(block: block)) + #expect(NorgNode(block: block, children: [NorgNode(block: block)]) != NorgNode(block: block)) + } + + @Test func roundTripsThroughJSON() throws { + let node = NorgNode(block: block, children: [NorgNode(block: block)]) + let data = try JSONEncoder().encode(node) + #expect(try JSONDecoder().decode(NorgNode.self, from: data) == node) + } +} diff --git a/Tests/NorgKitTests/Models/NorgTaskTests.swift b/Tests/NorgKitTests/Models/NorgTaskTests.swift new file mode 100644 index 0000000..e8707a1 --- /dev/null +++ b/Tests/NorgKitTests/Models/NorgTaskTests.swift @@ -0,0 +1,33 @@ +import Foundation +import Testing + +@testable import NorgKit + +struct NorgTaskTests { + + private let file = URL(filePath: "/tmp/notes.norg") + + @Test func idCombinesFileURLAndLine() { + let task = NorgTask(fileURL: file, line: 7, status: .undone, text: "do it") + #expect(task.id == "\(file.absoluteString):7") + } + + @Test func idIsStableForSameFileAndLine() { + let a = NorgTask(fileURL: file, line: 3, status: .done, text: "a") + let b = NorgTask(fileURL: file, line: 3, status: .urgent, text: "b") + #expect(a.id == b.id) + } + + @Test func differentLinesProduceDifferentIDs() { + let a = NorgTask(fileURL: file, line: 1, status: .done, text: "a") + let b = NorgTask(fileURL: file, line: 2, status: .done, text: "a") + #expect(a.id != b.id) + } + + @Test func roundTripsThroughJSON() throws { + let task = NorgTask(fileURL: file, line: 4, status: .pending, text: "keep") + let data = try JSONEncoder().encode(task) + let decoded = try JSONDecoder().decode(NorgTask.self, from: data) + #expect(decoded == task) + } +} diff --git a/Tests/NorgKitTests/Models/TaskStatusTests.swift b/Tests/NorgKitTests/Models/TaskStatusTests.swift new file mode 100644 index 0000000..a00e50c --- /dev/null +++ b/Tests/NorgKitTests/Models/TaskStatusTests.swift @@ -0,0 +1,54 @@ +import Foundation +import Testing + +@testable import NorgKit + +struct TaskStatusTests { + + @Test func parsesEveryMarker() { + #expect(TaskStatus(marker: " ") == .undone) + #expect(TaskStatus(marker: "x") == .done) + #expect(TaskStatus(marker: "?") == .needsInput) + #expect(TaskStatus(marker: "!") == .urgent) + #expect(TaskStatus(marker: "+") == .recurring) + #expect(TaskStatus(marker: "-") == .pending) + #expect(TaskStatus(marker: "=") == .onHold) + #expect(TaskStatus(marker: "_") == .cancelled) + } + + @Test func rejectsUnknownMarker() { + #expect(TaskStatus(marker: "z") == nil) + } + + @Test func parsesEveryMarkerByte() { + #expect(TaskStatus(markerByte: UInt8(ascii: " ")) == .undone) + #expect(TaskStatus(markerByte: UInt8(ascii: "x")) == .done) + #expect(TaskStatus(markerByte: UInt8(ascii: "?")) == .needsInput) + #expect(TaskStatus(markerByte: UInt8(ascii: "!")) == .urgent) + #expect(TaskStatus(markerByte: UInt8(ascii: "+")) == .recurring) + #expect(TaskStatus(markerByte: UInt8(ascii: "-")) == .pending) + #expect(TaskStatus(markerByte: UInt8(ascii: "=")) == .onHold) + #expect(TaskStatus(markerByte: UInt8(ascii: "_")) == .cancelled) + } + + @Test func rejectsUnknownMarkerByte() { + #expect(TaskStatus(markerByte: UInt8(ascii: "z")) == nil) + } + + @Test func idEqualsRawValue() { + for status in TaskStatus.allCases { + #expect(status.id == status.rawValue) + } + } + + @Test func rawValuesMatchMarkers() { + #expect(TaskStatus.undone.rawValue == " ") + #expect(TaskStatus.done.rawValue == "x") + #expect(TaskStatus.allCases.count == 8) + } + + @Test func roundTripsThroughJSON() throws { + let data = try JSONEncoder().encode(TaskStatus.urgent) + #expect(try JSONDecoder().decode(TaskStatus.self, from: data) == .urgent) + } +} diff --git a/Tests/NorgKitTests/Parsers/NorgInlineParserTests.swift b/Tests/NorgKitTests/Parsers/NorgInlineParserTests.swift new file mode 100644 index 0000000..d59fc35 --- /dev/null +++ b/Tests/NorgKitTests/Parsers/NorgInlineParserTests.swift @@ -0,0 +1,141 @@ +import Testing + +@testable import NorgKit + +struct NorgInlineParserTests { + + private func span(_ spans: [InlineSpan], withText text: String) -> InlineSpan? { + spans.first { $0.text == text } + } + + @Test func parsesBold() { + let spans = NorgInlineParser.parse("This is *bold*.") + #expect(spans.plainText == "This is bold.") + #expect(span(spans, withText: "bold")?.styles.contains(.bold) == true) + } + + @Test func parsesMixedStyles() { + let spans = NorgInlineParser.parse("a /italic/ and _underline_ and -strike-") + #expect(span(spans, withText: "italic")?.styles.contains(.italic) == true) + #expect(span(spans, withText: "underline")?.styles.contains(.underline) == true) + #expect(span(spans, withText: "strike")?.styles.contains(.strikethrough) == true) + } + + @Test func parsesNestedStyles() { + let spans = NorgInlineParser.parse("_under /italic/_") + let inner = span(spans, withText: "italic") + #expect(inner?.styles.contains(.italic) == true) + #expect(inner?.styles.contains(.underline) == true) + } + + @Test func verbatimIsLiteral() { + let spans = NorgInlineParser.parse("a `b*c*d` e") + let verbatim = span(spans, withText: "b*c*d") + #expect(verbatim?.styles.contains(.verbatim) == true) + #expect(spans.plainText == "a b*c*d e") + } + + @Test func dropsComments() { + let spans = NorgInlineParser.parse("visible %hidden% text") + #expect(!spans.plainText.contains("hidden")) + #expect(spans.plainText.contains("visible")) + #expect(spans.plainText.contains("text")) + } + + @Test func escapesModifier() { + let spans = NorgInlineParser.parse("\\*not bold\\*") + #expect(spans.plainText == "*not bold*") + #expect(spans.allSatisfy { !$0.styles.contains(.bold) }) + } + + @Test func doesNotMatchMidWordHyphen() { + let spans = NorgInlineParser.parse("well-known value") + #expect(spans.plainText == "well-known value") + #expect(spans.allSatisfy { !$0.styles.contains(.strikethrough) }) + } + + @Test func parsesLinkWithDescription() { + let spans = NorgInlineParser.parse("see {https://example.com}[the site] now") + let link = span(spans, withText: "the site") + #expect(link?.link == InlineLink(kind: .link, target: "https://example.com")) + } + + @Test func linkDescriptionIsLiteralNotPrefixStripped() { + // A `*`-prefixed description must not be mistaken for a location prefix. + let spans = NorgInlineParser.parse("{https://example.com}[* keep me]") + #expect(spans.first?.text == "* keep me") + #expect(spans.first?.link?.target == "https://example.com") + } + + @Test func stripsLocationPrefixFromBareLink() { + let spans = NorgInlineParser.parse("{* Some Heading}") + #expect(spans.first?.text == "Some Heading") + #expect(spans.first?.link == InlineLink(kind: .link, target: "* Some Heading")) + } + + @Test func parsesAnchorDefinition() { + let spans = NorgInlineParser.parse("[my label]{https://example.com}") + #expect(spans.first?.text == "my label") + #expect(spans.first?.link == InlineLink(kind: .anchor, target: "https://example.com")) + } + + @Test func bareAnchorDeclarationHasNoInlineTarget() { + // `[Neorg]` references a target defined elsewhere; its target is unknown + // at the declaration site, so it must not echo the label as the target. + let spans = NorgInlineParser.parse("I like [Neorg] a lot") + let anchor = span(spans, withText: "Neorg") + #expect(anchor?.link == InlineLink(kind: .anchor, target: nil)) + } + + @Test func anchorDeclarationWithDescription() { + let spans = NorgInlineParser.parse("[anchor name][shown text]") + #expect(spans.first?.text == "shown text") + #expect(spans.first?.link == InlineLink(kind: .anchor, target: nil)) + } + + @Test func unterminatedModifierIsLiteral() { + let spans = NorgInlineParser.parse("a * b c") + #expect(spans.plainText == "a * b c") + } + + @Test func unterminatedAttachedModifierWithOpenerIsLiteral() { + // A valid opener (`*` followed by non-space) with no closing modifier + // falls through to plain text rather than styling the rest of the line. + let spans = NorgInlineParser.parse("*bold but never closed") + #expect(spans.plainText == "*bold but never closed") + #expect(spans.allSatisfy { !$0.styles.contains(.bold) }) + } + + @Test func unterminatedVerbatimIsLiteral() { + let spans = NorgInlineParser.parse("a `b c d") + #expect(spans.plainText == "a `b c d") + #expect(spans.allSatisfy { !$0.styles.contains(.verbatim) }) + } + + @Test func unmatchedLinkBraceIsLiteral() { + let spans = NorgInlineParser.parse("see {never closed here") + #expect(spans.plainText == "see {never closed here") + #expect(spans.allSatisfy { $0.link == nil }) + } + + @Test func unmatchedAnchorBracketIsLiteral() { + let spans = NorgInlineParser.parse("see [never closed here") + #expect(spans.plainText == "see [never closed here") + #expect(spans.allSatisfy { $0.link == nil }) + } + + @Test func stripsFileSpecifierAndLocationPrefixFromBareLink() { + // `{:path:* Heading}` carries a `:file:` specifier before the location + // prefix; both are stripped to derive the display label. + let spans = NorgInlineParser.parse("{:notes.norg:* Some Heading}") + #expect(spans.first?.text == "Some Heading") + #expect(spans.first?.link == InlineLink(kind: .link, target: ":notes.norg:* Some Heading")) + } + + @Test func plainTextHelperReturnsUnstyledText() { + // Exercises the static plainText(_:) entry used by the task scanner. + #expect(NorgInlineParser.plainText("just *bold* here") == "just bold here") + #expect(NorgInlineParser.plainText("") == "") + #expect(NorgInlineParser.plainText("no markup at all") == "no markup at all") + } +} diff --git a/Tests/NorgKitTests/Parsers/NorgParserTests.swift b/Tests/NorgKitTests/Parsers/NorgParserTests.swift new file mode 100644 index 0000000..00ee0ea --- /dev/null +++ b/Tests/NorgKitTests/Parsers/NorgParserTests.swift @@ -0,0 +1,362 @@ +import Foundation +import Testing + +@testable import NorgKit + +struct NorgParserTests { + + @Test func parsesHeadingLevelAndStatus() { + let doc = NorgParser.parse("** (x) Ship it") + guard case .heading(let level, let status, let content, let line) = doc.blocks.first else { + Issue.record("Expected a heading") + return + } + #expect(level == 2) + #expect(status == .done) + #expect(content.plainText == "Ship it") + #expect(line == 0) + } + + @Test func parsesPlainHeading() { + let doc = NorgParser.parse("* Title") + guard case .heading(_, let status, let content, _) = doc.blocks.first else { + Issue.record("Expected a heading") + return + } + #expect(status == nil) + #expect(content.plainText == "Title") + } + + @Test func parsesUnorderedAndNestedList() { + let doc = NorgParser.parse("- ( ) todo\n-- nested") + guard case .unorderedListItem(let level1, let status, _, _) = doc.blocks.first else { + Issue.record("Expected list item") + return + } + #expect(level1 == 1) + #expect(status == .undone) + guard case .unorderedListItem(let level2, _, let content, _) = doc.blocks.last else { + Issue.record("Expected nested list item") + return + } + #expect(level2 == 2) + #expect(content.plainText == "nested") + } + + @Test func parsesOrderedList() { + let doc = NorgParser.parse("~ first\n~ second") + #expect(doc.blocks.count == 2) + if case .orderedListItem = doc.blocks.first {} else { Issue.record("Expected ordered item") } + } + + @Test func parsesQuote() { + let doc = NorgParser.parse("> a wise quote") + guard case .quote(let level, let status, let content, _) = doc.blocks.first else { + Issue.record("Expected quote") + return + } + #expect(level == 1) + #expect(status == nil) + #expect(content.plainText == "a wise quote") + } + + @Test func quoteCarriesStatus() { + let doc = NorgParser.parse("> (x) a settled matter") + guard case .quote(_, let status, let content, _) = doc.blocks.first else { + Issue.record("Expected quote") + return + } + #expect(status == .done) + #expect(content.plainText == "a settled matter") + } + + @Test func parsesUnknownRangedTagGenerically() { + let doc = NorgParser.parse("@math\nx^2 + y^2 = z^2\n@end") + guard case .rangedTag(let name, let parameters, let content, _) = doc.blocks.first else { + Issue.record("Expected ranged tag") + return + } + #expect(name == ["math"]) + #expect(parameters.isEmpty) + #expect(content == "x^2 + y^2 = z^2") + } + + @Test func rangedTagSplitsNameAndParameters() { + let doc = NorgParser.parse("@image.png alt text\n/img/cat.png\n@end") + guard case .rangedTag(let name, let parameters, _, _) = doc.blocks.first else { + Issue.record("Expected ranged tag") + return + } + #expect(name == ["image", "png"]) + #expect(parameters == ["alt", "text"]) + } + + @Test func parsesCrlfDocument() { + let doc = NorgParser.parse("* One\r\n\r\n- (x) two\r\n@code swift\r\nlet x = 1\r\n@end\r\n") + #expect(doc.blocks.count == 3) + guard case .heading(_, _, let headingContent, _) = doc.blocks[0] else { + Issue.record("Expected heading") + return + } + #expect(headingContent.plainText == "One") + guard case .unorderedListItem(_, let status, let listContent, let line) = doc.blocks[1] else { + Issue.record("Expected list item") + return + } + #expect(status == .done) + #expect(listContent.plainText == "two") + #expect(line == 2) + guard case .codeBlock(let language, let code, _) = doc.blocks[2] else { + Issue.record("Expected code block") + return + } + #expect(language == "swift") + #expect(code == "let x = 1") + } + + @Test func parsesCodeBlockWithLanguage() { + let doc = NorgParser.parse("@code swift\nlet x = 1\nlet y = 2\n@end") + guard case .codeBlock(let language, let code, _) = doc.blocks.first else { + Issue.record("Expected code block") + return + } + #expect(language == "swift") + #expect(code == "let x = 1\nlet y = 2") + } + + @Test func emptyVerbatimBodyProducesEmptyCode() { + // No body lines means no common indentation to compute. + let doc = NorgParser.parse("@code\n@end") + guard case .codeBlock(_, let code, _) = doc.blocks.first else { + Issue.record("Expected code block") + return + } + #expect(code.isEmpty) + } + + @Test func tagWithEmptyHeaderParsesWithEmptyName() { + // A bare `@` opener has no name field; it still forms a ranged tag. + let doc = NorgParser.parse("@\nbody\n@end") + guard case .rangedTag(let name, let parameters, let content, _) = doc.blocks.first else { + Issue.record("Expected ranged tag") + return + } + #expect(name.isEmpty) + #expect(parameters.isEmpty) + #expect(content == "body") + } + + @Test func dedentsCommonLeadingIndentationInVerbatimBody() { + // All body lines share four leading spaces, which are stripped while the + // relative indentation of the nested line is preserved. A blank line does + // not contribute to the common indent. + let doc = NorgParser.parse("@code\n one\n\n two\n three\n@end") + guard case .codeBlock(_, let code, _) = doc.blocks.first else { + Issue.record("Expected code block") + return + } + #expect(code == "one\n\n two\nthree") + } + + @Test func strayEndTagIsNoOpAndDoesNotSwallowDocument() { + let doc = NorgParser.parse("* One\n@end\n* Two") + #expect(doc.blocks.count == 2) + if case .heading = doc.blocks.first {} else { Issue.record("Expected first heading") } + if case .heading = doc.blocks.last {} else { Issue.record("Expected second heading") } + } + + @Test func hidesDocumentMetadata() { + let doc = NorgParser.parse("@document.meta\ntitle: Test\n@end\n* Heading") + #expect(doc.blocks.count == 1) + if case .heading = doc.blocks.first {} else { Issue.record("Expected only the heading") } + } +} + +// MARK: - Delimiters, paragraphs, and range-able blocks + +extension NorgParserTests { + + @Test func parsesDelimitingModifiers() { + if case .horizontalRule = NorgParser.parse("___").blocks.first { + } else { + Issue.record("Expected horizontal rule") + } + if case .weakDelimiter = NorgParser.parse("---").blocks.first { + } else { + Issue.record("Expected weak delimiter") + } + if case .strongDelimiter = NorgParser.parse("===").blocks.first { + } else { + Issue.record("Expected strong delimiter") + } + } + + @Test func mergesSoftWrappedParagraph() { + let doc = NorgParser.parse("line one\nline two") + #expect(doc.blocks.count == 1) + guard case .paragraph(let content, _) = doc.blocks.first else { + Issue.record("Expected paragraph") + return + } + #expect(content.plainText == "line one line two") + } + + @Test func boldAtLineStartIsParagraphNotHeading() { + let doc = NorgParser.parse("*bold* word") + guard case .paragraph(let content, _) = doc.blocks.first else { + Issue.record("Expected paragraph") + return + } + #expect(content.plainText == "bold word") + } + + @Test func tracksLineNumbersAcrossBlankLines() { + let doc = NorgParser.parse("* One\n\n* Two") + #expect(doc.blocks.count == 2) + #expect(doc.blocks[1].line == 2) + } + + @Test func listItemMergesSoftWrappedContinuation() { + // Nestable detached modifiers consume a whole paragraph as content, so a + // continuation line with no marker joins the item (Norg 1.0 §"Nestable + // Detached Modifiers"). + let doc = NorgParser.parse("- ( ) buy milk\n and eggs") + #expect(doc.blocks.count == 1) + guard case .unorderedListItem(_, let status, let content, _) = doc.blocks.first else { + Issue.record("Expected a single list item") + return + } + #expect(status == .undone) + #expect(content.plainText == "buy milk and eggs") + } + + @Test func quoteMergesSoftWrappedContinuation() { + let doc = NorgParser.parse("> a wise quote\nthat spans lines") + #expect(doc.blocks.count == 1) + guard case .quote(_, _, let content, _) = doc.blocks.first else { + Issue.record("Expected a single quote") + return + } + #expect(content.plainText == "a wise quote that spans lines") + } + + @Test func headingTitleDoesNotMergeContinuation() { + // Headings are structural: the title is a single paragraph segment, and + // the following line becomes its own block. + let doc = NorgParser.parse("* A heading\nbody text below") + #expect(doc.blocks.count == 2) + guard case .heading(_, _, let content, _) = doc.blocks.first else { + Issue.record("Expected heading") + return + } + #expect(content.plainText == "A heading") + guard case .paragraph(let body, let line) = doc.blocks.last else { + Issue.record("Expected paragraph") + return + } + #expect(body.plainText == "body text below") + #expect(line == 1) + } + + @Test func blankLineTerminatesListItemContent() { + let doc = NorgParser.parse("- item one\n\nseparate paragraph") + #expect(doc.blocks.count == 2) + guard case .unorderedListItem(_, _, let content, _) = doc.blocks.first else { + Issue.record("Expected list item") + return + } + #expect(content.plainText == "item one") + if case .paragraph = doc.blocks.last {} else { Issue.record("Expected paragraph") } + } + + @Test func nextListItemBreaksContinuation() { + let doc = NorgParser.parse("- first\n- second") + #expect(doc.blocks.count == 2) + } + + @Test func parsesSingleDefinition() { + let doc = NorgParser.parse("$ Norg\nA structured note format.\nplain follow-up") + // The definition owns only the immediately following paragraph; the + // blank-free continuation merges into that one body paragraph. + guard case .definition(let title, let status, let body, let line) = doc.blocks.first else { + Issue.record("Expected a definition") + return + } + #expect(title.plainText == "Norg") + #expect(status == nil) + #expect(line == 0) + #expect(body.count == 1) + guard case .paragraph(let content, let bodyLine) = body.first else { + Issue.record("Expected a paragraph body") + return + } + #expect(content.plainText == "A structured note format. plain follow-up") + #expect(bodyLine == 1) // absolute line number is preserved + } + + @Test func parsesRangedDefinitionWithNestedBlocks() { + let doc = NorgParser.parse("$$ Term\n- one\n- two\n$$\nafter") + #expect(doc.blocks.count == 2) + guard case .definition(let title, _, let body, _) = doc.blocks.first else { + Issue.record("Expected a definition") + return + } + #expect(title.plainText == "Term") + #expect(body.count == 2) + if case .unorderedListItem = body.first {} else { Issue.record("Expected a list body") } + // The block after the closer is back at the top level on its own line. + guard case .paragraph(let after, let line) = doc.blocks.last else { + Issue.record("Expected trailing paragraph") + return + } + #expect(after.plainText == "after") + #expect(line == 4) + } + + @Test func nestedRangeDoesNotCloseOuter() { + // An inner `$$ … $$` must not be mistaken for the outer range's closer. + let doc = NorgParser.parse("$$ outer\n$$ inner\nbody\n$$\nstill outer\n$$") + #expect(doc.blocks.count == 1) + guard case .definition(_, _, let body, _) = doc.blocks.first else { + Issue.record("Expected outer definition") + return + } + // Body holds the inner definition and the trailing paragraph. + #expect(body.count == 2) + if case .definition = body.first {} else { Issue.record("Expected nested definition") } + #expect(body.last?.content?.plainText == "still outer") + } + + @Test func parsesFootnoteAndTableCell() { + let doc = NorgParser.parse("^ source\nthe reference\n: A1\nthe cell") + guard case .footnote(let footTitle, _, _, _) = doc.blocks.first else { + Issue.record("Expected a footnote") + return + } + #expect(footTitle.plainText == "source") + guard case .tableCell(let cellTitle, _, _, _) = doc.blocks.last else { + Issue.record("Expected a table cell") + return + } + #expect(cellTitle.plainText == "A1") + } + + @Test func definitionCarriesStatus() { + let doc = NorgParser.parse("$ (x) Settled term\nits meaning") + guard case .definition(_, let status, _, _) = doc.blocks.first else { + Issue.record("Expected a definition") + return + } + #expect(status == .done) + } + + @Test func documentRoundTripsThroughJSON() throws { + // Includes a ranged definition so the recursive `body` encodes/decodes. + let doc = NorgParser.parse( + "* (x) Done\n- ( ) todo {https://example.com}[link]\n$$ Term\n- nested\n$$\n@code swift\nlet x = 1\n@end" + ) + let data = try JSONEncoder().encode(doc) + let decoded = try JSONDecoder().decode(NorgDocument.self, from: data) + #expect(decoded == doc) + } +} diff --git a/Tests/NorgKitTests/Parsers/TaskScannerTests.swift b/Tests/NorgKitTests/Parsers/TaskScannerTests.swift new file mode 100644 index 0000000..58226fd --- /dev/null +++ b/Tests/NorgKitTests/Parsers/TaskScannerTests.swift @@ -0,0 +1,110 @@ +import Foundation +import Testing + +@testable import NorgKit + +struct TaskScannerTests { + + private let file = URL(filePath: "/tmp/notes.norg") + + @Test func scansTasksWithLineNumbers() { + let content = """ + * Project + - ( ) first task + - (x) done task + plain text + ~ (-) ordered pending + """ + let tasks = TaskScanner.scan(content: content, fileURL: file) + #expect(tasks.count == 3) + #expect(tasks[0].line == 1) + #expect(tasks[0].status == .undone) + #expect(tasks[0].text == "first task") + #expect(tasks[1].status == .done) + #expect(tasks[2].status == .pending) + } + + @Test func ignoresNonTaskLines() { + let tasks = TaskScanner.scan(content: "- just a list item\n* heading", fileURL: file) + #expect(tasks.isEmpty) + } + + @Test func parsesSingleLineIntoTask() { + let task = TaskScanner.task(in: "- (!) urgent thing", fileURL: file, line: 9) + #expect(task?.line == 9) + #expect(task?.status == .urgent) + #expect(task?.text == "urgent thing") + #expect(task?.fileURL == file) + } + + @Test func parsesSingleLineReturnsNilForNonTask() { + #expect(TaskScanner.task(in: "- plain item", fileURL: file, line: 0) == nil) + } + + @Test func scansEveryStatusBearingModifier() { + // The Norg spec allows any detached modifier to carry a TODO status. We + // model the structural, nestable, and range-able modifiers (`%` + // attributes are intentionally not treated as tasks). + let content = """ + * (x) heading task + - ( ) list task + ~ (-) ordered task + > (!) quote task + $ (?) definition task + ^ (=) footnote task + : (+) table cell task + """ + let tasks = TaskScanner.scan(content: content, fileURL: file) + #expect(tasks.count == 7) + #expect( + tasks.map(\.status) == [ + .done, .undone, .pending, .urgent, .needsInput, .onHold, .recurring, + ]) + #expect(tasks[3].text == "quote task") + #expect(tasks[4].text == "definition task") + } + + @Test func stripsInlineMarkupFromTaskText() { + let tasks = TaskScanner.scan(content: "- ( ) buy *milk*", fileURL: file) + #expect(tasks.first?.text == "buy milk") + } + + @Test func updatesStatusMarker() { + let content = "- ( ) toggle me" + let updated = TaskScanner.updatedContent(content, line: 0, to: .done) + #expect(updated == "- (x) toggle me") + } + + @Test func updatesStatusPreservingIndentation() { + let content = "* Heading\n -- (x) nested" + let updated = TaskScanner.updatedContent(content, line: 1, to: .cancelled) + #expect(updated == "* Heading\n -- (_) nested") + } + + @Test func updateReturnsNilWhenNoMarker() { + #expect(TaskScanner.updatedContent("plain line", line: 0, to: .done) == nil) + } + + @Test func updateReturnsNilForOutOfRangeLine() { + #expect(TaskScanner.updatedContent("- ( ) a", line: 5, to: .done) == nil) + } + + @Test func updatesStatusOnLaterLineLeavingRestIntact() { + let content = "* heading\n> ( ) quote task\n- plain item" + let updated = TaskScanner.updatedContent(content, line: 1, to: .urgent) + #expect(updated == "* heading\n> (!) quote task\n- plain item") + } + + @Test func scansCrlfContent() { + let tasks = TaskScanner.scan(content: "- ( ) first\r\n- (x) second\r\n", fileURL: file) + #expect(tasks.count == 2) + #expect(tasks[0].text == "first") + #expect(tasks[1].status == .done) + } + + @Test func updatePreservesCrlfLineEndings() { + let content = "- ( ) a\r\n- ( ) b" + let updated = TaskScanner.updatedContent(content, line: 1, to: .done) + #expect(updated == "- ( ) a\r\n- (x) b") + } +} diff --git a/Tests/NorgKitTests/TreeFolderTests.swift b/Tests/NorgKitTests/TreeFolderTests.swift new file mode 100644 index 0000000..f3fe0a3 --- /dev/null +++ b/Tests/NorgKitTests/TreeFolderTests.swift @@ -0,0 +1,145 @@ +import Foundation +import Testing + +@testable import NorgKit + +struct TreeFolderTests { + + private func headingLevel(_ node: NorgNode?) -> Int? { + guard case .heading(let level, _, _, _)? = node?.block else { return nil } + return level + } + + private func plainText(_ node: NorgNode?) -> String? { + switch node?.block { + case .heading(_, _, let content, _), + .paragraph(let content, _), + .unorderedListItem(_, _, let content, _), + .quote(_, _, let content, _): + return content.plainText + default: + return nil + } + } + + @Test func headingOwnsFollowingParagraph() { + let tree = NorgParser.parseTree("* Title\nbody paragraph") + #expect(tree.count == 1) + #expect(headingLevel(tree.first) == 1) + #expect(tree.first?.children.count == 1) + #expect(plainText(tree.first?.children.first) == "body paragraph") + } + + @Test func subHeadingNestsUnderParent() { + let tree = NorgParser.parseTree("* H1\npara one\n** H2\npara two") + #expect(tree.count == 1) + let h1 = tree.first + #expect(headingLevel(h1) == 1) + #expect(h1?.children.count == 2) + #expect(plainText(h1?.children.first) == "para one") + let h2 = h1?.children.last + #expect(headingLevel(h2) == 2) + #expect(plainText(h2?.children.first) == "para two") + } + + @Test func sameOrHigherHeadingClosesScope() { + let tree = NorgParser.parseTree("* H1\nbody\n* H2") + #expect(tree.count == 2) + #expect(headingLevel(tree.first) == 1) + #expect(headingLevel(tree.last) == 1) + #expect(tree.first?.children.count == 1) + #expect(tree.last?.children.isEmpty == true) + } + + @Test func weakDelimiterClosesOneLevel() { + let tree = NorgParser.parseTree("* H1\n** H2\ninner\n*** H3\ninnerinner\n---\nback in h1") + #expect(tree.count == 1) + let h1 = tree.first + #expect(h1?.children.count == 1) + + let h2 = h1?.children.first + #expect(h2?.children.count == 3) + #expect(headingLevel(h2) == 2) + #expect(plainText(h2?.children.first) == "inner") + + let h3 = h2?.children[1] + #expect(headingLevel(h3) == 3) + #expect(plainText(h3?.children.first) == "innerinner") + #expect(h3?.children.count == 1) + + #expect(plainText(h2?.children.last) == "back in h1") + #expect( + h1?.children.allSatisfy { + if case .weakDelimiter = $0.block { return false } + return true + } == true) + } + + @Test func strongDelimiterClosesAllLevels() { + let tree = NorgParser.parseTree("* H1\n** H2\ninner\n*** H3\ninnerinner\n===\nat root") + #expect(tree.count == 2) + #expect(headingLevel(tree.first) == 1) + #expect(plainText(tree.last) == "at root") + } + + @Test func nestableItemsNestByLevelAndType() { + let tree = NorgParser.parseTree("- one\n-- nested\n--- deeper\n- two") + #expect(tree.count == 2) + let one = tree.first + #expect(plainText(one) == "one") + #expect(one?.children.count == 1) + let nested = one?.children.first + #expect(plainText(nested) == "nested") + #expect(plainText(nested?.children.first) == "deeper") + #expect(plainText(tree.last) == "two") + } + + @Test func differentNestableTypeDoesNotNest() { + let tree = NorgParser.parseTree("- item\n~ ordered") + #expect(tree.count == 2) + #expect(tree.first?.children.isEmpty == true) + } + + @Test func quotesNestByLevel() { + let tree = NorgParser.parseTree("> one\n>> nested\n> two") + #expect(tree.count == 2) + let one = tree.first + #expect(plainText(one) == "one") + #expect(one?.children.count == 1) + #expect(plainText(one?.children.first) == "nested") + #expect(plainText(tree.last) == "two") + } + + @Test func listNestsUnderHeading() { + let tree = NorgParser.parseTree("* Tasks\n- ( ) a\n-- ( ) sub") + let heading = tree.first + #expect(headingLevel(heading) == 1) + #expect(heading?.children.count == 1) + let item = heading?.children.first + #expect(plainText(item) == "a") + #expect(plainText(item?.children.first) == "sub") + } + + @Test func horizontalRuleStaysALeaf() { + let tree = NorgParser.parseTree("* H\n___\ntext") + let heading = tree.first + #expect(heading?.children.count == 2) + if case .horizontalRule = heading?.children.first?.block { + } else { + Issue.record("Expected horizontal rule leaf") + } + #expect(heading?.children.first?.children.isEmpty == true) + } + + @Test func parseTreeMatchesParseThenFold() { + let source = "* H1\npara\n** H2\n- list\n-- sub\n===\nroot para" + #expect(NorgParser.parseTree(source) == NorgParser.parse(source).tree()) + } + + @Test func treeRoundTripsThroughJSON() throws { + let tree = NorgParser.parseTree("* H1\nbody\n** H2\n- ( ) task") + let data = try JSONEncoder().encode(tree) + let decoded = try JSONDecoder().decode([NorgNode].self, from: data) + #expect(decoded == tree) + } +} |