Files
lanework/KanbanTests/ClipboardTests.swift
T
rzen 756e936291 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
2026-07-28 07:17:27 -04:00

793 lines
30 KiB
Swift

import Foundation
import Testing
@testable import Kanban
/// `ClipboardStore`'s own machinery — the manifest, the staging lifecycle, the sweep, the
/// changeCount-based takeover, and the deferred cut's arming and voiding (04-interactions.md ▸
/// Clipboard). The *writes* a paste performs live in `PasteWriteTests.swift`.
///
/// Every suite here drives a real store over a real temp board, with two things injected: a fake
/// pasteboard (so a test never races the machine's one real pasteboard, nor every other test in the
/// run) and a temp staging directory (so nothing goes near Application Support). Both seams exist
/// exactly because those two claims are the ones worth pinning.
// MARK: - Test doubles
/// The pasteboard, as a value a test can shove around.
///
/// `changeCount` behaves the way `NSPasteboard`'s does — a machine-wide counter that anyone's write
/// bumps — because that is the whole basis of takeover detection, and a double that only counted
/// *our* writes would make the interesting case untestable.
@MainActor
final class FakePasteboard: ClipboardPasteboard {
private(set) var changeCount = 0
private(set) var text: String?
private var data: Data?
func manifestData() -> Data? { data }
@discardableResult
func write(manifest: Data, text: String) -> Int {
changeCount += 1
data = manifest
self.text = text
return changeCount
}
/// Another app copied: ownership moves, our type is gone, the counter advanced.
func takeOver() {
changeCount += 1
data = nil
text = nil
}
}
// MARK: - Fixtures
let clipboardLane1 = ItemID(rawValue: Ident.lane1)
let clipboardLane2 = ItemID(rawValue: Ident.lane2)
let clipboardCard1 = ItemID(rawValue: Ident.card1)
let clipboardCard2 = ItemID(rawValue: Ident.card2)
let clipboardCard3 = ItemID(rawValue: Ident.card3)
let clipboardCard4 = ItemID(rawValue: Ident.card4)
func tombstonedItem(order: String, title: String) -> String {
"""
---
schema: 1
title: \(title)
order: \(order)
created: 2026-01-01T09:00:00Z
deleted: 2026-03-03T09:00:00Z
---
\(title) body.
"""
}
/// Two lanes: `lane1` holds three live cards and one tombstoned one, `lane2` holds a single card.
/// `card1` carries two attachments, which is what makes "the snapshot travels whole" and "the
/// fallback lost exactly two files" both assertable.
@MainActor
func makeClipboardBoard() 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.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/photo.png", Data("png bytes".utf8))
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/notes.txt", Data("notes".utf8))
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
try fixture.item("\(Ident.lane1)/\(Ident.card3)", tombstonedItem(order: "3072", title: "Trashed"))
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "1024", title: "Fourth"))
return fixture
}
/// A store plus the two seams, torn down together.
@MainActor
struct ClipboardHarness {
let fixture: WriterFixture
let staging: URL
let pasteboard: FakePasteboard
let clipboard: ClipboardStore
let store: BoardStore
init(fixture: WriterFixture) throws {
self.fixture = fixture
staging = FileManager.default.temporaryDirectory
.appendingPathComponent("ClipboardTests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true)
pasteboard = FakePasteboard()
clipboard = ClipboardStore(pasteboard: pasteboard, stagingRoot: staging, observesActivation: false)
store = try BoardStore(rootURL: fixture.root)
}
func tearDown() {
try? FileManager.default.removeItem(at: staging)
fixture.tearDown()
}
/// The staged copy directories, sorted — "at most the current copy" is a claim about this list.
func stagedCopyIDs() throws -> [String] {
try FileManager.default.contentsOfDirectory(atPath: staging.path).sorted()
}
}
@MainActor
func makeClipboardHarness() throws -> ClipboardHarness {
try ClipboardHarness(fixture: try makeClipboardBoard())
}
// MARK: - The manifest
@Suite("ClipboardManifest")
struct ClipboardManifestTests {
private func entry(_ id: String) -> ClipboardManifest.Entry {
ClipboardManifest.Entry(
id: id,
folder: id,
title: "First",
index: "---\nschema: 1\n---\nbody\n",
attachmentCount: 2
)
}
@Test("A manifest round-trips through JSON")
func roundTrip() throws {
let manifest = ClipboardManifest(
copyID: "abc",
boardRoot: URL(fileURLWithPath: "/tmp/Board.kanban", isDirectory: true),
kind: .card,
side: .live,
entries: [entry(Ident.card1)]
)
let data = try #require(manifest.encoded())
#expect(ClipboardManifest(data: data) == manifest)
}
@Test("A manifest from a future version is refused rather than half-read")
func futureVersion() throws {
var manifest = ClipboardManifest(
copyID: "abc",
boardRoot: URL(fileURLWithPath: "/tmp/B", isDirectory: true),
kind: .card,
side: .live,
entries: [entry(Ident.card1)]
)
manifest.version = ClipboardManifest.currentVersion + 1
let data = try #require(manifest.encoded())
#expect(ClipboardManifest(data: data) == nil)
}
@Test("An entryless manifest is nothing to paste")
func empty() throws {
let manifest = ClipboardManifest(
copyID: "abc",
boardRoot: URL(fileURLWithPath: "/tmp/B", isDirectory: true),
kind: .card,
side: .live,
entries: []
)
let data = try #require(manifest.encoded())
#expect(ClipboardManifest(data: data) == nil)
}
@Test("A lane entry's lost-attachment count totals its cards'")
func lostAttachments() {
let lane = ClipboardManifest.Entry(
id: Ident.lane1,
folder: Ident.lane1,
title: "Todo",
index: "---\nschema: 1\n---\n",
attachmentCount: 0,
cards: [
.init(id: Ident.card1, title: "First", index: "a", attachmentCount: 2),
.init(id: Ident.card2, title: "Second", index: "b", attachmentCount: 1),
]
)
#expect(lane.lostAttachmentCount == 3)
}
@Test("The plain-text rendering is the titles, untitled items rendered as the board renders them")
func plainText() {
var titled = entry(Ident.card1)
var untitled = entry(Ident.card2)
untitled.title = nil
titled.title = "First"
let manifest = ClipboardManifest(
copyID: "abc",
boardRoot: URL(fileURLWithPath: "/tmp/B", isDirectory: true),
kind: .card,
side: .live,
entries: [titled, untitled]
)
#expect(manifest.plainText == "First\nUntitled")
}
}
// MARK: - Copy and staging
@MainActor
@Suite("ClipboardStore ▸ copy")
struct ClipboardCopyTests {
@Test("A copy stages the whole card folder, attachments and all")
func stagesAttachments() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled()
let manifest = try #require(harness.clipboard.payload)
let staged = harness.staging
.appendingPathComponent(manifest.copyID, isDirectory: true)
.appendingPathComponent(Ident.card1, isDirectory: true)
#expect(FileManager.default.fileExists(atPath: staged.appendingPathComponent("index.md").path))
#expect(try Data(contentsOf: staged.appendingPathComponent("attachments/photo.png"))
== Data("png bytes".utf8))
#expect(try Data(contentsOf: staged.appendingPathComponent("attachments/notes.txt"))
== Data("notes".utf8))
}
@Test("The manifest records identity, side, kind and the source board root")
func manifestShape() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1, clipboardCard2], liveness: .live)
harness.clipboard.copy(from: harness.store)
let manifest = try #require(harness.clipboard.payload)
#expect(manifest.kind == .card)
#expect(manifest.side == .live)
#expect(manifest.rootURL.path == harness.fixture.root.path)
// Flatten order — lane `order`, then card `order`.
#expect(manifest.entries.map(\.id) == [Ident.card1, Ident.card2])
#expect(manifest.entries.map(\.title) == ["First", "Second"])
#expect(manifest.entries[0].attachmentCount == 2)
#expect(manifest.entries[1].attachmentCount == 0)
#expect(harness.pasteboard.text == "First\nSecond")
}
@Test("The embedded index text is the file's bytes, verbatim")
func embeddedIndexIsSourceBytes() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live)
harness.clipboard.copy(from: harness.store)
let manifest = try #require(harness.clipboard.payload)
let onDisk = try harness.fixture.indexText("\(Ident.lane1)/\(Ident.card1)")
#expect(manifest.entries[0].index == onDisk)
}
@Test("A lane copy embeds its live cards and leaves the tombstoned one out")
func laneEntryEmbedsLiveCards() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardLane1], liveness: .live)
harness.clipboard.copy(from: harness.store)
let manifest = try #require(harness.clipboard.payload)
#expect(manifest.kind == .lane)
#expect(manifest.entries.map(\.id) == [Ident.lane1])
#expect(manifest.entries[0].cards.map(\.id) == [Ident.card1, Ident.card2])
#expect(manifest.entries[0].lostAttachmentCount == 2)
}
@Test("A trashed selection copies out, side recorded")
func trashedSide() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.transient.isTrashVisible = true
harness.store.select([clipboardCard3], liveness: .trashed)
harness.clipboard.copy(from: harness.store)
let manifest = try #require(harness.clipboard.payload)
#expect(manifest.side == .trashed)
#expect(manifest.entries.map(\.id) == [Ident.card3])
}
@Test("An empty selection copies nothing and leaves the pasteboard alone")
func emptySelection() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.clipboard.copy(from: harness.store)
#expect(harness.pasteboard.changeCount == 0)
#expect(harness.clipboard.payload == nil)
}
@Test("The store holds at most the current copy — a second copy sweeps the first")
func atMostTheCurrentCopy() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled()
let first = try #require(harness.clipboard.payload?.copyID)
harness.store.select([clipboardCard2], liveness: .live)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled()
let second = try #require(harness.clipboard.payload?.copyID)
#expect(first != second)
#expect(try harness.stagedCopyIDs() == [second])
}
}
// MARK: - The sweep
@MainActor
@Suite("ClipboardStore ▸ sweep")
struct ClipboardSweepTests {
@Test("A launch sweep collects every tree the pasteboard no longer names")
func launchSweepPurgesOrphans() async throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let staging = FileManager.default.temporaryDirectory
.appendingPathComponent("ClipboardTests-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: staging) }
// Two trees from a previous launch, and a pasteboard that names neither.
for orphan in ["one", "two"] {
try FileManager.default.createDirectory(
at: staging.appendingPathComponent(orphan, isDirectory: true),
withIntermediateDirectories: true
)
}
let clipboard = ClipboardStore(
pasteboard: FakePasteboard(),
stagingRoot: staging,
observesActivation: false
)
await clipboard.stagingSettled()
#expect(try FileManager.default.contentsOfDirectory(atPath: staging.path).isEmpty)
}
@Test("A sweep keeps the tree the pasteboard still names")
func sweepKeepsTheCurrentCopy() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
try FileManager.default.createDirectory(
at: harness.staging.appendingPathComponent("stale", isDirectory: true),
withIntermediateDirectories: true
)
harness.store.select([clipboardCard1], liveness: .live)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled()
let current = try #require(harness.clipboard.payload?.copyID)
#expect(try harness.stagedCopyIDs() == [current])
}
@Test("A takeover makes our own tree an orphan, and the next sweep collects it")
func takeoverOrphansOurSnapshot() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled()
#expect(try harness.stagedCopyIDs().count == 1)
harness.pasteboard.takeOver()
harness.clipboard.sweep()
await harness.clipboard.stagingSettled()
#expect(try harness.stagedCopyIDs().isEmpty)
}
}
// MARK: - Takeover
@MainActor
@Suite("ClipboardStore ▸ takeover")
struct ClipboardTakeoverTests {
@Test("A changeCount that moved without us is a takeover: the payload goes")
func payloadClears() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live)
harness.clipboard.copy(from: harness.store)
#expect(harness.clipboard.payload != nil)
harness.pasteboard.takeOver()
harness.clipboard.refresh()
#expect(harness.clipboard.payload == nil)
#expect(harness.clipboard.canPaste(into: harness.store) == false)
}
@Test("An unchanged changeCount is one read and no decode")
func refreshIsGuarded() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live)
harness.clipboard.copy(from: harness.store)
let before = harness.pasteboard.changeCount
harness.clipboard.refresh()
harness.clipboard.refresh()
// Nothing was written, so nothing moved — and the payload survived the two refreshes intact.
#expect(harness.pasteboard.changeCount == before)
#expect(harness.clipboard.payload != nil)
}
@Test("A takeover voids an armed cut and undims its items")
func takeoverVoidsTheCut() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live)
harness.clipboard.cut(from: harness.store)
#expect(harness.store.transient.pendingCut.ids == [clipboardCard1])
harness.pasteboard.takeOver()
harness.clipboard.refresh()
#expect(harness.store.transient.pendingCut.isEmpty)
}
}
// MARK: - Cut arming
@MainActor
@Suite("ClipboardStore ▸ cut")
struct ClipboardCutTests {
@Test("A cut arms the source board's pending set, in flatten order membership")
func armsPendingCut() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1, clipboardCard4], liveness: .live)
harness.clipboard.cut(from: harness.store)
#expect(harness.store.transient.pendingCut.ids == [clipboardCard1, clipboardCard4])
#expect(harness.store.transient.pendingCut.liveness == .live)
}
@Test("A second copy voids the pending cut — its pasteboard entry has been overwritten")
func copyVoidsTheCut() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live)
harness.clipboard.cut(from: harness.store)
#expect(!harness.store.transient.pendingCut.isEmpty)
harness.store.select([clipboardCard2], liveness: .live)
harness.clipboard.copy(from: harness.store)
#expect(harness.store.transient.pendingCut.isEmpty)
}
@Test("Deletion voids per item: a tombstoned cut member leaves the pending set on reload")
func deletionVoidsPerItem() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1, clipboardCard2], liveness: .live)
harness.clipboard.cut(from: harness.store)
harness.store.delete([clipboardCard1])
harness.store.handleWatcherEvent(.treeChanged(.appMediated))
await harness.store.awaitQuiescence()
#expect(harness.store.transient.pendingCut.ids == [clipboardCard2])
}
}
// MARK: - Availability
@MainActor
@Suite("ClipboardStore ▸ availability")
struct ClipboardAvailabilityTests {
@Test("Copy needs a selection that names something the board renders")
func copyNeedsASelection() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
#expect(harness.clipboard.canCopy(from: harness.store) == false)
harness.store.select([clipboardCard1], liveness: .live)
#expect(harness.clipboard.canCopy(from: harness.store))
}
@Test("Copy works on a trashed selection; cut does not")
func trashIsCopyOutOnly() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.transient.isTrashVisible = true
harness.store.select([clipboardCard3], liveness: .trashed)
#expect(harness.clipboard.canCopy(from: harness.store))
#expect(harness.clipboard.canCut(from: harness.store) == false)
}
@Test("The read-only lock blocks cut but never copy")
func lockBlocksCutOnly() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live)
harness.store.enterVanishedRootLock()
#expect(harness.clipboard.canCopy(from: harness.store))
#expect(harness.clipboard.canCut(from: harness.store) == false)
#expect(harness.clipboard.canPaste(into: harness.store) == false)
}
@Test("An open inline editor closes all three — the focused-editor rule")
func focusedEditorClosesEverything() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live)
harness.clipboard.copy(from: harness.store)
harness.store.transient.beginRename(of: clipboardCard1, currentTitle: "First")
#expect(harness.clipboard.canCopy(from: harness.store) == false)
#expect(harness.clipboard.canCut(from: harness.store) == false)
#expect(harness.clipboard.canPaste(into: harness.store) == false)
}
@Test("Paste needs a payload")
func pasteNeedsAPayload() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
#expect(harness.clipboard.canPaste(into: harness.store) == false)
harness.store.select([clipboardCard1], liveness: .live)
harness.clipboard.copy(from: harness.store)
#expect(harness.clipboard.canPaste(into: harness.store))
}
@Test("On a zero-lane board a card payload disables paste and a lane payload does not")
func zeroLaneBoard() async throws {
let source = try makeClipboardBoard()
defer { source.tearDown() }
let empty = try WriterFixture()
defer { empty.tearDown() }
try empty.item("", Item.board)
let staging = FileManager.default.temporaryDirectory
.appendingPathComponent("ClipboardTests-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: staging) }
let clipboard = ClipboardStore(
pasteboard: FakePasteboard(),
stagingRoot: staging,
observesActivation: false
)
let sourceStore = try BoardStore(rootURL: source.root)
let emptyStore = try BoardStore(rootURL: empty.root)
sourceStore.select([clipboardCard1], liveness: .live)
clipboard.copy(from: sourceStore)
#expect(clipboard.canPaste(into: emptyStore) == false)
sourceStore.select([clipboardLane1], liveness: .live)
clipboard.copy(from: sourceStore)
#expect(clipboard.canPaste(into: emptyStore))
}
}
// MARK: - The paste anchors
@MainActor
@Suite("PasteTarget")
struct PasteTargetTests {
private func snapshot(_ fixture: WriterFixture) throws -> BoardModel {
try BoardLoader.load(boardRoot: fixture.root).model
}
@Test("A card payload lands after the anchor card")
func afterTheAnchorCard() throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let model = try snapshot(fixture)
let target = PasteTarget.cards(
selection: ItemReferenceSet(ids: [clipboardCard1], liveness: .live),
lastActiveLaneID: nil,
snapshot: model
)
#expect(target == PasteTarget.Cards(laneID: clipboardLane1, index: 1))
}
@Test("A selected lane appends to its bottom")
func appendsToASelectedLane() throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let model = try snapshot(fixture)
let target = PasteTarget.cards(
selection: ItemReferenceSet(ids: [clipboardLane1], liveness: .live),
lastActiveLaneID: nil,
snapshot: model
)
// Two rendered cards — the tombstoned third is not in the layout.
#expect(target == PasteTarget.Cards(laneID: clipboardLane1, index: 2))
}
@Test("A multi-selection anchors at its last member in flatten order")
func flattenOrderAnchor() throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let model = try snapshot(fixture)
let target = PasteTarget.cards(
selection: ItemReferenceSet(ids: [clipboardCard4, clipboardCard1], liveness: .live),
lastActiveLaneID: nil,
snapshot: model
)
// `card4` is in the second lane, so it is last in flatten order however the set is spelled.
#expect(target == PasteTarget.Cards(laneID: clipboardLane2, index: 1))
}
@Test("A tombstoned selection never anchors: it behaves as nothing selected")
func tombstonedSelectionNeverAnchors() throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let model = try snapshot(fixture)
let target = PasteTarget.cards(
selection: ItemReferenceSet(ids: [clipboardCard3], liveness: .trashed),
lastActiveLaneID: clipboardLane2,
snapshot: model
)
// The last-active lane, appended — never `card3`'s live disk-lane.
#expect(target == PasteTarget.Cards(laneID: clipboardLane2, index: 1))
}
@Test("Nothing selected and no last-active lane falls back to the first lane")
func firstLaneFallback() throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let model = try snapshot(fixture)
let target = PasteTarget.cards(selection: .empty, lastActiveLaneID: nil, snapshot: model)
#expect(target == PasteTarget.Cards(laneID: clipboardLane1, index: 2))
}
@Test("A zero-lane board has no card target at all")
func zeroLaneBoardHasNoCardTarget() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
let model = try snapshot(fixture)
#expect(PasteTarget.cards(selection: .empty, lastActiveLaneID: nil, snapshot: model) == nil)
}
@Test("A lane payload lands after the anchor lane")
func afterTheAnchorLane() throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let model = try snapshot(fixture)
#expect(PasteTarget.lanes(
selection: ItemReferenceSet(ids: [clipboardLane1], liveness: .live),
snapshot: model
) == 1)
}
@Test("A selected card names its lane for a lane paste")
func aSelectedCardNamesItsLane() throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let model = try snapshot(fixture)
#expect(PasteTarget.lanes(
selection: ItemReferenceSet(ids: [clipboardCard1], liveness: .live),
snapshot: model
) == 1)
}
@Test("Nothing (or something tombstoned) selected lands a lane at the board's right end")
func rightEnd() throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let model = try snapshot(fixture)
#expect(PasteTarget.lanes(selection: .empty, snapshot: model) == 2)
#expect(PasteTarget.lanes(
selection: ItemReferenceSet(ids: [clipboardCard3], liveness: .trashed),
snapshot: model
) == 2)
}
@Test("A zero-lane board still has a lane slot — position zero")
func zeroLaneBoardStillTakesALane() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
let model = try BoardLoader.load(boardRoot: fixture.root).model
#expect(PasteTarget.lanes(selection: .empty, snapshot: model) == 0)
}
}
// MARK: - The degraded paste's phrasing
@Suite("BannerCenter ▸ degraded paste")
struct DegradedPasteBannerTests {
@Test("04's own example sentence")
func theExampleSentence() {
#expect(BannerCenter.degradedPasteMessage(
for: [.init(title: "Fix login", attachments: 3)]
) == "Pasted 'Fix login' without its 3 attachments")
}
@Test("One attachment is singular")
func singular() {
#expect(BannerCenter.degradedPasteMessage(
for: [.init(title: "Fix login", attachments: 1)]
) == "Pasted 'Fix login' without its attachment")
}
@Test("An untitled item is 'the item', never the Untitled rendering")
func untitled() {
#expect(BannerCenter.degradedPasteMessage(
for: [.init(title: nil, attachments: 2)]
) == "Pasted the item without its 2 attachments")
}
@Test("Several items total their attachments rather than listing titles")
func several() {
#expect(BannerCenter.degradedPasteMessage(
for: [.init(title: "A", attachments: 2), .init(title: "B", attachments: 3)]
) == "Pasted 2 items without their 5 attachments")
}
@Test("Nothing lost says nothing")
func nothingLost() {
#expect(BannerCenter.degradedPasteMessage(for: []) == nil)
#expect(BannerCenter.degradedPasteMessage(for: [.init(title: "A", attachments: 0)]) == nil)
}
@Test("Posting an empty loss list adds no row")
@MainActor
func postingNothing() {
let center = BannerCenter()
center.postDegradedPaste([])
#expect(center.losses.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)
}
}