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
This commit is contained in:
@@ -0,0 +1,386 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user