Nine rulings land as code. Reorders don't stamp — one container-change predicate (WriteOperation.rewritesOrderOnly): within-container reorders and the renumber rescale rewrite only order, while cross-lane, cross-board, and trash moves stamp modified and clear modified-by; no trash special case exists, and the m8 undo inverses conform through the same seam. Copies are transactions: the root-strict/nested-lenient split retires for a whole-subtree stampability preflight that refuses loudly naming the offender, and every item-level copy severs remote/remote-state at every level (whole-board forks carry them verbatim). Paste refuses, never degrades: the embedded-index.md materialization and its loss row retire; a missing staged snapshot produces nothing and posts an error-tone one-shot named from manifest metadata. Coerce-tier fallbacks log through the Defect stream with path context attached loader-side. Displacement is level-uniform: a file squatting attachments inside a card heals by the same rename ladder as board-root squatters; comments stays tolerated. Delete Immediately joins card and lane context menus as Delete's ⌥-alternate with its own VO custom action, routed through an explicit container so the menu target outranks standing selection. Agent guide v7 teaches the stamp discipline and the card-level attachments claim, and sheds two stale v6 lines (lanes trash now; kind is taught). Verified conformant, unchanged: edition-aware Undo/Redo disable, trash marquee full-height backdrop. Both schemes 1854 tests / 318 suites green; verify-editions 30/30. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
728 lines
33 KiB
Swift
728 lines
33 KiB
Swift
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], in: .board)
|
|
harness.clipboard.copy(from: harness.store)
|
|
target.select([destinationLane], in: .board)
|
|
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], in: .board)
|
|
harness.clipboard.copy(from: harness.store)
|
|
target.select([destinationLane], in: .board)
|
|
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], in: .board)
|
|
harness.clipboard.copy(from: harness.store)
|
|
target.select([destinationLane], in: .board)
|
|
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], in: .board)
|
|
harness.clipboard.copy(from: harness.store)
|
|
target.select([destinationLane], in: .board)
|
|
await harness.clipboard.paste(into: target)?.value
|
|
target.handleWatcherEvent(.treeChanged(.appMediated))
|
|
await target.awaitQuiescence()
|
|
target.select([destinationLane], in: .board)
|
|
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], in: .board)
|
|
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 carries exactly its cards")
|
|
func laneCopyRemintsThroughout() 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], in: .board)
|
|
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")
|
|
// "A lane carries exactly its cards — the trash is board-level, so there is nothing
|
|
// lane-nested to strip or carry" (04 ▸ Drag and drop, resettled 2026-07-28).
|
|
#expect(arrived.cards.count == 2)
|
|
#expect(arrived.cards.map(\.id.rawValue).allSatisfy { $0 != Ident.card1 && $0 != Ident.card2 })
|
|
#expect(try pasted(harness.fixture).trash.count == 1, "and the source's trash is untouched")
|
|
}
|
|
|
|
@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], in: .board)
|
|
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], in: .board)
|
|
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 cards whole, identity and all")
|
|
func laneCutMoveCarriesItsCards() 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], in: .board)
|
|
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 every card came with it — nothing to strip.
|
|
#expect(arrived.cards.count == 2)
|
|
#expect(model.trash.isEmpty, "the source board's trash is board-level and stays there")
|
|
// 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], in: .board)
|
|
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 is an ordinary copy of an ordinary card")
|
|
func cardCopiesOutOrdinarily() 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], in: .trash)
|
|
harness.clipboard.copy(from: harness.store)
|
|
target.select([destinationLane], in: .board)
|
|
await harness.clipboard.paste(into: target)?.value
|
|
|
|
#expect(try pastedTitles(destinationLane, in: destination) == ["Resident", "Trashed"])
|
|
// The original stays in the source trash — a copy is a copy (04 ▸ The trash: "⌘C copies a
|
|
// trash card; a live copy lands wherever pasted, like copying out of Finder's Trash").
|
|
#expect(try pasted(harness.fixture).trash.map(\.id) == [clipboardCard3])
|
|
// And nothing had to be stripped on arrival: a trashed card carries no key at all.
|
|
let landed = try #require(try lane(destinationLane, in: destination)?.cards.last)
|
|
#expect(landed.deleted.isMissing)
|
|
}
|
|
|
|
/// **Cut in the trash, paste into a lane, is the keyboard-native restore** (04-interactions.md
|
|
/// ▸ The trash, resettled 2026-07-28) — "an ordinary folder move", which is exactly what the
|
|
/// armed cut already does.
|
|
@Test("Cut in the trash and paste into a lane is the keyboard restore — the folder moves")
|
|
func cutFromTheTrashIsTheKeyboardRestore() async throws {
|
|
let harness = try makeClipboardHarness()
|
|
defer { harness.tearDown() }
|
|
|
|
harness.store.transient.isTrashVisible = true
|
|
harness.store.select([clipboardCard3], in: .trash)
|
|
harness.clipboard.cut(from: harness.store)
|
|
harness.store.select([clipboardLane2], in: .board)
|
|
await harness.clipboard.paste(into: harness.store)?.value
|
|
|
|
let model = try pasted(harness.fixture)
|
|
#expect(model.trash.isEmpty, "the folder left the trash")
|
|
let lane = try #require(model.lanes.first { $0.id == clipboardLane2 })
|
|
#expect(lane.cards.map(\.id).contains(clipboardCard3), "identity travels — it is a move")
|
|
#expect(harness.store.transient.pendingCut.isEmpty, "the cut was consumed")
|
|
}
|
|
}
|
|
|
|
// MARK: - The destination's search, and the stale pasteboard
|
|
|
|
/// Two rules that meet at the same guard.
|
|
///
|
|
/// **A paste is a user-initiated creation, so it clears the destination's query**
|
|
/// (04-interactions.md § Search, stated by mechanism: "⌘N, Return-creation, the header button,
|
|
/// empty-space double-click, paste, and Finder file drops alike") — cards and lanes alike, since the
|
|
/// clipboard holds one or the other and both mint items on arrival.
|
|
///
|
|
/// **The pasteboard is re-read lazily, and a stale paste no-ops** (04 ▸ Clipboard, settled):
|
|
/// "changeCount is checked on activation, on menu validation, and before paste — no timers … the
|
|
/// paste itself re-validates and no-ops — nothing stale ever lands, which is the guarantee that
|
|
/// matters". Both checks are pinned here, and the clear rides behind the second of them: a paste that
|
|
/// lands nothing clears nothing.
|
|
@MainActor
|
|
@Suite("Paste ▸ the destination's search and the stale pasteboard")
|
|
struct PasteSearchAndStalenessTests {
|
|
|
|
@Test("A card paste clears the destination board's search")
|
|
func aCardPasteClearsTheSearch() 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], in: .board)
|
|
harness.clipboard.copy(from: harness.store)
|
|
target.select([destinationLane], in: .board)
|
|
// "First" would be hidden by this query — exactly the card that must not arrive invisible.
|
|
target.searchQuery = "resident"
|
|
await harness.clipboard.paste(into: target)?.value
|
|
|
|
#expect(target.searchQuery.isEmpty)
|
|
#expect(try pastedTitles(destinationLane, in: destination) == ["Resident", "First"])
|
|
}
|
|
|
|
@Test("A lane paste clears it too — the rule is creation, not the payload's kind")
|
|
func aLanePasteClearsTheSearch() 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([clipboardLane2], in: .board)
|
|
harness.clipboard.copy(from: harness.store)
|
|
target.searchQuery = "resident"
|
|
await harness.clipboard.paste(into: target)?.value
|
|
|
|
#expect(target.searchQuery.isEmpty)
|
|
#expect(try pasted(destination).lanes.count == 2)
|
|
}
|
|
|
|
@Test("Another app taking the pasteboard before ⌘V: the paste is refused outright")
|
|
func aTakeoverBeforeThePasteRefuses() 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], in: .board)
|
|
harness.clipboard.copy(from: harness.store)
|
|
harness.pasteboard.takeOver()
|
|
target.select([destinationLane], in: .board)
|
|
target.searchQuery = "resident"
|
|
|
|
// `refresh()` at the front of the paste sees the moved changeCount, so there is no payload
|
|
// and no task at all — the same condition the menu item's enablement reads.
|
|
#expect(harness.clipboard.paste(into: target) == nil)
|
|
#expect(harness.clipboard.canPaste(into: target) == false)
|
|
#expect(try pastedTitles(destinationLane, in: destination) == ["Resident"])
|
|
#expect(target.searchQuery == "resident")
|
|
}
|
|
|
|
@Test("Another app taking it while the staging chain settles: the paste re-validates and lands nothing")
|
|
func aTakeoverMidPasteLandsNothing() 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], in: .board)
|
|
harness.clipboard.copy(from: harness.store)
|
|
target.select([destinationLane], in: .board)
|
|
target.searchQuery = "resident"
|
|
|
|
// The gesture passed validation; the takeover lands while the task is still waiting on the
|
|
// staging chain, which is the window 04 calls the brief lie. Synchronous, so the task cannot
|
|
// have run yet: it can only resume where this test suspends.
|
|
let paste = harness.clipboard.paste(into: target)
|
|
harness.pasteboard.takeOver()
|
|
await paste?.value
|
|
|
|
#expect(try pastedTitles(destinationLane, in: destination) == ["Resident"])
|
|
// Nothing landed, so nothing was created — and the query the user was running stands.
|
|
#expect(target.searchQuery == "resident")
|
|
}
|
|
}
|
|
|
|
// 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], in: .board)
|
|
harness.clipboard.cut(from: harness.store)
|
|
target.select([destinationLane], in: .board)
|
|
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], in: .board)
|
|
harness.clipboard.cut(from: harness.store)
|
|
target.select([destinationLane], in: .board)
|
|
await harness.clipboard.paste(into: target)?.value
|
|
target.handleWatcherEvent(.treeChanged(.appMediated))
|
|
await target.awaitQuiescence()
|
|
target.select([destinationLane], in: .board)
|
|
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], in: .board)
|
|
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], in: .board)
|
|
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], in: .board)
|
|
harness.clipboard.cut(from: harness.store)
|
|
|
|
// One of the two is deleted 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], in: .board)
|
|
await harness.clipboard.paste(into: target)?.value
|
|
|
|
#expect(try pastedIDs(destinationLane, in: destination) == [Ident.indexless, Ident.card2])
|
|
// The deleted one stayed behind, in the source board's trash.
|
|
#expect(try pasted(harness.fixture).trash.map(\.id).contains(clipboardCard1))
|
|
}
|
|
|
|
@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], 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.isEmpty)
|
|
|
|
target.select([destinationLane], in: .board)
|
|
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: - Refuse, never degrade
|
|
|
|
/// **04-interactions.md ▸ Clipboard, re-ruled 2026-07-29** — Finder's invariant adopted:
|
|
///
|
|
/// > 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. An item arrives **whole — index, attachments, loose files, and comments when they ship
|
|
/// > — or not at all** … The refusal is transactional — all-or-nothing for the whole paste.
|
|
///
|
|
/// These are the former `PasteFallbackTests`, turned around: every case that used to assert an item
|
|
/// materialized from the manifest's embedded `index.md` now asserts that **nothing** was written and a
|
|
/// failure banner names the entry. The manifest still embeds the text — it is what names the entry in
|
|
/// the sentence below — it is simply never a materialization source.
|
|
@MainActor
|
|
@Suite("Paste ▸ refuse, never degrade")
|
|
struct PasteRefusalTests {
|
|
|
|
/// Drops the staged tree the way the world does: a sweep that ran early, an unreadable container,
|
|
/// a full disk mid-copy.
|
|
private func loseTheSnapshot(_ harness: ClipboardHarness) throws {
|
|
let copyID = try #require(harness.clipboard.payload?.copyID)
|
|
try FileManager.default.removeItem(
|
|
at: harness.staging.appendingPathComponent(copyID, isDirectory: true)
|
|
)
|
|
}
|
|
|
|
@Test("A missing snapshot writes nothing at all")
|
|
func aMissingSnapshotWritesNothing() 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], in: .board)
|
|
harness.clipboard.copy(from: harness.store)
|
|
await harness.clipboard.stagingSettled()
|
|
try loseTheSnapshot(harness)
|
|
|
|
target.select([destinationLane], in: .board)
|
|
await harness.clipboard.paste(into: target)?.value
|
|
|
|
#expect(
|
|
try pastedTitles(destinationLane, in: destination) == ["Resident"],
|
|
"the destination holds exactly what it held before"
|
|
)
|
|
}
|
|
|
|
/// 04's own example sentence, end to end: the entry is named from the manifest's metadata, which is
|
|
/// the whole reason the embedded `index.md` is still carried.
|
|
@Test("The refusal banners as a failure, naming the entry from the manifest")
|
|
func theRefusalBanners() 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], in: .board)
|
|
harness.clipboard.copy(from: harness.store)
|
|
await harness.clipboard.stagingSettled()
|
|
try loseTheSnapshot(harness)
|
|
|
|
target.select([destinationLane], in: .board)
|
|
await harness.clipboard.paste(into: target)?.value
|
|
|
|
#expect(target.banners.losses.isEmpty, "the degraded paste's loss row is retired")
|
|
#expect(target.banners.oneShots.count == 1)
|
|
let error = try #require(target.banners.oneShots.first).error
|
|
#expect(BannerCenter.headline(for: error) == "Couldn't paste 'First' — the copied content is gone")
|
|
}
|
|
|
|
/// **The attachment-less case refuses too**, which is the pivot at its sharpest: under the degraded
|
|
/// rule this entry pasted *silently* — its content was intact and it had no attachments to lose, so
|
|
/// nothing was reported. Refuse-don't-degrade does not ask what would have been lost; the bytes the
|
|
/// paste was to reproduce are gone, so there is nothing honest to write.
|
|
@Test("An entry with no attachments refuses just the same")
|
|
func anAttachmentLessEntryRefusesToo() 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([clipboardCard2], in: .board)
|
|
harness.clipboard.copy(from: harness.store)
|
|
await harness.clipboard.stagingSettled()
|
|
try loseTheSnapshot(harness)
|
|
|
|
target.select([destinationLane], in: .board)
|
|
await harness.clipboard.paste(into: target)?.value
|
|
|
|
#expect(try pastedTitles(destinationLane, in: destination) == ["Resident"])
|
|
#expect(target.banners.oneShots.count == 1)
|
|
}
|
|
|
|
@Test("A lane payload refuses whole — no lane, no cards")
|
|
func aLanePayloadRefusesWhole() 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], in: .board)
|
|
harness.clipboard.copy(from: harness.store)
|
|
await harness.clipboard.stagingSettled()
|
|
let before = try pasted(destination).lanes.map(\.id)
|
|
try loseTheSnapshot(harness)
|
|
|
|
await harness.clipboard.paste(into: target)?.value
|
|
|
|
#expect(try pasted(destination).lanes.map(\.id) == before, "the strip is untouched")
|
|
let error = try #require(target.banners.oneShots.first).error
|
|
#expect(BannerCenter.headline(for: error) == "Couldn't paste 'Todo' — the copied content is gone")
|
|
}
|
|
|
|
/// **All-or-nothing for the whole paste** — the transactional half of the ruling, which the former
|
|
/// mixed path is exactly what retired: one entry's snapshot going missing used to leave its
|
|
/// siblings arriving whole beside a hollowed copy of it. Now the gesture refuses as a unit.
|
|
@Test("One missing snapshot refuses the whole multi-entry paste")
|
|
func oneMissingEntryRefusesTheWholePaste() 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], in: .board)
|
|
harness.clipboard.copy(from: harness.store)
|
|
await harness.clipboard.stagingSettled()
|
|
|
|
// Only the *first* entry's tree is removed; the second is staged and perfectly pasteable.
|
|
let copyID = try #require(harness.clipboard.payload?.copyID)
|
|
try FileManager.default.removeItem(
|
|
at: harness.staging
|
|
.appendingPathComponent(copyID, isDirectory: true)
|
|
.appendingPathComponent(Ident.card1, isDirectory: true)
|
|
)
|
|
|
|
target.select([destinationLane], in: .board)
|
|
await harness.clipboard.paste(into: target)?.value
|
|
|
|
#expect(
|
|
try pastedTitles(destinationLane, in: destination) == ["Resident"],
|
|
"not even the entry that could have arrived whole"
|
|
)
|
|
#expect(target.banners.oneShots.count == 1, "one refusal for one gesture")
|
|
}
|
|
|
|
/// **A refusal costs the user their content *and* nothing else** — the destination's active search
|
|
/// survives it. "Any user-initiated creation on the board clears the query" (04 ▸ Search) is a rule
|
|
/// about creations, and a refused paste creates nothing.
|
|
@Test("A refused paste leaves the destination's search alone")
|
|
func aRefusalKeepsTheSearch() async throws {
|
|
let harness = try makeClipboardHarness()
|
|
defer { harness.tearDown() }
|
|
let destination = try makeDestination()
|
|
defer { destination.tearDown() }
|
|
let target = try BoardStore(rootURL: destination.root)
|
|
target.transient.searchQuery = "resident"
|
|
|
|
harness.store.select([clipboardCard1], in: .board)
|
|
harness.clipboard.copy(from: harness.store)
|
|
await harness.clipboard.stagingSettled()
|
|
try loseTheSnapshot(harness)
|
|
|
|
target.select([destinationLane], in: .board)
|
|
await harness.clipboard.paste(into: target)?.value
|
|
|
|
#expect(target.transient.searchQuery == "resident")
|
|
}
|
|
|
|
/// A cut whose staged snapshot is gone is a different story and stays one: an armed cut moves the
|
|
/// **originals**, which are real folders in the source board, so it never reads staging at all.
|
|
/// The refusal is the copy path's, and this pins that it did not spread.
|
|
@Test("An armed cut still moves its originals — it never reads staging")
|
|
func anArmedCutIsUnaffected() 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], in: .board)
|
|
harness.clipboard.cut(from: harness.store)
|
|
await harness.clipboard.stagingSettled()
|
|
try loseTheSnapshot(harness)
|
|
|
|
target.select([destinationLane], in: .board)
|
|
await harness.clipboard.paste(into: target)?.value
|
|
|
|
#expect(try pastedTitles(destinationLane, in: destination) == ["Resident", "First"])
|
|
#expect(target.banners.oneShots.isEmpty, "nothing failed — the folder moved")
|
|
#expect(harness.fixture.exists("\(Ident.lane1)/\(Ident.card1)") == false, "and it left the source")
|
|
}
|
|
}
|