The lane strip replaces the placeholder board: window width divides
across the lanes' width units (no horizontal scroll, no minimum width,
degenerate compression accepted), a lane of n units flowing its cards
into n round-robin masonry columns via a measurement-cached Layout.
Width has two deliberately opposite controls, both landing here: the
right-edge drag (ported verbatim from the pathfinder's ColumnResize)
freezes the 1x standard at drag start, snaps between integer widths
with the asymmetric shadow-leads tick and 10pt re-entry, grows the
window one standard width per snap so siblings keep their exact
pixels, and rubber-bands at the screen's visible frame — uncapped
otherwise; the Increase/Decrease Lane Width items (new Board menu,
Cmd-Opt-arrows) are the stepper's keyboard face and re-divide the
existing window width instead, never touching the window. Width
changes write through the new .resize WriteOperation ("Couldn't
resize…" in the banner vocabulary, which grows with the surfaces by
design); malformed width values render as one unit and stay untouched
on disk. 27 new tests port the pathfinder's resize-math suite onto
the uncapped range and pin the write path's fidelity.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
496 lines
22 KiB
Swift
496 lines
22 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"),
|
|
.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)),
|
|
]
|
|
|
|
// 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("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)",
|
|
])
|
|
}
|
|
}
|