Files
lanework/KanbanTests/BannerCenterTests.swift
T
rzen b35566e0fe Build lane chrome — title bar, badge, inline rename
The lane title bar becomes real: leading SF Symbol (hand-written names
render leniently, unknown ones fall back to the level default), title
or secondary untitled placeholder, a quiet count badge that counts
exactly the cards the body renders (so the m5 search filter is
followed by construction), and a new-card button. The whole bar is
the reorder drag surface — no grip — with click-vs-movement splitting
select from drag; a pure proposal function maps the drag to an
insertion index and release commits through the Writer's same-parent
degenerate reorder, compacting and retrying when midpoint precision
runs out. Clicking never edits: inline rename is Return on the sole
selected card or Board > Rename for either kind, a third transient
editor beside the placeholder that tracks its target by UUID, commits
on focus loss, discards silently when the target vanishes, and
removes the title key on an empty commit. The new-card placeholder
renders at last — the settled Cmd-N target rule (pure, tested) files
it after the anchor card, at a selected lane's bottom, or into the
last-active lane; Return commits and re-selects the lane, Cmd-Return
also opens the card window, and a failed create discards the overlay.
New Card / New Lane / Rename land in the menus with focused-editor
and read-only validation; rename gets its own WriteOperation case in
the banner vocabulary. 59 new tests.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-27 08:52:24 -04:00

512 lines
23 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"),
.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)",
])
}
}