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,232 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// The close flush is an *order*, and an order is only worth stating if something checks it. 02
|
||||
/// § Windows fixes it: the board's card windows and their sessions first, then pending debounced work
|
||||
/// with editor saves before the pending auto-commit, then the registry stamp, then teardown — and
|
||||
/// "nothing about this is conditional".
|
||||
///
|
||||
/// So every test here is an assertion about a list. The coordinator is driven with fakes that append
|
||||
/// to one event log, which is the only way to see an ordering that in production is spread across
|
||||
/// SwiftUI teardowns, a store's pipeline, and a JSON file.
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
@MainActor
|
||||
private final class FlushLog {
|
||||
private(set) var events: [String] = []
|
||||
|
||||
func record(_ event: String) {
|
||||
events.append(event)
|
||||
}
|
||||
}
|
||||
|
||||
/// A card window's session as the coordinator sees it: something that ends, once, in order.
|
||||
@MainActor
|
||||
private final class FakeCardSession: CardSessionFlushing {
|
||||
private let name: String
|
||||
private let log: FlushLog
|
||||
|
||||
init(name: String, log: FlushLog) {
|
||||
self.name = name
|
||||
self.log = log
|
||||
}
|
||||
|
||||
func endSession() async {
|
||||
log.record("card-session \(name)")
|
||||
}
|
||||
}
|
||||
|
||||
/// One board's worth of seams, wired to a shared log.
|
||||
///
|
||||
/// `dismissDrains` is the interesting knob: normally a dismissed card window unregisters and the
|
||||
/// ref goes, which is what the coordinator waits for. Turning it off simulates a window that never
|
||||
/// tears down — the case the drain deadline exists for.
|
||||
@MainActor
|
||||
private final class FakeBoard {
|
||||
|
||||
let name: String
|
||||
private let log: FlushLog
|
||||
private var cardRefs: [CardWindowRef]
|
||||
private var sessions: [CardWindowRef: FakeCardSession] = [:]
|
||||
private let dismissDrains: Bool
|
||||
private let drainDeadline: Duration
|
||||
|
||||
init(
|
||||
name: String,
|
||||
cards: [String],
|
||||
log: FlushLog,
|
||||
dismissDrains: Bool = true,
|
||||
drainDeadline: Duration = .seconds(2)
|
||||
) {
|
||||
self.name = name
|
||||
self.log = log
|
||||
self.dismissDrains = dismissDrains
|
||||
self.drainDeadline = drainDeadline
|
||||
cardRefs = cards.map { CardWindowRef(boardPath: "/boards/\(name)", cardID: $0) }
|
||||
for ref in cardRefs {
|
||||
sessions[ref] = FakeCardSession(name: "\(name)/\(ref.cardID)", log: log)
|
||||
}
|
||||
}
|
||||
|
||||
var coordinator: CloseFlushCoordinator {
|
||||
CloseFlushCoordinator(
|
||||
openCardRefs: { [self] in cardRefs },
|
||||
endCardSession: { [self] ref in await sessions[ref]?.endSession() },
|
||||
dismissCardWindow: { [self] ref in
|
||||
log.record("dismiss \(name)/\(ref.cardID)")
|
||||
guard dismissDrains else { return }
|
||||
cardRefs.removeAll { $0 == ref }
|
||||
},
|
||||
cardDrainDeadline: drainDeadline,
|
||||
storeFlush: { [self] in log.record("store-flush \(name)") },
|
||||
editorFlush: { [self] in log.record("editor-flush \(name)") },
|
||||
committerFlush: { [self] in log.record("committer-flush \(name)") },
|
||||
recordClose: { [self] in log.record("record-close \(name)") },
|
||||
clearOpenNow: { [self] in log.record("clear-open-now \(name)") },
|
||||
tearDown: { [self] in log.record("teardown \(name)") }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tests
|
||||
|
||||
@MainActor
|
||||
@Suite("Close flush")
|
||||
struct CloseFlushCoordinatorTests {
|
||||
|
||||
@Test("A user close runs every step, in the one order the design fixes")
|
||||
func userCloseOrdering() async {
|
||||
let log = FlushLog()
|
||||
let board = FakeBoard(name: "work", cards: ["card-a", "card-b"], log: log)
|
||||
|
||||
await board.coordinator.run(cause: .userClose)
|
||||
|
||||
#expect(log.events == [
|
||||
// Every session ends before any window is dismissed: commits are not interleaved with
|
||||
// teardowns.
|
||||
"card-session work/card-a",
|
||||
"card-session work/card-b",
|
||||
"dismiss work/card-a",
|
||||
"dismiss work/card-b",
|
||||
// Pending debounced work, editor saves before the pending auto-commit.
|
||||
"store-flush work",
|
||||
"editor-flush work",
|
||||
"committer-flush work",
|
||||
// The record, then the store.
|
||||
"record-close work",
|
||||
"clear-open-now work",
|
||||
"teardown work",
|
||||
])
|
||||
}
|
||||
|
||||
@Test("Quit runs the identical sequence but leaves the open-now flag standing")
|
||||
func quitDoesNotClearTheOpenNowFlag() async {
|
||||
let log = FlushLog()
|
||||
let board = FakeBoard(name: "work", cards: ["card-a"], log: log)
|
||||
|
||||
await board.coordinator.run(cause: .quit)
|
||||
|
||||
// The single difference between the two causes, and the whole restoration mechanism: the
|
||||
// boards open at quit are by definition the ones the next launch reopens.
|
||||
#expect(!log.events.contains("clear-open-now work"))
|
||||
#expect(log.events == [
|
||||
"card-session work/card-a",
|
||||
"dismiss work/card-a",
|
||||
"store-flush work",
|
||||
"editor-flush work",
|
||||
"committer-flush work",
|
||||
"record-close work",
|
||||
"teardown work",
|
||||
])
|
||||
}
|
||||
|
||||
@Test("A board with no card windows still runs the rest of the sequence")
|
||||
func noCardWindowsIsNotASpecialCase() async {
|
||||
let log = FlushLog()
|
||||
let board = FakeBoard(name: "solo", cards: [], log: log)
|
||||
|
||||
await board.coordinator.run(cause: .userClose)
|
||||
|
||||
// "Nothing about this is conditional" — the card step is empty, not skipped-with-a-branch,
|
||||
// and everything downstream is unchanged.
|
||||
#expect(log.events == [
|
||||
"store-flush solo",
|
||||
"editor-flush solo",
|
||||
"committer-flush solo",
|
||||
"record-close solo",
|
||||
"clear-open-now solo",
|
||||
"teardown solo",
|
||||
])
|
||||
}
|
||||
|
||||
@Test("The seams that are still nil are simply absent from the sequence")
|
||||
func absentSeamsAreSkipped() async {
|
||||
let log = FlushLog()
|
||||
let coordinator = CloseFlushCoordinator(
|
||||
openCardRefs: { [] },
|
||||
endCardSession: { _ in },
|
||||
dismissCardWindow: { _ in },
|
||||
storeFlush: { log.record("store-flush") },
|
||||
recordClose: { log.record("record-close") },
|
||||
clearOpenNow: { log.record("clear-open-now") },
|
||||
tearDown: { log.record("teardown") }
|
||||
)
|
||||
|
||||
// m4's real shape: no editor and no committer exist yet, and their absence must not change
|
||||
// the order of anything around them.
|
||||
await coordinator.run(cause: .userClose)
|
||||
#expect(log.events == ["store-flush", "record-close", "clear-open-now", "teardown"])
|
||||
}
|
||||
|
||||
@Test("Quitting with several boards runs each board's sequence whole, one after another")
|
||||
func quitPreservesPerBoardOrdering() async {
|
||||
let log = FlushLog()
|
||||
let first = FakeBoard(name: "alpha", cards: ["card-a"], log: log)
|
||||
let second = FakeBoard(name: "beta", cards: ["card-b"], log: log)
|
||||
|
||||
// Sequential, as `AppModel.flushAllBoardsForQuit()` runs them: a board's steps are never
|
||||
// interleaved with another's, so each board's ordering guarantee survives a multi-board quit.
|
||||
await first.coordinator.run(cause: .quit)
|
||||
await second.coordinator.run(cause: .quit)
|
||||
|
||||
#expect(log.events == [
|
||||
"card-session alpha/card-a",
|
||||
"dismiss alpha/card-a",
|
||||
"store-flush alpha",
|
||||
"editor-flush alpha",
|
||||
"committer-flush alpha",
|
||||
"record-close alpha",
|
||||
"teardown alpha",
|
||||
"card-session beta/card-b",
|
||||
"dismiss beta/card-b",
|
||||
"store-flush beta",
|
||||
"editor-flush beta",
|
||||
"committer-flush beta",
|
||||
"record-close beta",
|
||||
"teardown beta",
|
||||
])
|
||||
}
|
||||
|
||||
@Test("A card window that never unregisters cannot wedge the close")
|
||||
func theDrainIsBounded() async {
|
||||
let log = FlushLog()
|
||||
let board = FakeBoard(
|
||||
name: "stuck",
|
||||
cards: ["card-a"],
|
||||
log: log,
|
||||
dismissDrains: false,
|
||||
drainDeadline: .milliseconds(50)
|
||||
)
|
||||
|
||||
// The quit path runs inside `applicationShouldTerminate`'s deferred reply, so an unbounded
|
||||
// wait here would be an app that cannot be quit. The sessions have already ended by this
|
||||
// point, so expiring the deadline costs tidiness and no data.
|
||||
await board.coordinator.run(cause: .quit)
|
||||
|
||||
#expect(log.events.first == "card-session stuck/card-a")
|
||||
#expect(log.events.last == "teardown stuck")
|
||||
#expect(log.events.contains("record-close stuck"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user