Phase 2 completes the lanes-in-trash card. TrashEntry merges the trash's two kinds by rank in exactly ONE place (ItemPath.resolve's own merge deleted in favor of it — the three-merge-points finding shrinks instead of growing). TrashLaneRowView renders the opaque row — tertiary plate, level-default lane glyph never the lane's own icon, title + card count, no accents, no expansion; the column badge counts rendered rows. Selection grammar: kind-homogeneous trash selections — ranges skip the other kind, ⇧-extension stops at the kind boundary, plain arrows walk the merged order, marquee stays card-only (now load-bearing: rows register frames for arrows), Select All card-scoped; successor-on-purge crosses kinds like navigation as the interim for open Gap 7b5cbc90. Drag: TrashDrop accepts lane sessions (drop on shown trash deletes), restoreLanes routes a trash-sourced strip drop as an arrival-ranked within-board move with an undo step. Clipboard: ⌘X/⌘V lane restore via opaque lane subjects; fixed boardRoot(ofLaneFolder:) returning .trash as the root — a same-board restore looked like an import and would have reminted the lane it was restoring (pinned by test). A11y: row = one flattened "title, deleted lane, N cards" element with Delete/Reveal actions; BoardDiff crossings read lanes as deleted/restored, shown-trash churn digested at row level. Agent guide stays v7 — the literal already teaches lanes-trash-by-move and kind stamping; drift-guard pins those lines. README trash paragraph notes lanes. Both schemes 1893 tests / 322 suites green. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
960 lines
39 KiB
Swift
960 lines
39 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import Kanban
|
|
|
|
/// `ClipboardStore`'s own machinery — the manifest, the staging lifecycle, the sweep, the
|
|
/// changeCount-based takeover, and the deferred cut's arming and voiding (04-interactions.md ▸
|
|
/// Clipboard). The *writes* a paste performs live in `PasteWriteTests.swift`.
|
|
///
|
|
/// Every suite here drives a real store over a real temp board, with two things injected: a fake
|
|
/// pasteboard (so a test never races the machine's one real pasteboard, nor every other test in the
|
|
/// run) and a temp staging directory (so nothing goes near the shared App Group container, which is now
|
|
/// the sibling edition's staging store too). Both seams exist exactly because those two claims are the
|
|
/// ones worth pinning.
|
|
|
|
// MARK: - Test doubles
|
|
|
|
/// The pasteboard, as a value a test can shove around.
|
|
///
|
|
/// `changeCount` behaves the way `NSPasteboard`'s does — a machine-wide counter that anyone's write
|
|
/// bumps — because that is the whole basis of takeover detection, and a double that only counted
|
|
/// *our* writes would make the interesting case untestable.
|
|
@MainActor
|
|
final class FakePasteboard: ClipboardPasteboard {
|
|
|
|
private(set) var changeCount = 0
|
|
private(set) var text: String?
|
|
private var data: Data?
|
|
|
|
func manifestData() -> Data? { data }
|
|
|
|
@discardableResult
|
|
func write(manifest: Data, text: String) -> Int {
|
|
changeCount += 1
|
|
data = manifest
|
|
self.text = text
|
|
return changeCount
|
|
}
|
|
|
|
/// Another app copied: ownership moves, our type is gone, the counter advanced.
|
|
func takeOver() {
|
|
changeCount += 1
|
|
data = nil
|
|
text = nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Fixtures
|
|
|
|
let clipboardLane1 = ItemID(rawValue: Ident.lane1)
|
|
let clipboardLane2 = ItemID(rawValue: Ident.lane2)
|
|
let clipboardCard1 = ItemID(rawValue: Ident.card1)
|
|
let clipboardCard2 = ItemID(rawValue: Ident.card2)
|
|
let clipboardCard3 = ItemID(rawValue: Ident.card3)
|
|
let clipboardCard4 = ItemID(rawValue: Ident.card4)
|
|
|
|
/// 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, which is what makes a concurrent sweep by the
|
|
/// sibling edition safe). 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: The sibling edition's sweep
|
|
//
|
|
// The staging store now lives in the shared App Group container (12-editions.md ▸ Both editions
|
|
// installed), so base and Pro sweep the same directory on their own launches, activations, copies
|
|
// and pastes. Both compute the *same* answer — the keep set is the one `copyID` the machine-wide
|
|
// pasteboard names — so they never disagree about what should go; what they can do is arrive at the
|
|
// same doomed tree together. These two tests are the ruling's two clauses: atomic removals, and
|
|
// missing-entry = already swept.
|
|
|
|
@Test("Two editions sweeping the same store at once agree, and neither errors")
|
|
func concurrentSweepsFromBothEditionsAgree() 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 editions read the *same* 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
|
|
)
|
|
// The sibling 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 — the sibling swept, then something removed the
|
|
// shared folder — 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: - 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))
|
|
}
|
|
|
|
@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")
|
|
}
|
|
}
|