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
233 lines
8.5 KiB
Swift
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"))
|
|
}
|
|
}
|