Files
lanework/KanbanTests/BannerCenterTests.swift
T
rzen 797d020d01 Materialize the trash — faces, menus, and grammar
Phase 3 finishes the pivot at the surface. One card face serves two
containers: CardFaceView extracted with a role — board or trash — so
stripe, tint, chip, selection stroke, cut dim, marquee registration,
and drag are shared by construction, the trash side differing only in
its absences: no Open, no rename, no Style, no file-hover highlight,
and a Delete that goes through the confirmation host. The column
rewrote around the lanes' own single-column masonry so drag reflow
reads as positional slides; chrome stays the hatched header, symbol,
and count — 11 gives Empty Trash to the File menu alone. Two real
grammar bugs die here: plain Backspace on a trash selection purged
without the confirmation the menu raises, and the context menu's
Delete resolved against the standing selection, so right-clicking a
trash card under a board selection silently did nothing — it now
stages the clicked set explicitly. Open, Rename, Style, and Empty
Trash validation became testable store seams; the column is one named
accessibility container of ordinary card elements. The tombstone era
is swept: deleteItem, restoreItem, stripTombstonedChildren — dead
since lane copies stopped nesting trash — the restore verb, the
unreachable put-back banner row, and every quasi-lane doc comment.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-28 18:18:39 -04:00

650 lines
29 KiB
Swift

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"),
.purge(title: "Fix login"),
.style(title: "Fix login"),
.resize(title: "Fix login"),
.rename(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)),
(.purge(title: "Fix login"), .purge(title: nil)),
(.style(title: "Fix login"), .style(title: nil)),
(.resize(title: "Fix login"), .resize(title: nil)),
(.rename(title: "Fix login"), .rename(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 loss = LossBanner(
message: "Pasted 'Fix login' without its 3 attachments",
occurredAt: Date(timeIntervalSince1970: 150)
)
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],
losses: [loss],
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 >
// loss rows > 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)",
"loss:\(loss.id.uuidString)",
"history-suspension",
"one-shot:\(attachment.id.uuidString)",
"signpost:\(signpost.id.uuidString)",
])
#expect(rows.map(\.tone) == [.info, .error, .error, .error, .warning, .warning, .error, .info])
#expect(rows.map(\.isPinned) == [true, false, 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], losses: [], 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("A loss row ranks below one-shot write failures and above history suspension, attachment one-shots, and signposts")
func lossRankBetweenFailuresAndAmbientNotices() {
let center = BannerCenter()
center.post(error(.move(title: "Fix login")))
center.post(error(.importAttachment(filename: "photo.png")))
center.postLoss("Pasted 'Old ticket' without its 2 attachments")
center.postSignpost("This card changed on the remote")
let rows = BannerCenter.rows(
lock: nil,
breakage: nil,
oneShots: center.oneShots,
losses: center.losses,
suspension: HistorySuspension(reason: "disk full"),
operations: [],
signposts: center.signposts
)
// move (non-attachment one-shot) > loss > history suspension > attachment one-shot > signpost.
#expect(rows.map(\.id) == [
"one-shot:\(center.oneShots[1].id.uuidString)", // "Fix login" move, posted first, listed second (newest-first)
"loss:\(center.losses[0].id.uuidString)",
"history-suspension",
"one-shot:\(center.oneShots[0].id.uuidString)", // the attachment import
"signpost:\(center.signposts[0].id.uuidString)",
])
#expect(rows[1].tone == .warning)
}
@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],
losses: [],
suspension: nil,
operations: []
)
#expect(rows.map(\.id) == [
"one-shot:\(newest.id.uuidString)",
"one-shot:\(middle.id.uuidString)",
"one-shot:\(oldest.id.uuidString)",
])
}
@Test("Loss rows order newest first within their class")
func lossesAreNewestFirst() {
let oldest = LossBanner(message: "A", occurredAt: Date(timeIntervalSince1970: 1))
let middle = LossBanner(message: "B", occurredAt: Date(timeIntervalSince1970: 2))
let newest = LossBanner(message: "C", occurredAt: Date(timeIntervalSince1970: 3))
let rows = BannerCenter.rows(
lock: nil,
breakage: nil,
oneShots: [],
losses: [middle, oldest, newest],
suspension: nil,
operations: []
)
#expect(rows.map(\.id) == [
"loss:\(newest.id.uuidString)",
"loss:\(middle.id.uuidString)",
"loss:\(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,
losses: [],
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: [], losses: [], 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,
losses: [],
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("A loss row dismisses individually and is untimed — nothing but its own dismissal clears it")
func lossRowsDismissByIDAndNeverExpire() throws {
let center = BannerCenter()
center.postLoss("Pasted 'Fix login' without its 3 attachments")
center.postLoss("Folders can't be attached — 2 skipped")
#expect(center.losses.count == 2)
let doomed = try #require(center.losses.first)
center.dismiss(doomed.id)
#expect(center.losses.count == 1)
#expect(center.losses.first?.id != doomed.id, "dismissing one must not take its neighbour")
// No timer, no auto-expiry: everything else the center does — ending an in-progress
// operation, raising and clearing the history suspension — leaves a standing loss alone.
let id = center.beginOperation(label: "Pulling…", cancel: nil)
center.endOperation(id)
center.suspendHistory(reason: "disk full")
center.clearHistorySuspension()
#expect(center.losses.count == 1, "a loss survives everything except its own dismissal")
}
@Test("Dismissing all dismissable rows clears losses along with one-shots and signposts")
func dismissAllClearsLosses() {
let center = BannerCenter()
center.postLoss("Pasted 'Fix login' without its 3 attachments")
center.dismissAllDismissableRows()
#expect(center.losses.isEmpty)
}
@Test("postSkippedFolders no-ops when nothing was skipped")
func postSkippedFoldersNoOpsAtZero() {
let center = BannerCenter()
center.postSkippedFolders(count: 0)
#expect(center.losses.isEmpty)
center.postSkippedFolders(count: 2)
#expect(center.losses.count == 1)
#expect(center.losses[0].message == "Folders can't be attached — 2 skipped")
}
@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: [], losses: [], 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: [], losses: [], 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: [], losses: [], 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: [], losses: [], 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,
losses: [],
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,
losses: [],
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("Rename says the design's own sentence, and never borrows styling's")
func renameHasItsOwnVerb() {
// 02-architecture.md § Write-failure surfacing names this line verbatim when it settles
// that "the vocabulary grows with the surfaces": inline rename gets its own case rather
// than folding into the generic frontmatter bucket, so a failed rename must not tell the
// user the app could not *restyle* anything.
#expect(BannerCenter.headline(for: error(.rename(title: "Fix login"), .io(message: "the disk is full")))
== "Couldn't rename 'Fix login' — the disk is full")
#expect(BannerCenter.headline(for: error(.rename(title: nil), .io(message: "the disk is full")))
== "Couldn't rename the item — the disk is full")
#expect(BannerCenter.headline(for: error(.rename(title: "Fix login")))
!= BannerCenter.headline(for: error(.style(title: "Fix login"))))
}
@Test("The trash verbs speak the board's vocabulary, never the system Trash's")
func trashVerbsFollowTheNamingConstraint() {
// 03-board-ui.md § Trash's naming constraint, settled with the trash UI copy: two "Trash"
// concepts coexist, and "Finder's 'Move to Trash' phrasing is reserved for the system Trash;
// board deletion says 'Delete'". A banner is UI copy like any other.
#expect(BannerCenter.headline(for: error(.delete(title: "Fix login"), .io(message: "the disk is full")))
== "Couldn't delete 'Fix login' — the disk is full")
#expect(BannerCenter.headline(for: error(.purge(title: "Fix login"), .io(message: "the disk is full")))
== "Couldn't permanently delete 'Fix login' — the disk is full")
for operation in [WriteOperation.delete(title: "Fix login"), .delete(title: nil)] {
#expect(!BannerCenter.headline(for: error(operation)).contains("Trash"))
#expect(!BannerCenter.headline(for: error(operation)).contains("trash"))
}
}
@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")
}
@Test("The skipped-folders line matches 04's own example, plural and singular")
func skippedFoldersMessageMatchesTheDesignExample() {
// 04-interactions.md's own example sentence, verbatim.
#expect(BannerCenter.skippedFoldersMessage(count: 2) == "Folders can't be attached — 2 skipped")
// Singular keeps the same leading claim rather than recasting it as "A folder can't be
// attached" — one sentence shape for every count (see the type's own doc comment).
#expect(BannerCenter.skippedFoldersMessage(count: 1) == "Folders can't be attached — 1 skipped")
}
}
// 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.postLoss("Pasted 'Fix login' without its 3 attachments")
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)",
"loss:\(store.banners.losses[0].id.uuidString)",
"history-suspension",
"signpost:\(store.banners.signposts[0].id.uuidString)",
])
}
}