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
159 lines
5.5 KiB
Swift
159 lines
5.5 KiB
Swift
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)
|
|
}
|
|
}
|