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