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:
2026-07-27 07:47:11 -04:00
parent 61e18c3dfa
commit fccdf56cf4
19 changed files with 2767 additions and 6 deletions
+224
View File
@@ -0,0 +1,224 @@
import Foundation
import Testing
@testable import Kanban
/// `AppModel` is mostly window bookkeeping that only means anything with a window on screen, but two
/// of its members are pure facts about a snapshot and both are load-bearing: the counts a welcome row
/// advertises, and the name a window title shows. Neither is observable from a unit test any other
/// way once it is wrong a stale count looks like staleness, which the design accepts, and a wrong
/// count looks exactly the same.
// MARK: - Fixtures
/// Live and tombstoned at both levels, plus the case the ancestor walk exists for: live cards
/// underneath a tombstoned lane.
///
/// - lane 1 (live): two live cards, one tombstoned card
/// - lane 2 (**tombstoned**): two live cards, which render nowhere and must not count
/// - lane 3 (live): empty
@MainActor
private func makeMixedBoard() 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: "First"))
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
try fixture.item(
"\(Ident.lane1)/\(Ident.card3)",
"---\nschema: 1\norder: 3072\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.card4)", Item.rich(order: "1024", title: "Buried"))
try fixture.item(
"\(Ident.lane2)/\(Ident.indexless)",
"---\nschema: 1\norder: 2048\ntitle: Also buried\n---\nbody\n"
)
try fixture.item(Ident.lane3, Item.rich(order: "3072", title: "Doing"))
return fixture
}
/// An `AppModel` whose registry file lives in temp rather than in the test host's real Application
/// Support directory.
@MainActor
private func makeModel() throws -> (model: AppModel, tearDown: () -> Void) {
let folder = FileManager.default.temporaryDirectory
.appendingPathComponent("AppModelTests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
let model = AppModel(registryStorageURL: folder.appendingPathComponent("board-registry.json"))
return (model, { try? FileManager.default.removeItem(at: folder) })
}
/// Opens a board the way `BoardWindowHost` does acquire, record, flag, begin so the close tests
/// are closing something the app would recognise.
@MainActor
@discardableResult
private func openBoard(_ model: AppModel, at url: URL) throws -> BoardWindowRef {
let ref = BoardWindowRef(url: url)
let store = try model.storeRegistry.acquire(url)
let recordID = model.boardRegistry.recordOpen(of: url, displayName: AppModel.displayName(of: store))
model.boardRegistry.setOpenNow(id: recordID)
model.beginSession(ref: ref, store: store, recordID: recordID, access: nil)
return ref
}
// MARK: - Tests
@MainActor
@Suite("AppModel")
struct AppModelTests {
// MARK: Live-only counts
@Test("The recents counts are live items only, at both levels")
func liveCountsIgnoreTombstonesAndWhatHidesBeneathThem() throws {
let fixture = try makeMixedBoard()
defer { fixture.tearDown() }
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
// The snapshot itself keeps everything tombstones are what the trash renders so this is a
// genuine filter, not a property of the load.
#expect(snapshot.lanes.count == 3)
#expect(snapshot.lanes.flatMap(\.cards).count == 5)
let counts = AppModel.liveCounts(of: snapshot)
#expect(counts.lanes == 2, "the tombstoned lane is not part of the board's working size")
#expect(counts.cards == 2, "one tombstoned card, and two more hidden beneath a tombstoned lane")
}
@Test("A board with nothing live counts zero rather than declining to answer")
func liveCountsOfAnEmptyBoard() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(
Ident.lane1,
"---\nschema: 1\norder: 1024\ntitle: Gone\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n"
)
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Buried"))
let counts = AppModel.liveCounts(of: try BoardLoader.load(boardRoot: fixture.root).model)
#expect(counts.lanes == 0)
#expect(counts.cards == 0)
}
@Test("A malformed deleted: still counts as deleted")
func liveCountsFollowPresenceNotValidity() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item(
"\(Ident.lane1)/\(Ident.card1)",
"---\nschema: 1\norder: 1024\ntitle: Gone\ndeleted: yesterday\n---\nbody\n"
)
// The presence of the key is what encodes deletion intent (`Card.isDeleted`), so an
// unparseable timestamp hides the card here exactly as it hides it on the board.
let counts = AppModel.liveCounts(of: try BoardLoader.load(boardRoot: fixture.root).model)
#expect(counts.lanes == 1)
#expect(counts.cards == 0)
}
// MARK: Display name
@Test("A board's display name is its title, falling back to the folder name sans extension")
func displayNameFallsBackToTheFolderName() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("Weekly.kanban", "---\nschema: 1\ntitle: Weekly Review\n---\nbody\n")
let titled = try BoardStore(rootURL: fixture.url("Weekly.kanban"))
#expect(AppModel.displayName(of: titled) == "Weekly Review")
try fixture.item("Untitled Board.kanban", "---\nschema: 1\n---\nbody\n")
let untitled = try BoardStore(rootURL: fixture.url("Untitled Board.kanban"))
#expect(AppModel.displayName(of: untitled) == "Untitled Board", "sans extension, per 01 § Board naming")
}
// MARK: Sessions
@Test("A user close stamps live counts, unflags the board, and lets the store go")
func closingABoardRunsTheRealFlush() async throws {
let fixture = try makeMixedBoard()
defer { fixture.tearDown() }
let (model, tearDown) = try makeModel()
defer { tearDown() }
let ref = try openBoard(model, at: fixture.root)
let recordID = try #require(model.session(for: ref)?.recordID)
#expect(model.hasOpenBoards)
#expect(model.storeRegistry.liveStore(for: fixture.root) != nil)
await model.closeBoard(ref: ref, cause: .userClose)
#expect(model.session(for: ref) == nil)
#expect(!model.hasOpenBoards)
#expect(model.storeRegistry.liveStore(for: fixture.root) == nil, "the last reference went with the session")
let record = try #require(model.boardRegistry.record(id: recordID))
#expect(record.laneCount == 2, "the counts the welcome row will show are the live ones")
#expect(record.cardCount == 2)
#expect(record.isOpenNow == false)
#expect(model.boardRegistry.restorables().isEmpty)
// Twice is a no-op, which is what lets the window's close interception and its disappear both
// call this without the sequence running twice.
await model.closeBoard(ref: ref, cause: .userClose)
#expect(model.boardRegistry.record(id: recordID)?.isOpenNow == false)
}
@Test("Quit closes every board and leaves them all flagged for the next launch")
func quitFlushesEveryBoardAndPreservesTheRestorationSet() async throws {
let first = try makeMixedBoard()
defer { first.tearDown() }
let second = try makeMixedBoard()
defer { second.tearDown() }
let (model, tearDown) = try makeModel()
defer { tearDown() }
try openBoard(model, at: first.root)
try openBoard(model, at: second.root)
#expect(model.storeRegistry.openBoardCount == 2)
await model.flushAllBoardsForQuit()
#expect(!model.hasOpenBoards)
#expect(model.storeRegistry.openBoardCount == 0, "every board's store was released, not just the first")
#expect(model.boardRegistry.restorables().count == 2, "the flags describe what was open at quit")
for row in model.boardRegistry.restorables() {
#expect(row.record.laneCount == 2, "and every board was stamped on the way out")
}
}
@Test("Card windows join and leave their board's session")
func cardWindowMembershipIsTracked() throws {
let fixture = try makeMixedBoard()
defer { fixture.tearDown() }
let (model, tearDown) = try makeModel()
defer { tearDown() }
let ref = try openBoard(model, at: fixture.root)
let card = CardWindowRef(board: ref, cardID: ItemID(rawValue: Ident.card1))
let session = CardWindowSession()
model.registerCardWindow(card, session: session)
#expect(model.session(for: ref)?.cardRefs == [card])
// A card window against a board with no session is the one thing the ownership rule forbids;
// registering it would leave an entry the close flush never drains.
let orphan = CardWindowRef(boardPath: "/nowhere", cardID: Ident.card2)
model.registerCardWindow(orphan, session: CardWindowSession())
#expect(model.session(for: orphan.board) == nil)
model.unregisterCardWindow(card)
#expect(model.session(for: ref)?.cardRefs.isEmpty == true)
model.storeRegistry.release(try #require(model.session(for: ref)?.store))
}
}
+112
View File
@@ -346,6 +346,118 @@ struct BoardRegistryTests {
#expect(ids(registry.recents()) == [firstID, thirdID, secondID])
}
// MARK: The open-now marker
@Test("Opening flags a board, a user close unflags it, and quit deliberately leaves it standing")
func openNowTracksWindowsAndSurvivesQuit() async throws {
let storage = try RegistryStorage()
defer { storage.tearDown() }
let fixture = try makeBoard()
defer { fixture.tearDown() }
let registry = BoardRegistry(storageURL: storage.url)
let id = registry.recordOpen(of: fixture.root, displayName: "Work")
#expect(registry.record(id: id)?.isOpenNow == nil, "recording an open is not opening a window")
#expect(registry.restorables().isEmpty)
registry.setOpenNow(id: id)
#expect(registry.record(id: id)?.isOpenNow == true)
#expect(ids(registry.restorables()) == [id])
// A user close. The flag goes, and with it the board's place in the next launch.
registry.clearOpenNow(id: id)
#expect(registry.record(id: id)?.isOpenNow == false)
#expect(registry.restorables().isEmpty)
// A quit. The teardown stamps counts and does *not* clear the flag that omission is the
// whole restoration mechanism, so it is asserted rather than assumed, and asserted across a
// reload of the file because a relaunch is what consumes it.
registry.setOpenNow(id: id)
registry.recordClose(id: id, laneCount: 2, cardCount: 5)
let afterRelaunch = BoardRegistry(storageURL: storage.url)
#expect(afterRelaunch.record(id: id)?.isOpenNow == true, "the flags describe what was open at quit")
#expect(ids(afterRelaunch.restorables()) == [id])
#expect(afterRelaunch.record(id: id)?.laneCount == 2)
}
@Test("Restorables are the flagged records only, oldest first")
func restorablesReopenInLastOpenedOrder() async throws {
let storage = try RegistryStorage()
defer { storage.tearDown() }
let first = try makeBoard()
defer { first.tearDown() }
let second = try makeBoard()
defer { second.tearDown() }
let third = try makeBoard()
defer { third.tearDown() }
let registry = BoardRegistry(storageURL: storage.url)
let firstID = registry.recordOpen(of: first.root, displayName: "First")
try await Task.sleep(for: .milliseconds(5))
let secondID = registry.recordOpen(of: second.root, displayName: "Second")
try await Task.sleep(for: .milliseconds(5))
let thirdID = registry.recordOpen(of: third.root, displayName: "Third")
registry.setOpenNow(id: firstID)
registry.setOpenNow(id: thirdID)
// Ascending, the exact inverse of `recents()` these are reopened in order, so the board
// opened last at quit opens last again and ends up frontmost.
#expect(ids(registry.restorables()) == [firstID, thirdID])
#expect(ids(registry.recents()) == [thirdID, secondID, firstID], "recents is unchanged, and still newest first")
#expect(!ids(registry.restorables()).contains(secondID), "a board that was not open does not restore")
// A flagged board whose folder has gone still comes back classified, not dropped. The
// launch flow renders it as a failed restoration rather than pretending it was never open.
third.tearDown()
let rows = registry.restorables()
#expect(ids(rows) == [firstID, thirdID])
guard case .unavailable = rows[1] else {
Issue.record("expected the deleted board to classify unavailable, got \(rows[1])")
return
}
}
@Test("A registry file written before the open-now key existed still decodes")
func oldRegistryFilesDecodeWithoutTheOpenNowKey() async throws {
let storage = try RegistryStorage()
defer { storage.tearDown() }
// Byte-for-byte the shape this file had one milestone ago: no `isOpenNow` anywhere. The
// struct's evolution rule says a new key must be optional precisely so this file survives
// a required key would have failed to decode, quarantined the file, and emptied the user's
// recents on upgrade.
let id = UUID()
let garbage = Data("not a bookmark".utf8).base64EncodedString()
let json = """
[
{
"bookmark" : "\(garbage)",
"cardCount" : 9,
"displayName" : "Archive",
"id" : "\(id.uuidString)",
"laneCount" : 4,
"lastKnownPath" : "/Volumes/Archive/Boards/Archive",
"lastOpened" : "2026-01-01T09:00:00.000Z",
"pushOnCommit" : true,
"remoteLocationWarned" : true
}
]
"""
try Data(json.utf8).write(to: storage.url)
let registry = BoardRegistry(storageURL: storage.url)
#expect(registry.recents().count == 1, "the file decoded; nothing was quarantined")
#expect(registry.record(id: id)?.isOpenNow == nil, "a missing key reads as 'not open'")
#expect(registry.restorables().isEmpty)
#expect(registry.record(id: id)?.laneCount == 4, "and every other field survived")
// And the key writes through from here on.
registry.setOpenNow(id: id)
#expect(BoardRegistry(storageURL: storage.url).record(id: id)?.isOpenNow == true)
}
// MARK: Bookmarks in a sandboxed host
@Test("A bookmark is always produced, and resolves back to the same folder")
+105
View File
@@ -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")
}
}
@@ -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"))
}
}
+56
View File
@@ -0,0 +1,56 @@
import AppKit
import Testing
@testable import Kanban
/// Per-board frame memory has one rule that is not "put it back where it was": a frame saved on a
/// display that is no longer attached must land somewhere visible (02-architecture.md § Windows).
/// That rule is untestable against real hardware the interesting case *is* the monitor that is not
/// plugged in which is why the decision takes its screens as an argument.
@MainActor
@Suite("Window placement")
struct WindowPlacementTests {
private let laptop = NSRect(x: 0, y: 0, width: 1512, height: 916)
private let external = NSRect(x: 1512, y: 0, width: 2560, height: 1415)
@Test("A frame on an attached screen is restored exactly")
func aVisibleFrameIsUntouched() {
let saved = WindowFrame(x: 120, y: 80, width: 1200, height: 700)
let placed = HostedWindowController.placement(for: saved, onScreens: [laptop, external], fallback: laptop)
#expect(placed == NSRect(x: 120, y: 80, width: 1200, height: 700))
}
@Test("A frame straddling two screens is still where the user left it")
func aStraddlingFrameIsUntouched() {
// AppKit's own `constrainFrameRect(_:to:)` nudges the remainder into view when the frame is
// set, so intersection not containment is the right test: a window hanging slightly off
// an edge is a place, not a problem.
let saved = WindowFrame(x: 1400, y: 100, width: 900, height: 600)
let placed = HostedWindowController.placement(for: saved, onScreens: [laptop, external], fallback: laptop)
#expect(placed.origin.x == 1400)
}
@Test("A frame on a screen that is gone keeps its size and centers on the fallback")
func aVanishedScreenRelocates() {
let saved = WindowFrame(x: 3000, y: 200, width: 1000, height: 600)
let placed = HostedWindowController.placement(for: saved, onScreens: [laptop], fallback: laptop)
// Size is a preference and survives; position is a place, and the place stopped existing.
#expect(placed.size == NSSize(width: 1000, height: 600))
#expect(placed.midX == laptop.midX)
#expect(placed.midY == laptop.midY)
#expect(laptop.intersects(placed))
}
@Test("With no screens at all the fallback still produces a frame")
func noScreensStillPlaces() {
// Not a real state, but the accessor's `NSScreen.screens` can be empty during a display
// reconfiguration, and returning something sane beats a window at the saved coordinates of a
// display nobody has.
let saved = WindowFrame(x: 4000, y: 4000, width: 800, height: 500)
let placed = HostedWindowController.placement(for: saved, onScreens: [], fallback: laptop)
#expect(placed.size == NSSize(width: 800, height: 500))
#expect(laptop.contains(placed.origin))
}
}
+82
View File
@@ -0,0 +1,82 @@
import Foundation
import Testing
@testable import Kanban
/// A window ref is two things at once the value `WindowGroup(for:)` keys its windows on, and the
/// argument the host is built from so it has exactly two promises: it survives the round trip
/// through a scene's archive, and two refs naming one thing are *equal*. Both are pinned here,
/// because a failure of either is invisible until the app opens a second window for something that
/// already has one.
@Suite("Window refs")
struct WindowRefsTests {
// MARK: Codable
@Test("Both refs round-trip through JSON unchanged")
func refsRoundTripThroughCoding() throws {
let board = BoardWindowRef(path: "/Users/x/Boards/Work.kanban")
let card = CardWindowRef(boardPath: board.path, cardID: "55555555-5555-4555-8555-555555555555")
let encoder = JSONEncoder()
let decoder = JSONDecoder()
#expect(try decoder.decode(BoardWindowRef.self, from: encoder.encode(board)) == board)
let decodedCard = try decoder.decode(CardWindowRef.self, from: encoder.encode(card))
#expect(decodedCard == card)
#expect(decodedCard.cardID == card.cardID, "the folder's exact spelling survives, not just its value")
#expect(decodedCard.boardPath == board.path)
}
@Test("A board ref and a URL agree in both directions")
func boardRefAndURLAgree() {
let url = URL(fileURLWithPath: "/Users/x/Boards/Work.kanban", isDirectory: true)
let ref = BoardWindowRef(url: url)
#expect(ref.path == url.path)
#expect(ref.url.path == url.path)
}
// MARK: Identity
@Test("A card ref's id compares by UUID value, not by spelling")
func cardRefFoldsTheCaseOfItsCardID() {
let board = "/Users/x/Boards/Work.kanban"
let lower = CardWindowRef(boardPath: board, cardID: "55555555-5555-4555-8555-555555555555")
let upper = CardWindowRef(boardPath: board, cardID: "55555555-5555-4555-8555-555555555555".uppercased())
// The consequence that matters: `WindowGroup(for:)` keys on this equality, so a card whose
// folder is spelled in caps focuses the window the lowercase spelling already opened instead
// of opening a second one for the same card.
#expect(lower == upper)
#expect(lower.hashValue == upper.hashValue)
#expect(Set([lower, upper]).count == 1)
// And `rawValue` is untouched by any of that it is what builds URLs.
#expect(upper.cardID == "55555555-5555-4555-8555-555555555555".uppercased())
#expect(upper.cardIdentity == lower.cardIdentity)
}
@Test("The board half of a card ref is compared verbatim")
func cardRefsOnDifferentBoardsAreDifferent() {
let card = "55555555-5555-4555-8555-555555555555"
let here = CardWindowRef(boardPath: "/Users/x/Boards/Work.kanban", cardID: card)
let there = CardWindowRef(boardPath: "/Users/x/Boards/Home.kanban", cardID: card)
// The cross-board move rule in one assertion: the card's UUID travels with it, but the key
// that named its window does not so the window dismisses exactly like a delete.
#expect(here != there)
#expect(here.board == BoardWindowRef(path: "/Users/x/Boards/Work.kanban"))
#expect(here.boardURL.path == "/Users/x/Boards/Work.kanban")
}
@Test("A card ref built from an ItemID keeps the id's exact spelling")
func cardRefFromItemIDKeepsRawValue() {
let board = BoardWindowRef(path: "/Users/x/Boards/Work.kanban")
let id = ItemID(rawValue: "AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA")
let ref = CardWindowRef(board: board, cardID: id)
#expect(ref.cardID == id.rawValue)
#expect(ref == CardWindowRef(boardPath: board.path, cardID: id.rawValue.lowercased()))
}
}