Files
lanework/KanbanTests/DirtyBufferGuardTests.swift
rzen 747dea552d 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
2026-07-26 20:41:48 -04:00

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)
}
}