Surface write failures — banners, locks, one modal
The banner surface as one vocabulary (Kanban/LiveStore/BannerCenter, Kanban/UI/BannerStripView): a pure precedence rule — in-progress pinned above the collapse (ratified mid-build), lock > breakage > one-shot write failures > commit+attachment, signposts last — with all user-facing phrasing owned here via exhaustive switches over the closed WriteOperation enum; free-form English survives only in diagnostics. performWrite posts its failures before rethrowing, so no one-shot can bypass the strip; refusals under lock post nothing. The lock vocabulary completes: vanishedRoot and unwritableLocation join bracketedReloadFailed, each with its own clearing rule (unwritable clears only on a reconciling reload's writability re-probe). The registry now owns root recovery: bookmark re-resolution absorbs renames transparently, a dead root locks read-only and re-arms FSEvents on the gone path so the root's return round-trips back through rootChanged, re-minting and re-keying on the way. DirtyBufferGuard is the one modal moment, retry / save a copy / discard, no fourth button. 36 new tests; full suite 333 tests in 62 suites green. Five findings filed on the Redesign board. Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
This commit is contained in:
@@ -486,21 +486,40 @@ struct BoardStoreTests {
|
||||
|
||||
// MARK: Root changes
|
||||
|
||||
@Test("A root change is accepted and leaves the last good snapshot alone")
|
||||
func rootChangeIsAStubToday() async throws {
|
||||
@Test("A root change with no delegate wired leaves the last good snapshot alone")
|
||||
func rootChangeWithoutADelegateIsANoOp() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let lastGood = store.snapshot
|
||||
|
||||
// Documented no-op until the registry's bookmark arrives: re-resolve-or-lock needs an
|
||||
// identity this store does not own yet, and guessing would lock a board that merely moved.
|
||||
// Re-resolve-or-lock needs a bookmark this store does not own, so the response is
|
||||
// delegated (the registry wires it — `RootRecoveryTests` drives the real thing). With no
|
||||
// delegate, the honest answer is the same one every failure path gives: keep the last good
|
||||
// snapshot. Guessing here would lock a board that had merely moved.
|
||||
store.handleWatcherEvent(.rootChanged)
|
||||
await store.awaitQuiescence()
|
||||
|
||||
#expect(store.snapshot == lastGood)
|
||||
#expect(store.reloadFailure == nil)
|
||||
#expect(store.readOnlyLock == nil)
|
||||
#expect(store.reloadGeneration == 0, "a root change schedules no reload today")
|
||||
#expect(store.reloadGeneration == 0, "a root change schedules no reload of its own")
|
||||
}
|
||||
|
||||
@Test("A root change with a delegate hands over and starts no reload of its own")
|
||||
func rootChangeIsDelegated() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
var calls = 0
|
||||
store.rootChangeDelegate = { calls += 1 }
|
||||
store.handleWatcherEvent(.rootChanged)
|
||||
await store.awaitQuiescence()
|
||||
|
||||
#expect(calls == 1)
|
||||
// The delegate's two outcomes both end in a reload — at the re-resolved root, or on the
|
||||
// root's return. One fired from here would walk a path that just stopped being the board.
|
||||
#expect(store.reloadGeneration == 0)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user