Implement the hybrid clipboard with deferred cut
⌘X/⌘C/⌘V for cards and lanes per 04-interactions.md § Clipboard: - ClipboardStore stages full folder snapshots eagerly at the gesture into Application Support (at most the current copy; sweep at launch and on each copy purges what the pasteboard no longer references; a copy made before quitting pastes whole after restart) and writes the pasteboard a JSON manifest — every entry embedding its index.md, lane entries their cards' too — plus plain-text titles. - Cut is Finder-style deferred: items dim in place off pendingCut, void on pasteboard takeover (changeCount, no timers), source-board close, or per-item external tombstoning; the first armed paste moves the surviving originals whole (tombstoned interior cards land in the destination's trash), a second paste materializes copies from staging. - Paste anchors by the shared flatten-order rule (NewCardTarget's anchor, extracted); a tombstoned selection never anchors; lane paste reaches the right end and stays enabled on a zero-lane board; paste into the source board is the within-board lane duplicate; copies keep created, take fresh GUIDs, and strip tombstoned cards; trash-sourced copies strip deleted: at materialization; ⌘X is disabled on the trash side. - A degraded paste is loud, never silent: staging gone → the embedded index.md fallback lands content-intact, attachments absent, and a BannerCenter-phrased row names what was lost. - The standard Edit items validate through conditionally-attached onCommand handlers, so AppKit's enablement mirrors the availability predicates; text fields keep their own clipboard while focused. 879 unit tests (68 new). Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -0,0 +1,769 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// `ClipboardStore`'s own machinery — the manifest, the staging lifecycle, the sweep, the
|
||||
/// changeCount-based takeover, and the deferred cut's arming and voiding (04-interactions.md ▸
|
||||
/// Clipboard). The *writes* a paste performs live in `PasteWriteTests.swift`.
|
||||
///
|
||||
/// Every suite here drives a real store over a real temp board, with two things injected: a fake
|
||||
/// pasteboard (so a test never races the machine's one real pasteboard, nor every other test in the
|
||||
/// run) and a temp staging directory (so nothing goes near Application Support). Both seams exist
|
||||
/// exactly because those two claims are the ones worth pinning.
|
||||
|
||||
// MARK: - Test doubles
|
||||
|
||||
/// The pasteboard, as a value a test can shove around.
|
||||
///
|
||||
/// `changeCount` behaves the way `NSPasteboard`'s does — a machine-wide counter that anyone's write
|
||||
/// bumps — because that is the whole basis of takeover detection, and a double that only counted
|
||||
/// *our* writes would make the interesting case untestable.
|
||||
@MainActor
|
||||
final class FakePasteboard: ClipboardPasteboard {
|
||||
|
||||
private(set) var changeCount = 0
|
||||
private(set) var text: String?
|
||||
private var data: Data?
|
||||
|
||||
func manifestData() -> Data? { data }
|
||||
|
||||
@discardableResult
|
||||
func write(manifest: Data, text: String) -> Int {
|
||||
changeCount += 1
|
||||
data = manifest
|
||||
self.text = text
|
||||
return changeCount
|
||||
}
|
||||
|
||||
/// Another app copied: ownership moves, our type is gone, the counter advanced.
|
||||
func takeOver() {
|
||||
changeCount += 1
|
||||
data = nil
|
||||
text = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
let clipboardLane1 = ItemID(rawValue: Ident.lane1)
|
||||
let clipboardLane2 = ItemID(rawValue: Ident.lane2)
|
||||
let clipboardCard1 = ItemID(rawValue: Ident.card1)
|
||||
let clipboardCard2 = ItemID(rawValue: Ident.card2)
|
||||
let clipboardCard3 = ItemID(rawValue: Ident.card3)
|
||||
let clipboardCard4 = ItemID(rawValue: Ident.card4)
|
||||
|
||||
func tombstonedItem(order: String, title: String) -> String {
|
||||
"""
|
||||
---
|
||||
schema: 1
|
||||
title: \(title)
|
||||
order: \(order)
|
||||
created: 2026-01-01T09:00:00Z
|
||||
deleted: 2026-03-03T09:00:00Z
|
||||
---
|
||||
\(title) body.
|
||||
|
||||
"""
|
||||
}
|
||||
|
||||
/// Two lanes: `lane1` holds three live cards and one tombstoned one, `lane2` holds a single card.
|
||||
/// `card1` carries two attachments, which is what makes "the snapshot travels whole" and "the
|
||||
/// fallback lost exactly two files" both assertable.
|
||||
@MainActor
|
||||
func makeClipboardBoard() throws -> WriterFixture {
|
||||
let fixture = try WriterFixture()
|
||||
try fixture.item("", Item.board)
|
||||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
|
||||
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/photo.png", Data("png bytes".utf8))
|
||||
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/notes.txt", Data("notes".utf8))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card3)", tombstonedItem(order: "3072", title: "Trashed"))
|
||||
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
||||
try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "1024", title: "Fourth"))
|
||||
return fixture
|
||||
}
|
||||
|
||||
/// A store plus the two seams, torn down together.
|
||||
@MainActor
|
||||
struct ClipboardHarness {
|
||||
let fixture: WriterFixture
|
||||
let staging: URL
|
||||
let pasteboard: FakePasteboard
|
||||
let clipboard: ClipboardStore
|
||||
let store: BoardStore
|
||||
|
||||
init(fixture: WriterFixture) throws {
|
||||
self.fixture = fixture
|
||||
staging = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("ClipboardTests-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true)
|
||||
pasteboard = FakePasteboard()
|
||||
clipboard = ClipboardStore(pasteboard: pasteboard, stagingRoot: staging, observesActivation: false)
|
||||
store = try BoardStore(rootURL: fixture.root)
|
||||
}
|
||||
|
||||
func tearDown() {
|
||||
try? FileManager.default.removeItem(at: staging)
|
||||
fixture.tearDown()
|
||||
}
|
||||
|
||||
/// The staged copy directories, sorted — "at most the current copy" is a claim about this list.
|
||||
func stagedCopyIDs() throws -> [String] {
|
||||
try FileManager.default.contentsOfDirectory(atPath: staging.path).sorted()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func makeClipboardHarness() throws -> ClipboardHarness {
|
||||
try ClipboardHarness(fixture: try makeClipboardBoard())
|
||||
}
|
||||
|
||||
// MARK: - The manifest
|
||||
|
||||
@Suite("ClipboardManifest")
|
||||
struct ClipboardManifestTests {
|
||||
|
||||
private func entry(_ id: String) -> ClipboardManifest.Entry {
|
||||
ClipboardManifest.Entry(
|
||||
id: id,
|
||||
folder: id,
|
||||
title: "First",
|
||||
index: "---\nschema: 1\n---\nbody\n",
|
||||
attachmentCount: 2
|
||||
)
|
||||
}
|
||||
|
||||
@Test("A manifest round-trips through JSON")
|
||||
func roundTrip() throws {
|
||||
let manifest = ClipboardManifest(
|
||||
copyID: "abc",
|
||||
boardRoot: URL(fileURLWithPath: "/tmp/Board.kanban", isDirectory: true),
|
||||
kind: .card,
|
||||
side: .live,
|
||||
entries: [entry(Ident.card1)]
|
||||
)
|
||||
let data = try #require(manifest.encoded())
|
||||
#expect(ClipboardManifest(data: data) == manifest)
|
||||
}
|
||||
|
||||
@Test("A manifest from a future version is refused rather than half-read")
|
||||
func futureVersion() throws {
|
||||
var manifest = ClipboardManifest(
|
||||
copyID: "abc",
|
||||
boardRoot: URL(fileURLWithPath: "/tmp/B", isDirectory: true),
|
||||
kind: .card,
|
||||
side: .live,
|
||||
entries: [entry(Ident.card1)]
|
||||
)
|
||||
manifest.version = ClipboardManifest.currentVersion + 1
|
||||
let data = try #require(manifest.encoded())
|
||||
#expect(ClipboardManifest(data: data) == nil)
|
||||
}
|
||||
|
||||
@Test("An entryless manifest is nothing to paste")
|
||||
func empty() throws {
|
||||
let manifest = ClipboardManifest(
|
||||
copyID: "abc",
|
||||
boardRoot: URL(fileURLWithPath: "/tmp/B", isDirectory: true),
|
||||
kind: .card,
|
||||
side: .live,
|
||||
entries: []
|
||||
)
|
||||
let data = try #require(manifest.encoded())
|
||||
#expect(ClipboardManifest(data: data) == nil)
|
||||
}
|
||||
|
||||
@Test("A lane entry's lost-attachment count totals its cards'")
|
||||
func lostAttachments() {
|
||||
let lane = ClipboardManifest.Entry(
|
||||
id: Ident.lane1,
|
||||
folder: Ident.lane1,
|
||||
title: "Todo",
|
||||
index: "---\nschema: 1\n---\n",
|
||||
attachmentCount: 0,
|
||||
cards: [
|
||||
.init(id: Ident.card1, title: "First", index: "a", attachmentCount: 2),
|
||||
.init(id: Ident.card2, title: "Second", index: "b", attachmentCount: 1),
|
||||
]
|
||||
)
|
||||
#expect(lane.lostAttachmentCount == 3)
|
||||
}
|
||||
|
||||
@Test("The plain-text rendering is the titles, untitled items rendered as the board renders them")
|
||||
func plainText() {
|
||||
var titled = entry(Ident.card1)
|
||||
var untitled = entry(Ident.card2)
|
||||
untitled.title = nil
|
||||
titled.title = "First"
|
||||
let manifest = ClipboardManifest(
|
||||
copyID: "abc",
|
||||
boardRoot: URL(fileURLWithPath: "/tmp/B", isDirectory: true),
|
||||
kind: .card,
|
||||
side: .live,
|
||||
entries: [titled, untitled]
|
||||
)
|
||||
#expect(manifest.plainText == "First\nUntitled")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Copy and staging
|
||||
|
||||
@MainActor
|
||||
@Suite("ClipboardStore ▸ copy")
|
||||
struct ClipboardCopyTests {
|
||||
|
||||
@Test("A copy stages the whole card folder, attachments and all")
|
||||
func stagesAttachments() async throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
|
||||
harness.store.select([clipboardCard1], liveness: .live)
|
||||
harness.clipboard.copy(from: harness.store)
|
||||
await harness.clipboard.stagingSettled()
|
||||
|
||||
let manifest = try #require(harness.clipboard.payload)
|
||||
let staged = harness.staging
|
||||
.appendingPathComponent(manifest.copyID, isDirectory: true)
|
||||
.appendingPathComponent(Ident.card1, isDirectory: true)
|
||||
#expect(FileManager.default.fileExists(atPath: staged.appendingPathComponent("index.md").path))
|
||||
#expect(try Data(contentsOf: staged.appendingPathComponent("attachments/photo.png"))
|
||||
== Data("png bytes".utf8))
|
||||
#expect(try Data(contentsOf: staged.appendingPathComponent("attachments/notes.txt"))
|
||||
== Data("notes".utf8))
|
||||
}
|
||||
|
||||
@Test("The manifest records identity, side, kind and the source board root")
|
||||
func manifestShape() async throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
|
||||
harness.store.select([clipboardCard1, clipboardCard2], liveness: .live)
|
||||
harness.clipboard.copy(from: harness.store)
|
||||
|
||||
let manifest = try #require(harness.clipboard.payload)
|
||||
#expect(manifest.kind == .card)
|
||||
#expect(manifest.side == .live)
|
||||
#expect(manifest.rootURL.path == harness.fixture.root.path)
|
||||
// Flatten order — lane `order`, then card `order`.
|
||||
#expect(manifest.entries.map(\.id) == [Ident.card1, Ident.card2])
|
||||
#expect(manifest.entries.map(\.title) == ["First", "Second"])
|
||||
#expect(manifest.entries[0].attachmentCount == 2)
|
||||
#expect(manifest.entries[1].attachmentCount == 0)
|
||||
#expect(harness.pasteboard.text == "First\nSecond")
|
||||
}
|
||||
|
||||
@Test("The embedded index text is the file's bytes, verbatim")
|
||||
func embeddedIndexIsSourceBytes() async throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
|
||||
harness.store.select([clipboardCard1], liveness: .live)
|
||||
harness.clipboard.copy(from: harness.store)
|
||||
|
||||
let manifest = try #require(harness.clipboard.payload)
|
||||
let onDisk = try harness.fixture.indexText("\(Ident.lane1)/\(Ident.card1)")
|
||||
#expect(manifest.entries[0].index == onDisk)
|
||||
}
|
||||
|
||||
@Test("A lane copy embeds its live cards and leaves the tombstoned one out")
|
||||
func laneEntryEmbedsLiveCards() async throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
|
||||
harness.store.select([clipboardLane1], liveness: .live)
|
||||
harness.clipboard.copy(from: harness.store)
|
||||
|
||||
let manifest = try #require(harness.clipboard.payload)
|
||||
#expect(manifest.kind == .lane)
|
||||
#expect(manifest.entries.map(\.id) == [Ident.lane1])
|
||||
#expect(manifest.entries[0].cards.map(\.id) == [Ident.card1, Ident.card2])
|
||||
#expect(manifest.entries[0].lostAttachmentCount == 2)
|
||||
}
|
||||
|
||||
@Test("A trashed selection copies out, side recorded")
|
||||
func trashedSide() async throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
|
||||
harness.store.transient.isTrashVisible = true
|
||||
harness.store.select([clipboardCard3], liveness: .trashed)
|
||||
harness.clipboard.copy(from: harness.store)
|
||||
|
||||
let manifest = try #require(harness.clipboard.payload)
|
||||
#expect(manifest.side == .trashed)
|
||||
#expect(manifest.entries.map(\.id) == [Ident.card3])
|
||||
}
|
||||
|
||||
@Test("An empty selection copies nothing and leaves the pasteboard alone")
|
||||
func emptySelection() async throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
|
||||
harness.clipboard.copy(from: harness.store)
|
||||
#expect(harness.pasteboard.changeCount == 0)
|
||||
#expect(harness.clipboard.payload == nil)
|
||||
}
|
||||
|
||||
@Test("The store holds at most the current copy — a second copy sweeps the first")
|
||||
func atMostTheCurrentCopy() async throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
|
||||
harness.store.select([clipboardCard1], liveness: .live)
|
||||
harness.clipboard.copy(from: harness.store)
|
||||
await harness.clipboard.stagingSettled()
|
||||
let first = try #require(harness.clipboard.payload?.copyID)
|
||||
|
||||
harness.store.select([clipboardCard2], liveness: .live)
|
||||
harness.clipboard.copy(from: harness.store)
|
||||
await harness.clipboard.stagingSettled()
|
||||
let second = try #require(harness.clipboard.payload?.copyID)
|
||||
|
||||
#expect(first != second)
|
||||
#expect(try harness.stagedCopyIDs() == [second])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The sweep
|
||||
|
||||
@MainActor
|
||||
@Suite("ClipboardStore ▸ sweep")
|
||||
struct ClipboardSweepTests {
|
||||
|
||||
@Test("A launch sweep collects every tree the pasteboard no longer names")
|
||||
func launchSweepPurgesOrphans() async throws {
|
||||
let fixture = try makeClipboardBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let staging = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("ClipboardTests-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: staging) }
|
||||
// Two trees from a previous launch, and a pasteboard that names neither.
|
||||
for orphan in ["one", "two"] {
|
||||
try FileManager.default.createDirectory(
|
||||
at: staging.appendingPathComponent(orphan, isDirectory: true),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
}
|
||||
|
||||
let clipboard = ClipboardStore(
|
||||
pasteboard: FakePasteboard(),
|
||||
stagingRoot: staging,
|
||||
observesActivation: false
|
||||
)
|
||||
await clipboard.stagingSettled()
|
||||
|
||||
#expect(try FileManager.default.contentsOfDirectory(atPath: staging.path).isEmpty)
|
||||
}
|
||||
|
||||
@Test("A sweep keeps the tree the pasteboard still names")
|
||||
func sweepKeepsTheCurrentCopy() async throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
try FileManager.default.createDirectory(
|
||||
at: harness.staging.appendingPathComponent("stale", isDirectory: true),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
|
||||
harness.store.select([clipboardCard1], liveness: .live)
|
||||
harness.clipboard.copy(from: harness.store)
|
||||
await harness.clipboard.stagingSettled()
|
||||
|
||||
let current = try #require(harness.clipboard.payload?.copyID)
|
||||
#expect(try harness.stagedCopyIDs() == [current])
|
||||
}
|
||||
|
||||
@Test("A takeover makes our own tree an orphan, and the next sweep collects it")
|
||||
func takeoverOrphansOurSnapshot() async throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
|
||||
harness.store.select([clipboardCard1], liveness: .live)
|
||||
harness.clipboard.copy(from: harness.store)
|
||||
await harness.clipboard.stagingSettled()
|
||||
#expect(try harness.stagedCopyIDs().count == 1)
|
||||
|
||||
harness.pasteboard.takeOver()
|
||||
harness.clipboard.sweep()
|
||||
await harness.clipboard.stagingSettled()
|
||||
|
||||
#expect(try harness.stagedCopyIDs().isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Takeover
|
||||
|
||||
@MainActor
|
||||
@Suite("ClipboardStore ▸ takeover")
|
||||
struct ClipboardTakeoverTests {
|
||||
|
||||
@Test("A changeCount that moved without us is a takeover: the payload goes")
|
||||
func payloadClears() async throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
|
||||
harness.store.select([clipboardCard1], liveness: .live)
|
||||
harness.clipboard.copy(from: harness.store)
|
||||
#expect(harness.clipboard.payload != nil)
|
||||
|
||||
harness.pasteboard.takeOver()
|
||||
harness.clipboard.refresh()
|
||||
#expect(harness.clipboard.payload == nil)
|
||||
#expect(harness.clipboard.canPaste(into: harness.store) == false)
|
||||
}
|
||||
|
||||
@Test("An unchanged changeCount is one read and no decode")
|
||||
func refreshIsGuarded() async throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
|
||||
harness.store.select([clipboardCard1], liveness: .live)
|
||||
harness.clipboard.copy(from: harness.store)
|
||||
let before = harness.pasteboard.changeCount
|
||||
harness.clipboard.refresh()
|
||||
harness.clipboard.refresh()
|
||||
// Nothing was written, so nothing moved — and the payload survived the two refreshes intact.
|
||||
#expect(harness.pasteboard.changeCount == before)
|
||||
#expect(harness.clipboard.payload != nil)
|
||||
}
|
||||
|
||||
@Test("A takeover voids an armed cut and undims its items")
|
||||
func takeoverVoidsTheCut() async throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
|
||||
harness.store.select([clipboardCard1], liveness: .live)
|
||||
harness.clipboard.cut(from: harness.store)
|
||||
#expect(harness.store.transient.pendingCut.ids == [clipboardCard1])
|
||||
|
||||
harness.pasteboard.takeOver()
|
||||
harness.clipboard.refresh()
|
||||
#expect(harness.store.transient.pendingCut.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cut arming
|
||||
|
||||
@MainActor
|
||||
@Suite("ClipboardStore ▸ cut")
|
||||
struct ClipboardCutTests {
|
||||
|
||||
@Test("A cut arms the source board's pending set, in flatten order membership")
|
||||
func armsPendingCut() async throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
|
||||
harness.store.select([clipboardCard1, clipboardCard4], liveness: .live)
|
||||
harness.clipboard.cut(from: harness.store)
|
||||
|
||||
#expect(harness.store.transient.pendingCut.ids == [clipboardCard1, clipboardCard4])
|
||||
#expect(harness.store.transient.pendingCut.liveness == .live)
|
||||
}
|
||||
|
||||
@Test("A second copy voids the pending cut — its pasteboard entry has been overwritten")
|
||||
func copyVoidsTheCut() async throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
|
||||
harness.store.select([clipboardCard1], liveness: .live)
|
||||
harness.clipboard.cut(from: harness.store)
|
||||
#expect(!harness.store.transient.pendingCut.isEmpty)
|
||||
|
||||
harness.store.select([clipboardCard2], liveness: .live)
|
||||
harness.clipboard.copy(from: harness.store)
|
||||
#expect(harness.store.transient.pendingCut.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Deletion voids per item: a tombstoned cut member leaves the pending set on reload")
|
||||
func deletionVoidsPerItem() async throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
|
||||
harness.store.select([clipboardCard1, clipboardCard2], liveness: .live)
|
||||
harness.clipboard.cut(from: harness.store)
|
||||
|
||||
harness.store.delete([clipboardCard1])
|
||||
harness.store.handleWatcherEvent(.treeChanged(.appMediated))
|
||||
await harness.store.awaitQuiescence()
|
||||
|
||||
#expect(harness.store.transient.pendingCut.ids == [clipboardCard2])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Availability
|
||||
|
||||
@MainActor
|
||||
@Suite("ClipboardStore ▸ availability")
|
||||
struct ClipboardAvailabilityTests {
|
||||
|
||||
@Test("Copy needs a selection that names something the board renders")
|
||||
func copyNeedsASelection() throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
|
||||
#expect(harness.clipboard.canCopy(from: harness.store) == false)
|
||||
harness.store.select([clipboardCard1], liveness: .live)
|
||||
#expect(harness.clipboard.canCopy(from: harness.store))
|
||||
}
|
||||
|
||||
@Test("Copy works on a trashed selection; cut does not")
|
||||
func trashIsCopyOutOnly() throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
|
||||
harness.store.transient.isTrashVisible = true
|
||||
harness.store.select([clipboardCard3], liveness: .trashed)
|
||||
#expect(harness.clipboard.canCopy(from: harness.store))
|
||||
#expect(harness.clipboard.canCut(from: harness.store) == false)
|
||||
}
|
||||
|
||||
@Test("The read-only lock blocks cut but never copy")
|
||||
func lockBlocksCutOnly() throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
|
||||
harness.store.select([clipboardCard1], liveness: .live)
|
||||
harness.store.enterVanishedRootLock()
|
||||
#expect(harness.clipboard.canCopy(from: harness.store))
|
||||
#expect(harness.clipboard.canCut(from: harness.store) == false)
|
||||
#expect(harness.clipboard.canPaste(into: harness.store) == false)
|
||||
}
|
||||
|
||||
@Test("An open inline editor closes all three — the focused-editor rule")
|
||||
func focusedEditorClosesEverything() throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
|
||||
harness.store.select([clipboardCard1], liveness: .live)
|
||||
harness.clipboard.copy(from: harness.store)
|
||||
harness.store.transient.beginRename(of: clipboardCard1, currentTitle: "First")
|
||||
|
||||
#expect(harness.clipboard.canCopy(from: harness.store) == false)
|
||||
#expect(harness.clipboard.canCut(from: harness.store) == false)
|
||||
#expect(harness.clipboard.canPaste(into: harness.store) == false)
|
||||
}
|
||||
|
||||
@Test("Paste needs a payload")
|
||||
func pasteNeedsAPayload() throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
|
||||
#expect(harness.clipboard.canPaste(into: harness.store) == false)
|
||||
harness.store.select([clipboardCard1], liveness: .live)
|
||||
harness.clipboard.copy(from: harness.store)
|
||||
#expect(harness.clipboard.canPaste(into: harness.store))
|
||||
}
|
||||
|
||||
@Test("On a zero-lane board a card payload disables paste and a lane payload does not")
|
||||
func zeroLaneBoard() async throws {
|
||||
let source = try makeClipboardBoard()
|
||||
defer { source.tearDown() }
|
||||
let empty = try WriterFixture()
|
||||
defer { empty.tearDown() }
|
||||
try empty.item("", Item.board)
|
||||
|
||||
let staging = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("ClipboardTests-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: staging) }
|
||||
let clipboard = ClipboardStore(
|
||||
pasteboard: FakePasteboard(),
|
||||
stagingRoot: staging,
|
||||
observesActivation: false
|
||||
)
|
||||
let sourceStore = try BoardStore(rootURL: source.root)
|
||||
let emptyStore = try BoardStore(rootURL: empty.root)
|
||||
|
||||
sourceStore.select([clipboardCard1], liveness: .live)
|
||||
clipboard.copy(from: sourceStore)
|
||||
#expect(clipboard.canPaste(into: emptyStore) == false)
|
||||
|
||||
sourceStore.select([clipboardLane1], liveness: .live)
|
||||
clipboard.copy(from: sourceStore)
|
||||
#expect(clipboard.canPaste(into: emptyStore))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The paste anchors
|
||||
|
||||
@MainActor
|
||||
@Suite("PasteTarget")
|
||||
struct PasteTargetTests {
|
||||
|
||||
private func snapshot(_ fixture: WriterFixture) throws -> BoardModel {
|
||||
try BoardLoader.load(boardRoot: fixture.root).model
|
||||
}
|
||||
|
||||
@Test("A card payload lands after the anchor card")
|
||||
func afterTheAnchorCard() throws {
|
||||
let fixture = try makeClipboardBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let model = try snapshot(fixture)
|
||||
|
||||
let target = PasteTarget.cards(
|
||||
selection: ItemReferenceSet(ids: [clipboardCard1], liveness: .live),
|
||||
lastActiveLaneID: nil,
|
||||
snapshot: model
|
||||
)
|
||||
#expect(target == PasteTarget.Cards(laneID: clipboardLane1, index: 1))
|
||||
}
|
||||
|
||||
@Test("A selected lane appends to its bottom")
|
||||
func appendsToASelectedLane() throws {
|
||||
let fixture = try makeClipboardBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let model = try snapshot(fixture)
|
||||
|
||||
let target = PasteTarget.cards(
|
||||
selection: ItemReferenceSet(ids: [clipboardLane1], liveness: .live),
|
||||
lastActiveLaneID: nil,
|
||||
snapshot: model
|
||||
)
|
||||
// Two rendered cards — the tombstoned third is not in the layout.
|
||||
#expect(target == PasteTarget.Cards(laneID: clipboardLane1, index: 2))
|
||||
}
|
||||
|
||||
@Test("A multi-selection anchors at its last member in flatten order")
|
||||
func flattenOrderAnchor() throws {
|
||||
let fixture = try makeClipboardBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let model = try snapshot(fixture)
|
||||
|
||||
let target = PasteTarget.cards(
|
||||
selection: ItemReferenceSet(ids: [clipboardCard4, clipboardCard1], liveness: .live),
|
||||
lastActiveLaneID: nil,
|
||||
snapshot: model
|
||||
)
|
||||
// `card4` is in the second lane, so it is last in flatten order however the set is spelled.
|
||||
#expect(target == PasteTarget.Cards(laneID: clipboardLane2, index: 1))
|
||||
}
|
||||
|
||||
@Test("A tombstoned selection never anchors: it behaves as nothing selected")
|
||||
func tombstonedSelectionNeverAnchors() throws {
|
||||
let fixture = try makeClipboardBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let model = try snapshot(fixture)
|
||||
|
||||
let target = PasteTarget.cards(
|
||||
selection: ItemReferenceSet(ids: [clipboardCard3], liveness: .trashed),
|
||||
lastActiveLaneID: clipboardLane2,
|
||||
snapshot: model
|
||||
)
|
||||
// The last-active lane, appended — never `card3`'s live disk-lane.
|
||||
#expect(target == PasteTarget.Cards(laneID: clipboardLane2, index: 1))
|
||||
}
|
||||
|
||||
@Test("Nothing selected and no last-active lane falls back to the first lane")
|
||||
func firstLaneFallback() throws {
|
||||
let fixture = try makeClipboardBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let model = try snapshot(fixture)
|
||||
|
||||
let target = PasteTarget.cards(selection: .empty, lastActiveLaneID: nil, snapshot: model)
|
||||
#expect(target == PasteTarget.Cards(laneID: clipboardLane1, index: 2))
|
||||
}
|
||||
|
||||
@Test("A zero-lane board has no card target at all")
|
||||
func zeroLaneBoardHasNoCardTarget() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item("", Item.board)
|
||||
let model = try snapshot(fixture)
|
||||
|
||||
#expect(PasteTarget.cards(selection: .empty, lastActiveLaneID: nil, snapshot: model) == nil)
|
||||
}
|
||||
|
||||
@Test("A lane payload lands after the anchor lane")
|
||||
func afterTheAnchorLane() throws {
|
||||
let fixture = try makeClipboardBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let model = try snapshot(fixture)
|
||||
|
||||
#expect(PasteTarget.lanes(
|
||||
selection: ItemReferenceSet(ids: [clipboardLane1], liveness: .live),
|
||||
snapshot: model
|
||||
) == 1)
|
||||
}
|
||||
|
||||
@Test("A selected card names its lane for a lane paste")
|
||||
func aSelectedCardNamesItsLane() throws {
|
||||
let fixture = try makeClipboardBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let model = try snapshot(fixture)
|
||||
|
||||
#expect(PasteTarget.lanes(
|
||||
selection: ItemReferenceSet(ids: [clipboardCard1], liveness: .live),
|
||||
snapshot: model
|
||||
) == 1)
|
||||
}
|
||||
|
||||
@Test("Nothing (or something tombstoned) selected lands a lane at the board's right end")
|
||||
func rightEnd() throws {
|
||||
let fixture = try makeClipboardBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let model = try snapshot(fixture)
|
||||
|
||||
#expect(PasteTarget.lanes(selection: .empty, snapshot: model) == 2)
|
||||
#expect(PasteTarget.lanes(
|
||||
selection: ItemReferenceSet(ids: [clipboardCard3], liveness: .trashed),
|
||||
snapshot: model
|
||||
) == 2)
|
||||
}
|
||||
|
||||
@Test("A zero-lane board still has a lane slot — position zero")
|
||||
func zeroLaneBoardStillTakesALane() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item("", Item.board)
|
||||
let model = try BoardLoader.load(boardRoot: fixture.root).model
|
||||
|
||||
#expect(PasteTarget.lanes(selection: .empty, snapshot: model) == 0)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The degraded paste's phrasing
|
||||
|
||||
@Suite("BannerCenter ▸ degraded paste")
|
||||
struct DegradedPasteBannerTests {
|
||||
|
||||
@Test("04's own example sentence")
|
||||
func theExampleSentence() {
|
||||
#expect(BannerCenter.degradedPasteMessage(
|
||||
for: [.init(title: "Fix login", attachments: 3)]
|
||||
) == "Pasted 'Fix login' without its 3 attachments")
|
||||
}
|
||||
|
||||
@Test("One attachment is singular")
|
||||
func singular() {
|
||||
#expect(BannerCenter.degradedPasteMessage(
|
||||
for: [.init(title: "Fix login", attachments: 1)]
|
||||
) == "Pasted 'Fix login' without its attachment")
|
||||
}
|
||||
|
||||
@Test("An untitled item is 'the item', never the Untitled rendering")
|
||||
func untitled() {
|
||||
#expect(BannerCenter.degradedPasteMessage(
|
||||
for: [.init(title: nil, attachments: 2)]
|
||||
) == "Pasted the item without its 2 attachments")
|
||||
}
|
||||
|
||||
@Test("Several items total their attachments rather than listing titles")
|
||||
func several() {
|
||||
#expect(BannerCenter.degradedPasteMessage(
|
||||
for: [.init(title: "A", attachments: 2), .init(title: "B", attachments: 3)]
|
||||
) == "Pasted 2 items without their 5 attachments")
|
||||
}
|
||||
|
||||
@Test("Nothing lost says nothing")
|
||||
func nothingLost() {
|
||||
#expect(BannerCenter.degradedPasteMessage(for: []) == nil)
|
||||
#expect(BannerCenter.degradedPasteMessage(for: [.init(title: "A", attachments: 0)]) == nil)
|
||||
}
|
||||
|
||||
@Test("Posting an empty loss list adds no row")
|
||||
@MainActor
|
||||
func postingNothing() {
|
||||
let center = BannerCenter()
|
||||
center.postDegradedPaste([])
|
||||
#expect(center.signposts.isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,646 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// What a paste actually writes (04-interactions.md ▸ Clipboard) — the materialization rules, the
|
||||
/// armed cut's move, and the anchors applied end to end.
|
||||
///
|
||||
/// Like every other write suite here these drive a **real store over a real temp board** and then
|
||||
/// read back through the loader or the raw bytes, never through a snapshot the store handed out: the
|
||||
/// interesting claims are about the files — which folder arrived, which UUID was minted, which
|
||||
/// `deleted:` was stripped, which attachment travelled. `WriterFixture`, `Ident` and `Item` come from
|
||||
/// `WriterTestSupport.swift`; `FakePasteboard`, `ClipboardHarness` and the board fixture come from
|
||||
/// `ClipboardTests.swift`.
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// The board as the loader sees it — never the store's snapshot, which a paste deliberately does not
|
||||
/// touch (the one-way flow: the write lands, the watcher reloads).
|
||||
private func pasted(_ fixture: WriterFixture) throws -> BoardModel {
|
||||
try BoardLoader.load(boardRoot: fixture.root).model
|
||||
}
|
||||
|
||||
private func lane(_ id: ItemID, in fixture: WriterFixture) throws -> Lane? {
|
||||
try pasted(fixture).lanes.first { $0.id == id }
|
||||
}
|
||||
|
||||
/// A lane's rendered card titles, in display order.
|
||||
private func pastedTitles(_ id: ItemID, in fixture: WriterFixture) throws -> [String] {
|
||||
try lane(id, in: fixture)?.cards.filter { !$0.isDeleted }.compactMap(\.title.value) ?? []
|
||||
}
|
||||
|
||||
/// A lane's rendered card folder names, in display order — identity, where titles cannot tell an
|
||||
/// original from its copy.
|
||||
private func pastedIDs(_ id: ItemID, in fixture: WriterFixture) throws -> [String] {
|
||||
try lane(id, in: fixture)?.cards.filter { !$0.isDeleted }.map(\.id.rawValue) ?? []
|
||||
}
|
||||
|
||||
/// A fresh, empty destination board — one lane holding one card, so an arrival has neighbours.
|
||||
@MainActor
|
||||
private func makeDestination() throws -> WriterFixture {
|
||||
let fixture = try WriterFixture()
|
||||
try fixture.item("", Item.board)
|
||||
try fixture.item(Ident.lane4, Item.rich(order: "1024", title: "Inbox"))
|
||||
try fixture.item("\(Ident.lane4)/\(Ident.indexless)", Item.rich(order: "1024", title: "Resident"))
|
||||
return fixture
|
||||
}
|
||||
|
||||
private let destinationLane = ItemID(rawValue: Ident.lane4)
|
||||
|
||||
// MARK: - Copy materialization
|
||||
|
||||
@MainActor
|
||||
@Suite("Paste ▸ copy from staging")
|
||||
struct PasteFromStagingTests {
|
||||
|
||||
@Test("A pasted card is byte-perfect from the snapshot, attachments and all, under a fresh GUID")
|
||||
func bytePerfectCopy() async throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
let destination = try makeDestination()
|
||||
defer { destination.tearDown() }
|
||||
let target = try BoardStore(rootURL: destination.root)
|
||||
|
||||
harness.store.select([clipboardCard1], liveness: .live)
|
||||
harness.clipboard.copy(from: harness.store)
|
||||
target.select([destinationLane], liveness: .live)
|
||||
await harness.clipboard.paste(into: target)?.value
|
||||
|
||||
let arrived = try #require(try pastedIDs(destinationLane, in: destination).last)
|
||||
// Fresh identity — "copies mint fresh ones" (01-storage-format.md).
|
||||
#expect(arrived != Ident.card1)
|
||||
#expect(try pastedTitles(destinationLane, in: destination) == ["Resident", "First"])
|
||||
// The attachments came with it, byte for byte.
|
||||
#expect(try destination.data("\(Ident.lane4)/\(arrived)/attachments/photo.png")
|
||||
== Data("png bytes".utf8))
|
||||
#expect(try destination.data("\(Ident.lane4)/\(arrived)/attachments/notes.txt")
|
||||
== Data("notes".utf8))
|
||||
}
|
||||
|
||||
@Test("A copy keeps `created` and takes a fresh `modified` — a duplicate is a fork")
|
||||
func forkStamps() async throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
let destination = try makeDestination()
|
||||
defer { destination.tearDown() }
|
||||
let target = try BoardStore(rootURL: destination.root)
|
||||
|
||||
harness.store.select([clipboardCard1], liveness: .live)
|
||||
harness.clipboard.copy(from: harness.store)
|
||||
target.select([destinationLane], liveness: .live)
|
||||
await harness.clipboard.paste(into: target)?.value
|
||||
|
||||
let arrived = try #require(try pastedIDs(destinationLane, in: destination).last)
|
||||
let document = try FrontmatterDocument.parse(destination.indexText("\(Ident.lane4)/\(arrived)"))
|
||||
#expect(document.created.value == ISO8601DateFormatter().date(from: "2026-01-01T09:00:00Z"))
|
||||
#expect(document.modified.value != ISO8601DateFormatter().date(from: "2026-02-02T09:00:00Z"))
|
||||
// The unknown keys and the body rode along untouched.
|
||||
#expect(document.value(for: "project") != nil)
|
||||
#expect(document.body.contains("First body"))
|
||||
}
|
||||
|
||||
@Test("The originals stay exactly where they were")
|
||||
func originalsUntouched() async throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
let destination = try makeDestination()
|
||||
defer { destination.tearDown() }
|
||||
let target = try BoardStore(rootURL: destination.root)
|
||||
|
||||
harness.store.select([clipboardCard1], liveness: .live)
|
||||
harness.clipboard.copy(from: harness.store)
|
||||
target.select([destinationLane], liveness: .live)
|
||||
await harness.clipboard.paste(into: target)?.value
|
||||
|
||||
#expect(try pastedTitles(clipboardLane1, in: harness.fixture) == ["First", "Second"])
|
||||
}
|
||||
|
||||
@Test("A second paste materializes a second copy")
|
||||
func secondPasteCopiesAgain() async throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
let destination = try makeDestination()
|
||||
defer { destination.tearDown() }
|
||||
let target = try BoardStore(rootURL: destination.root)
|
||||
|
||||
harness.store.select([clipboardCard1], liveness: .live)
|
||||
harness.clipboard.copy(from: harness.store)
|
||||
target.select([destinationLane], liveness: .live)
|
||||
await harness.clipboard.paste(into: target)?.value
|
||||
target.handleWatcherEvent(.treeChanged(.appMediated))
|
||||
await target.awaitQuiescence()
|
||||
target.select([destinationLane], liveness: .live)
|
||||
await harness.clipboard.paste(into: target)?.value
|
||||
|
||||
let ids = try pastedIDs(destinationLane, in: destination)
|
||||
#expect(ids.count == 3)
|
||||
#expect(Set(ids).count == 3)
|
||||
}
|
||||
|
||||
@Test("Pasting into the source board is the within-board duplicate")
|
||||
func pasteIntoSource() async throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
|
||||
harness.store.select([clipboardCard1], liveness: .live)
|
||||
harness.clipboard.copy(from: harness.store)
|
||||
await harness.clipboard.paste(into: harness.store)?.value
|
||||
|
||||
// The copy landed immediately after its own original, which is the anchor rule.
|
||||
#expect(try pastedTitles(clipboardLane1, in: harness.fixture) == ["First", "First", "Second"])
|
||||
let ids = try pastedIDs(clipboardLane1, in: harness.fixture)
|
||||
#expect(Set(ids).count == 3)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lane pastes
|
||||
|
||||
@MainActor
|
||||
@Suite("Paste ▸ lanes")
|
||||
struct PasteLaneTests {
|
||||
|
||||
@Test("A pasted lane copy takes fresh GUIDs throughout and strips tombstoned cards")
|
||||
func laneCopyStripsTombstones() async throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
let destination = try makeDestination()
|
||||
defer { destination.tearDown() }
|
||||
let target = try BoardStore(rootURL: destination.root)
|
||||
|
||||
harness.store.select([clipboardLane1], liveness: .live)
|
||||
harness.clipboard.copy(from: harness.store)
|
||||
await harness.clipboard.paste(into: target)?.value
|
||||
|
||||
let model = try pasted(destination)
|
||||
#expect(model.lanes.count == 2)
|
||||
let arrived = try #require(model.lanes.last)
|
||||
#expect(arrived.id.rawValue != Ident.lane1)
|
||||
#expect(arrived.title.value == "Todo")
|
||||
// The tombstoned card is gone — "the copy transfers content, and trash isn't content".
|
||||
#expect(arrived.cards.count == 2)
|
||||
#expect(arrived.cards.allSatisfy { !$0.isDeleted })
|
||||
#expect(arrived.cards.map(\.id.rawValue).allSatisfy { $0 != Ident.card1 && $0 != Ident.card2 })
|
||||
// The tombstoned original is still recoverable where it always was.
|
||||
#expect(try lane(clipboardLane1, in: harness.fixture)?.cards.count == 3)
|
||||
}
|
||||
|
||||
@Test("A lane paste with nothing selected lands at the board's right end")
|
||||
func laneLandsAtTheRightEnd() async throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
let destination = try makeDestination()
|
||||
defer { destination.tearDown() }
|
||||
let target = try BoardStore(rootURL: destination.root)
|
||||
|
||||
harness.store.select([clipboardLane1], liveness: .live)
|
||||
harness.clipboard.copy(from: harness.store)
|
||||
await harness.clipboard.paste(into: target)?.value
|
||||
|
||||
#expect(try pasted(destination).lanes.compactMap(\.title.value) == ["Inbox", "Todo"])
|
||||
}
|
||||
|
||||
@Test("A lane pastes onto a board with no lanes at all")
|
||||
func laneOntoZeroLaneBoard() async throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
let empty = try WriterFixture()
|
||||
defer { empty.tearDown() }
|
||||
try empty.item("", Item.board)
|
||||
let target = try BoardStore(rootURL: empty.root)
|
||||
|
||||
harness.store.select([clipboardLane1], liveness: .live)
|
||||
harness.clipboard.copy(from: harness.store)
|
||||
await harness.clipboard.paste(into: target)?.value
|
||||
|
||||
#expect(try pasted(empty).lanes.compactMap(\.title.value) == ["Todo"])
|
||||
}
|
||||
|
||||
@Test("A lane cut-move carries its tombstoned cards whole")
|
||||
func laneCutMoveCarriesTombstones() async throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
let destination = try makeDestination()
|
||||
defer { destination.tearDown() }
|
||||
let target = try BoardStore(rootURL: destination.root)
|
||||
|
||||
harness.store.select([clipboardLane1], liveness: .live)
|
||||
harness.clipboard.cut(from: harness.store)
|
||||
await harness.clipboard.paste(into: target)?.value
|
||||
|
||||
let model = try pasted(destination)
|
||||
let arrived = try #require(model.lanes.first { $0.id == clipboardLane1 })
|
||||
// Identity travelled, and the tombstone landed in the destination's trash.
|
||||
#expect(arrived.cards.count == 3)
|
||||
#expect(arrived.cards.contains { $0.isDeleted })
|
||||
// The lane left the source board entirely.
|
||||
#expect(try pasted(harness.fixture).lanes.map(\.id) == [clipboardLane2])
|
||||
}
|
||||
|
||||
@Test("The within-board lane duplicate — paste into the source board")
|
||||
func withinBoardLaneDuplicate() async throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
|
||||
harness.store.select([clipboardLane1], liveness: .live)
|
||||
harness.clipboard.copy(from: harness.store)
|
||||
await harness.clipboard.paste(into: harness.store)?.value
|
||||
|
||||
let model = try pasted(harness.fixture)
|
||||
#expect(model.lanes.compactMap(\.title.value) == ["Todo", "Todo", "Doing"])
|
||||
let duplicate = try #require(model.lanes.dropFirst().first)
|
||||
#expect(duplicate.id != clipboardLane1)
|
||||
#expect(duplicate.cards.count == 2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The trash's copy-out
|
||||
|
||||
@MainActor
|
||||
@Suite("Paste ▸ from the trash")
|
||||
struct PasteFromTrashTests {
|
||||
|
||||
@Test("A card copied out of the trash arrives live")
|
||||
func cardArrivesLive() async throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
let destination = try makeDestination()
|
||||
defer { destination.tearDown() }
|
||||
let target = try BoardStore(rootURL: destination.root)
|
||||
|
||||
harness.store.transient.isTrashVisible = true
|
||||
harness.store.select([clipboardCard3], liveness: .trashed)
|
||||
harness.clipboard.copy(from: harness.store)
|
||||
target.select([destinationLane], liveness: .live)
|
||||
await harness.clipboard.paste(into: target)?.value
|
||||
|
||||
#expect(try pastedTitles(destinationLane, in: destination) == ["Resident", "Trashed"])
|
||||
// And the tombstoned original stays in the source trash — copy-out, never a move.
|
||||
#expect(try lane(clipboardLane1, in: harness.fixture)?
|
||||
.cards.first { $0.id == clipboardCard3 }?.isDeleted == true)
|
||||
}
|
||||
|
||||
@Test("A lane entry copied out of the trash arrives live, its tombstoned interior cards stripped")
|
||||
func laneEntryStripsBothWays() async throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item("", Item.board)
|
||||
// A tombstoned lane holding one plain card and one that carries its own tombstone.
|
||||
try fixture.item(Ident.lane1, tombstonedItem(order: "1024", title: "Archive"))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Kept"))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card2)", tombstonedItem(order: "2048", title: "Gone"))
|
||||
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
||||
|
||||
let harness = try ClipboardHarness(fixture: fixture)
|
||||
defer { try? FileManager.default.removeItem(at: harness.staging) }
|
||||
let destination = try makeDestination()
|
||||
defer { destination.tearDown() }
|
||||
let target = try BoardStore(rootURL: destination.root)
|
||||
|
||||
harness.store.transient.isTrashVisible = true
|
||||
harness.store.select([clipboardLane1], liveness: .trashed)
|
||||
harness.clipboard.copy(from: harness.store)
|
||||
await harness.clipboard.paste(into: target)?.value
|
||||
|
||||
let model = try pasted(destination)
|
||||
let arrived = try #require(model.lanes.last)
|
||||
#expect(arrived.isDeleted == false)
|
||||
#expect(arrived.title.value == "Archive")
|
||||
#expect(arrived.cards.count == 1)
|
||||
#expect(arrived.cards.first?.title.value == "Kept")
|
||||
#expect(arrived.cards.first?.isDeleted == false)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The deferred cut
|
||||
|
||||
@MainActor
|
||||
@Suite("Paste ▸ the deferred cut")
|
||||
struct PasteCutTests {
|
||||
|
||||
@Test("The first armed paste moves the originals and clears the cut")
|
||||
func armedPasteMoves() async throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
let destination = try makeDestination()
|
||||
defer { destination.tearDown() }
|
||||
let target = try BoardStore(rootURL: destination.root)
|
||||
|
||||
harness.store.select([clipboardCard1], liveness: .live)
|
||||
harness.clipboard.cut(from: harness.store)
|
||||
target.select([destinationLane], liveness: .live)
|
||||
await harness.clipboard.paste(into: target)?.value
|
||||
|
||||
// Identity travelled.
|
||||
#expect(try pastedIDs(destinationLane, in: destination) == [Ident.indexless, Ident.card1])
|
||||
// The attachments came with the folder.
|
||||
#expect(try destination.data("\(Ident.lane4)/\(Ident.card1)/attachments/photo.png")
|
||||
== Data("png bytes".utf8))
|
||||
// The original left the source board.
|
||||
#expect(try pastedTitles(clipboardLane1, in: harness.fixture) == ["Second"])
|
||||
#expect(harness.store.transient.pendingCut.isEmpty)
|
||||
}
|
||||
|
||||
@Test("A second paste after an armed cut materializes a copy from staging")
|
||||
func secondPasteAfterACutCopies() async throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
let destination = try makeDestination()
|
||||
defer { destination.tearDown() }
|
||||
let target = try BoardStore(rootURL: destination.root)
|
||||
|
||||
harness.store.select([clipboardCard1], liveness: .live)
|
||||
harness.clipboard.cut(from: harness.store)
|
||||
target.select([destinationLane], liveness: .live)
|
||||
await harness.clipboard.paste(into: target)?.value
|
||||
target.handleWatcherEvent(.treeChanged(.appMediated))
|
||||
await target.awaitQuiescence()
|
||||
target.select([destinationLane], liveness: .live)
|
||||
await harness.clipboard.paste(into: target)?.value
|
||||
|
||||
let ids = try pastedIDs(destinationLane, in: destination)
|
||||
#expect(ids.count == 3)
|
||||
#expect(ids.contains(Ident.card1))
|
||||
// The second arrival is a fresh identity, not the moved one seen twice.
|
||||
#expect(Set(ids).count == 3)
|
||||
}
|
||||
|
||||
@Test("A voided cut downgrades to a copy: the originals stay")
|
||||
func voidedCutCopies() async throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
let destination = try makeDestination()
|
||||
defer { destination.tearDown() }
|
||||
let target = try BoardStore(rootURL: destination.root)
|
||||
|
||||
harness.store.select([clipboardCard1], liveness: .live)
|
||||
harness.clipboard.cut(from: harness.store)
|
||||
// The source board closes — its store goes, and with it the cut's arming.
|
||||
harness.store.transient.pendingCut = .empty
|
||||
|
||||
target.select([destinationLane], liveness: .live)
|
||||
await harness.clipboard.paste(into: target)?.value
|
||||
|
||||
// A copy: a fresh identity at the destination, and the original still at home.
|
||||
let arrived = try #require(try pastedIDs(destinationLane, in: destination).last)
|
||||
#expect(arrived != Ident.card1)
|
||||
#expect(try pastedTitles(clipboardLane1, in: harness.fixture) == ["First", "Second"])
|
||||
}
|
||||
|
||||
@Test("Per-item voiding: the paste moves only the survivors")
|
||||
func survivorsOnly() async throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
let destination = try makeDestination()
|
||||
defer { destination.tearDown() }
|
||||
let target = try BoardStore(rootURL: destination.root)
|
||||
|
||||
harness.store.select([clipboardCard1, clipboardCard2], liveness: .live)
|
||||
harness.clipboard.cut(from: harness.store)
|
||||
|
||||
// One of the two is tombstoned before the paste: the reload ejects it from the pending cut.
|
||||
harness.store.delete([clipboardCard1])
|
||||
harness.store.handleWatcherEvent(.treeChanged(.appMediated))
|
||||
await harness.store.awaitQuiescence()
|
||||
#expect(harness.store.transient.pendingCut.ids == [clipboardCard2])
|
||||
|
||||
target.select([destinationLane], liveness: .live)
|
||||
await harness.clipboard.paste(into: target)?.value
|
||||
|
||||
#expect(try pastedIDs(destinationLane, in: destination) == [Ident.indexless, Ident.card2])
|
||||
// The tombstoned one stayed behind, in the source board's trash.
|
||||
#expect(try lane(clipboardLane1, in: harness.fixture)?
|
||||
.cards.first { $0.id == clipboardCard1 }?.isDeleted == true)
|
||||
}
|
||||
|
||||
@Test("A cut emptied down to nothing is simply void — a paste copies instead")
|
||||
func emptiedCutIsVoid() async throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
let destination = try makeDestination()
|
||||
defer { destination.tearDown() }
|
||||
let target = try BoardStore(rootURL: destination.root)
|
||||
|
||||
harness.store.select([clipboardCard1], liveness: .live)
|
||||
harness.clipboard.cut(from: harness.store)
|
||||
harness.store.delete([clipboardCard1])
|
||||
harness.store.handleWatcherEvent(.treeChanged(.appMediated))
|
||||
await harness.store.awaitQuiescence()
|
||||
#expect(harness.store.transient.pendingCut.isEmpty)
|
||||
|
||||
target.select([destinationLane], liveness: .live)
|
||||
await harness.clipboard.paste(into: target)?.value
|
||||
|
||||
// The staged snapshot is still there, so the paste is a copy — content intact.
|
||||
#expect(try pastedTitles(destinationLane, in: destination) == ["Resident", "First"])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The staging-less fallback
|
||||
|
||||
@MainActor
|
||||
@Suite("Paste ▸ the staging-less fallback")
|
||||
struct PasteFallbackTests {
|
||||
|
||||
@Test("A missing snapshot falls back to the embedded index.md, byte-faithfully")
|
||||
func fallbackWritesTheSourceBytes() async throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
let destination = try makeDestination()
|
||||
defer { destination.tearDown() }
|
||||
let target = try BoardStore(rootURL: destination.root)
|
||||
|
||||
harness.store.select([clipboardCard1], liveness: .live)
|
||||
harness.clipboard.copy(from: harness.store)
|
||||
await harness.clipboard.stagingSettled()
|
||||
// The snapshot goes — a swept tree, a full disk, an unreadable container.
|
||||
let copyID = try #require(harness.clipboard.payload?.copyID)
|
||||
try FileManager.default.removeItem(
|
||||
at: harness.staging.appendingPathComponent(copyID, isDirectory: true)
|
||||
)
|
||||
|
||||
target.select([destinationLane], liveness: .live)
|
||||
await harness.clipboard.paste(into: target)?.value
|
||||
|
||||
let arrived = try #require(try pastedIDs(destinationLane, in: destination).last)
|
||||
let document = try FrontmatterDocument.parse(destination.indexText("\(Ident.lane4)/\(arrived)"))
|
||||
#expect(document.title.value == "First")
|
||||
// Content intact: unknown keys, the comment's key, and the body all survived.
|
||||
#expect(document.value(for: "project") != nil)
|
||||
#expect(document.value(for: "labels") != nil)
|
||||
#expect(document.body.contains("First body — with *markdown*"))
|
||||
// `created` kept, fresh `order`.
|
||||
#expect(document.created.value == ISO8601DateFormatter().date(from: "2026-01-01T09:00:00Z"))
|
||||
#expect(document.order.value != 1024)
|
||||
// Attachments absent — which is exactly what the banner is about to say.
|
||||
#expect(!destination.exists("\(Ident.lane4)/\(arrived)/attachments"))
|
||||
}
|
||||
|
||||
@Test("A degraded paste banners, naming exactly what was lost")
|
||||
func fallbackBanners() async throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
let destination = try makeDestination()
|
||||
defer { destination.tearDown() }
|
||||
let target = try BoardStore(rootURL: destination.root)
|
||||
|
||||
harness.store.select([clipboardCard1], liveness: .live)
|
||||
harness.clipboard.copy(from: harness.store)
|
||||
await harness.clipboard.stagingSettled()
|
||||
let copyID = try #require(harness.clipboard.payload?.copyID)
|
||||
try FileManager.default.removeItem(
|
||||
at: harness.staging.appendingPathComponent(copyID, isDirectory: true)
|
||||
)
|
||||
|
||||
target.select([destinationLane], liveness: .live)
|
||||
await harness.clipboard.paste(into: target)?.value
|
||||
|
||||
#expect(target.banners.signposts.map(\.message) == ["Pasted 'First' without its 2 attachments"])
|
||||
}
|
||||
|
||||
@Test("A fallback that lost nothing says nothing")
|
||||
func fallbackWithoutAttachmentsIsSilent() async throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
let destination = try makeDestination()
|
||||
defer { destination.tearDown() }
|
||||
let target = try BoardStore(rootURL: destination.root)
|
||||
|
||||
// `card2` has no attachments, so a fallback loses nothing at all.
|
||||
harness.store.select([clipboardCard2], liveness: .live)
|
||||
harness.clipboard.copy(from: harness.store)
|
||||
await harness.clipboard.stagingSettled()
|
||||
let copyID = try #require(harness.clipboard.payload?.copyID)
|
||||
try FileManager.default.removeItem(
|
||||
at: harness.staging.appendingPathComponent(copyID, isDirectory: true)
|
||||
)
|
||||
|
||||
target.select([destinationLane], liveness: .live)
|
||||
await harness.clipboard.paste(into: target)?.value
|
||||
|
||||
#expect(try pastedTitles(destinationLane, in: destination) == ["Resident", "Second"])
|
||||
#expect(target.banners.signposts.isEmpty)
|
||||
}
|
||||
|
||||
@Test("A lane's fallback materializes its embedded cards")
|
||||
func laneFallbackCarriesItsCards() async throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
let destination = try makeDestination()
|
||||
defer { destination.tearDown() }
|
||||
let target = try BoardStore(rootURL: destination.root)
|
||||
|
||||
harness.store.select([clipboardLane1], liveness: .live)
|
||||
harness.clipboard.copy(from: harness.store)
|
||||
await harness.clipboard.stagingSettled()
|
||||
let copyID = try #require(harness.clipboard.payload?.copyID)
|
||||
try FileManager.default.removeItem(
|
||||
at: harness.staging.appendingPathComponent(copyID, isDirectory: true)
|
||||
)
|
||||
|
||||
await harness.clipboard.paste(into: target)?.value
|
||||
|
||||
let arrived = try #require(try pasted(destination).lanes.last)
|
||||
#expect(arrived.title.value == "Todo")
|
||||
// The two live cards, and not the tombstoned third.
|
||||
#expect(arrived.cards.count == 2)
|
||||
#expect(Set(arrived.cards.compactMap(\.title.value)) == ["First", "Second"])
|
||||
#expect(target.banners.signposts.map(\.message) == ["Pasted 'Todo' without its 2 attachments"])
|
||||
}
|
||||
|
||||
@Test("A trash-sourced fallback still strips `deleted:` at materialization")
|
||||
func trashedFallbackStripsDeleted() async throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
let destination = try makeDestination()
|
||||
defer { destination.tearDown() }
|
||||
let target = try BoardStore(rootURL: destination.root)
|
||||
|
||||
harness.store.transient.isTrashVisible = true
|
||||
harness.store.select([clipboardCard3], liveness: .trashed)
|
||||
harness.clipboard.copy(from: harness.store)
|
||||
await harness.clipboard.stagingSettled()
|
||||
let copyID = try #require(harness.clipboard.payload?.copyID)
|
||||
try FileManager.default.removeItem(
|
||||
at: harness.staging.appendingPathComponent(copyID, isDirectory: true)
|
||||
)
|
||||
|
||||
target.select([destinationLane], liveness: .live)
|
||||
await harness.clipboard.paste(into: target)?.value
|
||||
|
||||
#expect(try pastedTitles(destinationLane, in: destination) == ["Resident", "Trashed"])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - BoardWriter.materializeItem
|
||||
|
||||
@Suite("BoardWriter ▸ materializeItem")
|
||||
struct MaterializeItemTests {
|
||||
|
||||
@Test("The supplied bytes land verbatim but for the rewritten order and stamps")
|
||||
func writesTheSuppliedBytes() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item("", Item.board)
|
||||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||
|
||||
let id = try BoardWriter.materializeItem(
|
||||
inParent: fixture.url(Ident.lane1),
|
||||
indexText: Item.rich(order: "9999", title: "Pasted"),
|
||||
order: 512
|
||||
)
|
||||
|
||||
let document = try FrontmatterDocument.parse(fixture.indexText("\(Ident.lane1)/\(id.rawValue)"))
|
||||
#expect(document.title.value == "Pasted")
|
||||
#expect(document.order.value == 512)
|
||||
#expect(document.value(for: "project") != nil)
|
||||
#expect(document.value(for: "labels") != nil)
|
||||
// The app-write stamps: `modified` set, `modified-by` cleared.
|
||||
#expect(document.modified.value != ISO8601DateFormatter().date(from: "2026-02-02T09:00:00Z"))
|
||||
#expect(document.modifiedBy.isMissing)
|
||||
// `created` untouched — a paste is a fork.
|
||||
#expect(document.created.value == ISO8601DateFormatter().date(from: "2026-01-01T09:00:00Z"))
|
||||
}
|
||||
|
||||
@Test("Children are materialized under fresh identities and never rewritten")
|
||||
func childrenAreVerbatim() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item("", Item.board)
|
||||
|
||||
let id = try BoardWriter.materializeItem(
|
||||
inParent: fixture.root,
|
||||
indexText: Item.rich(order: "1024", title: "Lane"),
|
||||
children: [Item.rich(order: "1024", title: "One"), Item.uneditable],
|
||||
order: 1024
|
||||
)
|
||||
|
||||
let lane = try #require(try BoardLoader.load(boardRoot: fixture.root).model.lanes.first)
|
||||
#expect(lane.id == id)
|
||||
#expect(lane.cards.count == 2)
|
||||
// An uneditable child arrives exactly as it was — the leniency `copyItem` extends below its
|
||||
// root, applied here.
|
||||
let names = try FileManager.default.contentsOfDirectory(atPath: fixture.url(id.rawValue).path)
|
||||
.filter { $0 != "index.md" }
|
||||
let odd = try #require(names.first { name in
|
||||
(try? fixture.indexText("\(id.rawValue)/\(name)")) == Item.uneditable
|
||||
})
|
||||
#expect(try fixture.indexText("\(id.rawValue)/\(odd)") == Item.uneditable)
|
||||
}
|
||||
|
||||
@Test("An unparseable root refuses and leaves nothing behind")
|
||||
func unparseableRootLeavesNoResidue() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item("", Item.board)
|
||||
|
||||
let error = writeFailure {
|
||||
_ = try BoardWriter.materializeItem(
|
||||
inParent: fixture.root,
|
||||
indexText: "no frontmatter here at all\n",
|
||||
order: 1024
|
||||
)
|
||||
}
|
||||
#expect(error != nil)
|
||||
#expect(try fixture.entryNames("") == ["index.md"])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user