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") } }