Files
lanework/KanbanTests/BannerCenterTests.swift
T

1056 lines
50 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")
// The failure class's second shape (settled 2026-07-31) — newer than the failed move, so it
// leads the rank the two of them share.
let restore = GitFailureBanner(
operation: .undo,
reason: "could not write to 'index.md': Permission denied",
occurredAt: Date(timeIntervalSince1970: 120)
)
let rows = BannerCenter.rows(
lock: .vanishedRoot,
breakage: BoardLoadFailure(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],
gitFailures: [restore],
repositoryUnreadable: true
)
// in-progress (pinned) > read-only lock > reload breakage > **the unreadable repository** >
// one-shot failures, both shapes > loss rows > commit and attachment failures > passive info
// rows. The two info classes sit at opposite ends of the strip, and the breakage class holds
// two rows now (06-history-undo.md ▸ Rules, ruled 2026-07-31): the reload breakage first,
// because it is the one saying the board on screen is not the board on disk.
#expect(rows.map(\.id) == [
"operation:\(operation.id.uuidString)",
"read-only-lock",
"reload-breakage",
"repository-unreadable",
"git-failure:\(restore.id.uuidString)",
"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, .error, .error, .warning, .warning, .error, .info])
#expect(rows.map(\.isPinned) == [true, false, false, false, false, false, false, false, false, false],
"a spinner may never hide behind '+N more' — nothing else is pinned")
}
/// **The corrupt-`.git` loud failure's row** (06-history-undo.md ▸ Rules, ruled 2026-07-31) —
/// it stands with the breakage class and above every one-shot, which is what "breakage-class"
/// buys it: a failed move posted a second ago never pushes it down the strip.
@Test("The unreadable repository outranks every failure, and only the breakage class outranks it")
func theUnreadableRepositoryStandsInTheBreakageClass() {
let move = OneShotBanner(error: error(.move(title: "Fix login")))
let rows = BannerCenter.rows(
lock: nil,
breakage: nil,
oneShots: [move],
losses: [],
suspension: nil,
operations: [],
repositoryUnreadable: true
)
#expect(rows.map(\.id) == ["repository-unreadable", "one-shot:\(move.id.uuidString)"])
#expect(rows.first?.tone == .error, "the ruling's word is breakage, and breakage is an error")
}
@Test("A readable repository contributes no row at all")
func aReadableRepositoryIsSilent() {
let rows = BannerCenter.rows(
lock: nil,
breakage: nil,
oneShots: [],
losses: [],
suspension: nil,
operations: [],
repositoryUnreadable: false
)
#expect(rows.isEmpty)
}
@Test("Both failure shapes share one rank, interleaved by recency")
func theFailureRankHoldsBothShapes() {
// "Failures rank by what they are, not by which error vocabulary threw them" (02 § The
// banner surface, settled 2026-07-31): the two shapes are one precedence class, so recency
// — not vocabulary — decides which of them a user reads first.
let oldMove = OneShotBanner(error: error(.move(title: "Old")), occurredAt: Date(timeIntervalSince1970: 1))
let newMove = OneShotBanner(error: error(.move(title: "New")), occurredAt: Date(timeIntervalSince1970: 3))
let oldSwitch = GitFailureBanner(
operation: .branchSwitch,
reason: "your local changes would be overwritten",
occurredAt: Date(timeIntervalSince1970: 2)
)
let newUndo = GitFailureBanner(
operation: .undo,
reason: "the repository is locked",
occurredAt: Date(timeIntervalSince1970: 4)
)
let rows = BannerCenter.rows(
lock: nil,
breakage: nil,
oneShots: [oldMove, newMove],
losses: [LossBanner(message: "Folders can't be attached — 1 skipped")],
suspension: nil,
operations: [],
gitFailures: [oldSwitch, newUndo]
)
#expect(rows.map(\.id).prefix(4) == [
"git-failure:\(newUndo.id.uuidString)",
"one-shot:\(newMove.id.uuidString)",
"git-failure:\(oldSwitch.id.uuidString)",
"one-shot:\(oldMove.id.uuidString)",
])
#expect(rows.map(\.tone) == [.error, .error, .error, .error, .warning],
"and every one of them is a failure, above the warning-tone loss row")
}
@Test("A git failure outranks a loss row however much older it is — the compromise is retired")
func aGitFailureOutranksALossRow() {
// The shipped build posted these as loss rows, which put a failed ⌘Z *below* a folder-drop
// notice and painted it warning-tone. Both halves of that are retired (settled 2026-07-31).
let ancient = GitFailureBanner(
operation: .redo,
reason: "the repository is locked",
occurredAt: Date(timeIntervalSince1970: 1)
)
let fresh = LossBanner(message: "Folders can't be attached — 2 skipped", occurredAt: Date(timeIntervalSince1970: 900))
let rows = BannerCenter.rows(
lock: nil, breakage: nil, oneShots: [], losses: [fresh], suspension: nil, operations: [],
gitFailures: [ancient]
)
#expect(rows.map(\.id) == ["git-failure:\(ancient.id.uuidString)", "loss:\(fresh.id.uuidString)"])
#expect(rows.map(\.tone) == [.error, .warning])
}
@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: BoardLoadFailure(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("A git failure dismisses individually and is untimed — the one-shot's lifecycle exactly")
func gitFailuresDismissByIDAndNeverExpire() throws {
let center = BannerCenter()
center.postGitFailure(.undo, reason: "the repository is locked")
center.postGitFailure(.branchSwitch, reason: "your local changes would be overwritten")
#expect(center.gitFailures.count == 2)
#expect(center.gitFailures.map(\.operation) == [.branchSwitch, .undo], "newest first on insertion")
let doomed = try #require(center.gitFailures.first)
center.dismiss(doomed.id)
#expect(center.gitFailures.count == 1)
#expect(center.gitFailures.first?.id != doomed.id, "dismissing one must not take its neighbour")
// No timer, no auto-expiry: an error never evaporates unread, whichever vocabulary raised it.
let id = center.beginOperation(label: "Switching to 'main'…", cancel: nil)
center.endOperation(id)
center.suspendHistory(reason: "disk full")
center.clearHistorySuspension()
#expect(center.gitFailures.count == 1, "a failure survives everything except its own dismissal")
// And it is a *failure*, so nothing about it lands in the loss class.
#expect(center.losses.isEmpty)
#expect(center.oneShots.isEmpty)
}
@Test("Dismissing all dismissable rows clears losses along with both failure shapes and signposts")
func dismissAllClearsLosses() {
let center = BannerCenter()
center.postLoss("Pasted 'Fix login' without its 3 attachments")
center.postGitFailure(.redo, reason: "the repository is locked")
center.dismissAllDismissableRows()
#expect(center.losses.isEmpty)
#expect(center.gitFailures.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)
}
/// **The corrupt-`.git` loud failure** (06-history-undo.md ▸ Rules, ruled 2026-07-31): the row
/// is raised at detection, stands with no dismiss, and *heals* — "the banner clears when a later
/// open or reload finds the repo readable".
@Test("The unreadable repository is a standing condition that heals, never a dismissable row")
func theUnreadableRepositoryIsAHealingCondition() throws {
let center = BannerCenter()
#expect(!center.isRepositoryUnreadable)
center.raiseRepositoryUnreadable()
#expect(center.isRepositoryUnreadable)
let rows = BannerCenter.rows(
lock: nil, breakage: nil, oneShots: [], losses: [], suspension: nil, operations: [],
repositoryUnreadable: center.isRepositoryUnreadable
)
#expect(rows.count == 1)
#expect(rows[0].tone == .error)
#expect(rows[0].dismissID == nil, "a condition is never dismissed while it is still true")
// 06's own sentence, verbatim — the three clauses being what is wrong, what it costs, and
// the promise that makes waiting safe.
#expect(rows[0].headline
== "This board's git repository can't be read — history is paused; Lanework leaves the repository untouched")
#expect(rows[0].headline == BannerCenter.repositoryUnreadableMessage)
center.clearRepositoryUnreadable()
#expect(!center.isRepositoryUnreadable)
#expect(BannerCenter.rows(
lock: nil, breakage: nil, oneShots: [], losses: [], suspension: nil, operations: [],
repositoryUnreadable: center.isRepositoryUnreadable
).isEmpty)
}
@Test("Raising and clearing are idempotent — a re-read that confirms the condition changes nothing")
func raisingTheUnreadableRepositoryIsIdempotent() {
let center = BannerCenter()
center.raiseRepositoryUnreadable()
center.raiseRepositoryUnreadable()
#expect(center.isRepositoryUnreadable)
center.clearRepositoryUnreadable()
center.clearRepositoryUnreadable()
#expect(!center.isRepositoryUnreadable)
}
@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: - A row's controls
/// **"Every control" is literal and includes banner-row buttons** (10-accessibility.md ▸ Full
/// Keyboard Access, ruled 2026-07-29): "a Dismiss or Cancel on a banner must be a Tab stop — FKA
/// serves sighted keyboard-only users, to whom VO custom actions are invisible, and Cancel on an
/// in-progress operation is exactly the control that cannot require a pointer."
///
/// The Tab loop itself is the focus system's and needs a window; what is assertable headlessly — and
/// what the strip renders straight from (`BannerStripView.trailingControls`) — is **which** controls
/// a row has and in what order, which is the half that could silently go missing.
@MainActor
@Suite("BannerRow ▸ controls")
struct BannerRowControlsTests {
@Test("A cancelable in-progress row's one control is Cancel, and it runs the caller's closure")
func inProgressRowsOfferCancel() throws {
let cancelled = Box()
let row = BannerRow.inProgress(
InProgressOperation(label: "Importing 24 attachments…", cancel: { cancelled.value = true })
)
#expect(row.controls.map(\.label) == ["Cancel"], "a spinner is not dismissable — it completes or is cancelled")
guard case let .cancel(action) = try #require(row.controls.first) else {
Issue.record("expected a cancel control")
return
}
action()
#expect(cancelled.value)
}
@Test("A git bracket's row offers no control at all — no Cancel, nothing to dismiss")
func uncancelableInProgressRowsOfferNothing() {
let row = BannerRow.inProgress(InProgressOperation(label: "Pulling…"))
#expect(row.controls.isEmpty)
}
@Test("Every dismissable row offers Dismiss, carrying the id the center dismisses by")
func dismissableRowsOfferDismiss() throws {
let banner = OneShotBanner(error: error(.move(title: "Fix login")))
let loss = LossBanner(message: "Pasted 'Fix login' without its 3 attachments")
let signpost = InfoSignpost(message: "This card changed on the remote — your edits still win")
let gitFailure = GitFailureBanner(operation: .undo, reason: "the repository is locked")
for (row, id) in [
(BannerRow.oneShot(banner), banner.id),
(BannerRow.gitFailure(gitFailure), gitFailure.id),
(BannerRow.loss(loss), loss.id),
(BannerRow.signpost(signpost), signpost.id),
] {
#expect(row.controls.map(\.label) == ["Dismiss"])
guard case let .dismiss(carried) = try #require(row.controls.first) else {
Issue.record("expected a dismiss control")
return
}
#expect(carried == id, "the button dismisses this row, not whichever row is first")
}
}
/// "A condition is never dismissed while it is still true" — the rows that heal on their own
/// carry no button, so they contribute no tab stop either.
@Test("Condition rows carry no controls")
func conditionRowsCarryNoControls() {
let rows: [BannerRow] = [
.readOnlyLock(.vanishedRoot),
.reloadBreakage(BoardLoadFailure(BoardLoadError(path: "Todo/index.md", reason: .missingOrder))),
.repositoryUnreadable,
.historySuspended(HistorySuspension(reason: "the repository is corrupt")),
]
for row in rows {
#expect(row.controls.isEmpty, "\(row.id) is a condition — it heals, it is not waved away")
#expect(row.dismissID == nil)
}
}
/// The inventory is the view's source of truth, so it has to agree with `dismissID`, which the
/// center's own dismissal path uses. One row, one answer.
@Test("The inventory agrees with dismissID on every row class")
func inventoryAgreesWithDismissID() {
let rows: [BannerRow] = [
.readOnlyLock(.vanishedRoot),
.reloadBreakage(BoardLoadFailure(BoardLoadError(path: "Todo/index.md", reason: .missingOrder))),
.oneShot(OneShotBanner(error: error(.move(title: "Fix login")))),
.gitFailure(GitFailureBanner(operation: .branchSwitch, reason: "the repository is locked")),
.loss(LossBanner(message: "Pasted 'Fix login' without its 3 attachments")),
.historySuspended(HistorySuspension(reason: "disk full")),
.inProgress(InProgressOperation(label: "Pulling…")),
.signpost(InfoSignpost(message: "This card changed on the remote")),
]
for row in rows {
let dismisses = row.controls.contains { if case .dismiss = $0 { true } else { false } }
#expect(dismisses == (row.dismissID != nil), "\(row.id)")
}
}
}
// 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 git operation names itself in the user's words, with the error as the tail")
func everyGitOperationSaysSomethingDistinct() {
// The vocabulary is closed and the sentences are here, not at the call sites (02 § The
// banner surface, settled 2026-07-31: "the operation named in the user's words plus the
// underlying error, phrasing still BannerCenter's"). `CaseIterable` is what keeps this test
// honest when pro-m2 adds pull and push.
let headlines = GitOperation.allCases.map {
BannerCenter.headline(for: GitFailureBanner(operation: $0, reason: "the repository is locked"))
}
for (operation, headline) in zip(GitOperation.allCases, headlines) {
#expect(!headline.isEmpty, "\(operation) has no headline")
#expect(headline.hasSuffix(" — the repository is locked"), "\(operation) drops the underlying error")
#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("The git failure's sentences are the ruling's own")
func gitFailureSentencesArePinned() {
// Pinned as literals, unlike most phrasing here, because 02 wrote these two shapes by hand
// and the third is their mirror: the undo pair names the command the user pressed, the
// switch names the control they used.
#expect(BannerCenter.headline(for: GitFailureBanner(operation: .undo, reason: "the repository is locked"))
== "Undo failed — the repository is locked")
#expect(BannerCenter.headline(for: GitFailureBanner(operation: .redo, reason: "the repository is locked"))
== "Redo failed — the repository is locked")
#expect(BannerCenter.headline(for: GitFailureBanner(
operation: .branchSwitch,
reason: "your local changes would be overwritten"
)) == "Couldn't switch branches — your local changes would be overwritten")
// The tail is trimmed like every other diagnostic tail, and an absent one leaves the action
// clause alone rather than trailing a dash into nothing.
#expect(BannerCenter.headline(for: GitFailureBanner(operation: .undo, reason: " the disk is full. "))
== "Undo failed — the disk is full")
#expect(BannerCenter.headline(for: GitFailureBanner(operation: .undo, reason: " ")) == "Undo failed")
}
@Test("The restore pair maps from the direction the provider crossed in")
func restoreOperationsMapFromDirection() {
// The provider knows which key was pressed and nothing else about banners; this is the whole
// of the translation, kept in one place so no wiring can get it backwards.
#expect(GitOperation.restore(.undo) == .undo)
#expect(GitOperation.restore(.redo) == .redo)
}
@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]
+ UnwritableCause.allCases.map(ReadOnlyLockReason.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)
}
/// "Which specific cause, not a shared line" (02 § Write-failure surfacing, settled): the two
/// halves of the unwritable location name **different repairs**, so they get different lines.
@Test("The unwritable location names which cause it is")
func unwritableCausesGetTheirOwnLines() {
let volume = BannerCenter.headline(for: .unwritableLocation(.readOnlyVolume))
let folder = BannerCenter.headline(for: .unwritableLocation(.permissionDenied))
#expect(volume == "This board's volume is read-only — showing the last good view, read-only")
#expect(folder == "You don't have permission to change this folder — showing the last good view, read-only")
// The distinction the design spends the extra line on: one says volume, the other says this
// folder, and neither says the other's word.
#expect(volume.contains("volume") && !volume.contains("permission"))
#expect(folder.contains("permission") && !folder.contains("volume"))
}
@Test("Reload breakage carries fail-fast's specifics — the path and what is wrong with it")
func breakageHeadlineNamesThePath() {
let headline = BannerCenter.headline(
for: BoardLoadFailure(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: BoardLoadFailure(BoardLoadError(path: ".", reason: .boardRootMissingIndex)))
#expect(!rootHeadline.contains("'.'"))
#expect(rootHeadline.hasPrefix("This board isn't loading"))
}
/// **One defect named, the rest counted** (01-storage-format.md § Malformed input — the loader
/// collects every fail-fast defect in a walk). A banner is one line, so the sentence stays the
/// sentence it always was and the remainder rides as a count; the full list is the decision
/// surface's to show on the next attended open.
///
/// Both spellings are pinned, because the single-defect one is what every existing surface reads
/// and it must not have drifted when the aggregate arrived.
@Test("A multi-defect breakage names the first and counts the rest")
func breakageHeadlineCountsTheRest() {
let first = BoardLoadError(path: "index.md", reason: .missingSchema)
let second = BoardLoadError(path: "todo/index.md", reason: .schemaNewerThanApp(found: 2))
let third = BoardLoadError(path: "done/index.md", reason: .malformedSchema(raw: "one"))
#expect(BannerCenter.headline(for: BoardLoadFailure([first]))
== "'index.md' isn't loading: missing required 'schema' field — showing the last good view")
#expect(BannerCenter.headline(for: BoardLoadFailure([first, second]))
== "'index.md' isn't loading: missing required 'schema' field, and 1 more — showing the last good view")
#expect(BannerCenter.headline(for: BoardLoadFailure([first, second, third]))
== "'index.md' isn't loading: missing required 'schema' field, and 2 more — showing the last good view")
// The row that carries it says the same thing — the headline is not re-derived anywhere.
#expect(BannerRow.reloadBreakage(BoardLoadFailure([first, second])).headline
== BannerCenter.headline(for: BoardLoadFailure([first, second])))
}
@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(.permissionDenied)
store.banners.postGitFailure(.undo, reason: "the working tree is locked")
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")
// The git failure posts before the write failure, so recency (and the tie rule alike)
// puts the write one-shot first within the shared failure rank.
#expect(store.bannerRows.map(\.id) == [
"operation:\(store.banners.operations[0].id.uuidString)",
"read-only-lock",
"one-shot:\(store.banners.oneShots[0].id.uuidString)",
"git-failure:\(store.banners.gitFailures[0].id.uuidString)",
"loss:\(store.banners.losses[0].id.uuidString)",
"history-suspension",
"signpost:\(store.banners.signposts[0].id.uuidString)",
])
}
/// **The row the git state raises, through the store** (06-history-undo.md ▸ Rules, the
/// corrupt-`.git` loud failure, ruled 2026-07-31) — raised and healed by
/// `noteRepositoryUnreadable(_:)`, which is the seam `AppModel.beginSession` wires the
/// committer's pause transitions to, and **announced** both ways per 10-accessibility.md.
@Test("The unreadable repository stands on the strip and is spoken when it appears and clears")
func theUnreadableRepositoryRowIsRaisedAndSpoken() throws {
let fixture = try makeBoardWithUneditableLane()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
var spoken: [String] = []
store.announce = { if let line = $0 { spoken.append(line) } }
store.noteRepositoryUnreadable(true)
#expect(store.bannerRows.map(\.id) == ["repository-unreadable"])
#expect(store.bannerRows[0].headline == BannerCenter.repositoryUnreadableMessage)
#expect(spoken == ["Error: \(BannerCenter.repositoryUnreadableMessage)"],
"a standing banner is announced when it appears — the row's own sentence, tone first")
// The 15 s re-read confirming what is already standing must not say it again.
store.noteRepositoryUnreadable(true)
#expect(spoken.count == 1)
store.noteRepositoryUnreadable(false)
#expect(store.bannerRows.isEmpty)
#expect(spoken.last == "History is recording again")
}
}