aboutsummaryrefslogtreecommitdiff
path: root/Tests/NorgKitTests
diff options
context:
space:
mode:
Diffstat (limited to 'Tests/NorgKitTests')
-rw-r--r--Tests/NorgKitTests/Extensions/Array+InlineSpanTests.swift19
-rw-r--r--Tests/NorgKitTests/Helpers/ASCIIByteSetTests.swift42
-rw-r--r--Tests/NorgKitTests/Helpers/ASCIIHelperTests.swift22
-rw-r--r--Tests/NorgKitTests/Helpers/TextHelperTests.swift71
-rw-r--r--Tests/NorgKitTests/Models/DetachedModifierTests.swift61
-rw-r--r--Tests/NorgKitTests/Models/InlineLinkTests.swift32
-rw-r--r--Tests/NorgKitTests/Models/InlineSpanTests.swift35
-rw-r--r--Tests/NorgKitTests/Models/InlineStyleTests.swift35
-rw-r--r--Tests/NorgKitTests/Models/NorgBlockTests.swift127
-rw-r--r--Tests/NorgKitTests/Models/NorgDocumentTests.swift32
-rw-r--r--Tests/NorgKitTests/Models/NorgNodeTests.swift31
-rw-r--r--Tests/NorgKitTests/Models/NorgTaskTests.swift33
-rw-r--r--Tests/NorgKitTests/Models/TaskStatusTests.swift54
-rw-r--r--Tests/NorgKitTests/Parsers/NorgInlineParserTests.swift141
-rw-r--r--Tests/NorgKitTests/Parsers/NorgParserTests.swift362
-rw-r--r--Tests/NorgKitTests/Parsers/TaskScannerTests.swift110
-rw-r--r--Tests/NorgKitTests/TreeFolderTests.swift145
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)
+ }
+}