Files
lanework/KanbanTests/BoardOutlineTests.swift
T
rzen b18f7ca609 Space in Finder opens the board — a Quick Look preview extension that outlines a .kanban package
A board is a folder, and a folder previews as a folder. `KanbanQuickLook.appex` gives it a
document's preview instead: the board's name, its tint and its symbol, then its lanes in display
order with each one's card count and its first few card titles.

The reading is `BoardOutline` (Kanban/Storage), deliberately not `BoardLoader.load`. The loader
throws on a half-broken board — right for opening one, wrong for pressing Space, where the honest
answer is the part that reads; it visits every card and lists `attachments/` and counts `comments/`
inside each; and it carries trash, tombstone migration and the defect stream, none of which renders.
This walk never throws and is capped at every level (`BoardOutlineLimits`): 12 lanes shown of at
most 100 considered, 6 card titles per lane of at most 200 parsed, counts by readdir-plus-stat up to
2000 per lane and never a parse. It re-derives nothing that decides *what* the answer is —
`FrontmatterDocument` parses, `IntegrityRules.isIdentityShaped` says what a lane or a card is,
`BoardLoader.directoryCandidates` supplies the stray tolerance, `Ranks` supplies display order,
`Palette` resolves colours — only *how far to look*.

The reply is HTML, the one data-based reply that reflows: a Quick Look panel is resized by the
user and a board outline is a wrapping row of columns, so a drawing block baked at a fixed
`contentSize` would be the wrong size a moment later. It gets vector text, its own scrolling and
light/dark for free. The board tint is a wash under the title and a lane's edge accent — never
under text, because a preview has none of `ContrastMath`'s ink-picking machinery and should not
grow one.

The extension compiles `Kanban/Storage` whole, the `KanbanMobile` arrangement — the directory is
one unit in practice, so a narrower list is not on offer. `STORAGE_ONLY` is new: EchoLedger's
consumer sections speak the live store's vocabulary, and the phone's `#if os(macOS)` cannot exclude
them from a target that *is* macOS. Platform, and layer. Nothing else defines it.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-09 02:37:10 -04:00

387 lines
18 KiB
Swift

import Foundation
import Testing
@testable import Kanban
// The Quick Look preview's reading of a board (`BoardOutline`, Kanban/Storage/BoardOutline.swift):
// what a `.kanban` package says about itself when Space is pressed on it in Finder. The extension
// shell around it — `QLPreviewProvider`, the HTML page, the symbol PNGs — is untested by design and
// by nature: it is a bundle the system loads out of process, and everything in it that could be
// wrong about a *board* is decided here.
//
// The walk is deliberately total (it never throws), so these tests assert the degrade as hard as they
// assert the happy path: a folder that is not a board, a lane whose YAML is broken, a card with no
// title, and every cap.
// MARK: - Fixture builders
/// A synthetic board tree under a temp directory — `BoardLoaderTests`' builder, one suite over,
/// carrying only the two shapes this walk can see (an `index.md`, and a folder without one).
private struct OutlineFixture {
let root: URL
init(named name: String = "Board.kanban") throws {
root = FileManager.default.temporaryDirectory
.appendingPathComponent("BoardOutlineTests-\(UUID().uuidString)", isDirectory: true)
.appendingPathComponent(name, isDirectory: true)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
}
func tearDown() {
try? FileManager.default.removeItem(at: root.deletingLastPathComponent())
}
/// Writes `index.md` at `relativePath` (`""` for the board root), with `frontmatter` between the
/// delimiters. `frontmatter` must end in a newline, exactly as the loader's own builder requires.
@discardableResult
func index(_ relativePath: String, _ frontmatter: String, body: String = "") throws -> URL {
let folder = relativePath.isEmpty
? root
: root.appendingPathComponent(relativePath, isDirectory: true)
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
try "---\n\(frontmatter)---\n\(body)"
.write(to: folder.appendingPathComponent("index.md"), atomically: true, encoding: .utf8)
return folder
}
/// A folder with no `index.md` — the interrupted-create shape, which is not a lane and not a card.
@discardableResult
func emptyFolder(_ relativePath: String) throws -> URL {
let folder = root.appendingPathComponent(relativePath, isDirectory: true)
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
return folder
}
func file(_ relativePath: String, contents: String) throws {
let url = root.appendingPathComponent(relativePath)
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
)
try contents.write(to: url, atomically: true, encoding: .utf8)
}
}
/// A UUID-shaped folder name whose *lexicographic* order is the test's to choose — the walk's
/// tie-break is the folder name, so a test about ordering needs names it controls.
private func identity(_ nibble: String) -> String {
"\(nibble)0000000-0000-4000-8000-000000000000"
}
/// The `Fixtures/` folder reference in the test bundle — `FixtureBoardTests`' resolution, restated
/// here because that file keeps its own private (there is no `Bundle.module` in an xcodeproj target).
private final class OutlineFixtureBundleAnchor {}
private func fixtureBoard(_ relativePath: String) -> URL {
guard let resources = Bundle(for: OutlineFixtureBundleAnchor.self).resourceURL else {
fatalError("test bundle has no resourceURL")
}
return resources
.appendingPathComponent("Fixtures", isDirectory: true)
.appendingPathComponent(relativePath, isDirectory: true)
}
// MARK: - The golden board
/// `Fixtures/Valid/rich-board.kanban` is the disk-backed case: a real tree, hand-authored, with two
/// lanes, three cards, styling at every level and unknown keys throughout. If the preview reads this
/// board correctly it reads the format correctly.
struct BoardOutlineFixtureTests {
@Test func readsTheRichBoardsNameStylingAndLanes() {
let summary = BoardOutline.read(boardRoot: fixtureBoard("Valid/rich-board.kanban"))
#expect(summary.title == "Rich Demo Board")
#expect(summary.background == "#1E1E1E")
#expect(summary.icon == "rectangle.stack.fill")
#expect(summary.iconColor == "purple")
#expect(summary.laneCount == 2)
#expect(summary.laneCountIsCapped == false)
#expect(summary.hiddenLaneCount == 0)
// Lane display order is `order` ascending — Doing (1024) then Done (2048).
#expect(summary.lanes.map(\.title) == ["Doing", "Done"])
#expect(summary.lanes.map(\.cardCount) == [2, 1])
#expect(summary.lanes.map(\.cardCountIsCapped) == [false, false])
#expect(summary.lanes.map(\.background) == ["#3478F6", "green"])
// Cards in display order within each lane, and `attachments/`/`comments/` beside one of them
// are not cards: they are not identity-shaped, so the count stays at two.
#expect(summary.lanes[0].cardTitles == [
"Design the fixture taxonomy", "Wire up the loader's stray tolerance",
])
#expect(summary.lanes[1].cardTitles == ["Ship v1"])
#expect(summary.lanes.map(\.hiddenCardCount) == [0, 0])
}
}
// MARK: - The board's own name
struct BoardOutlineTitleTests {
@Test func fallsBackToTheFolderNameWithoutTheExtension() throws {
let fixture = try OutlineFixture(named: "Quarterly Plan.kanban")
defer { fixture.tearDown() }
try fixture.index("", "schema: 1\n")
// **A board never reads "Untitled"** — `AppModel.displayName(of:)`'s rule, one layer down:
// a board always has a folder, and the folder name is the Finder document name.
#expect(BoardOutline.read(boardRoot: fixture.root).title == "Quarterly Plan")
}
@Test func aBlankTitleIsAnAbsentOne() throws {
let fixture = try OutlineFixture(named: "Roadmap.kanban")
defer { fixture.tearDown() }
try fixture.index("", "schema: 1\ntitle: \" \"\n")
#expect(BoardOutline.read(boardRoot: fixture.root).title == "Roadmap")
}
@Test func aFolderThatIsNotABoardStillPreviewsAsItself() throws {
let fixture = try OutlineFixture(named: "Not A Board.kanban")
defer { fixture.tearDown() }
try fixture.file("notes.txt", contents: "nothing to see")
// No `index.md` at all: the walk cannot throw, so the floor is the folder's own name and no
// lanes — a truthful preview of a folder that is not a board.
let summary = BoardOutline.read(boardRoot: fixture.root)
#expect(summary.title == "Not A Board")
#expect(summary.lanes.isEmpty)
#expect(summary.laneCount == 0)
}
@Test func aRootThatDoesNotExistIsNotAnError() {
let missing = FileManager.default.temporaryDirectory
.appendingPathComponent("BoardOutlineTests-absent-\(UUID().uuidString).kanban")
let summary = BoardOutline.read(boardRoot: missing)
#expect(summary.title.hasPrefix("BoardOutlineTests-absent-"))
#expect(summary.lanes.isEmpty)
}
@Test func anUnreadableRootIndexFallsBackWithoutLosingTheLanes() throws {
let fixture = try OutlineFixture(named: "Broken Root.kanban")
defer { fixture.tearDown() }
// Not UTF-8: `BoardLoader` fails the whole board on this (01-storage-format.md § Malformed
// input). Here it costs the board's own fields and nothing else — the lanes are enumerated by
// folder shape and read their own files, so nothing below the root depends on it.
try Data([0xFF, 0xFE, 0x00]).write(to: fixture.root.appendingPathComponent("index.md"))
try fixture.index(identity("a"), "schema: 1\norder: 1024\ntitle: Doing\n")
let summary = BoardOutline.read(boardRoot: fixture.root)
#expect(summary.title == "Broken Root")
#expect(summary.background == nil)
#expect(summary.lanes.map(\.title) == ["Doing"])
}
}
// MARK: - Untitled lanes and cards
struct BoardOutlineUntitledTests {
@Test func anUntitledLaneOrCardCarriesNilRatherThanAPlaceholder() throws {
let fixture = try OutlineFixture()
defer { fixture.tearDown() }
try fixture.index("", "schema: 1\ntitle: Board\n")
let lane = identity("a")
try fixture.index(lane, "schema: 1\norder: 1024\n")
try fixture.index("\(lane)/\(identity("1"))", "schema: 1\norder: 1024\n")
try fixture.index("\(lane)/\(identity("2"))", "schema: 1\norder: 2048\ntitle: \"\"\n")
// **"Untitled" is a rendering, never a value** (03-board-ui.md § Card face): the model says
// nothing, and the page is where the placeholder appears. A blank title reads the same as an
// absent one.
let summary = BoardOutline.read(boardRoot: fixture.root)
#expect(summary.lanes.count == 1)
#expect(summary.lanes[0].title == nil)
#expect(summary.lanes[0].cardTitles == [nil, nil])
}
@Test func aLaneWithBrokenFrontmatterIsKeptRatherThanDropped() throws {
let fixture = try OutlineFixture()
defer { fixture.tearDown() }
try fixture.index("", "schema: 1\ntitle: Board\n")
try fixture.index(identity("a"), "schema: 1\norder: 1024\ntitle: Doing\n")
// Not UTF-8 — the strict decode refuses it, exactly as `BoardLoader.parseDocument` does.
let broken = try fixture.emptyFolder(identity("b"))
try Data([0xFF, 0xFE, 0x00]).write(to: broken.appendingPathComponent("index.md"))
try fixture.index("\(identity("b"))/\(identity("1"))", "schema: 1\ntitle: Orphan\n")
// The unreadable lane is order-less, so it appends past every ranked sibling — and its cards
// are still counted, because a preview that silently dropped a lane would disagree with
// Finder about what is in the folder.
let summary = BoardOutline.read(boardRoot: fixture.root)
#expect(summary.laneCount == 2)
#expect(summary.lanes.map(\.title) == ["Doing", nil])
#expect(summary.lanes[1].cardCount == 1)
#expect(summary.lanes[1].cardTitles == ["Orphan"])
}
}
// MARK: - What counts as a lane or a card
struct BoardOutlineCandidateTests {
@Test func straysAreNeitherLanesNorCards() throws {
let fixture = try OutlineFixture()
defer { fixture.tearDown() }
try fixture.index("", "schema: 1\ntitle: Board\n")
let lane = identity("a")
try fixture.index(lane, "schema: 1\norder: 1024\ntitle: Doing\n")
try fixture.index("\(lane)/\(identity("1"))", "schema: 1\norder: 1024\ntitle: Real\n")
// Four shapes that are not levels, each for the reason the loader states:
try fixture.index("notes", "schema: 1\ntitle: Not a lane\n") // not identity-shaped
try fixture.emptyFolder(identity("b")) // no index.md
try fixture.emptyFolder("\(lane)/\(identity("2"))") // no index.md
try fixture.file("\(lane)/stray.md", contents: "loose") // a file, not a folder
try fixture.index(".trash/\(identity("9"))", "schema: 1\ntitle: Gone\n") // hidden
let summary = BoardOutline.read(boardRoot: fixture.root)
#expect(summary.laneCount == 1)
#expect(summary.lanes[0].cardCount == 1)
#expect(summary.lanes[0].cardTitles == ["Real"])
}
}
// MARK: - Ordering
struct BoardOutlineOrderingTests {
@Test func lanesAndCardsFollowTheBoardsOwnDisplayOrder() throws {
let fixture = try OutlineFixture()
defer { fixture.tearDown() }
try fixture.index("", "schema: 1\ntitle: Board\n")
// Folder names ascend a, b, c while `order` descends — so a walk that trusted the listing
// rather than the rank would read backwards.
try fixture.index(identity("a"), "schema: 1\norder: 3000\ntitle: Third\n")
try fixture.index(identity("b"), "schema: 1\norder: 2000\ntitle: Second\n")
try fixture.index(identity("c"), "schema: 1\norder: 1000\ntitle: First\n")
try fixture.index("\(identity("c"))/\(identity("1"))", "schema: 1\norder: 20\ntitle: B\n")
try fixture.index("\(identity("c"))/\(identity("2"))", "schema: 1\norder: 10\ntitle: A\n")
let summary = BoardOutline.read(boardRoot: fixture.root)
#expect(summary.lanes.map(\.title) == ["First", "Second", "Third"])
#expect(summary.lanes[0].cardTitles == ["A", "B"])
}
@Test func anOrderlessSiblingAppendsAtTheEndByFolderName() throws {
let fixture = try OutlineFixture()
defer { fixture.tearDown() }
try fixture.index("", "schema: 1\ntitle: Board\n")
let lane = identity("a")
try fixture.index(lane, "schema: 1\norder: 1024\ntitle: Doing\n")
try fixture.index("\(lane)/\(identity("c"))", "schema: 1\ntitle: NoOrderC\n")
try fixture.index("\(lane)/\(identity("b"))", "schema: 1\ntitle: NoOrderB\n")
try fixture.index("\(lane)/\(identity("a"))", "schema: 1\norder: 5000\ntitle: Ranked\n")
// `Ranks.resolvedOrders`' reading: past every ordered sibling, then by folder name
// (01-storage-format.md § Ordering, re-ruled 2026-07-31).
let summary = BoardOutline.read(boardRoot: fixture.root)
#expect(summary.lanes[0].cardTitles == ["Ranked", "NoOrderB", "NoOrderC"])
}
}
// MARK: - The caps
struct BoardOutlineLimitTests {
/// A board of `lanes` lanes, each holding `cards` cards, all ranked so display order is the
/// order they were made in.
private func board(lanes: Int, cards: Int) throws -> OutlineFixture {
let fixture = try OutlineFixture()
try fixture.index("", "schema: 1\ntitle: Board\n")
for lane in 0..<lanes {
let name = String(format: "%08x-0000-4000-8000-000000000000", lane)
try fixture.index(name, "schema: 1\norder: \(1024 * (lane + 1))\ntitle: Lane \(lane)\n")
for card in 0..<cards {
let cardName = String(format: "%08x-0000-4000-8000-%012x", lane, card)
try fixture.index(
"\(name)/\(cardName)", "schema: 1\norder: \(10 * (card + 1))\ntitle: Card \(card)\n"
)
}
}
return fixture
}
@Test func cardTitlesStopAtTheCapAndTheRestAreCounted() throws {
let fixture = try board(lanes: 1, cards: 10)
defer { fixture.tearDown() }
let limits = BoardOutlineLimits(lanes: 12, laneScan: 100, cardTitles: 3, cardScan: 200, cardCount: 2000)
let lane = BoardOutline.read(boardRoot: fixture.root, limits: limits).lanes[0]
#expect(lane.cardTitles == ["Card 0", "Card 1", "Card 2"])
#expect(lane.cardCount == 10)
#expect(lane.cardCountIsCapped == false)
#expect(lane.hiddenCardCount == 7)
}
@Test func lanesStopAtTheCapAndTheRestAreCounted() throws {
let fixture = try board(lanes: 5, cards: 1)
defer { fixture.tearDown() }
let limits = BoardOutlineLimits(lanes: 2, laneScan: 100, cardTitles: 6, cardScan: 200, cardCount: 2000)
let summary = BoardOutline.read(boardRoot: fixture.root, limits: limits)
#expect(summary.lanes.map(\.title) == ["Lane 0", "Lane 1"])
#expect(summary.laneCount == 5)
#expect(summary.laneCountIsCapped == false)
#expect(summary.hiddenLaneCount == 3)
}
@Test func theCardCountStopsCountingAndSaysSo() throws {
let fixture = try board(lanes: 1, cards: 5)
defer { fixture.tearDown() }
let limits = BoardOutlineLimits(lanes: 12, laneScan: 100, cardTitles: 6, cardScan: 200, cardCount: 2)
let lane = BoardOutline.read(boardRoot: fixture.root, limits: limits).lanes[0]
// "2+", not "5" and not "2": the walk stopped, and the flag is how the page avoids stating a
// number it would be lying about.
#expect(lane.cardCount == 2)
#expect(lane.cardCountIsCapped)
}
@Test func theLaneCountStopsCountingAndSaysSo() throws {
let fixture = try board(lanes: 4, cards: 1)
defer { fixture.tearDown() }
let limits = BoardOutlineLimits(lanes: 12, laneScan: 2, cardTitles: 6, cardScan: 200, cardCount: 2000)
let summary = BoardOutline.read(boardRoot: fixture.root, limits: limits)
#expect(summary.laneCount == 2)
#expect(summary.laneCountIsCapped)
#expect(summary.lanes.count == 2)
}
@Test func theScanCapKeepsTheCountExactWhileNarrowingTheTitles() throws {
let fixture = try board(lanes: 1, cards: 6)
defer { fixture.tearDown() }
let limits = BoardOutlineLimits(lanes: 12, laneScan: 100, cardTitles: 6, cardScan: 2, cardCount: 2000)
let lane = BoardOutline.read(boardRoot: fixture.root, limits: limits).lanes[0]
// The stated cost of `cardScan` (BoardOutlineLimits ▸ cardScan): the titles come from the
// first two cards **in folder-name order** rather than from the lane's true top, while the
// count stays exact and `hiddenCardCount` still adds up.
#expect(lane.cardCount == 6)
#expect(lane.cardCountIsCapped == false)
#expect(lane.cardTitles == ["Card 0", "Card 1"])
#expect(lane.hiddenCardCount == 4)
}
@Test func anEmptyLaneShowsNoTitlesAndHidesNothing() throws {
let fixture = try board(lanes: 1, cards: 0)
defer { fixture.tearDown() }
let lane = BoardOutline.read(boardRoot: fixture.root).lanes[0]
#expect(lane.cardCount == 0)
#expect(lane.cardTitles.isEmpty)
#expect(lane.hiddenCardCount == 0)
}
@Test func theShippedLimitsAreTheOnesThePreviewUses() {
// Stated as a test because they are a product decision, not an implementation detail: the
// preview shows twelve lanes and six titles each, and nothing else in the repo says so.
#expect(BoardOutlineLimits.preview.lanes == 12)
#expect(BoardOutlineLimits.preview.cardTitles == 6)
#expect(BoardOutlineLimits.preview.laneScan == 100)
#expect(BoardOutlineLimits.preview.cardScan == 200)
#expect(BoardOutlineLimits.preview.cardCount == 2000)
}
}