Stand up the window architecture — welcome, board, card
Four scenes (welcome, restore bootstrap, board group, card group) with system restoration disabled in favor of the registry's open-now flags: set when a window actually opens, cleared only on user close, so quit — and crash — leave exactly the restoration set behind. AppModel joins windows to sessions (shared store, registry record, card refs, held security scope); CloseFlushCoordinator pins 02's strict close order as a seam-injected machine (card sessions end, windows drain, store flushes, record stamps, teardown) with named slots where m6/m7 flushes land. HostedWindowController proxies — never replaces — SwiftUI's window delegate to intercept windowShouldClose for the flush, report frames, and place saved frames onto live screens. Card windows are (board path, case-folded card id) values: reopen focuses, and a snapshot-pure fate function dismisses on delete, tombstone, tombstoned lane, or cross-board move. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
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(card) = fate else { return nil }
|
||||
return card.title.value
|
||||
}
|
||||
|
||||
// MARK: - Tests
|
||||
|
||||
@MainActor
|
||||
@Suite("Card window fate")
|
||||
struct CardWindowFateTests {
|
||||
|
||||
@Test("A live card in a live lane keeps its window")
|
||||
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")
|
||||
}
|
||||
|
||||
@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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user