import Foundation import Testing @testable import Kanban /// The executable spec for the card body's parse → render **model** (05-card-window.md ▸ Preview). /// /// The renderer beneath it draws pixels a unit test cannot see; what it draws *from* is this model, /// and every rule 05 settles about Preview is a fact about the model rather than about the drawing: /// which constructs are in the subset, that HTML is literal text rather than markup, that an image /// with a scheme is never fetched, that a link knows whether it is the browser's or the Finder's, /// and — the one that reaches disk — where a task checkbox's single byte lives in the source. /// /// So this file is where those hold still. A regression here is a rendering that would be *wrong*; /// a regression in the renderer is one that would be ugly. // MARK: - Reading the model /// The first block of a parsed body — most cases here are one construct, and naming the unwrap once /// keeps each test to its claim. private func firstBlock(_ source: String) -> BodyBlock? { BodyMarkup.parse(source).blocks.first } /// Every `BodyInline` flattened to the plain text it carries, containers descended into — what the /// reader ends up seeing, with the structure taken away. private func plainText(_ inlines: [BodyInline]) -> String { inlines.map { inline in switch inline { case let .text(text): text case let .code(code): code case let .html(raw): raw case let .emphasis(children), let .strong(children), let .strikethrough(children): plainText(children) case let .link(_, children): plainText(children) case let .image(image): image.alt case .lineBreak: "\n" case .softBreak: " " } }.joined() } /// The body slice a block's source range names — the assertion that a range is *the* range rather /// than merely plausible. private func slice(_ body: String, _ span: BodySpan?) -> String? { guard let span else { return nil } let bytes = Array(body.utf8) guard span.start <= span.end, span.end <= bytes.count else { return nil } return String(decoding: bytes[span.start ..< span.end], as: UTF8.self) } private func span(of block: BodyBlock?) -> BodySpan? { switch block { case let .heading(_, _, range), let .paragraph(_, range), let .code(_, _, range), let .html(_, range), let .thematicBreak(range), let .quote(_, range), let .list(_, range), let .table(_, range): range case nil: nil } } // MARK: - The subset, construct by construct @Suite("Body markup — the GFM subset") struct BodyMarkupSubsetTests { @Test("Headings carry their level and their inlines") func headings() { guard case let .heading(level, inlines, _)? = firstBlock("## Fix *login*\n") else { Issue.record("expected a heading") return } #expect(level == 2) #expect(plainText(inlines) == "Fix login") // The emphasis is structure, not a string: a renderer that lost it would still pass a // plain-text check. #expect(inlines.contains { if case .emphasis = $0 { true } else { false } }) } @Test("Bold, italic, inline code and strikethrough are each their own inline") func inlineRuns() { guard case let .paragraph(inlines, _)? = firstBlock("**a** _b_ `c` ~~d~~\n") else { Issue.record("expected a paragraph") return } #expect(inlines.contains { if case .strong = $0 { true } else { false } }) #expect(inlines.contains { if case .emphasis = $0 { true } else { false } }) #expect(inlines.contains { if case .code("c") = $0 { true } else { false } }) #expect(inlines.contains { if case .strikethrough = $0 { true } else { false } }) } @Test("A fenced block keeps its language; an indented one is code all the same") func codeBlocks() { guard case let .code(fenced, language, _)? = firstBlock("```swift\nlet x = 1\n```\n") else { Issue.record("expected a fenced code block") return } #expect(language == "swift") #expect(fenced.hasPrefix("let x = 1")) // The indented form is the same construct with nothing to label it — 05 lists "fenced + // indented code" as one line for a reason. guard case let .code(indented, indentedLanguage, _)? = firstBlock(" let y = 2\n") else { Issue.record("expected an indented code block") return } #expect(indentedLanguage == nil) #expect(indented.hasPrefix("let y = 2")) } @Test("Quotes nest") func nestedQuotes() { guard case let .quote(outer, _)? = firstBlock("> outer\n>\n> > inner\n") else { Issue.record("expected a block quote") return } let inner = outer.compactMap { block -> [BodyBlock]? in if case let .quote(blocks, _) = block { return blocks } return nil } #expect(inner.count == 1, "a quote inside a quote is a quote inside a quote, not a flattened one") if case let .paragraph(inlines, _)? = inner.first?.first { #expect(plainText(inlines) == "inner") } else { Issue.record("expected the nested quote's paragraph") } } @Test("An ordered list remembers where it starts; a bullet list does not pretend to") func lists() { guard case let .list(ordered, _)? = firstBlock("3. three\n4. four\n") else { Issue.record("expected an ordered list") return } #expect(ordered.isOrdered) #expect(ordered.start == 3) #expect(ordered.items.count == 2) guard case let .list(bullets, _)? = firstBlock("- one\n- two\n") else { Issue.record("expected a bullet list") return } #expect(!bullets.isOrdered) #expect(bullets.items.allSatisfy { $0.task == nil }) } @Test("A thematic break is its own block") func thematicBreak() { guard case .thematicBreak? = firstBlock("---\n") else { Issue.record("expected a thematic break") return } } @Test("A GFM table carries per-column alignment, its header, and its rows") func tables() { let source = """ | Left | Middle | Right | | :--- | :----: | ----: | | a | b | c | | d | e | f | """ guard case let .table(table, _)? = firstBlock(source) else { Issue.record("expected a table") return } #expect(table.columnCount == 3) #expect(table.alignments == [.leading, .center, .trailing]) #expect(table.header.map { plainText($0.inlines) } == ["Left", "Middle", "Right"]) #expect(table.rows.count == 2) #expect(table.rows.first?.map { plainText($0.inlines) } == ["a", "b", "c"]) } @Test("A table with no alignment row markers leaves its columns unspecified") func tableWithoutAlignments() { let source = """ | One | Two | | --- | --- | | a | b | """ guard case let .table(table, _)? = firstBlock(source) else { Issue.record("expected a table") return } #expect(table.alignments == [.unspecified, .unspecified]) // One entry per column, always — a renderer indexes this array by column and must not have // to guard every access. #expect(table.alignments.count == table.columnCount) } } // MARK: - HTML is text @Suite("Body markup — HTML is never interpreted") struct BodyMarkupHTMLTests { @Test("An HTML block is carried verbatim, as literal text") func htmlBlockIsVerbatim() { guard case let .html(raw, _)? = firstBlock("
\n bold\n
\n") else { Issue.record("expected an HTML block") return } // Verbatim: the angle brackets, the attribute quoting and the inner tag are all still // characters. Nothing here is a `` the app is about to honour (05 ▸ Preview; 00-vision's // no-web-tech stance). #expect(raw.contains("
")) #expect(raw.contains("bold")) } @Test("Inline HTML is an inline of its own, never a rendered tag") func inlineHTMLIsVerbatim() { guard case let .paragraph(inlines, _)? = firstBlock("before x after\n") else { Issue.record("expected a paragraph") return } let html = inlines.compactMap { inline -> String? in if case let .html(raw) = inline { return raw } return nil } #expect(html == ["", ""]) // And crucially *not* a strong run: the tags are text and "x" is text beside them. #expect(!inlines.contains { if case .strong = $0 { true } else { false } }) #expect(plainText(inlines) == "before x after") } } // MARK: - Where a link or an image points @Suite("Body markup — link and image classification") struct BodyTargetTests { @Test("A path with no scheme is relative; anything with one is absolute") func classification() { #expect(BodyTarget.classify("attachments/shot.png") == .relative("attachments/shot.png")) #expect(BodyTarget.classify("./notes.md") == .relative("./notes.md")) #expect(BodyTarget.classify("../sibling/index.md") == .relative("../sibling/index.md")) #expect(BodyTarget.classify("https://example.com/x.png") == .absolute("https://example.com/x.png")) #expect(BodyTarget.classify("mailto:a@b.c") == .absolute("mailto:a@b.c")) #expect(BodyTarget.classify("file:///tmp/x.png") == .absolute("file:///tmp/x.png")) // Nothing to point at is a relative nothing, not an absolute one. #expect(BodyTarget.classify(nil) == .relative("")) #expect(BodyTarget.classify("") == .relative("")) } @Test("A relative image resolves against the card's own folder; a remote one is never fetched") func imageClassification() { guard case let .paragraph(local, _)? = firstBlock("![a shot](attachments/shot.png)\n"), case let .image(localImage)? = local.first else { Issue.record("expected a local image") return } #expect(localImage.target == .relative("attachments/shot.png")) #expect(localImage.alt == "a shot") guard case let .paragraph(remote, _)? = firstBlock("![alt text](https://example.com/x.png)\n"), case let .image(remoteImage)? = remote.first else { Issue.record("expected a remote image") return } // `.absolute` is the whole of "Preview does no networking": nothing downstream is given a // loader for this case at all — it renders as a chip carrying the alt text (05 ▸ Preview). #expect(remoteImage.target == .absolute("https://example.com/x.png")) #expect(remoteImage.alt == "alt text") } @Test("A link keeps both its destination's kind and its own text") func linkClassification() { guard case let .paragraph(inlines, _)? = firstBlock("see [the notes](notes.md) and [home](https://example.com)\n") else { Issue.record("expected a paragraph") return } let links = inlines.compactMap { inline -> (BodyTarget, String)? in if case let .link(target, children) = inline { return (target, plainText(children)) } return nil } #expect(links.count == 2) #expect(links.first?.0 == .relative("notes.md")) #expect(links.first?.1 == "the notes") #expect(links.last?.0 == .absolute("https://example.com")) } @Test("Resolution is against the card folder, and a rooted path is taken as written") func resolution() { let folder = URL(fileURLWithPath: "/tmp/board/lane/card", isDirectory: true) #expect( BodyTarget.relative("attachments/shot.png").resolve(inCardFolder: folder)?.path == "/tmp/board/lane/card/attachments/shot.png" ) // Percent-encoding is the author's escaping of a filename, not part of the filename. #expect( BodyTarget.relative("attachments/my%20shot.png").resolve(inCardFolder: folder)?.path == "/tmp/board/lane/card/attachments/my shot.png" ) // A rooted path names itself; gluing it onto the card folder would name a file nobody meant. #expect(BodyTarget.relative("/etc/hosts").resolve(inCardFolder: folder)?.path == "/etc/hosts") #expect(BodyTarget.absolute("https://example.com/x").resolve(inCardFolder: folder)?.scheme == "https") // Nothing to resolve, and nothing to resolve against. #expect(BodyTarget.relative("").resolve(inCardFolder: folder) == nil) #expect(BodyTarget.relative("notes.md").resolve(inCardFolder: nil) == nil) } } // MARK: - Task checkboxes @Suite("Body markup — task checkboxes") struct BodyTaskTests { /// Two items, one of each state, with a nested one under the second. private static let source = """ - [ ] first - [x] second - [ ] nested """ private func tasks(of blocks: [BodyBlock]) -> [BodyTask] { blocks.flatMap { block -> [BodyTask] in switch block { case let .list(list, _): list.items.flatMap { item in (item.task.map { [$0] } ?? []) + tasks(of: item.blocks) } case let .quote(children, _): tasks(of: children) default: [] } } } @Test("A task item's checkbox state and its source byte both survive the parse") func markersAreLocated() { let markup = BodyMarkup.parse(Self.source) let found = tasks(of: markup.blocks) #expect(found.map(\.isChecked) == [false, true, false]) // The offsets, counted by hand from the source above — the one number in this model that a // write will aim at, so it is asserted as a number rather than as "somewhere in the line": // "- [ ] first\n" → `[` at 2, marker at 3 // "- [x] second\n" → line starts at 12, marker at 15 // " - [ ] nested\n"→ line starts at 25, marker at 30 #expect(found.map(\.markerOffset) == [3, 15, 30]) // And each one really is the character between the brackets. let bytes = Array(Self.source.utf8) for task in found { guard let offset = task.markerOffset else { Issue.record("a parsed checkbox with no located marker") continue } #expect(bytes[offset - 1] == UInt8(ascii: "[")) #expect(bytes[offset + 1] == UInt8(ascii: "]")) #expect(BodyMarkup.markerState(bytes[offset]) == task.isChecked) } } @Test("An ordinary list item has no checkbox, and a literal [x] in prose is not one") func nonTasksAreNotTasks() { #expect(tasks(of: BodyMarkup.parse("- plain\n- also plain\n").blocks).isEmpty) // The scan is line-scoped and starts at a *task item*: prose that happens to contain the // three characters is prose (`taskMarkerOffset`). #expect(tasks(of: BodyMarkup.parse("A sentence with a [x] in it.\n").blocks).isEmpty) } @Test("A mixed list keeps its plain items plain") func mixedList() { guard case let .list(list, _)? = firstBlock("- [ ] a task\n- not a task\n") else { Issue.record("expected a list") return } #expect(list.items.count == 2) #expect(list.items.first?.task?.isChecked == false) #expect(list.items.last?.task == nil) } } // MARK: - The flip @Suite("Body markup — flipping a task marker") struct BodyMarkerFlipTests { private static let body = "- [ ] first\n- [x] second\n" @Test("A flip changes exactly one byte, in each direction") func flipsOneByte() { guard let ticked = BodyMarkup.flippingTaskMarker(in: Self.body, at: 3, expecting: false) else { Issue.record("expected the flip to land") return } #expect(ticked == "- [x] first\n- [x] second\n") guard let unticked = BodyMarkup.flippingTaskMarker(in: Self.body, at: 15, expecting: true) else { Issue.record("expected the flip to land") return } #expect(unticked == "- [ ] first\n- [ ] second\n") // The one-byte claim, stated as a byte count rather than inferred from the strings. let before = Array(Self.body.utf8) let after = Array(ticked.utf8) #expect(before.count == after.count) #expect(zip(before, after).filter { $0 != $1 }.count == 1) } @Test("A flip round-trips") func roundTrips() { let ticked = BodyMarkup.flippingTaskMarker(in: Self.body, at: 3, expecting: false) let back = ticked.flatMap { BodyMarkup.flippingTaskMarker(in: $0, at: 3, expecting: true) } #expect(back == Self.body) } @Test("An uppercase X reads as checked and unticks to a space") func uppercaseMarker() { #expect(BodyMarkup.flippingTaskMarker(in: "- [X] one\n", at: 3, expecting: true) == "- [ ] one\n") } @Test("A stale offset, a stale state, or a byte that is not a marker all refuse") func refusals() { // The state the user saw no longer matches disk — an external edit ticked it first. #expect(BodyMarkup.flippingTaskMarker(in: Self.body, at: 3, expecting: true) == nil) // Not between brackets at all. #expect(BodyMarkup.flippingTaskMarker(in: Self.body, at: 7, expecting: false) == nil) // Past the end, and before the beginning. #expect(BodyMarkup.flippingTaskMarker(in: Self.body, at: 9_999, expecting: false) == nil) #expect(BodyMarkup.flippingTaskMarker(in: Self.body, at: 0, expecting: false) == nil) } @Test("Multibyte text before the marker does not move it") func multibyteIsCountedInBytes() { // "é" is two UTF-8 bytes and "→" is three: an offset counted in Characters would be wrong // by five here, which is exactly the class of bug that puts an `x` in the middle of a word. let body = "prosé →\n\n- [ ] task\n" let markup = BodyMarkup.parse(body) let task = markup.blocks.compactMap { block -> BodyTask? in if case let .list(list, _) = block { return list.items.first?.task } return nil }.first guard let offset = task?.markerOffset else { Issue.record("expected a located marker") return } #expect(Array(body.utf8)[offset] == UInt8(ascii: " ")) #expect(BodyMarkup.flippingTaskMarker(in: body, at: offset, expecting: false) == "prosé →\n\n- [x] task\n") } } // MARK: - Source ranges @Suite("Body markup — source ranges") struct BodySpanTests { @Test("A block's range names the bytes it was parsed from") func blockRangesSlice() { let source = "# Title\n\nA paragraph.\n" let blocks = BodyMarkup.parse(source).blocks #expect(blocks.count == 2) #expect(slice(source, span(of: blocks.first)) == "# Title") #expect(slice(source, span(of: blocks.last)) == "A paragraph.") } @Test("Ranges are byte offsets, so multibyte text does not shift them") func rangesAreBytes() { let source = "# Café\n\n→ next\n" let blocks = BodyMarkup.parse(source).blocks #expect(slice(source, span(of: blocks.first)) == "# Café") #expect(slice(source, span(of: blocks.last)) == "→ next") } } // MARK: - The empty-body question @Suite("Body markup — emptiness") struct BodyEmptinessTests { @Test("Whitespace is empty; anything else is not") func emptiness() { // The input to 05's opening rule: "a card opens in Preview — unless its body is empty". #expect(BodyMarkup.isEmpty("")) #expect(BodyMarkup.isEmpty("\n")) #expect(BodyMarkup.isEmpty(" \n\n\t")) #expect(!BodyMarkup.isEmpty("x")) #expect(!BodyMarkup.isEmpty("\n# Heading\n")) } } // MARK: - The clickable-run codec @Suite("Body markup — the checkbox link codec") @MainActor struct CardBodyLinkTests { @Test("A checkbox round-trips through its URL, in both states") func taskURLsRoundTrip() { for (offset, checked) in [(0, false), (3, true), (12_345, false)] { guard let url = CardBodyLink.task(offset: offset, isChecked: checked) else { Issue.record("expected a URL for \(offset)/\(checked)") continue } let parsed = CardBodyLink.parseTask(url) #expect(parsed?.offset == offset) #expect(parsed?.isChecked == checked) } } @Test("An ordinary link is not a checkbox") func ordinaryLinksAreNotTasks() { // How the delegate tells the two apart — a body link must never be mistaken for a write. #expect(CardBodyLink.parseTask(URL(string: "https://example.com")!) == nil) #expect(CardBodyLink.parseTask(URL(fileURLWithPath: "/tmp/x.md")) == nil) } }