A lane folds to a fixed slim vertical strip carrying its glyph, its card-count badge and its title turned on its side, and the strip is deliberately not part of the window's division: the expanded lanes' units divide what is left once each folded strip's fixed width has come off the top, so folding a lane is a re-divide trigger of the Show/Hide Trash family — the window never moves and the siblings grow into what the lane gave up. The state is a first-class lane frontmatter key, `collapsed: true`, and document state exactly as `width` is: the files are the board, so an agent folds a lane by writing one key. Absent means expanded, expanding removes the key rather than writing `false` (the remove-at-default family beside a one-unit `width`, the empty rename's `title` and the None well's `background`), and the lane's `width` rides along untouched so expanding restores the lane the user had. The read is `width`'s leniency one type over — a boolean scalar or a quoted boolean word reads as itself, everything else has no reading at all and renders as expanded, bytes preserved either way. Toggling is the header's always-visible collapse chevron, the lane context menu's single Collapse Lane / Expand Lane row, and a plain click anywhere on the strip; a modified click on the strip stays the ordinary selection grammar, so a folded lane is still selectable by pointer. The title reads bottom-up and is justified to the top of the room below the strip's chrome (owner ruling 2026-08-08), truncating against the strip's own height. While folded the lane draws no cards at all, which is what makes every exclusion true by construction rather than by a guard per gesture: no card face means no marquee target and no navigation frame, and no registered grid means the masonry's drop zones have nothing to resolve against. What did need code is the half that names absolute destinations — the option-arrow jumps and the arrow seed scan past a folded lane, the lane domain's down-arrow is inert on one, and New Card skips it (a selection inside one falls through to the last-active lane, the stale selection's rule). A drop on the strip appends at the lane's end, cards and Finder files alike, with an accent edge standing in for the shadow the strip has no masonry to open; there is no hover-to-auto- expand yet. Lane reorder works on the strip, and a dragged folded lane carries its fold, so its shadow and its replica are the strip rather than its units. The write is `writeLaneWidths` clause for clause — one `updateIndex` bracket, the same stamp behaviour, the same three do-nothing paths — with two new `WriteOperation` cases and two new undo verbs rather than one of each, because a banner or an Edit-menu row that said "resize" after Collapse Lane would name a control the user never touched. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
799 lines
37 KiB
Swift
799 lines
37 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"),
|
|
.collapse(title: "Todo"),
|
|
.expand(title: "Todo"),
|
|
.rename(title: "Fix login"),
|
|
.importAttachment(filename: "photo.png"),
|
|
.listAttachments,
|
|
.renumberChildren,
|
|
]
|
|
|
|
/// The titled cases whose untitled fallback is the family's kind-free "the item" — the invariant the
|
|
/// loop below reads. `.collapse` / `.expand` are deliberately absent: only a lane carries `collapsed`,
|
|
/// so their fallback names the kind outright ("Couldn't collapse the lane") and there is no guess for
|
|
/// the rule to protect against (see `BannerCenter.actionPhrase(for:)`).
|
|
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: "Duplicating…")
|
|
let signpost = InfoSignpost(message: "This card changed on the remote")
|
|
|
|
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]
|
|
)
|
|
|
|
// in-progress (pinned) > read-only lock > reload breakage > one-shot failures > 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",
|
|
"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, .warning, .warning, .error, .info])
|
|
#expect(rows.map(\.isPinned) == [true, false, 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], 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("Dismissing all dismissable rows clears losses along with the failures and signposts")
|
|
func dismissAllClearsLosses() {
|
|
let center = BannerCenter()
|
|
center.postLoss("Pasted 'Fix login' without its 3 attachments")
|
|
center.postSignpost("This card changed on the remote")
|
|
center.dismissAllDismissableRows()
|
|
#expect(center.losses.isEmpty)
|
|
#expect(center.signposts.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, "an operation that cannot be abandoned gets 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("An uncancelable bracket's row offers no control at all — no Cancel, nothing to dismiss")
|
|
func uncancelableInProgressRowsOfferNothing() {
|
|
let row = BannerRow.inProgress(InProgressOperation(label: "Rebuilding…"))
|
|
|
|
#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")
|
|
|
|
for (row, id) in [
|
|
(BannerRow.oneShot(banner), banner.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))),
|
|
.historySuspended(HistorySuspension(reason: "the volume is full")),
|
|
]
|
|
|
|
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")))),
|
|
.loss(LossBanner(message: "Pasted 'Fix login' without its 3 attachments")),
|
|
.historySuspended(HistorySuspension(reason: "disk full")),
|
|
.inProgress(InProgressOperation(label: "Duplicating…")),
|
|
.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 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.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")
|
|
|
|
#expect(store.bannerRows.map(\.id) == [
|
|
"operation:\(store.banners.operations[0].id.uuidString)",
|
|
"read-only-lock",
|
|
"one-shot:\(store.banners.oneShots[0].id.uuidString)",
|
|
"loss:\(store.banners.losses[0].id.uuidString)",
|
|
"history-suspension",
|
|
"signpost:\(store.banners.signposts[0].id.uuidString)",
|
|
])
|
|
}
|
|
}
|