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: 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] ) // in-progress (pinned) > read-only lock > reload breakage > 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. #expect(rows.map(\.id) == [ "operation:\(operation.id.uuidString)", "read-only-lock", "reload-breakage", "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, .warning, .warning, .error, .info]) #expect(rows.map(\.isPinned) == [true, false, false, false, false, false, false, false, false], "a spinner may never hide behind '+N more' — nothing else is pinned") } @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: 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) } @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(BoardLoadError(path: "Todo/index.md", reason: .missingOrder)), .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") } } /// 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(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: 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(.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)", ]) } }