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:
@@ -0,0 +1,158 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// The close-with-a-dirty-buffer state machine (02-architecture.md § Write-failure surfacing, "The
|
||||
/// one modal moment on the write-failure path").
|
||||
///
|
||||
/// Everything specific is a closure, so these tests are about one thing only: **whether the close
|
||||
/// may proceed**. Each of the four exits — the save that just works, the retry that works on the
|
||||
/// second try, the copy saved elsewhere, and the deliberate discard — has to leave the guard
|
||||
/// `.idle`, and the failing save has to leave it `.blocked` carrying the error the alert will
|
||||
/// phrase.
|
||||
|
||||
// MARK: - Support
|
||||
|
||||
/// Stands in for an editor's dirty buffer: some text, a save that can be made to fail, and a record
|
||||
/// of where things actually went.
|
||||
@MainActor
|
||||
private final class FakeBuffer {
|
||||
var text: String
|
||||
/// Non-`nil` makes the next save (and every save after it) fail.
|
||||
var saveFailure: BoardWriteError?
|
||||
/// Set when `writeCopy` should refuse — a save panel pointed at a full disk.
|
||||
var copyFails = false
|
||||
|
||||
private(set) var saveAttempts = 0
|
||||
private(set) var savedText: String?
|
||||
private(set) var copies: [URL: String] = [:]
|
||||
|
||||
init(text: String = "the paragraph that exists nowhere else") {
|
||||
self.text = text
|
||||
}
|
||||
|
||||
func makeGuard() -> DirtyBufferGuard {
|
||||
DirtyBufferGuard(
|
||||
attemptSave: { [self] () throws(BoardWriteError) in
|
||||
saveAttempts += 1
|
||||
if let saveFailure {
|
||||
throw saveFailure
|
||||
}
|
||||
savedText = text
|
||||
},
|
||||
writeCopy: { [self] url in
|
||||
if copyFails {
|
||||
throw CocoaError(.fileWriteOutOfSpace)
|
||||
}
|
||||
copies[url] = text
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private let diskFull = BoardWriteError(
|
||||
operation: .style(title: "Fix login"),
|
||||
path: "/Boards/Work/todo/fix-login/index.md",
|
||||
reason: .io(message: "the disk is full")
|
||||
)
|
||||
|
||||
private func copyDestination() -> URL {
|
||||
FileManager.default.temporaryDirectory.appendingPathComponent("dirty-buffer-\(UUID().uuidString).md")
|
||||
}
|
||||
|
||||
// MARK: - Tests
|
||||
|
||||
@MainActor
|
||||
@Suite("DirtyBufferGuard")
|
||||
struct DirtyBufferGuardTests {
|
||||
|
||||
@Test("A save that lands never blocks — the close proceeds with no modal at all")
|
||||
func successfulSaveNeverBlocks() {
|
||||
let buffer = FakeBuffer()
|
||||
let bufferGuard = buffer.makeGuard()
|
||||
|
||||
#expect(bufferGuard.beginClose())
|
||||
#expect(bufferGuard.phase == .idle)
|
||||
#expect(buffer.savedText == "the paragraph that exists nowhere else")
|
||||
#expect(buffer.saveAttempts == 1)
|
||||
}
|
||||
|
||||
@Test("A failing save blocks the close and carries the error the alert will phrase")
|
||||
func failingSaveBlocks() {
|
||||
let buffer = FakeBuffer()
|
||||
buffer.saveFailure = diskFull
|
||||
let bufferGuard = buffer.makeGuard()
|
||||
|
||||
#expect(!bufferGuard.beginClose())
|
||||
#expect(bufferGuard.phase == .blocked(diskFull))
|
||||
|
||||
guard case let .blocked(error) = bufferGuard.phase else {
|
||||
Issue.record("expected the blocked phase")
|
||||
return
|
||||
}
|
||||
#expect(BannerCenter.headline(for: error) == "Couldn't restyle 'Fix login' — the disk is full")
|
||||
}
|
||||
|
||||
@Test("Retrying after the cause is fixed unblocks the close")
|
||||
func retrySucceedsAndCloses() {
|
||||
let buffer = FakeBuffer()
|
||||
buffer.saveFailure = diskFull
|
||||
let bufferGuard = buffer.makeGuard()
|
||||
#expect(!bufferGuard.beginClose())
|
||||
|
||||
// A retry while the disk is still full stays blocked — the alert returns, which is the
|
||||
// honest outcome and the reason there is no "close anyway" button.
|
||||
#expect(!bufferGuard.retry())
|
||||
#expect(bufferGuard.phase == .blocked(diskFull))
|
||||
|
||||
buffer.saveFailure = nil
|
||||
#expect(bufferGuard.retry())
|
||||
#expect(bufferGuard.phase == .idle)
|
||||
#expect(buffer.savedText == "the paragraph that exists nowhere else")
|
||||
#expect(buffer.saveAttempts == 3)
|
||||
}
|
||||
|
||||
@Test("Saving a copy elsewhere writes the text and unblocks the close")
|
||||
func saveCopyWritesAndUnblocks() throws {
|
||||
let buffer = FakeBuffer()
|
||||
buffer.saveFailure = diskFull
|
||||
let bufferGuard = buffer.makeGuard()
|
||||
#expect(!bufferGuard.beginClose())
|
||||
|
||||
let destination = copyDestination()
|
||||
try bufferGuard.saveCopy(to: destination)
|
||||
|
||||
#expect(bufferGuard.phase == .idle, "the text is safe somewhere; the close may proceed")
|
||||
#expect(buffer.copies[destination] == "the paragraph that exists nowhere else")
|
||||
// The buffer's real home is still unwritten — that is the trade the user knowingly made.
|
||||
#expect(buffer.savedText == nil)
|
||||
}
|
||||
|
||||
@Test("A copy that itself fails leaves the close blocked")
|
||||
func failedCopyStaysBlocked() {
|
||||
let buffer = FakeBuffer()
|
||||
buffer.saveFailure = diskFull
|
||||
buffer.copyFails = true
|
||||
let bufferGuard = buffer.makeGuard()
|
||||
#expect(!bufferGuard.beginClose())
|
||||
|
||||
#expect(throws: (any Error).self) {
|
||||
try bufferGuard.saveCopy(to: copyDestination())
|
||||
}
|
||||
#expect(bufferGuard.phase == .blocked(diskFull), "the text is still nowhere but memory")
|
||||
}
|
||||
|
||||
@Test("Discarding unblocks the close and writes nothing anywhere")
|
||||
func discardUnblocks() {
|
||||
let buffer = FakeBuffer()
|
||||
buffer.saveFailure = diskFull
|
||||
let bufferGuard = buffer.makeGuard()
|
||||
#expect(!bufferGuard.beginClose())
|
||||
|
||||
bufferGuard.discard()
|
||||
|
||||
#expect(bufferGuard.phase == .idle)
|
||||
#expect(buffer.savedText == nil)
|
||||
#expect(buffer.copies.isEmpty)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user