⌘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
647 lines
28 KiB
Swift
647 lines
28 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], 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"])
|
|
}
|
|
}
|