Files
lanework/KanbanTests/CardWindowFateTests.swift
T
rzen 1e65b7c986 Build the card window shell and lifecycle
The m4 scene plumbing was already honest — one WindowGroup value per
CardWindowRef enforces one-window-per-card, and CardWindowFate's
ancestor walk answered dismissal — so this card fills the window: a
two-column shell whose body column takes all resize flex and whose
sidebar width derives once from font metrics (26 characters of average
body advance plus em gutters), the five 05-ordered section headers as
placeholders, and the card body as selectable plain text until Preview
mode lands. The fate walk now returns a CardPlacement (card + lane), so
one pass answers both liveness and the live board › lane subtitle; a
board rename lands for free through displayName. Card windows remember
their frames per card in the board record (case-folded id keys,
unchanged-writes-nothing), restoring instead of cascading; only
unremembered cards take the last-used size and cascade. Store
acquisition stays gated on liveStore — a card window never opens a
board — and the close-flush hook stands with nothing to flush until the
Edit-session card.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-28 09:27:01 -04:00

116 lines
5.2 KiB
Swift

import Foundation
import Testing
@testable import Kanban
/// A card window's whole lifecycle is one decision re-taken on every snapshot: does this key still
/// name a card? Four answers, three of which are "no" for different reasons, and the one that is
/// easiest to get wrong — a live card under a tombstoned lane — is invisible in the card's own data.
/// So the decision is a pure function and this is its suite; nothing here needs a window.
// MARK: - Fixtures
/// - lane 1 (live): one live card, one tombstoned card
/// - lane 2 (**tombstoned**): one live card, whose own flag is clear
@MainActor
private func makeBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Fix login"))
try fixture.item(
"\(Ident.lane1)/\(Ident.card2)",
"---\nschema: 1\norder: 2048\ntitle: Gone\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n"
)
try fixture.item(
Ident.lane2,
"---\nschema: 1\norder: 2048\ntitle: Archive\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n"
)
try fixture.item("\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Buried alive"))
return fixture
}
private func title(_ fate: CardWindowFate) -> String? {
guard case let .shows(placement) = fate else { return nil }
return placement.card.title.value
}
/// The lane the fate says the card is in — the subtitle's live half, resolved by the very same walk
/// that decides whether the window lives at all (`CardPlacement`).
private func laneTitle(_ fate: CardWindowFate) -> String? {
guard case let .shows(placement) = fate else { return nil }
return placement.lane.title.value
}
// MARK: - Tests
@MainActor
@Suite("Card window fate")
struct CardWindowFateTests {
@Test("A live card in a live lane keeps its window, and names the lane it is in")
func aLiveCardShows() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
let fate = CardWindowHost.cardWindowFate(cardID: Ident.card1, in: snapshot)
#expect(title(fate) == "Fix login")
// The window's subtitle rides on this same resolution rather than on a second walk, so the
// lane it reports and the card it renders can never be one snapshot apart.
#expect(laneTitle(fate) == "Todo")
}
@Test("A tombstoned card dismisses its window")
func aTombstonedCardDismisses() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
// ⌫ on the board closes the card's open window — "a tombstone counts as deleted"
// (05-card-window.md). The card is still in the snapshot; the trash renders it.
#expect(snapshot.lanes[0].cards.contains { $0.id.rawValue == Ident.card2 })
#expect(CardWindowHost.cardWindowFate(cardID: Ident.card2, in: snapshot) == .dismisses)
}
@Test("A live card under a tombstoned lane dismisses too — liveness is ancestor-walked")
func aLaneTombstoneDismissesItsCards() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
// The card's own flag says nothing is wrong. Its lane's does, and 03-board-ui.md collapses a
// tombstoned lane to one restorable trash entry — so the card renders nowhere, and a window
// onto something that renders nowhere is the case this walk exists for.
let buried = try #require(snapshot.lanes.first { $0.id.rawValue == Ident.lane2 }?.cards.first)
#expect(!buried.isDeleted)
#expect(CardWindowHost.cardWindowFate(cardID: Ident.card3, in: snapshot) == .dismisses)
}
@Test("A card that is not in this board's snapshot dismisses — the cross-board move")
func anAbsentCardDismisses() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
// What a cross-board move looks like from the source board: the UUID travels with the card,
// but the board half of the window's key no longer names it, so the window goes exactly as it
// would for a delete.
#expect(CardWindowHost.cardWindowFate(cardID: Ident.card4, in: snapshot) == .dismisses)
}
@Test("A case-respelled card id still finds its card")
func theCardIDIsComparedAsAUUIDValue() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
// An agent's `uuidgen` prints uppercase (01-storage-format.md § Fractal layout), so a card
// window keyed on one spelling must still find a folder written in the other. Comparing the
// strings would dismiss a perfectly live card.
let fate = CardWindowHost.cardWindowFate(cardID: Ident.card1.uppercased(), in: snapshot)
#expect(title(fate) == "Fix login")
}
}