1056 lines
50 KiB
Swift
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")
|
|
}
|
|
}
|