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:
@@ -36,6 +36,34 @@ public struct OneShotBanner: Identifiable, Sendable, Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A loss row: content that didn't arrive though nothing failed (02-architecture.md § The banner
|
||||||
|
/// surface, "Loss rows are the warning-tone class for non-failure losses", settled 2026-07-28) — a
|
||||||
|
/// degraded paste, folders skipped from a Finder drop, their future kin.
|
||||||
|
///
|
||||||
|
/// **It takes the one-shot's lifecycle**, deliberately: "a loss the user didn't notice is the harm,
|
||||||
|
/// so it never auto-expires" is `OneShotBanner`'s "an error never evaporates unread", read for a row
|
||||||
|
/// that reports something incomplete rather than something failed. Carrying its own `id` is the same
|
||||||
|
/// consequence: two identical losses a minute apart are two rows, and dismissing one must not take
|
||||||
|
/// the other with it.
|
||||||
|
///
|
||||||
|
/// **It ranks below the true failures and above the ambient notices** — "an action that didn't
|
||||||
|
/// happen outranks one that partially did" — which is why it is its own `BannerRow` case rather than
|
||||||
|
/// an `OneShotBanner` with a `nil` error or a `InfoSignpost` with a heavier tone: neither of those
|
||||||
|
/// vocabularies has a slot at loss's precedence, and bending one to fit would blur the reading that
|
||||||
|
/// the class exists to make precise.
|
||||||
|
public struct LossBanner: Identifiable, Sendable, Equatable {
|
||||||
|
public let id: UUID
|
||||||
|
public let message: String
|
||||||
|
/// When the loss happened — the sort key for "newest first within a class".
|
||||||
|
public let occurredAt: Date
|
||||||
|
|
||||||
|
public init(id: UUID = UUID(), message: String, occurredAt: Date = Date()) {
|
||||||
|
self.id = id
|
||||||
|
self.message = message
|
||||||
|
self.occurredAt = occurredAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The standing condition "changes aren't being recorded to history" (02-architecture.md §
|
/// The standing condition "changes aren't being recorded to history" (02-architecture.md §
|
||||||
/// Write-failure surfacing, "Auto-commit failures beyond `index.lock` contention").
|
/// Write-failure surfacing, "Auto-commit failures beyond `index.lock` contention").
|
||||||
///
|
///
|
||||||
@@ -116,7 +144,7 @@ public struct InProgressOperation: Identifiable, Sendable {
|
|||||||
|
|
||||||
/// One row in a window's banner strip.
|
/// One row in a window's banner strip.
|
||||||
///
|
///
|
||||||
/// The six cases are the whole vocabulary of 02-architecture.md § The banner surface, and they
|
/// The seven cases are the whole vocabulary of 02-architecture.md § The banner surface, and they
|
||||||
/// divide into three lifecycles that the view renders differently and that the ordering rule
|
/// divide into three lifecycles that the view renders differently and that the ordering rule
|
||||||
/// treats as classes:
|
/// treats as classes:
|
||||||
///
|
///
|
||||||
@@ -124,8 +152,11 @@ public struct InProgressOperation: Identifiable, Sendable {
|
|||||||
/// ongoing state and carry no dismiss control — "an error never evaporates unread" has a twin,
|
/// ongoing state and carry no dismiss control — "an error never evaporates unread" has a twin,
|
||||||
/// "a condition is never dismissed while it is still true". Each leaves when the thing it
|
/// "a condition is never dismissed while it is still true". Each leaves when the thing it
|
||||||
/// describes stops being true.
|
/// describes stops being true.
|
||||||
/// - **One-shots dismiss**: `oneShot` and `signpost`. Each reports something that already happened,
|
/// - **One-shots dismiss**: `oneShot`, `loss`, and `signpost`. Each reports something that already
|
||||||
/// so only the user can clear it.
|
/// happened, so only the user can clear it. `loss` shares this lifecycle deliberately (settled
|
||||||
|
/// 2026-07-28) even though it reports no failure: "a loss the user didn't notice is the harm, so
|
||||||
|
/// it never auto-expires" is the same reasoning that keeps a one-shot from evaporating unread,
|
||||||
|
/// aimed at a row that isn't an error at all.
|
||||||
/// - **In-progress rows complete or fail**: `inProgress`. Completion clears the row; failure swaps
|
/// - **In-progress rows complete or fail**: `inProgress`. Completion clears the row; failure swaps
|
||||||
/// it for a one-shot (`BannerCenter.endOperation(_:)` + `post(_:)`).
|
/// it for a one-shot (`BannerCenter.endOperation(_:)` + `post(_:)`).
|
||||||
///
|
///
|
||||||
@@ -141,6 +172,10 @@ public enum BannerRow: Identifiable, Sendable {
|
|||||||
case reloadBreakage(BoardLoadError)
|
case reloadBreakage(BoardLoadError)
|
||||||
/// A write that did not happen. Dismissable, error tone.
|
/// A write that did not happen. Dismissable, error tone.
|
||||||
case oneShot(OneShotBanner)
|
case oneShot(OneShotBanner)
|
||||||
|
/// Content that didn't arrive though nothing failed — a degraded paste, folders skipped from a
|
||||||
|
/// Finder drop, their future kin. Dismissable, warning tone: below the true failures above it,
|
||||||
|
/// above the ambient notices below it (settled 2026-07-28, see `LossBanner`).
|
||||||
|
case loss(LossBanner)
|
||||||
/// History has stopped advancing. Condition, warning tone — the files are safe, only the undo
|
/// History has stopped advancing. Condition, warning tone — the files are safe, only the undo
|
||||||
/// trail is degraded, which is a warning rather than an error. (m7's committer drives it.)
|
/// trail is degraded, which is a warning rather than an error. (m7's committer drives it.)
|
||||||
case historySuspended(HistorySuspension)
|
case historySuspended(HistorySuspension)
|
||||||
@@ -156,6 +191,7 @@ public enum BannerRow: Identifiable, Sendable {
|
|||||||
case .readOnlyLock: "read-only-lock"
|
case .readOnlyLock: "read-only-lock"
|
||||||
case .reloadBreakage: "reload-breakage"
|
case .reloadBreakage: "reload-breakage"
|
||||||
case let .oneShot(banner): "one-shot:\(banner.id.uuidString)"
|
case let .oneShot(banner): "one-shot:\(banner.id.uuidString)"
|
||||||
|
case let .loss(loss): "loss:\(loss.id.uuidString)"
|
||||||
case .historySuspended: "history-suspension"
|
case .historySuspended: "history-suspension"
|
||||||
case let .inProgress(operation): "operation:\(operation.id.uuidString)"
|
case let .inProgress(operation): "operation:\(operation.id.uuidString)"
|
||||||
case let .signpost(signpost): "signpost:\(signpost.id.uuidString)"
|
case let .signpost(signpost): "signpost:\(signpost.id.uuidString)"
|
||||||
@@ -165,7 +201,7 @@ public enum BannerRow: Identifiable, Sendable {
|
|||||||
public var tone: BannerTone {
|
public var tone: BannerTone {
|
||||||
switch self {
|
switch self {
|
||||||
case .readOnlyLock, .reloadBreakage, .oneShot: .error
|
case .readOnlyLock, .reloadBreakage, .oneShot: .error
|
||||||
case .historySuspended: .warning
|
case .historySuspended, .loss: .warning
|
||||||
case .inProgress, .signpost: .info
|
case .inProgress, .signpost: .info
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -184,18 +220,20 @@ public enum BannerRow: Identifiable, Sendable {
|
|||||||
case let .readOnlyLock(reason): BannerCenter.headline(for: reason)
|
case let .readOnlyLock(reason): BannerCenter.headline(for: reason)
|
||||||
case let .reloadBreakage(error): BannerCenter.headline(for: error)
|
case let .reloadBreakage(error): BannerCenter.headline(for: error)
|
||||||
case let .oneShot(banner): BannerCenter.headline(for: banner.error)
|
case let .oneShot(banner): BannerCenter.headline(for: banner.error)
|
||||||
|
case let .loss(loss): loss.message
|
||||||
case let .historySuspended(suspension): BannerCenter.headline(for: suspension)
|
case let .historySuspended(suspension): BannerCenter.headline(for: suspension)
|
||||||
case let .inProgress(operation): operation.label
|
case let .inProgress(operation): operation.label
|
||||||
case let .signpost(signpost): signpost.message
|
case let .signpost(signpost): signpost.message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Only the rows reporting something that already happened carry a dismiss control — one-shot
|
/// Only the rows reporting something that already happened carry a dismiss control —
|
||||||
/// failures and signposts. Conditions stand until they heal; in-progress rows complete, fail, or
|
/// one-shot failures, loss rows, and signposts. Conditions stand until they heal; in-progress
|
||||||
/// are cancelled — neither is something a user can wave away.
|
/// rows complete, fail, or are cancelled — neither is something a user can wave away.
|
||||||
public var dismissID: UUID? {
|
public var dismissID: UUID? {
|
||||||
switch self {
|
switch self {
|
||||||
case let .oneShot(banner): banner.id
|
case let .oneShot(banner): banner.id
|
||||||
|
case let .loss(loss): loss.id
|
||||||
case let .signpost(signpost): signpost.id
|
case let .signpost(signpost): signpost.id
|
||||||
case .readOnlyLock, .reloadBreakage, .historySuspended, .inProgress: nil
|
case .readOnlyLock, .reloadBreakage, .historySuspended, .inProgress: nil
|
||||||
}
|
}
|
||||||
@@ -209,12 +247,12 @@ public enum BannerRow: Identifiable, Sendable {
|
|||||||
///
|
///
|
||||||
/// ### What lives here and what does not
|
/// ### What lives here and what does not
|
||||||
///
|
///
|
||||||
/// A center holds the state nothing else does: dismissable one-shot write failures, the history
|
/// A center holds the state nothing else does: dismissable one-shot write failures, loss rows, the
|
||||||
/// suspension, in-progress operations, and passive signposts. It deliberately does **not** hold the
|
/// history suspension, in-progress operations, and passive signposts. It deliberately does **not**
|
||||||
/// read-only lock or the reload breakage — those are `BoardStore`'s truths, and copying them here
|
/// hold the read-only lock or the reload breakage — those are `BoardStore`'s truths, and copying
|
||||||
/// would create a second place for them to be stale. `BoardStore.bannerRows` composes both halves
|
/// them here would create a second place for them to be stale. `BoardStore.bannerRows` composes both
|
||||||
/// through `rows(lock:breakage:oneShots:suspension:operations:signposts:)`, which is a *pure
|
/// halves through `rows(lock:breakage:oneShots:losses:suspension:operations:signposts:)`, which is a
|
||||||
/// function* precisely so the precedence rule can be tested without a store, a window, or a
|
/// *pure function* precisely so the precedence rule can be tested without a store, a window, or a
|
||||||
/// filesystem.
|
/// filesystem.
|
||||||
///
|
///
|
||||||
/// ### Phrasing lives here too
|
/// ### Phrasing lives here too
|
||||||
@@ -243,6 +281,11 @@ public final class BannerCenter {
|
|||||||
/// turn — still order deterministically.
|
/// turn — still order deterministically.
|
||||||
public private(set) var oneShots: [OneShotBanner] = []
|
public private(set) var oneShots: [OneShotBanner] = []
|
||||||
|
|
||||||
|
/// Newest first, like `oneShots` — content that didn't arrive though nothing failed,
|
||||||
|
/// dismissable and untimed for the same reason a one-shot failure is (settled 2026-07-28, see
|
||||||
|
/// `LossBanner`).
|
||||||
|
public private(set) var losses: [LossBanner] = []
|
||||||
|
|
||||||
/// The standing "history isn't advancing" condition, or `nil` when commits are landing.
|
/// The standing "history isn't advancing" condition, or `nil` when commits are landing.
|
||||||
public private(set) var historySuspension: HistorySuspension?
|
public private(set) var historySuspension: HistorySuspension?
|
||||||
|
|
||||||
@@ -265,6 +308,12 @@ public final class BannerCenter {
|
|||||||
oneShots.insert(OneShotBanner(error: error), at: 0)
|
oneShots.insert(OneShotBanner(error: error), at: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Posts a loss row — content that didn't arrive though nothing failed (settled 2026-07-28, see
|
||||||
|
/// `LossBanner`). Newest first, like the one-shots it shares a lifecycle with.
|
||||||
|
public func postLoss(_ message: String) {
|
||||||
|
losses.insert(LossBanner(message: message), at: 0)
|
||||||
|
}
|
||||||
|
|
||||||
/// Posts a passive notice — m6's remote-change signpost and whatever joins it. Newest first,
|
/// Posts a passive notice — m6's remote-change signpost and whatever joins it. Newest first,
|
||||||
/// like the one-shots it shares a lifecycle with.
|
/// like the one-shots it shares a lifecycle with.
|
||||||
public func postSignpost(_ message: String) {
|
public func postSignpost(_ message: String) {
|
||||||
@@ -292,34 +341,52 @@ public final class BannerCenter {
|
|||||||
/// intact, attachments absent — and this is the row that says so. "A degraded paste is loud,
|
/// intact, attachments absent — and this is the row that says so. "A degraded paste is loud,
|
||||||
/// never silent … the user never discovers an empty `attachments/` later."
|
/// never silent … the user never discovers an empty `attachments/` later."
|
||||||
///
|
///
|
||||||
/// **A signpost, not a `oneShot`**, and the choice is the vocabulary's rather than a compromise:
|
/// **A loss row, not a `oneShot` and not a signpost** — the vocabulary's answer rather than a
|
||||||
/// 02-architecture.md's `oneShot` is *a write that did not happen*, carrying a `BoardWriteError`,
|
/// compromise (settled 2026-07-28). 02-architecture.md's `oneShot` is *a write that did not
|
||||||
/// and nothing here failed — the items landed, whole but for files that were never on the
|
/// happen*, carrying a `BoardWriteError`, and nothing here failed — the items landed, whole but
|
||||||
/// pasteboard's side of the transfer. A signpost is the other member of the same lifecycle class
|
/// for files that were never on the pasteboard's side of the transfer. This row first shipped as
|
||||||
/// ("one-shots dismiss"): it reports something that already happened, it has no timeout, and only
|
/// a signpost, the vocabulary's other one-shot-lifecycle member at the time, and it read quieter
|
||||||
/// the user clears it, which is the whole of "never evaporates unread". What it costs is
|
/// than 04's "loud" deserved: a signpost ranks last and may collapse behind "+N more", exactly
|
||||||
/// precedence — a signpost ranks last and may collapse behind "+N more" — which is the one place
|
/// where a board already showing real trouble would bury it. The loss class exists to close that
|
||||||
/// this row reads quieter than 04's "loud" deserves. See the report's design-gap note.
|
/// gap — content that didn't arrive though nothing failed ranks below the true failures and
|
||||||
|
/// above the ambient notices, keeping the signpost's dismissable, untimed lifecycle without
|
||||||
|
/// inheriting its bottom-of-the-strip precedence.
|
||||||
///
|
///
|
||||||
/// An empty list posts nothing: a fallback that lost no attachments lost nothing at all, and a
|
/// An empty list posts nothing: a fallback that lost no attachments lost nothing at all, and a
|
||||||
/// banner announcing that would be noise.
|
/// banner announcing that would be noise.
|
||||||
public func postDegradedPaste(_ losses: [AttachmentLoss]) {
|
public func postDegradedPaste(_ losses: [AttachmentLoss]) {
|
||||||
guard let message = Self.degradedPasteMessage(for: losses) else { return }
|
guard let message = Self.degradedPasteMessage(for: losses) else { return }
|
||||||
postSignpost(message)
|
postLoss(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes a dismissable row: a one-shot failure or a signpost. **An id that names an
|
/// Posts the skipped-folders loss row for a Finder drop that imported its files but refused its
|
||||||
/// in-progress operation is ignored** rather than ending it, because "dismiss" and "cancel" are
|
/// folders (04-interactions.md ▸ Selection, drag & drop, "Folders are refused at hover"): "a
|
||||||
/// different promises and a row that offers one must never quietly do the other.
|
/// mixed drag proposes for its files only, and the drop imports the files while a one-shot
|
||||||
|
/// banner names the skipped folders" — now a loss row, for the same reason the degraded paste is
|
||||||
|
/// one (settled 2026-07-28): folders that never arrived are a non-failure loss, not a write
|
||||||
|
/// failure.
|
||||||
|
///
|
||||||
|
/// A drop with no skipped folders posts nothing — nothing was lost, so there is nothing to say.
|
||||||
|
public func postSkippedFolders(count: Int) {
|
||||||
|
guard count > 0 else { return }
|
||||||
|
postLoss(Self.skippedFoldersMessage(count: count))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a dismissable row: a one-shot failure, a loss row, or a signpost. **An id that names
|
||||||
|
/// an in-progress operation is ignored** rather than ending it, because "dismiss" and "cancel"
|
||||||
|
/// are different promises and a row that offers one must never quietly do the other.
|
||||||
public func dismiss(_ id: UUID) {
|
public func dismiss(_ id: UUID) {
|
||||||
oneShots.removeAll { $0.id == id }
|
oneShots.removeAll { $0.id == id }
|
||||||
|
losses.removeAll { $0.id == id }
|
||||||
signposts.removeAll { $0.id == id }
|
signposts.removeAll { $0.id == id }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes every dismissable row — one-shots and signposts alike. The strip's own "clear all"
|
/// Removes every dismissable row — one-shots, losses, and signposts alike. The strip's own
|
||||||
/// affordance later; today it is what a window uses when it re-homes its rows elsewhere (m6).
|
/// "clear all" affordance later; today it is what a window uses when it re-homes its rows
|
||||||
|
/// elsewhere (m6).
|
||||||
public func dismissAllDismissableRows() {
|
public func dismissAllDismissableRows() {
|
||||||
oneShots.removeAll()
|
oneShots.removeAll()
|
||||||
|
losses.removeAll()
|
||||||
signposts.removeAll()
|
signposts.removeAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,8 +438,8 @@ public final class BannerCenter {
|
|||||||
/// function** (02 § The banner surface, "Concurrent conditions stack").
|
/// function** (02 § The banner surface, "Concurrent conditions stack").
|
||||||
///
|
///
|
||||||
/// The precedence, settled: **in-progress rows (pinned)** > read-only lock > reload breakage >
|
/// The precedence, settled: **in-progress rows (pinned)** > read-only lock > reload breakage >
|
||||||
/// one-shot write failures > commit and attachment failures > **passive info rows**. Three
|
/// one-shot write failures > **loss rows** > commit and attachment failures > **passive info
|
||||||
/// readings of it are worth stating because the code depends on them:
|
/// rows**. Four readings of it are worth stating because the code depends on them:
|
||||||
///
|
///
|
||||||
/// - **The two info classes sit at opposite ends of the strip.** An in-progress row is pinned
|
/// - **The two info classes sit at opposite ends of the strip.** An in-progress row is pinned
|
||||||
/// above everything: it is the only explanation the strip offers for a bracket's write lock
|
/// above everything: it is the only explanation the strip offers for a bracket's write lock
|
||||||
@@ -380,6 +447,13 @@ public final class BannerCenter {
|
|||||||
/// — "a spinner may never hide behind '+N more'". A passive signpost ranks last and may
|
/// — "a spinner may never hide behind '+N more'". A passive signpost ranks last and may
|
||||||
/// collapse: calm by design, nothing gated on seeing it instantly. Same tone, opposite
|
/// collapse: calm by design, nothing gated on seeing it instantly. Same tone, opposite
|
||||||
/// urgency.
|
/// urgency.
|
||||||
|
/// - **Loss rows slot between the true failures and the ambient notices** (settled 2026-07-28):
|
||||||
|
/// "an action that didn't happen outranks one that partially did" is why a loss ranks below
|
||||||
|
/// every one-shot write failure, while "content that didn't arrive though nothing failed" is
|
||||||
|
/// still more consequential than a condition or a signpost that only reports ambient state —
|
||||||
|
/// so a loss also ranks above `historySuspended` and the attachment one-shots that follow it.
|
||||||
|
/// Concretely: non-attachment one-shots, then loss rows, then the commit-and-attachment class,
|
||||||
|
/// then signposts.
|
||||||
/// - **An attachment failure is a one-shot**, not a separate kind — its `WriteOperation` is
|
/// - **An attachment failure is a one-shot**, not a separate kind — its `WriteOperation` is
|
||||||
/// `.importAttachment`. It ranks in the commit-failure class rather than with its fellow
|
/// `.importAttachment`. It ranks in the commit-failure class rather than with its fellow
|
||||||
/// one-shots, so a fresh failed import sits below an older failed move. That is the design's
|
/// one-shots, so a fresh failed import sits below an older failed move. That is the design's
|
||||||
@@ -393,11 +467,13 @@ public final class BannerCenter {
|
|||||||
/// so they are ordered by kind, and recency orders only the one-shots among themselves.
|
/// so they are ordered by kind, and recency orders only the one-shots among themselves.
|
||||||
///
|
///
|
||||||
/// `signposts` carries a default because its producer is m6's card window and nothing posts one
|
/// `signposts` carries a default because its producer is m6's card window and nothing posts one
|
||||||
/// today; every other class has a live producer and is spelled out at every call site.
|
/// today; every other class has a live producer and is spelled out at every call site — `losses`
|
||||||
|
/// included, since a degraded paste already posts one (`postDegradedPaste`).
|
||||||
public nonisolated static func rows(
|
public nonisolated static func rows(
|
||||||
lock: ReadOnlyLockReason?,
|
lock: ReadOnlyLockReason?,
|
||||||
breakage: BoardLoadError?,
|
breakage: BoardLoadError?,
|
||||||
oneShots: [OneShotBanner],
|
oneShots: [OneShotBanner],
|
||||||
|
losses: [LossBanner],
|
||||||
suspension: HistorySuspension?,
|
suspension: HistorySuspension?,
|
||||||
operations: [InProgressOperation],
|
operations: [InProgressOperation],
|
||||||
signposts: [InfoSignpost] = []
|
signposts: [InfoSignpost] = []
|
||||||
@@ -416,6 +492,8 @@ public final class BannerCenter {
|
|||||||
let ordered = newestFirst(oneShots)
|
let ordered = newestFirst(oneShots)
|
||||||
rows.append(contentsOf: ordered.lazy.filter { !$0.isAttachmentImport }.map(BannerRow.oneShot))
|
rows.append(contentsOf: ordered.lazy.filter { !$0.isAttachmentImport }.map(BannerRow.oneShot))
|
||||||
|
|
||||||
|
rows.append(contentsOf: newestFirst(losses).map(BannerRow.loss))
|
||||||
|
|
||||||
if let suspension {
|
if let suspension {
|
||||||
rows.append(.historySuspended(suspension))
|
rows.append(.historySuspended(suspension))
|
||||||
}
|
}
|
||||||
@@ -440,6 +518,20 @@ public final class BannerCenter {
|
|||||||
.map(\.element)
|
.map(\.element)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The same stable newest-first ordering as the overload above, for loss rows — the two classes
|
||||||
|
/// share a lifecycle, and `postLoss` already maintains newest-first on insertion the way
|
||||||
|
/// `post(_:)` does.
|
||||||
|
private nonisolated static func newestFirst(_ losses: [LossBanner]) -> [LossBanner] {
|
||||||
|
losses
|
||||||
|
.enumerated()
|
||||||
|
.sorted { lhs, rhs in
|
||||||
|
lhs.element.occurredAt == rhs.element.occurredAt
|
||||||
|
? lhs.offset < rhs.offset
|
||||||
|
: lhs.element.occurredAt > rhs.element.occurredAt
|
||||||
|
}
|
||||||
|
.map(\.element)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Phrasing
|
// MARK: - Phrasing
|
||||||
|
|
||||||
/// The user-facing line for a failed write: what the app could not do, then why.
|
/// The user-facing line for a failed write: what the app could not do, then why.
|
||||||
@@ -575,6 +667,18 @@ public final class BannerCenter {
|
|||||||
return "Pasted \(subject) without \(tail)"
|
return "Pasted \(subject) without \(tail)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The skipped-folders line — 04-interactions.md's own example, "Folders can't be attached — 2
|
||||||
|
/// skipped", generalized over the count. `postSkippedFolders` never calls this at `count == 0`,
|
||||||
|
/// so every real call already has something to report.
|
||||||
|
///
|
||||||
|
/// **Singular stays "Folders can't be attached — 1 skipped"** rather than recasting the leading
|
||||||
|
/// clause to "A folder can't be attached": the claim is always about the drag as a whole — *its*
|
||||||
|
/// folders didn't make it in — and only the trailing count varies, which is one sentence shape
|
||||||
|
/// for every count instead of two that would have to be kept in agreement with each other.
|
||||||
|
public nonisolated static func skippedFoldersMessage(count: Int) -> String {
|
||||||
|
"Folders can't be attached — \(count) skipped"
|
||||||
|
}
|
||||||
|
|
||||||
/// The suspended-history line. It names the *consequence* the user cares about — undo and the
|
/// The suspended-history line. It names the *consequence* the user cares about — undo and the
|
||||||
/// flush-before-overwrite guarantee are degraded — rather than the git mechanics, and carries
|
/// flush-before-overwrite guarantee are degraded — rather than the git mechanics, and carries
|
||||||
/// the diagnosis as its tail.
|
/// the diagnosis as its tail.
|
||||||
|
|||||||
@@ -196,9 +196,9 @@ public final class BoardStore {
|
|||||||
/// failures, and re-homes those rows here when it closes; injecting a shared center would
|
/// failures, and re-homes those rows here when it closes; injecting a shared center would
|
||||||
/// erase precisely that distinction.
|
/// erase precisely that distinction.
|
||||||
///
|
///
|
||||||
/// It holds only what nothing else does — one-shot write failures, the history suspension,
|
/// It holds only what nothing else does — one-shot write failures, loss rows, the history
|
||||||
/// in-progress operations, passive signposts. The lock and the reload breakage stay this
|
/// suspension, in-progress operations, passive signposts. The lock and the reload breakage stay
|
||||||
/// store's own state and are composed in at render time by `bannerRows`.
|
/// this store's own state and are composed in at render time by `bannerRows`.
|
||||||
public let banners = BannerCenter()
|
public let banners = BannerCenter()
|
||||||
|
|
||||||
/// The rows the board window's strip renders, in precedence order.
|
/// The rows the board window's strip renders, in precedence order.
|
||||||
@@ -211,6 +211,7 @@ public final class BoardStore {
|
|||||||
lock: readOnlyLock,
|
lock: readOnlyLock,
|
||||||
breakage: reloadFailure,
|
breakage: reloadFailure,
|
||||||
oneShots: banners.oneShots,
|
oneShots: banners.oneShots,
|
||||||
|
losses: banners.losses,
|
||||||
suspension: banners.historySuspension,
|
suspension: banners.historySuspension,
|
||||||
operations: banners.operations,
|
operations: banners.operations,
|
||||||
signposts: banners.signposts
|
signposts: banners.signposts
|
||||||
|
|||||||
@@ -245,6 +245,7 @@ private func previewError(
|
|||||||
.inProgress(InProgressOperation(label: "Pulling…")),
|
.inProgress(InProgressOperation(label: "Pulling…")),
|
||||||
.readOnlyLock(.vanishedRoot),
|
.readOnlyLock(.vanishedRoot),
|
||||||
.oneShot(OneShotBanner(error: previewError(.delete(title: "Ship the beta")))),
|
.oneShot(OneShotBanner(error: previewError(.delete(title: "Ship the beta")))),
|
||||||
|
.loss(LossBanner(message: "Pasted 'Fix login' without its 3 attachments")),
|
||||||
.historySuspended(HistorySuspension(reason: "the repository is corrupt")),
|
.historySuspended(HistorySuspension(reason: "the repository is corrupt")),
|
||||||
.signpost(InfoSignpost(message: "This card changed on the remote — your edits still win")),
|
.signpost(InfoSignpost(message: "This card changed on the remote — your edits still win")),
|
||||||
],
|
],
|
||||||
@@ -253,6 +254,19 @@ private func previewError(
|
|||||||
.frame(width: 520)
|
.frame(width: 520)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A loss row on its own: warning tone, but — unlike the standing `historySuspended` condition
|
||||||
|
/// beside it in "Stacked tones" — dismissable, since a loss reports something that already
|
||||||
|
/// happened rather than an ongoing state (settled 2026-07-28, BannerCenter's `LossBanner`).
|
||||||
|
#Preview("Loss row") {
|
||||||
|
BannerStripView(
|
||||||
|
rows: [
|
||||||
|
.loss(LossBanner(message: BannerCenter.skippedFoldersMessage(count: 2))),
|
||||||
|
],
|
||||||
|
onDismiss: { _ in }
|
||||||
|
)
|
||||||
|
.frame(width: 520)
|
||||||
|
}
|
||||||
|
|
||||||
/// Seven rows, one of them pinned: the strip shows the spinner plus the first three of the rest,
|
/// Seven rows, one of them pinned: the strip shows the spinner plus the first three of the rest,
|
||||||
/// and "+3 more" counts only what actually collapsed.
|
/// and "+3 more" counts only what actually collapsed.
|
||||||
#Preview("Collapse") {
|
#Preview("Collapse") {
|
||||||
|
|||||||
@@ -83,6 +83,10 @@ struct BannerCenterOrderingTests {
|
|||||||
error: error(.importAttachment(filename: "photo.png")),
|
error: error(.importAttachment(filename: "photo.png")),
|
||||||
occurredAt: Date(timeIntervalSince1970: 200)
|
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 operation = InProgressOperation(label: "Pulling…")
|
||||||
let signpost = InfoSignpost(message: "This card changed on the remote")
|
let signpost = InfoSignpost(message: "This card changed on the remote")
|
||||||
|
|
||||||
@@ -90,25 +94,27 @@ struct BannerCenterOrderingTests {
|
|||||||
lock: .vanishedRoot,
|
lock: .vanishedRoot,
|
||||||
breakage: BoardLoadError(path: "todo/index.md", reason: .missingOrder),
|
breakage: BoardLoadError(path: "todo/index.md", reason: .missingOrder),
|
||||||
oneShots: [attachment, move],
|
oneShots: [attachment, move],
|
||||||
|
losses: [loss],
|
||||||
suspension: HistorySuspension(reason: "disk full", since: Date(timeIntervalSince1970: 50)),
|
suspension: HistorySuspension(reason: "disk full", since: Date(timeIntervalSince1970: 50)),
|
||||||
operations: [operation],
|
operations: [operation],
|
||||||
signposts: [signpost]
|
signposts: [signpost]
|
||||||
)
|
)
|
||||||
|
|
||||||
// in-progress (pinned) > read-only lock > reload breakage > one-shot write failures >
|
// 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
|
// loss rows > commit and attachment failures > passive info rows. The two info classes
|
||||||
// ends of the strip.
|
// sit at opposite ends of the strip.
|
||||||
#expect(rows.map(\.id) == [
|
#expect(rows.map(\.id) == [
|
||||||
"operation:\(operation.id.uuidString)",
|
"operation:\(operation.id.uuidString)",
|
||||||
"read-only-lock",
|
"read-only-lock",
|
||||||
"reload-breakage",
|
"reload-breakage",
|
||||||
"one-shot:\(move.id.uuidString)",
|
"one-shot:\(move.id.uuidString)",
|
||||||
|
"loss:\(loss.id.uuidString)",
|
||||||
"history-suspension",
|
"history-suspension",
|
||||||
"one-shot:\(attachment.id.uuidString)",
|
"one-shot:\(attachment.id.uuidString)",
|
||||||
"signpost:\(signpost.id.uuidString)",
|
"signpost:\(signpost.id.uuidString)",
|
||||||
])
|
])
|
||||||
#expect(rows.map(\.tone) == [.info, .error, .error, .error, .warning, .error, .info])
|
#expect(rows.map(\.tone) == [.info, .error, .error, .error, .warning, .warning, .error, .info])
|
||||||
#expect(rows.map(\.isPinned) == [true, false, false, false, false, false, false],
|
#expect(rows.map(\.isPinned) == [true, false, false, false, false, false, false, false],
|
||||||
"a spinner may never hide behind '+N more' — nothing else is pinned")
|
"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 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)"],
|
#expect(rows.map(\.id) == ["one-shot:\(style.id.uuidString)", "one-shot:\(attachment.id.uuidString)"],
|
||||||
"precedence class outranks recency; recency only orders within a class")
|
"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")
|
@Test("One-shots order newest first within their class")
|
||||||
func oneShotsAreNewestFirst() {
|
func oneShotsAreNewestFirst() {
|
||||||
let oldest = OneShotBanner(error: error(.move(title: "A")), occurredAt: Date(timeIntervalSince1970: 1))
|
let oldest = OneShotBanner(error: error(.move(title: "A")), occurredAt: Date(timeIntervalSince1970: 1))
|
||||||
@@ -136,6 +173,7 @@ struct BannerCenterOrderingTests {
|
|||||||
lock: nil,
|
lock: nil,
|
||||||
breakage: nil,
|
breakage: nil,
|
||||||
oneShots: [middle, oldest, newest],
|
oneShots: [middle, oldest, newest],
|
||||||
|
losses: [],
|
||||||
suspension: nil,
|
suspension: nil,
|
||||||
operations: []
|
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")
|
@Test("Failures sharing a timestamp keep the order they were posted in")
|
||||||
func tiesAreStable() {
|
func tiesAreStable() {
|
||||||
let center = BannerCenter()
|
let center = BannerCenter()
|
||||||
@@ -160,6 +220,7 @@ struct BannerCenterOrderingTests {
|
|||||||
lock: nil,
|
lock: nil,
|
||||||
breakage: nil,
|
breakage: nil,
|
||||||
oneShots: center.oneShots,
|
oneShots: center.oneShots,
|
||||||
|
losses: [],
|
||||||
suspension: nil,
|
suspension: nil,
|
||||||
operations: []
|
operations: []
|
||||||
)
|
)
|
||||||
@@ -169,7 +230,7 @@ struct BannerCenterOrderingTests {
|
|||||||
|
|
||||||
@Test("Nothing standing is an empty strip")
|
@Test("Nothing standing is an empty strip")
|
||||||
func quietBoardHasNoRows() {
|
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,
|
lock: .bracketedReloadFailed,
|
||||||
breakage: BoardLoadError(path: ".", reason: .boardRootMissingIndex),
|
breakage: BoardLoadError(path: ".", reason: .boardRootMissingIndex),
|
||||||
oneShots: center.oneShots,
|
oneShots: center.oneShots,
|
||||||
|
losses: [],
|
||||||
suspension: HistorySuspension(reason: "disk full"),
|
suspension: HistorySuspension(reason: "disk full"),
|
||||||
operations: [InProgressOperation(label: "Pulling…")]
|
operations: [InProgressOperation(label: "Pulling…")]
|
||||||
)
|
)
|
||||||
@@ -212,6 +274,47 @@ struct BannerCenterLifecycleTests {
|
|||||||
#expect(center.operations.count == 1)
|
#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")
|
@Test("Suspending history raises a warning row; clearing it takes the row away")
|
||||||
func historySuspensionIsAHealingCondition() throws {
|
func historySuspensionIsAHealingCondition() throws {
|
||||||
let center = BannerCenter()
|
let center = BannerCenter()
|
||||||
@@ -221,7 +324,9 @@ struct BannerCenterLifecycleTests {
|
|||||||
let suspension = try #require(center.historySuspension)
|
let suspension = try #require(center.historySuspension)
|
||||||
#expect(suspension.reason == "the repository is corrupt")
|
#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.count == 1)
|
||||||
#expect(rows[0].tone == .warning, "the files are safe; only the undo trail is degraded")
|
#expect(rows[0].tone == .warning, "the files are safe; only the undo trail is degraded")
|
||||||
#expect(rows[0].headline.contains("history"))
|
#expect(rows[0].headline.contains("history"))
|
||||||
@@ -229,7 +334,9 @@ struct BannerCenterLifecycleTests {
|
|||||||
|
|
||||||
center.clearHistorySuspension()
|
center.clearHistorySuspension()
|
||||||
#expect(center.historySuspension == nil)
|
#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")
|
@Test("Re-suspending keeps the original start and takes the newer diagnosis")
|
||||||
@@ -250,7 +357,9 @@ struct BannerCenterLifecycleTests {
|
|||||||
let center = BannerCenter()
|
let center = BannerCenter()
|
||||||
let id = center.beginOperation(label: "Pulling…", cancel: nil)
|
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.count == 1)
|
||||||
#expect(rows[0].tone == .info)
|
#expect(rows[0].tone == .info)
|
||||||
#expect(rows[0].headline == "Pulling…")
|
#expect(rows[0].headline == "Pulling…")
|
||||||
@@ -261,7 +370,9 @@ struct BannerCenterLifecycleTests {
|
|||||||
#expect(!operation.isCancelable, "git brackets get no Cancel — settled")
|
#expect(!operation.isCancelable, "git brackets get no Cancel — settled")
|
||||||
|
|
||||||
center.endOperation(id)
|
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)
|
#expect(rows.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,6 +401,7 @@ struct BannerCenterLifecycleTests {
|
|||||||
lock: nil,
|
lock: nil,
|
||||||
breakage: nil,
|
breakage: nil,
|
||||||
oneShots: center.oneShots,
|
oneShots: center.oneShots,
|
||||||
|
losses: [],
|
||||||
suspension: nil,
|
suspension: nil,
|
||||||
operations: center.operations,
|
operations: center.operations,
|
||||||
signposts: center.signposts
|
signposts: center.signposts
|
||||||
@@ -315,6 +427,7 @@ struct BannerCenterLifecycleTests {
|
|||||||
lock: nil,
|
lock: nil,
|
||||||
breakage: nil,
|
breakage: nil,
|
||||||
oneShots: center.oneShots,
|
oneShots: center.oneShots,
|
||||||
|
losses: [],
|
||||||
suspension: nil,
|
suspension: nil,
|
||||||
operations: center.operations
|
operations: center.operations
|
||||||
)
|
)
|
||||||
@@ -444,6 +557,15 @@ struct BannerCenterPhrasingTests {
|
|||||||
#expect(BannerCenter.headline(for: HistorySuspension(reason: ""))
|
#expect(BannerCenter.headline(for: HistorySuspension(reason: ""))
|
||||||
== "Changes aren't being recorded to history")
|
== "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
|
// MARK: - Store integration
|
||||||
@@ -514,6 +636,7 @@ struct BannerCenterStoreTests {
|
|||||||
|
|
||||||
store.enterUnwritableLock()
|
store.enterUnwritableLock()
|
||||||
store.banners.post(BoardWriteError(operation: .createCard, path: "/x", reason: .io(message: "the disk is full")))
|
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.suspendHistory(reason: "the disk is full")
|
||||||
store.banners.beginOperation(label: "Duplicating…", cancel: nil)
|
store.banners.beginOperation(label: "Duplicating…", cancel: nil)
|
||||||
store.banners.postSignpost("This card changed on the remote")
|
store.banners.postSignpost("This card changed on the remote")
|
||||||
@@ -522,6 +645,7 @@ struct BannerCenterStoreTests {
|
|||||||
"operation:\(store.banners.operations[0].id.uuidString)",
|
"operation:\(store.banners.operations[0].id.uuidString)",
|
||||||
"read-only-lock",
|
"read-only-lock",
|
||||||
"one-shot:\(store.banners.oneShots[0].id.uuidString)",
|
"one-shot:\(store.banners.oneShots[0].id.uuidString)",
|
||||||
|
"loss:\(store.banners.losses[0].id.uuidString)",
|
||||||
"history-suspension",
|
"history-suspension",
|
||||||
"signpost:\(store.banners.signposts[0].id.uuidString)",
|
"signpost:\(store.banners.signposts[0].id.uuidString)",
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -764,6 +764,29 @@ struct DegradedPasteBannerTests {
|
|||||||
func postingNothing() {
|
func postingNothing() {
|
||||||
let center = BannerCenter()
|
let center = BannerCenter()
|
||||||
center.postDegradedPaste([])
|
center.postDegradedPaste([])
|
||||||
|
#expect(center.losses.isEmpty)
|
||||||
#expect(center.signposts.isEmpty)
|
#expect(center.signposts.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("A degraded paste lands in the loss class, not the signpost class")
|
||||||
|
@MainActor
|
||||||
|
func postingLandsAsALossRow() {
|
||||||
|
// Settled 2026-07-28 (DESIGN/02-architecture.md § The banner surface, "Loss rows"): the
|
||||||
|
// degraded paste retoned from a signpost onto the new warning-tone loss class — content
|
||||||
|
// that didn't arrive though nothing failed, ranking below the true failures and above the
|
||||||
|
// ambient notices rather than at the bottom of the strip.
|
||||||
|
let center = BannerCenter()
|
||||||
|
center.postDegradedPaste([.init(title: "Fix login", attachments: 3)])
|
||||||
|
|
||||||
|
#expect(center.losses.count == 1)
|
||||||
|
#expect(center.losses.first?.message == "Pasted 'Fix login' without its 3 attachments")
|
||||||
|
#expect(center.signposts.isEmpty, "the degraded paste no longer posts a signpost")
|
||||||
|
|
||||||
|
let rows = BannerCenter.rows(
|
||||||
|
lock: nil, breakage: nil, oneShots: [], losses: center.losses, suspension: nil, operations: []
|
||||||
|
)
|
||||||
|
#expect(rows.count == 1)
|
||||||
|
#expect(rows[0].tone == .warning)
|
||||||
|
#expect(rows[0].dismissID == center.losses.first?.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -494,7 +494,7 @@ struct PasteFallbackTests {
|
|||||||
target.select([destinationLane], liveness: .live)
|
target.select([destinationLane], liveness: .live)
|
||||||
await harness.clipboard.paste(into: target)?.value
|
await harness.clipboard.paste(into: target)?.value
|
||||||
|
|
||||||
#expect(target.banners.signposts.map(\.message) == ["Pasted 'First' without its 2 attachments"])
|
#expect(target.banners.losses.map(\.message) == ["Pasted 'First' without its 2 attachments"])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A fallback that lost nothing says nothing")
|
@Test("A fallback that lost nothing says nothing")
|
||||||
@@ -518,7 +518,7 @@ struct PasteFallbackTests {
|
|||||||
await harness.clipboard.paste(into: target)?.value
|
await harness.clipboard.paste(into: target)?.value
|
||||||
|
|
||||||
#expect(try pastedTitles(destinationLane, in: destination) == ["Resident", "Second"])
|
#expect(try pastedTitles(destinationLane, in: destination) == ["Resident", "Second"])
|
||||||
#expect(target.banners.signposts.isEmpty)
|
#expect(target.banners.losses.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A lane's fallback materializes its embedded cards")
|
@Test("A lane's fallback materializes its embedded cards")
|
||||||
@@ -544,7 +544,7 @@ struct PasteFallbackTests {
|
|||||||
// The two live cards, and not the tombstoned third.
|
// The two live cards, and not the tombstoned third.
|
||||||
#expect(arrived.cards.count == 2)
|
#expect(arrived.cards.count == 2)
|
||||||
#expect(Set(arrived.cards.compactMap(\.title.value)) == ["First", "Second"])
|
#expect(Set(arrived.cards.compactMap(\.title.value)) == ["First", "Second"])
|
||||||
#expect(target.banners.signposts.map(\.message) == ["Pasted 'Todo' without its 2 attachments"])
|
#expect(target.banners.losses.map(\.message) == ["Pasted 'Todo' without its 2 attachments"])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A trash-sourced fallback still strips `deleted:` at materialization")
|
@Test("A trash-sourced fallback still strips `deleted:` at materialization")
|
||||||
|
|||||||
Reference in New Issue
Block a user