Files
lanework/KanbanTests/ClipboardTests.swift
T
rzen 72ca22251b A card's context menu finds its four groups — style, clipboard, navigation, and the trash
CardFaceView.boardMenu/boardActions restructured to the owner's shape (card
fe66c461): Open/Copy Link/Rename/Style▸(Symbol,Color), a divider, then
Copy/Cut/Paste/Paste Special▸(Paste Image into Card), a divider, then
Navigation▸(Move Left,Move Right), a divider, then Send to Trash. Every row
routes through existing machinery — no new commands.

ClipboardStore gains copy(from:targeting:)/cut(from:targeting:) and their
canCopy/canCut twins, so Copy and Cut can widen to the clicked card exactly
as Delete and Style already do ("right-clicking something outside the
selection acts on what was clicked"), without disturbing the Edit-menu path.

LaneMoveTarget.destination is extracted out of MoveLaneCommands so the
card menu's Navigation rows validate against the identical sole-live-lane
predicate as Board ▸ Move Left/Right. Since a card id can never itself
satisfy that predicate, the two rows are wired to the real store call but
unconditionally disabled — reading the live selection per card face would
reproduce the O(board) render regression isSelected/selectedCount exist to
prevent (contextMenu's builder is not lazy).

Style ▸ Symbol and ▸ Color both open the one existing style popover — no
per-section pre-focus (StyleEditorSession has no such concept, and
StyleEditorView internals are out of scope while another pass redesigns
the pickers). Paste and Paste Image into Card reduce their .disabled
checks to selection/snapshot-free forms, proven safe by construction (a
rendered card face already guarantees a live lane / a live board card).

Journaled on the card: Copy Link kept (shipped same day, not in the
owner's list), "Delete" relabeled "Send to Trash" (board-side move, not
the permanent trash delete), quick-style recents row dropped from this
menu, Navigation's always-disabled rows, and Paste not retargeting to the
clicked card — all flagged needs owner review. DESIGN/11-command-nexus.md's
Card row is owed a rewrite, left for the main session.

Tests: LaneMoveTarget.destination (new), targeted copy/cut (new), plus
existing ClipboardStore/PasteTarget/MoveLane/CopyLink/Trash-menu/PasteImage/
PasteFile/Style/CaretChord/render-performance/equatable-gate suites —
156 tests, all passing.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-09 09:44:30 -04:00

1141 lines
48 KiB
Swift

import Foundation
import Testing
import UniformTypeIdentifiers
@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 the app's real Application Support home).
/// 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?
/// **What some other app put down**, by type — the raw material the image branch classifies
/// (`PastedImage.flavor(hasBoardItems:types:)`). Ordered so `availableTypes()` can answer in a
/// stable order, which is what makes "our preference order wins over the pasteboard's" a claim a
/// test can actually make.
private var foreign: [(type: String, data: Data)] = []
/// **A multi-item read, unlike `foreign`** — the file-URL branch's own `fileURLs()`, which reads
/// across every pasteboard item rather than the first item's types (`availableTypes()`'s
/// carve-out). `foreign`'s flat `(type, data)` list cannot represent "the same type on several
/// items", which is exactly what a multi-file Finder copy needs seeded.
private var seededFileURLs: [URL] = []
func manifestData() -> Data? { data }
@discardableResult
func write(manifest: Data, text: String) -> Int {
changeCount += 1
data = manifest
self.text = text
// A real write clears the pasteboard first, so anything another app left is gone.
foreign = []
return changeCount
}
func availableTypes() -> [String] {
(data != nil ? [UTType.laneworkClipboard.identifier] : []) + foreign.map(\.type)
}
func data(forType type: String) -> Data? {
if type == UTType.laneworkClipboard.identifier { return data }
return foreign.first { $0.type == type }?.data
}
func fileURLs() -> [URL] { seededFileURLs }
/// Another app copied: ownership moves, our type is gone, the counter advanced.
func takeOver() {
changeCount += 1
data = nil
text = nil
foreign = []
seededFileURLs = []
}
/// Another app put *these* flavors down — a screenshot, a browser's Copy Image, a Finder copy.
/// `takeOver`'s shape with a payload: the counter advances and our own type goes, because that is
/// what `clearContents()` does to it.
func seed(_ payloads: [(type: String, data: Data)]) {
takeOver()
foreign = payloads
}
/// A Finder copy of one or more **files** — every URL as its own pasteboard item, exactly as a
/// real multi-select copy is, with `public.file-url` reported through `availableTypes()` (the
/// first-item read the image branch's precedence classifies) so `carriesFileURL` sees it. `also`
/// seeds types riding beside the file URL on that same first item — an image flavor, for the
/// precedence tests where a Finder-copied image file carries both.
func seedFileURLs(_ urls: [URL], also: [(type: String, data: Data)] = []) {
takeOver()
seededFileURLs = urls
foreign = urls.isEmpty ? also : [(UTType.fileURL.identifier, Data())] + also
}
/// A foreign type layered over whatever this pasteboard already holds, counter bumped but nothing
/// cleared — a combination `write()` alone can never produce (a real copy's `clearContents()`
/// takes everything with it), but exactly what "the app's own type wins outright" needs to
/// construct to prove the guard actually fires rather than merely never being exercised
/// (`PastedImageClassificationTests.boardItemsWin`'s same synthetic shape, one layer up).
func layerForeign(_ payloads: [(type: String, data: Data)]) {
changeCount += 1
foreign = payloads
}
}
// 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)
/// The trash's **lane row** in the harness below — the opaque unit ⌘X restores (03-board-ui.md §
/// Trash, lanes rejoined 2026-07-29).
let clipboardTrashedLane = ItemID(rawValue: Ident.lane3)
/// An ordinary card body, for the trash's resident.
func trashResidentItem(order: String, title: String) -> String {
"""
---
schema: 1
title: \(title)
order: \(order)
created: 2026-01-01T09:00:00Z
---
\(title) body.
"""
}
/// Two lanes: `lane1` holds two cards, `lane2` holds a single card, and one card sits in the trash.
/// `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.lane2, Item.rich(order: "2048", title: "Doing"))
try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "1024", title: "Fourth"))
try fixture.item(".trash/\(Ident.card3)", trashResidentItem(order: "1024", title: "Trashed"))
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.
///
/// Hidden entries are excluded because the sweep keeps its own bookkeeping folder among them
/// (`ClipboardStore.prune`'s claim-then-delete). A staged copy is never hidden — its name is a
/// lowercased UUID.
func stagedCopyIDs() throws -> [String] {
try FileManager.default.contentsOfDirectory(atPath: staging.path)
.filter { !$0.hasPrefix(".") }
.sorted()
}
}
@MainActor
func makeClipboardHarness() throws -> ClipboardHarness {
try ClipboardHarness(fixture: try makeClipboardBoard())
}
/// The same board with a **lane row in its trash**, carrying one card — the clipboard's other trash
/// subject (04-interactions.md ▸ The trash: "⌘X works … a trashed lane pastes after the anchor
/// lane"). Its own fixture rather than a line in `makeClipboardBoard`, so every suite that counts the
/// trash's cards keeps counting exactly what it did.
@MainActor
func makeTrashedLaneHarness() throws -> ClipboardHarness {
let fixture = try makeClipboardBoard()
try fixture.item(
".trash/\(Ident.lane3)",
"---\nschema: 1\ntitle: Done\norder: 512\nkind: lane\nproject: lanework\n---\nDone body.\n"
)
try fixture.item("\(".trash/\(Ident.lane3)")/\(Ident.indexless)", Item.rich(order: "1024", title: "Freight"))
return try ClipboardHarness(fixture: fixture)
}
// 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,
container: .board,
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,
container: .board,
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,
container: .board,
entries: []
)
let data = try #require(manifest.encoded())
#expect(ClipboardManifest(data: data) == nil)
}
@Test("A lane entry's attachment count totals its cards'")
func totalAttachments() {
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.totalAttachmentCount == 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,
container: .board,
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], in: .board)
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], in: .board)
harness.clipboard.copy(from: harness.store)
let manifest = try #require(harness.clipboard.payload)
#expect(manifest.kind == .card)
#expect(manifest.container == .board)
#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], in: .board)
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 exactly its cards — the trash is board-level, so none is nested")
func laneEntryEmbedsItsCards() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardLane1], in: .board)
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].totalAttachmentCount == 2)
}
@Test("A trash selection copies out, container recorded")
func trashContainerRecorded() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.transient.isTrashVisible = true
harness.store.select([clipboardCard3], in: .trash)
harness.clipboard.copy(from: harness.store)
let manifest = try #require(harness.clipboard.payload)
#expect(manifest.container == .trash)
#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], in: .board)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled()
let first = try #require(harness.clipboard.payload?.copyID)
harness.store.select([clipboardCard2], in: .board)
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], in: .board)
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], in: .board)
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: Two sweeps over one store
//
// One app, so the ordinary case is one sweeper — but the sweep is written claim-then-delete
// anyway (`ClipboardStore.prune`), which is what makes a second sweeper a non-event: a second
// copy of the app launched with `open -n` shares this container, and so does the next sweep after
// a crash mid-delete. These two tests are that property's two halves: atomic removals, and
// missing-entry = already swept.
@Test("Two stores sweeping the same staging root at once agree, and neither errors")
func concurrentSweepsAgree() async throws {
let staging = FileManager.default.temporaryDirectory
.appendingPathComponent("ClipboardTests-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: staging) }
try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true)
// Six trees, each with a file in it so a removal is a real recursive delete rather than an
// empty-directory unlink — the case where two sweepers walking one tree could see it half gone.
for name in ["a", "b", "c", "d", "e", "keep"] {
let tree = staging.appendingPathComponent(name, isDirectory: true)
try FileManager.default.createDirectory(
at: tree.appendingPathComponent("nested", isDirectory: true),
withIntermediateDirectories: true
)
try Data("bytes".utf8).write(to: tree.appendingPathComponent("nested/file.txt", isDirectory: false))
}
// Both sweepers read the *same* machine-wide pasteboard, which is why both keep sets are
// `keep`. Modelled as two stores over one staging root with pasteboards holding the same
// manifest, since two processes are not something a unit test can have.
let manifest = ClipboardManifest(
copyID: "keep",
boardRoot: URL(fileURLWithPath: "/Boards/Shared.kanban", isDirectory: true),
kind: .card,
container: .board,
// One entry, because a manifest with none is refused outright (`init?(data:)`) — and a
// refused manifest is a keep set of nothing, which is a different test.
entries: [
ClipboardManifest.Entry(
id: Ident.card1,
folder: Ident.card1,
title: "First",
index: "---\nschema: 1\ntitle: First\norder: 1024\n---\n",
attachmentCount: 0
)
]
)
let data = try #require(manifest.encoded())
let onePasteboard = FakePasteboard()
onePasteboard.write(manifest: data, text: manifest.plainText)
let otherPasteboard = FakePasteboard()
otherPasteboard.write(manifest: data, text: manifest.plainText)
// Each `init` sweeps — the launch sweep — so the two are already racing before either explicit
// call below.
let one = ClipboardStore(pasteboard: onePasteboard, stagingRoot: staging, observesActivation: false)
let other = ClipboardStore(pasteboard: otherPasteboard, stagingRoot: staging, observesActivation: false)
one.sweep()
other.sweep()
one.sweep()
await one.stagingSettled()
await other.stagingSettled()
// The answer both computed, arrived at exactly once: the named tree intact, everything else
// gone, and no bookkeeping left behind.
#expect(try FileManager.default.contentsOfDirectory(atPath: staging.path).sorted() == ["keep"])
#expect(FileManager.default.fileExists(atPath: staging.appendingPathComponent("keep/nested/file.txt").path))
}
@Test("A tree that vanished before the sweep reached it is already swept, not an error")
func aVanishedEntryIsANoOp() async throws {
let staging = FileManager.default.temporaryDirectory
.appendingPathComponent("ClipboardTests-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: staging) }
try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true)
let doomed = staging.appendingPathComponent("gone", isDirectory: true)
try FileManager.default.createDirectory(at: doomed, withIntermediateDirectories: true)
let clipboard = ClipboardStore(
pasteboard: FakePasteboard(),
stagingRoot: staging,
observesActivation: false
)
// Something got there first — which from this store's side is indistinguishable from the
// directory listing simply being stale by the time it is walked.
try FileManager.default.removeItem(at: doomed)
clipboard.sweep()
await clipboard.stagingSettled()
#expect(try FileManager.default.contentsOfDirectory(atPath: staging.path).isEmpty)
// And a staging root that has gone altogether is nothing to do either, rather than a throw on
// the way to a no-op.
try FileManager.default.removeItem(at: staging)
clipboard.sweep()
await clipboard.stagingSettled()
#expect(!FileManager.default.fileExists(atPath: staging.path), "a vanished store is not recreated by a sweep")
}
}
// 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], in: .board)
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], in: .board)
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], in: .board)
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], in: .board)
harness.clipboard.cut(from: harness.store)
#expect(harness.store.transient.pendingCut.ids == [clipboardCard1, clipboardCard4])
#expect(harness.store.transient.pendingCut.container == .board)
}
@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], in: .board)
harness.clipboard.cut(from: harness.store)
#expect(!harness.store.transient.pendingCut.isEmpty)
harness.store.select([clipboardCard2], in: .board)
harness.clipboard.copy(from: harness.store)
#expect(harness.store.transient.pendingCut.isEmpty)
}
@Test("Deletion voids per item: a deleted cut member leaves the pending set on reload")
func deletionVoidsPerItem() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1, clipboardCard2], in: .board)
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: - Targeted copy/cut (the card context menu's own reading)
/// `copy(from:targeting:)`/`cut(from:targeting:)` — the card context menu's Copy/Cut
/// (`CardFaceView`, 2026-08-09 ▸ "redesign context menu for cards"), Delete's widening rule
/// (`targetIDs`) extended to the clipboard for the first time. The interesting claim these pin: the
/// explicit *target* wins over whatever the store's live selection happens to be — the
/// `TrashWriteTests.contextMenuDeleteIgnoresTheSelection` precedent, one type over.
@MainActor
@Suite("ClipboardStore ▸ targeted copy/cut")
struct ClipboardTargetedCopyCutTests {
@Test("A targeted copy stages and writes the target, ignoring an unrelated live selection")
func targetedCopyIgnoresTheSelection() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
// The live selection names a card the target never mentions.
harness.store.select([clipboardCard4], in: .board)
let target = ItemReferenceSet(ids: [clipboardCard1], container: .board)
harness.clipboard.copy(from: harness.store, targeting: target)
await harness.clipboard.stagingSettled()
let manifest = try #require(harness.clipboard.payload)
#expect(manifest.entries.map(\.id) == [Ident.card1])
}
@Test("A targeted cut arms the target, not the live selection")
func targetedCutArmsTheTarget() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard4], in: .board)
let target = ItemReferenceSet(ids: [clipboardCard1], container: .board)
harness.clipboard.cut(from: harness.store, targeting: target)
#expect(harness.store.transient.pendingCut.ids == [clipboardCard1])
#expect(harness.store.transient.pendingCut.container == .board)
}
@Test("A targeted copy still supports multiple members, in flatten order")
func targetedCopySupportsMultipleMembers() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard4], in: .board)
let target = ItemReferenceSet(ids: [clipboardCard1, clipboardCard2], container: .board)
harness.clipboard.copy(from: harness.store, targeting: target)
let manifest = try #require(harness.clipboard.payload)
#expect(manifest.entries.map(\.id) == [Ident.card1, Ident.card2])
}
@Test("canCopy/canCut targeting answer for the target, not the live selection")
func canCopyCanCutReadTheTarget() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
// Nothing is live-selected at all, yet a target still enables both.
let target = ItemReferenceSet(ids: [clipboardCard1], container: .board)
#expect(harness.clipboard.canCopy(from: harness.store, targeting: target))
#expect(harness.clipboard.canCut(from: harness.store, targeting: target))
}
@Test("An open inline editor closes the targeted forms too — the focused-editor rule")
func focusedEditorClosesTargetedForms() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let target = ItemReferenceSet(ids: [clipboardCard1], container: .board)
harness.store.transient.beginPlaceholder(inLane: clipboardLane1)
#expect(!harness.clipboard.canCopy(from: harness.store, targeting: target))
#expect(!harness.clipboard.canCut(from: harness.store, targeting: target))
}
}
// 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], in: .board)
#expect(harness.clipboard.canCopy(from: harness.store))
}
/// 04-interactions.md ▸ The trash, resettled 2026-07-28: "⌘X **works** (it was disabled under
/// the tombstone model): cut in the trash, paste into a lane is the keyboard-native restore, an
/// ordinary folder move."
@Test("Both copy and cut work on a trash selection — cut is the keyboard restore")
func trashTakesCopyAndCut() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.transient.isTrashVisible = true
harness.store.select([clipboardCard3], in: .trash)
#expect(harness.clipboard.canCopy(from: harness.store))
#expect(harness.clipboard.canCut(from: harness.store))
}
/// The row is a lane on the clipboard's own axis: "the payload kinds never mix because the
/// selection never does" (04-interactions.md ▸ The trash), so a cut row writes a **lane** payload
/// recorded in the **trash** container.
@Test("A trashed lane row copies and cuts, and its payload is a lane in the trash")
func trashedLaneRowTakesCopyAndCut() throws {
let harness = try makeTrashedLaneHarness()
defer { harness.tearDown() }
harness.store.transient.isTrashVisible = true
harness.store.select([clipboardTrashedLane], in: .trash)
#expect(harness.clipboard.canCopy(from: harness.store))
#expect(harness.clipboard.canCut(from: harness.store))
harness.clipboard.cut(from: harness.store)
let manifest = try #require(harness.clipboard.payload)
#expect(manifest.kind == .lane)
#expect(manifest.container == .trash)
#expect(manifest.entries.map(\.title) == ["Done"])
// The opaque unit describes itself and not its subtree: the *content* travels through the
// staged folder, which is copied whole.
#expect(manifest.entries.first?.cards.isEmpty == true)
#expect(harness.store.transient.pendingCut
== ItemReferenceSet(ids: [clipboardTrashedLane], container: .trash))
}
/// **The guard the trash's kind-blind selection moved to the exits** (04-interactions.md ▸ The
/// trash, ruled 2026-07-31): "the pasteboard's payload types are per-kind, so Cut and Copy grey
/// out via ordinary menu validation while a trash selection mixes kinds — no failed gesture, no
/// beep".
///
/// It is also what keeps `ClipboardManifest.kind` honest: the manifest names one payload type,
/// and a set spanning both never reaches the capture.
@Test("A mixed trash selection greys out both Copy and Cut")
func mixedTrashSelectionClosesCopyAndCut() throws {
let harness = try makeTrashedLaneHarness()
defer { harness.tearDown() }
harness.store.transient.isTrashVisible = true
// Each kind alone is fine — the selection is legal either way, and so is the gesture.
harness.store.select([clipboardCard3], in: .trash)
#expect(harness.clipboard.canCopy(from: harness.store))
harness.store.select([clipboardTrashedLane], in: .trash)
#expect(harness.clipboard.canCopy(from: harness.store))
// Together — a selection the grammar now allows — the exits close.
harness.store.select([clipboardCard3, clipboardTrashedLane], in: .trash)
#expect(harness.store.selection.ids.count == 2, "the selection itself is legal")
#expect(harness.clipboard.canCopy(from: harness.store) == false)
#expect(harness.clipboard.canCut(from: harness.store) == false)
// Delete is deliberately *not* gated: it works on a mixed selection, the alert counting both
// kinds (04 ▸ The trash).
#expect(TrashModel.canDelete(selection: harness.store.selection, in: harness.store.snapshot))
}
/// The live board's own mixed set cannot be built by any gesture — but the predicate answers for
/// it anyway rather than assuming, so a future caller cannot smuggle one past the exits.
@Test("The mixed-kind predicate answers for the live board too")
func mixedKindPredicateCoversTheBoard() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let snapshot = harness.store.snapshot
let laneID = try #require(snapshot.lanes.first?.id)
#expect(!SelectionGrammar.mixesKinds(
ItemReferenceSet(ids: [clipboardCard1], container: .board), in: snapshot))
#expect(SelectionGrammar.mixesKinds(
ItemReferenceSet(ids: [clipboardCard1, laneID], container: .board), in: snapshot))
// Rows the container no longer holds are ignored — a ghost must not grey out a menu item.
#expect(!SelectionGrammar.mixesKinds(
ItemReferenceSet(ids: [clipboardCard1, ItemID(rawValue: Ident.indexless)], container: .board),
in: snapshot))
#expect(!SelectionGrammar.mixesKinds(.empty, in: snapshot))
}
@Test("The read-only lock blocks cut but never copy")
func lockBlocksCutOnly() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1], in: .board)
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], in: .board)
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], in: .board)
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], in: .board)
clipboard.copy(from: sourceStore)
#expect(clipboard.canPaste(into: emptyStore) == false)
sourceStore.select([clipboardLane1], in: .board)
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], container: .board),
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], container: .board),
lastActiveLaneID: nil,
snapshot: model
)
// Two rendered cards.
#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], container: .board),
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 trash selection never anchors: it behaves as nothing selected")
func trashSelectionNeverAnchors() throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let model = try snapshot(fixture)
let target = PasteTarget.cards(
selection: ItemReferenceSet(ids: [clipboardCard3], container: .trash),
lastActiveLaneID: clipboardLane2,
snapshot: model
)
// The last-active lane, appended — the trash is never the destination.
#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], container: .board),
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], container: .board),
snapshot: model
) == 1)
}
@Test("Nothing (or a trash selection) 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], container: .trash),
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 refused paste's phrasing
/// **04-interactions.md ▸ Clipboard, re-ruled 2026-07-29** — refuse, never degrade:
///
/// > A paste whose staged snapshot is missing or unreadable refuses loudly — never degrades …
/// > the paste produces **nothing**, and a one-shot failure banner names it from the manifest's
/// > metadata ("Couldn't paste 'Fix login' — the copied content is gone").
///
/// The retired suite these replace pinned `degradedPasteMessage(for:)` and its loss row — "Pasted
/// 'Fix login' without its 3 attachments". Both are gone with the degraded materialization: nothing
/// arrives, so there is no partial arrival to account for.
@Suite("BannerCenter ▸ refused paste")
struct RefusedPasteBannerTests {
/// 04's own example sentence, composed the way every failure headline is: the action clause the
/// banner owns, an em dash, the cause.
@Test("04's own example sentence")
@MainActor
func theExampleSentence() {
let center = BannerCenter()
center.postRefusedPaste(title: "Fix login", stagedAt: "/tmp/staging/abc")
#expect(center.oneShots.count == 1)
let headline = try? #require(center.oneShots.first).error
#expect(headline.map(BannerCenter.headline(for:)) == "Couldn't paste 'Fix login' — the copied content is gone")
}
/// "Untitled" is a rendering, never a value (03-board-ui.md § Card face), so an untitled entry is
/// "the item" — `actionPhrase`'s standing convention for a failure with no title to quote.
@Test("An untitled entry is 'the item', never the Untitled rendering")
@MainActor
func untitled() {
let center = BannerCenter()
center.postRefusedPaste(title: nil, stagedAt: "/tmp/staging/abc")
let error = try? #require(center.oneShots.first).error
#expect(error.map(BannerCenter.headline(for:)) == "Couldn't paste the item — the copied content is gone")
}
/// **The pivot, stated as a class change**: the degraded paste was a loss row because the items
/// landed and only their attachments did not. A refusal is *a write that did not happen*, which is
/// 02-architecture.md's own definition of a one-shot — so it ranks with the true failures, carries
/// the error tone, and posts no loss row at all.
@Test("A refused paste is an error-tone one-shot, not a loss row")
@MainActor
func refusalIsAOneShotNotALossRow() {
let center = BannerCenter()
center.postRefusedPaste(title: "Fix login", stagedAt: "/tmp/staging/abc")
#expect(center.losses.isEmpty, "the degraded paste's loss row is retired")
#expect(center.signposts.isEmpty)
let rows = BannerCenter.rows(
lock: nil, breakage: nil, oneShots: center.oneShots, losses: [], suspension: nil, operations: []
)
#expect(rows.count == 1)
#expect(rows[0].tone == .error)
#expect(rows[0].dismissID == center.oneShots.first?.id)
}
/// The staging path is what the error names, so a bug report about a refusal has something to go
/// on — the file that was not there.
@Test("The refusal names the staged path it could not find")
@MainActor
func namesTheStagedPath() {
let center = BannerCenter()
center.postRefusedPaste(title: "Fix login", stagedAt: "/tmp/staging/abc")
#expect(center.oneShots.first?.error.path == "/tmp/staging/abc")
#expect(center.oneShots.first?.error.reason == .clipboardContentGone)
#expect(center.oneShots.first?.error.operation == .paste(title: "Fix login"))
}
/// **The loss class survives the retirement** — 02's warning-tone class still has live producers
/// (a Finder drop that skipped folders, the app's own relocation and repair notices); only the
/// degraded-paste row left it.
@Test("The loss class still has its other producers")
@MainActor
func theLossClassSurvives() {
let center = BannerCenter()
center.postSkippedFolders(count: 2)
#expect(center.losses.count == 1)
#expect(center.losses.first?.message == "Folders can't be attached — 2 skipped")
}
}