LaneView.laneMenu/laneActions restructured to the owner's shape (card 22f660b0),
twinning the card menu's own reshape from earlier today (72ca222, card fe66c461):
Rename/Properties…/Style ▸ (Symbol, Color), a divider, then Copy/Cut/Paste, a
divider, then Width — Increase/Decrease/Reset, a divider, then Collapse Lane /
Expand Lane, a divider, then Send to Trash (relabeled from "Delete"). Every row
routes through existing machinery — no new commands, no new store method.
ClipboardStore's targeted copy(from:targeting:)/cut(from:targeting:) — added for
the card menu — are reused verbatim here: a new clipboardTarget computed property
wraps targetIDs (the lane's existing widen-to-selection rule) as an
ItemReferenceSet, exactly the card menu's clipboardTarget one type over. Paste
does not retarget, the card menu's own posture (a destination operation with no
per-item widening precedent). Reset Width is exactly setLaneWidth(lane.id,
units: 1) — the same call Decrease already makes at the floor — which
setLaneWidth's own remove-at-default rule already turns into an absent width
key, so no new write path is needed.
Style ▸ Symbol and ▸ Color both open the one existing style popover (the card
menu's v1 posture, unchanged), and the quick-style recents row is dropped from
this menu for symmetry with the card menu's own drop — StyleMenuItems is no
longer called from LaneView, though it and QuickStyleRow are left in place
(unused, easy to restore) exactly as the card menu's own commit chose to leave
them. Properties… is a disabled placeholder row, the owner's own word, left out
of laneActions since an always-disabled row has nothing to announce a custom
action for.
Unlike CardFaceView, LaneView's Copy/Cut/Paste enablement (copyEnabled/
cutEnabled/pasteEnabled) calls ClipboardStore's real predicates directly rather
than reducing them to selection/snapshot-free forms: this body is already
unconditionally subscribed to store.selection (isSelected) and store.snapshot
(headerInk) every pass — the struct's own "Equality gate" doc section says so —
so nothing new is subscribed, and the predicate cost is paid once per lane
(a handful) rather than once per card (hundreds), the axis the card menu's own
reduction was protecting.
Journaled on the card: Style's one-popover posture and the dropped recents row
both flagged "needs owner review" (mirroring the card menu's own flags);
DESIGN/11-command-nexus.md's Lane row is owed a rewrite, left for the main
session, same as the card menu's commit left its own Card row.
Tests: a new targeted copy/cut test proving ClipboardStore's targeted overloads
work on a lane id (writes a lane manifest, arms a lane pending-cut) — the exact
call LaneView's new Copy/Cut rows make — plus the full existing suite: 3220
tests, 3 pre-existing environmental failures (PointerLatencyTests, confirmed by
isolated rerun, unrelated to this change), all else passing.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
1173 lines
50 KiB
Swift
1173 lines
50 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))
|
|
}
|
|
|
|
/// **The lane context menu's own Copy/Cut** (`LaneView`, 2026-08-09 ▸ "redesign context menu for
|
|
/// lanes") — the identical targeted overloads the card menu's Copy/Cut already exercise above,
|
|
/// aimed at a lane id instead of a card id for the first time. Nothing in `ClipboardStore` branches
|
|
/// on kind at the API surface, but `SelectionGrammar.kind(of:in:)` does internally
|
|
/// (`canCopy(from:targeting:)`'s own guard), so this is worth its own test rather than assumed by
|
|
/// analogy: a lane target must write a **lane** manifest, and it must ignore an unrelated live
|
|
/// card selection exactly as the card menu's targeted copy ignores an unrelated live selection.
|
|
@Test("A targeted copy/cut of a lane writes a lane manifest, ignoring an unrelated live selection")
|
|
func targetedCopyAndCutWorkOnALaneID() async throws {
|
|
let harness = try makeClipboardHarness()
|
|
defer { harness.tearDown() }
|
|
|
|
// The live selection names a card the target never mentions — `LaneView.clipboardTarget`'s
|
|
// own "right-clicking something outside the selection acts on what was clicked" widening.
|
|
harness.store.select([clipboardCard4], in: .board)
|
|
let target = ItemReferenceSet(ids: [clipboardLane1], container: .board)
|
|
|
|
#expect(harness.clipboard.canCopy(from: harness.store, targeting: target))
|
|
#expect(harness.clipboard.canCut(from: harness.store, targeting: target))
|
|
|
|
harness.clipboard.copy(from: harness.store, targeting: target)
|
|
await harness.clipboard.stagingSettled()
|
|
|
|
let manifest = try #require(harness.clipboard.payload)
|
|
#expect(manifest.kind == .lane)
|
|
#expect(manifest.entries.map(\.id) == [Ident.lane1])
|
|
|
|
harness.clipboard.cut(from: harness.store, targeting: target)
|
|
#expect(harness.store.transient.pendingCut.ids == [clipboardLane1])
|
|
#expect(harness.store.transient.pendingCut.container == .board)
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
}
|