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,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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user