Files
lanework/KanbanTests/CloseFlushCoordinatorTests.swift
rzen fccdf56cf4 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
2026-07-27 07:47:11 -04:00

233 lines
8.5 KiB
Swift

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