Files
lanework/KanbanTests/CardWindowFateTests.swift
rzen bec75e4282 Realign code with the 2026-07-31 rulings
The trash sorts by modified descending — the arrival rank mint retires
(Ranks.isOrderedForTrash one comparator, loader + merged order agree;
the legacy deleted: migration stamps modified from the tombstone
timestamp where parseable; delete undo steps validate existence-only;
agent guide v8). Trash selection goes kind-blind — ranges, marquee,
Select All, and the successor walk sweep both kinds; the guard moves to
the exits (mixed-payload drop refusal, copy/cut validation). The copy
stamping preflight widens back to comment depth (load-scoped posture —
the board always loads, the gesture refuses whole). Fixes a latent
no-op: trashed-lane drag restore never fired (DragSession.beginLanes
hard-coded the board container).

2403 tests in 413 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-07-31 18:35:07 -04:00

132 lines
6.4 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 **on the board**? The decision is a pure function and this is its suite; nothing here
/// needs a window.
///
/// **The walk got simpler with the materialized trash** (05-card-window.md ▸ Deletion & lifecycle,
/// resettled 2026-07-28): "entering the trash counts as deleted", and a trashed card's folder has
/// physically left its lane — so "is it under one of this board's lanes" is the whole question, and
/// the tombstone era's ancestor walk is gone.
// MARK: - Fixtures
/// - lane 1: one card
/// - the trash: one card, moved there by a delete
@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(".trash/\(Ident.card2)", Item.rich(order: "1024", title: "Gone"))
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 card in the trash dismisses its window")
func aTrashedCardDismisses() 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 — "entering the trash counts as deleted"
// (05-card-window.md). The card is still in the snapshot; the trash column renders it.
#expect(snapshot.trash.contains { $0.id.rawValue == Ident.card2 })
#expect(CardWindowHost.cardWindowFate(cardID: Ident.card2, in: snapshot) == .dismisses)
}
@Test("Deleting a card's lane dismisses its window, because the card is gone with it")
func aLaneDeleteDismissesItsCards() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try FileManager.default.removeItem(at: fixture.url(Ident.lane1))
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
// "Deleting the card's *lane* deletes the card with it — the window dismisses because the
// card is gone" (05). A lane delete is physical, so this needs no ancestor walk: the card
// is simply not in the snapshot.
#expect(CardWindowHost.cardWindowFate(cardID: Ident.card1, in: snapshot) == .dismisses)
}
/// The lane's own delete is a *move* now (03 § Trash, re-ruled 2026-07-29), and the dismissal
/// still falls out of the same absence rather than needing a rule: a trashed lane is an opaque
/// unit, so its cards are not in the snapshot at all — not under a lane, not among the trash's
/// cards — and the walk simply does not find them.
@Test("Trashing a card's lane dismisses its window too — the freight leaves the snapshot with it")
func aLaneTrashedDismissesItsCards() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try BoardWriter.deleteLaneToTrash(at: fixture.url(Ident.lane1), inBoard: fixture.root)
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
#expect(snapshot.trashedLanes.map(\.id.rawValue) == [Ident.lane1])
#expect(!snapshot.trash.contains { $0.id.rawValue == Ident.card1 })
#expect(CardWindowHost.cardWindowFate(cardID: Ident.card1, in: snapshot) == .dismisses)
// And the same absence is what ejects it from every item-referencing set — selection, the
// pending cut, drag membership (02-architecture.md § Live-reload resilience).
#expect(!ItemContainer.board.ids(in: snapshot).contains(ItemID(rawValue: Ident.card1)))
#expect(!ItemContainer.trash.ids(in: snapshot).contains(ItemID(rawValue: Ident.card1)))
#expect(ItemContainer.trash.ids(in: snapshot).contains(ItemID(rawValue: Ident.lane1)),
"the lane row itself is in the trash's universe")
}
@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")
}
}