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..