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,493 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// `BannerCenter` is two things, and this suite is two suites: an **ordering rule** that is a pure
|
||||
/// function over six row classes, and the **product's voice** — every sentence a user reads when a
|
||||
/// write fails. The first is tested the way pure functions are, with no store and no filesystem;
|
||||
/// the second is tested for the properties phrasing has to have (non-empty, distinct per
|
||||
/// operation, titles quoted when known and graceful when not) rather than by pinning literals that
|
||||
/// would turn every copy edit into a failing test.
|
||||
///
|
||||
/// The third part — that a failed write *reaches* the strip at all — needs a real store and a real
|
||||
/// Writer failure, because the claim is about `performWrite`'s wiring rather than about the center.
|
||||
/// Those tests are at the bottom, over `WriterTestSupport`'s fixtures like every other suite here.
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
/// `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`.
|
||||
|
||||
/// A board with one lane whose frontmatter is readable but **uneditable** — the whole-frontmatter
|
||||
/// flow mapping. It loads and renders fine; any app write to it refuses loudly, which is the
|
||||
/// cheapest genuine Writer failure there is (no permissions to fiddle with, no disk to fill).
|
||||
@MainActor
|
||||
private func makeBoardWithUneditableLane() throws -> WriterFixture {
|
||||
let fixture = try WriterFixture()
|
||||
try fixture.item("", Item.board)
|
||||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||
try fixture.item(Ident.lane2, Item.uneditable)
|
||||
return fixture
|
||||
}
|
||||
|
||||
private func error(
|
||||
_ operation: WriteOperation,
|
||||
_ reason: BoardWriteError.Reason = .io(message: "the disk is full")
|
||||
) -> BoardWriteError {
|
||||
BoardWriteError(operation: operation, path: "/Boards/Work/lane/index.md", reason: reason)
|
||||
}
|
||||
|
||||
/// One representative error per `WriteOperation` case. Spelled out rather than derived so that a
|
||||
/// new case added to the enum shows up here as a missing entry the moment anyone looks — the
|
||||
/// switch in `actionPhrase(for:)` is the compile-time guard; this is the reading guard.
|
||||
private let everyOperation: [WriteOperation] = [
|
||||
.createBoard,
|
||||
.createLane,
|
||||
.createCard,
|
||||
.move(title: "Fix login"),
|
||||
.reorder(title: "Fix login"),
|
||||
.copy(title: "Fix login"),
|
||||
.delete(title: "Fix login"),
|
||||
.restore(title: "Fix login"),
|
||||
.purge(title: "Fix login"),
|
||||
.style(title: "Fix login"),
|
||||
.importAttachment(filename: "photo.png"),
|
||||
.listAttachments,
|
||||
.renumberChildren,
|
||||
]
|
||||
|
||||
/// The titled cases, and only those: `withTitle(_:)`'s own list of what can carry one.
|
||||
private let titledOperations: [(with: WriteOperation, without: WriteOperation)] = [
|
||||
(.move(title: "Fix login"), .move(title: nil)),
|
||||
(.reorder(title: "Fix login"), .reorder(title: nil)),
|
||||
(.copy(title: "Fix login"), .copy(title: nil)),
|
||||
(.delete(title: "Fix login"), .delete(title: nil)),
|
||||
(.restore(title: "Fix login"), .restore(title: nil)),
|
||||
(.purge(title: "Fix login"), .purge(title: nil)),
|
||||
(.style(title: "Fix login"), .style(title: nil)),
|
||||
]
|
||||
|
||||
// MARK: - Ordering
|
||||
|
||||
@MainActor
|
||||
@Suite("BannerCenter ▸ ordering")
|
||||
struct BannerCenterOrderingTests {
|
||||
|
||||
@Test("Every class present produces the settled precedence order")
|
||||
func everyClassOrdersByPrecedence() {
|
||||
let move = OneShotBanner(error: error(.move(title: "Fix login")), occurredAt: Date(timeIntervalSince1970: 100))
|
||||
let attachment = OneShotBanner(
|
||||
error: error(.importAttachment(filename: "photo.png")),
|
||||
occurredAt: Date(timeIntervalSince1970: 200)
|
||||
)
|
||||
let operation = InProgressOperation(label: "Pulling…")
|
||||
let signpost = InfoSignpost(message: "This card changed on the remote")
|
||||
|
||||
let rows = BannerCenter.rows(
|
||||
lock: .vanishedRoot,
|
||||
breakage: BoardLoadError(path: "todo/index.md", reason: .missingOrder),
|
||||
oneShots: [attachment, move],
|
||||
suspension: HistorySuspension(reason: "disk full", since: Date(timeIntervalSince1970: 50)),
|
||||
operations: [operation],
|
||||
signposts: [signpost]
|
||||
)
|
||||
|
||||
// in-progress (pinned) > read-only lock > reload breakage > one-shot write failures >
|
||||
// commit and attachment failures > passive info rows. The two info classes sit at opposite
|
||||
// ends of the strip.
|
||||
#expect(rows.map(\.id) == [
|
||||
"operation:\(operation.id.uuidString)",
|
||||
"read-only-lock",
|
||||
"reload-breakage",
|
||||
"one-shot:\(move.id.uuidString)",
|
||||
"history-suspension",
|
||||
"one-shot:\(attachment.id.uuidString)",
|
||||
"signpost:\(signpost.id.uuidString)",
|
||||
])
|
||||
#expect(rows.map(\.tone) == [.info, .error, .error, .error, .warning, .error, .info])
|
||||
#expect(rows.map(\.isPinned) == [true, false, false, false, false, false, false],
|
||||
"a spinner may never hide behind '+N more' — nothing else is pinned")
|
||||
}
|
||||
|
||||
@Test("An attachment failure ranks below other one-shots even when it is newer")
|
||||
func attachmentFailuresRankLast() {
|
||||
let attachment = OneShotBanner(
|
||||
error: error(.importAttachment(filename: "photo.png")),
|
||||
occurredAt: Date(timeIntervalSince1970: 900)
|
||||
)
|
||||
let style = OneShotBanner(error: error(.style(title: "Old")), occurredAt: Date(timeIntervalSince1970: 1))
|
||||
|
||||
let rows = BannerCenter.rows(lock: nil, breakage: nil, oneShots: [attachment, style], suspension: nil, operations: [])
|
||||
|
||||
#expect(rows.map(\.id) == ["one-shot:\(style.id.uuidString)", "one-shot:\(attachment.id.uuidString)"],
|
||||
"precedence class outranks recency; recency only orders within a class")
|
||||
}
|
||||
|
||||
@Test("One-shots order newest first within their class")
|
||||
func oneShotsAreNewestFirst() {
|
||||
let oldest = OneShotBanner(error: error(.move(title: "A")), occurredAt: Date(timeIntervalSince1970: 1))
|
||||
let middle = OneShotBanner(error: error(.move(title: "B")), occurredAt: Date(timeIntervalSince1970: 2))
|
||||
let newest = OneShotBanner(error: error(.move(title: "C")), occurredAt: Date(timeIntervalSince1970: 3))
|
||||
|
||||
let rows = BannerCenter.rows(
|
||||
lock: nil,
|
||||
breakage: nil,
|
||||
oneShots: [middle, oldest, newest],
|
||||
suspension: nil,
|
||||
operations: []
|
||||
)
|
||||
|
||||
#expect(rows.map(\.id) == [
|
||||
"one-shot:\(newest.id.uuidString)",
|
||||
"one-shot:\(middle.id.uuidString)",
|
||||
"one-shot:\(oldest.id.uuidString)",
|
||||
])
|
||||
}
|
||||
|
||||
@Test("Failures sharing a timestamp keep the order they were posted in")
|
||||
func tiesAreStable() {
|
||||
let center = BannerCenter()
|
||||
// Posted in one run loop turn: `Date()` may well hand both the same value, and the strip
|
||||
// must not shuffle between renders because of it.
|
||||
center.post(error(.move(title: "First")))
|
||||
center.post(error(.move(title: "Second")))
|
||||
center.post(error(.move(title: "Third")))
|
||||
|
||||
let rows = BannerCenter.rows(
|
||||
lock: nil,
|
||||
breakage: nil,
|
||||
oneShots: center.oneShots,
|
||||
suspension: nil,
|
||||
operations: []
|
||||
)
|
||||
#expect(rows.map(\.headline).map { $0.contains("'Third'") } == [true, false, false])
|
||||
#expect(rows.count == 3)
|
||||
}
|
||||
|
||||
@Test("Nothing standing is an empty strip")
|
||||
func quietBoardHasNoRows() {
|
||||
#expect(BannerCenter.rows(lock: nil, breakage: nil, oneShots: [], suspension: nil, operations: []).isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lifecycles
|
||||
|
||||
@MainActor
|
||||
@Suite("BannerCenter ▸ lifecycles")
|
||||
struct BannerCenterLifecycleTests {
|
||||
|
||||
@Test("One-shots dismiss individually; conditions carry no dismiss control at all")
|
||||
func oneShotsDismissIndividually() throws {
|
||||
let center = BannerCenter()
|
||||
center.post(error(.move(title: "First")))
|
||||
center.post(error(.style(title: "Second")))
|
||||
#expect(center.oneShots.count == 2)
|
||||
|
||||
let doomed = try #require(center.oneShots.first)
|
||||
center.dismiss(doomed.id)
|
||||
|
||||
#expect(center.oneShots.count == 1)
|
||||
#expect(center.oneShots.first?.id != doomed.id, "dismissing one must not take its neighbour")
|
||||
|
||||
// The condition rows have no id to dismiss with — the API shape *is* the rule ("conditions
|
||||
// heal", 02 § The banner surface), and this is where it is stated as a test.
|
||||
let rows = BannerCenter.rows(
|
||||
lock: .bracketedReloadFailed,
|
||||
breakage: BoardLoadError(path: ".", reason: .boardRootMissingIndex),
|
||||
oneShots: center.oneShots,
|
||||
suspension: HistorySuspension(reason: "disk full"),
|
||||
operations: [InProgressOperation(label: "Pulling…")]
|
||||
)
|
||||
#expect(rows.filter { $0.dismissID != nil }.count == 1, "only the one-shot may be dismissed")
|
||||
}
|
||||
|
||||
@Test("Dismissing an in-progress operation's id does nothing — dismiss is not cancel")
|
||||
func dismissDoesNotEndOperations() {
|
||||
let center = BannerCenter()
|
||||
let id = center.beginOperation(label: "Importing…", cancel: nil)
|
||||
center.dismiss(id)
|
||||
#expect(center.operations.count == 1)
|
||||
}
|
||||
|
||||
@Test("Suspending history raises a warning row; clearing it takes the row away")
|
||||
func historySuspensionIsAHealingCondition() throws {
|
||||
let center = BannerCenter()
|
||||
#expect(center.historySuspension == nil)
|
||||
|
||||
center.suspendHistory(reason: "the repository is corrupt")
|
||||
let suspension = try #require(center.historySuspension)
|
||||
#expect(suspension.reason == "the repository is corrupt")
|
||||
|
||||
let rows = BannerCenter.rows(lock: nil, breakage: nil, oneShots: [], suspension: center.historySuspension, operations: [])
|
||||
#expect(rows.count == 1)
|
||||
#expect(rows[0].tone == .warning, "the files are safe; only the undo trail is degraded")
|
||||
#expect(rows[0].headline.contains("history"))
|
||||
#expect(rows[0].dismissID == nil)
|
||||
|
||||
center.clearHistorySuspension()
|
||||
#expect(center.historySuspension == nil)
|
||||
#expect(BannerCenter.rows(lock: nil, breakage: nil, oneShots: [], suspension: center.historySuspension, operations: []).isEmpty)
|
||||
}
|
||||
|
||||
@Test("Re-suspending keeps the original start and takes the newer diagnosis")
|
||||
func resuspendingKeepsTheClock() throws {
|
||||
let center = BannerCenter()
|
||||
center.suspendHistory(reason: "disk full")
|
||||
let first = try #require(center.historySuspension)
|
||||
|
||||
center.suspendHistory(reason: "the repository is corrupt")
|
||||
let second = try #require(center.historySuspension)
|
||||
|
||||
#expect(second.since == first.since, "the condition never stopped being true")
|
||||
#expect(second.reason == "the repository is corrupt")
|
||||
}
|
||||
|
||||
@Test("Beginning an operation shows an info row; ending it clears the row")
|
||||
func operationsCompleteByLeaving() {
|
||||
let center = BannerCenter()
|
||||
let id = center.beginOperation(label: "Pulling…", cancel: nil)
|
||||
|
||||
var rows = BannerCenter.rows(lock: nil, breakage: nil, oneShots: [], suspension: nil, operations: center.operations)
|
||||
#expect(rows.count == 1)
|
||||
#expect(rows[0].tone == .info)
|
||||
#expect(rows[0].headline == "Pulling…")
|
||||
guard case let .inProgress(operation) = rows[0] else {
|
||||
Issue.record("expected an in-progress row")
|
||||
return
|
||||
}
|
||||
#expect(!operation.isCancelable, "git brackets get no Cancel — settled")
|
||||
|
||||
center.endOperation(id)
|
||||
rows = BannerCenter.rows(lock: nil, breakage: nil, oneShots: [], suspension: nil, operations: center.operations)
|
||||
#expect(rows.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Copy-shaped work carries Cancel, and cancelling is the caller's closure")
|
||||
func cancelableOperationsRunTheirClosure() throws {
|
||||
let center = BannerCenter()
|
||||
let cancelled = Box()
|
||||
let id = center.beginOperation(label: "Importing 24 attachments…", cancel: { cancelled.value = true })
|
||||
|
||||
let operation = try #require(center.operations.first)
|
||||
#expect(operation.isCancelable)
|
||||
operation.cancel?()
|
||||
#expect(cancelled.value)
|
||||
|
||||
center.endOperation(id)
|
||||
#expect(center.operations.isEmpty)
|
||||
}
|
||||
|
||||
@Test("A signpost is a calm, dismissable row that ranks last and may collapse")
|
||||
func signpostsRankLastAndDismiss() throws {
|
||||
let center = BannerCenter()
|
||||
center.postSignpost("This card changed on the remote — your edits still win")
|
||||
center.post(error(.move(title: "Fix login")))
|
||||
|
||||
let rows = BannerCenter.rows(
|
||||
lock: nil,
|
||||
breakage: nil,
|
||||
oneShots: center.oneShots,
|
||||
suspension: nil,
|
||||
operations: center.operations,
|
||||
signposts: center.signposts
|
||||
)
|
||||
#expect(rows.count == 2)
|
||||
#expect(rows.last?.tone == .info, "same tone as an in-progress row, opposite end of the strip")
|
||||
#expect(rows.last?.isPinned == false, "calm by design — nothing is gated on seeing it instantly")
|
||||
|
||||
let dismissID = try #require(rows.last?.dismissID)
|
||||
center.dismiss(dismissID)
|
||||
#expect(center.signposts.isEmpty)
|
||||
#expect(center.oneShots.count == 1, "dismissing a signpost leaves the failure standing")
|
||||
}
|
||||
|
||||
@Test("A failed operation is an end plus a post — the row swaps for the error")
|
||||
func failureSwapsTheRow() {
|
||||
let center = BannerCenter()
|
||||
let id = center.beginOperation(label: "Importing 'photo.png'…", cancel: nil)
|
||||
center.endOperation(id)
|
||||
center.post(error(.importAttachment(filename: "photo.png"), .unreadable(message: "the source file could not be read")))
|
||||
|
||||
let rows = BannerCenter.rows(
|
||||
lock: nil,
|
||||
breakage: nil,
|
||||
oneShots: center.oneShots,
|
||||
suspension: nil,
|
||||
operations: center.operations
|
||||
)
|
||||
#expect(rows.count == 1)
|
||||
#expect(rows[0].tone == .error)
|
||||
#expect(rows[0].headline == "Couldn't import 'photo.png' — the source file could not be read")
|
||||
}
|
||||
}
|
||||
|
||||
/// A one-field reference cell, so a `@Sendable` cancel closure has somewhere to record that it ran.
|
||||
@MainActor
|
||||
private final class Box {
|
||||
var value = false
|
||||
}
|
||||
|
||||
// MARK: - Phrasing
|
||||
|
||||
@MainActor
|
||||
@Suite("BannerCenter ▸ phrasing")
|
||||
struct BannerCenterPhrasingTests {
|
||||
|
||||
@Test("Every write operation has its own non-empty headline")
|
||||
func everyOperationSaysSomethingDistinct() {
|
||||
let headlines = everyOperation.map { BannerCenter.headline(for: error($0)) }
|
||||
|
||||
for (operation, headline) in zip(everyOperation, headlines) {
|
||||
#expect(!headline.isEmpty, "\(operation) has no headline")
|
||||
#expect(headline.hasPrefix("Couldn't "), "\(operation) does not name what failed")
|
||||
#expect(!headline.contains("nil"), "\(operation) leaked an optional into the product's voice")
|
||||
}
|
||||
#expect(Set(headlines).count == headlines.count, "two operations share a sentence — one of them is wrong")
|
||||
}
|
||||
|
||||
@Test("Titles are quoted when known and the phrasing stays graceful when they are not")
|
||||
func titlesAreQuotedOrGracefullyAbsent() {
|
||||
for (withTitle, withoutTitle) in titledOperations {
|
||||
let named = BannerCenter.headline(for: error(withTitle))
|
||||
let anonymous = BannerCenter.headline(for: error(withoutTitle))
|
||||
|
||||
#expect(named.contains("'Fix login'"), "\(withTitle) does not quote the title it carries")
|
||||
#expect(!anonymous.contains("''"), "\(withoutTitle) rendered an empty pair of quotes")
|
||||
#expect(anonymous.contains("the item"), "\(withoutTitle) should fall back to the kind-free noun")
|
||||
#expect(named != anonymous)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("The cause tail comes from the error's reason and nowhere else")
|
||||
func causeTailCarriesTheDiagnosis() {
|
||||
#expect(BannerCenter.headline(for: error(.move(title: "Fix login"), .io(message: "the disk is full")))
|
||||
== "Couldn't move 'Fix login' — the disk is full")
|
||||
|
||||
// A trailing full stop in a diagnostic message must not survive into the middle of a line.
|
||||
#expect(BannerCenter.headline(for: error(.createCard, .io(message: "no space left on device.")))
|
||||
== "Couldn't create a card — no space left on device")
|
||||
|
||||
// The uneditable-frontmatter reason is the one whose own `description` is written for a
|
||||
// developer, so the banner translates it rather than quoting it.
|
||||
let uneditable = BannerCenter.headline(for: error(.style(title: "Odd"), .uneditableFrontmatter(.keyWithoutOwnLine)))
|
||||
#expect(uneditable.hasPrefix("Couldn't restyle 'Odd' — "))
|
||||
#expect(uneditable.contains("frontmatter"))
|
||||
}
|
||||
|
||||
@Test("Every lock reason says what is wrong and that the view is still the last good one")
|
||||
func lockHeadlinesReassure() {
|
||||
let reasons: [ReadOnlyLockReason] = [.bracketedReloadFailed, .vanishedRoot, .unwritableLocation]
|
||||
let headlines = reasons.map(BannerCenter.headline(for:))
|
||||
|
||||
for headline in headlines {
|
||||
#expect(headline.contains("last good view"))
|
||||
#expect(headline.contains("read-only"))
|
||||
}
|
||||
#expect(Set(headlines).count == reasons.count)
|
||||
}
|
||||
|
||||
@Test("Reload breakage carries fail-fast's specifics — the path and what is wrong with it")
|
||||
func breakageHeadlineNamesThePath() {
|
||||
let headline = BannerCenter.headline(
|
||||
for: BoardLoadError(path: "todo/fix-login/index.md", reason: .unparseableYAML(message: "unexpected end", line: 4))
|
||||
)
|
||||
#expect(headline.contains("'todo/fix-login/index.md'"))
|
||||
#expect(headline.contains("line 4"))
|
||||
#expect(headline.contains("showing the last good view"))
|
||||
|
||||
// The board's own index.md reports as "." — a lone dot in the product's voice would be a
|
||||
// bug report, not a sentence.
|
||||
let rootHeadline = BannerCenter.headline(for: BoardLoadError(path: ".", reason: .boardRootMissingIndex))
|
||||
#expect(!rootHeadline.contains("'.'"))
|
||||
#expect(rootHeadline.hasPrefix("This board isn't loading"))
|
||||
}
|
||||
|
||||
@Test("The suspended-history line names the consequence, then the diagnosis")
|
||||
func suspensionHeadlineNamesTheConsequence() {
|
||||
#expect(BannerCenter.headline(for: HistorySuspension(reason: "the disk is full"))
|
||||
== "Changes aren't being recorded to history — the disk is full")
|
||||
#expect(BannerCenter.headline(for: HistorySuspension(reason: ""))
|
||||
== "Changes aren't being recorded to history")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Store integration
|
||||
|
||||
@MainActor
|
||||
@Suite("BannerCenter ▸ store integration")
|
||||
struct BannerCenterStoreTests {
|
||||
|
||||
@Test("A failed write lands exactly one one-shot in the store's banners, and still throws")
|
||||
func failedWritePostsOnce() throws {
|
||||
let fixture = try makeBoardWithUneditableLane()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
// A genuine Writer refusal: the lane's frontmatter is a whole-frontmatter flow mapping, so
|
||||
// the surgical editor cannot address it and the write fails loudly rather than corrupting
|
||||
// the file (01-storage-format.md § Frontmatter).
|
||||
var thrown: BoardWriteError?
|
||||
do {
|
||||
try store.performWrite { () throws(BoardWriteError) -> Void in
|
||||
try BoardWriter.updateIndex(inItemFolder: fixture.url(Ident.lane2), operation: .style(title: nil)) { _ in }
|
||||
}
|
||||
Issue.record("expected the write to fail")
|
||||
} catch let failure as BoardWriteError {
|
||||
thrown = failure
|
||||
}
|
||||
|
||||
let error = try #require(thrown)
|
||||
#expect(store.banners.oneShots.count == 1, "the banner is posted once, not per layer")
|
||||
#expect(store.banners.oneShots.first?.error == error, "the strip carries the same failure the caller saw")
|
||||
|
||||
// And it is the whole of what the strip shows: a healthy board with one unread failure.
|
||||
#expect(store.bannerRows.count == 1)
|
||||
#expect(store.bannerRows[0].tone == .error)
|
||||
#expect(store.bannerRows[0].headline == "Couldn't restyle 'Odd' — this file's frontmatter can't be edited in place (a top-level key has no line of its own)")
|
||||
#expect(store.bannerRows[0].dismissID != nil)
|
||||
}
|
||||
|
||||
@Test("A write refused by the read-only lock posts nothing — the lock row already stands")
|
||||
func refusalUnderLockPostsNothing() throws {
|
||||
let fixture = try makeBoardWithUneditableLane()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
store.enterVanishedRootLock()
|
||||
|
||||
do {
|
||||
try store.performWrite { () throws(BoardWriteError) -> Void in
|
||||
try BoardWriter.updateIndex(inItemFolder: fixture.url(Ident.lane1), operation: .style(title: nil)) { _ in }
|
||||
}
|
||||
Issue.record("expected the store to refuse the write")
|
||||
} catch let refusal as BoardStoreWriteRefusal {
|
||||
#expect(refusal == .readOnlyLocked(.vanishedRoot))
|
||||
}
|
||||
|
||||
#expect(store.banners.oneShots.isEmpty, "a refusal is not a failed write; the lock row is the message")
|
||||
#expect(store.bannerRows.count == 1)
|
||||
guard case .readOnlyLock(.vanishedRoot) = store.bannerRows[0] else {
|
||||
Issue.record("expected the lock row alone")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@Test("The store's rows compose its own conditions with the center's")
|
||||
func bannerRowsComposeBothHalves() throws {
|
||||
let fixture = try makeBoardWithUneditableLane()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
store.enterUnwritableLock()
|
||||
store.banners.post(BoardWriteError(operation: .createCard, path: "/x", reason: .io(message: "the disk is full")))
|
||||
store.banners.suspendHistory(reason: "the disk is full")
|
||||
store.banners.beginOperation(label: "Duplicating…", cancel: nil)
|
||||
store.banners.postSignpost("This card changed on the remote")
|
||||
|
||||
#expect(store.bannerRows.map(\.id) == [
|
||||
"operation:\(store.banners.operations[0].id.uuidString)",
|
||||
"read-only-lock",
|
||||
"one-shot:\(store.banners.oneShots[0].id.uuidString)",
|
||||
"history-suspension",
|
||||
"signpost:\(store.banners.signposts[0].id.uuidString)",
|
||||
])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user