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