Add the loss-row banner class and retone degraded paste
02 ratified a warning-tone class for non-failure losses — content that didn't arrive though nothing failed: a degraded paste, folders skipped from a Finder drop, their future kin. Loss rows take the one-shot's dismissable-untimed lifecycle (a loss the user didn't notice is the harm) and rank below the true failures, above commit and attachment notices. BannerCenter grows LossBanner, postLoss, and the skipped-folders phrasing; the degraded-paste notice moves off its signpost onto the new class, ending its too-quiet ranking. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -83,6 +83,10 @@ struct BannerCenterOrderingTests {
|
||||
error: error(.importAttachment(filename: "photo.png")),
|
||||
occurredAt: Date(timeIntervalSince1970: 200)
|
||||
)
|
||||
let loss = LossBanner(
|
||||
message: "Pasted 'Fix login' without its 3 attachments",
|
||||
occurredAt: Date(timeIntervalSince1970: 150)
|
||||
)
|
||||
let operation = InProgressOperation(label: "Pulling…")
|
||||
let signpost = InfoSignpost(message: "This card changed on the remote")
|
||||
|
||||
@@ -90,25 +94,27 @@ struct BannerCenterOrderingTests {
|
||||
lock: .vanishedRoot,
|
||||
breakage: BoardLoadError(path: "todo/index.md", reason: .missingOrder),
|
||||
oneShots: [attachment, move],
|
||||
losses: [loss],
|
||||
suspension: HistorySuspension(reason: "disk full", since: Date(timeIntervalSince1970: 50)),
|
||||
operations: [operation],
|
||||
signposts: [signpost]
|
||||
)
|
||||
|
||||
// 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.
|
||||
// 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, .error, .info])
|
||||
#expect(rows.map(\.isPinned) == [true, false, false, false, false, false, false],
|
||||
#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")
|
||||
}
|
||||
|
||||
@@ -120,12 +126,43 @@ struct BannerCenterOrderingTests {
|
||||
)
|
||||
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: [])
|
||||
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))
|
||||
@@ -136,6 +173,7 @@ struct BannerCenterOrderingTests {
|
||||
lock: nil,
|
||||
breakage: nil,
|
||||
oneShots: [middle, oldest, newest],
|
||||
losses: [],
|
||||
suspension: nil,
|
||||
operations: []
|
||||
)
|
||||
@@ -147,6 +185,28 @@ struct BannerCenterOrderingTests {
|
||||
])
|
||||
}
|
||||
|
||||
@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()
|
||||
@@ -160,6 +220,7 @@ struct BannerCenterOrderingTests {
|
||||
lock: nil,
|
||||
breakage: nil,
|
||||
oneShots: center.oneShots,
|
||||
losses: [],
|
||||
suspension: nil,
|
||||
operations: []
|
||||
)
|
||||
@@ -169,7 +230,7 @@ struct BannerCenterOrderingTests {
|
||||
|
||||
@Test("Nothing standing is an empty strip")
|
||||
func quietBoardHasNoRows() {
|
||||
#expect(BannerCenter.rows(lock: nil, breakage: nil, oneShots: [], suspension: nil, operations: []).isEmpty)
|
||||
#expect(BannerCenter.rows(lock: nil, breakage: nil, oneShots: [], losses: [], suspension: nil, operations: []).isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,6 +259,7 @@ struct BannerCenterLifecycleTests {
|
||||
lock: .bracketedReloadFailed,
|
||||
breakage: BoardLoadError(path: ".", reason: .boardRootMissingIndex),
|
||||
oneShots: center.oneShots,
|
||||
losses: [],
|
||||
suspension: HistorySuspension(reason: "disk full"),
|
||||
operations: [InProgressOperation(label: "Pulling…")]
|
||||
)
|
||||
@@ -212,6 +274,47 @@ struct BannerCenterLifecycleTests {
|
||||
#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 one-shots and signposts")
|
||||
func dismissAllClearsLosses() {
|
||||
let center = BannerCenter()
|
||||
center.postLoss("Pasted 'Fix login' without its 3 attachments")
|
||||
center.dismissAllDismissableRows()
|
||||
#expect(center.losses.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()
|
||||
@@ -221,7 +324,9 @@ struct BannerCenterLifecycleTests {
|
||||
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: [])
|
||||
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"))
|
||||
@@ -229,7 +334,9 @@ struct BannerCenterLifecycleTests {
|
||||
|
||||
center.clearHistorySuspension()
|
||||
#expect(center.historySuspension == nil)
|
||||
#expect(BannerCenter.rows(lock: nil, breakage: nil, oneShots: [], suspension: center.historySuspension, operations: []).isEmpty)
|
||||
#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")
|
||||
@@ -250,7 +357,9 @@ struct BannerCenterLifecycleTests {
|
||||
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)
|
||||
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…")
|
||||
@@ -261,7 +370,9 @@ struct BannerCenterLifecycleTests {
|
||||
#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)
|
||||
rows = BannerCenter.rows(
|
||||
lock: nil, breakage: nil, oneShots: [], losses: [], suspension: nil, operations: center.operations
|
||||
)
|
||||
#expect(rows.isEmpty)
|
||||
}
|
||||
|
||||
@@ -290,6 +401,7 @@ struct BannerCenterLifecycleTests {
|
||||
lock: nil,
|
||||
breakage: nil,
|
||||
oneShots: center.oneShots,
|
||||
losses: [],
|
||||
suspension: nil,
|
||||
operations: center.operations,
|
||||
signposts: center.signposts
|
||||
@@ -315,6 +427,7 @@ struct BannerCenterLifecycleTests {
|
||||
lock: nil,
|
||||
breakage: nil,
|
||||
oneShots: center.oneShots,
|
||||
losses: [],
|
||||
suspension: nil,
|
||||
operations: center.operations
|
||||
)
|
||||
@@ -444,6 +557,15 @@ struct BannerCenterPhrasingTests {
|
||||
#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
|
||||
@@ -514,6 +636,7 @@ struct BannerCenterStoreTests {
|
||||
|
||||
store.enterUnwritableLock()
|
||||
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")
|
||||
@@ -522,6 +645,7 @@ struct BannerCenterStoreTests {
|
||||
"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)",
|
||||
])
|
||||
|
||||
Reference in New Issue
Block a user