Phase 2 swaps every consumer: Liveness and its ancestor walk are gone, replaced by ItemContainer — a UUID set plus the container side it lives on, presence the whole test, one selection boundary instead of the old liveness law. Deletion stages by place: board cards move to the trash at a store-minted head rank, trash-side delete is permanent behind its confirmation, Delete Immediately skips the trash from anywhere, lane delete captures the subtree and removes the folder. Restore has no method at all — moveCards resolves members in either container, so drag-out and cut-paste are the ordinary moves 13 calls them, registering ordinary Move steps. The delete inverse moves the card back to its captured lane and rank; redo replays the captured trash rank, a value the gesture actually wrote; lane undo recreates the subtree byte-faithfully in session. Purges register nothing — where 13's trash section contradicts its own Rules on that, Rules wins, filed for ruling. Staleness collapsed to present-or-absent: a container is a path, so a foreign restore fails the delete step's expectation structurally. Legacy tombstones migrate on the loose-file tail hook, cards oldest-first so minting above top reproduces the retired newest-first column, lanes returning live, one folded loss row naming both directions. Put Back, restoreByDrag, receiveRestoredCards, TrashEntry, and the kind machinery are deleted; the trash column renders the container correctly with its full face rework left to phase 3. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
110 lines
4.9 KiB
Swift
110 lines
4.9 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)
|
|
}
|
|
|
|
@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")
|
|
}
|
|
}
|