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