import Foundation import Testing import UniformTypeIdentifiers @testable import Kanban /// `ClipboardStore`'s own machinery — the manifest, the staging lifecycle, the sweep, the /// changeCount-based takeover, and the deferred cut's arming and voiding (04-interactions.md ▸ /// Clipboard). The *writes* a paste performs live in `PasteWriteTests.swift`. /// /// Every suite here drives a real store over a real temp board, with two things injected: a fake /// pasteboard (so a test never races the machine's one real pasteboard, nor every other test in the /// run) and a temp staging directory (so nothing goes near the app's real Application Support home). /// Both seams exist exactly because those two claims are the ones worth pinning. // MARK: - Test doubles /// The pasteboard, as a value a test can shove around. /// /// `changeCount` behaves the way `NSPasteboard`'s does — a machine-wide counter that anyone's write /// bumps — because that is the whole basis of takeover detection, and a double that only counted /// *our* writes would make the interesting case untestable. @MainActor final class FakePasteboard: ClipboardPasteboard { private(set) var changeCount = 0 private(set) var text: String? private var data: Data? /// **What some other app put down**, by type — the raw material the image branch classifies /// (`PastedImage.flavor(hasBoardItems:types:)`). Ordered so `availableTypes()` can answer in a /// stable order, which is what makes "our preference order wins over the pasteboard's" a claim a /// test can actually make. private var foreign: [(type: String, data: Data)] = [] /// **A multi-item read, unlike `foreign`** — the file-URL branch's own `fileURLs()`, which reads /// across every pasteboard item rather than the first item's types (`availableTypes()`'s /// carve-out). `foreign`'s flat `(type, data)` list cannot represent "the same type on several /// items", which is exactly what a multi-file Finder copy needs seeded. private var seededFileURLs: [URL] = [] func manifestData() -> Data? { data } @discardableResult func write(manifest: Data, text: String) -> Int { changeCount += 1 data = manifest self.text = text // A real write clears the pasteboard first, so anything another app left is gone. foreign = [] return changeCount } func availableTypes() -> [String] { (data != nil ? [UTType.laneworkClipboard.identifier] : []) + foreign.map(\.type) } func data(forType type: String) -> Data? { if type == UTType.laneworkClipboard.identifier { return data } return foreign.first { $0.type == type }?.data } func fileURLs() -> [URL] { seededFileURLs } /// Another app copied: ownership moves, our type is gone, the counter advanced. func takeOver() { changeCount += 1 data = nil text = nil foreign = [] seededFileURLs = [] } /// Another app put *these* flavors down — a screenshot, a browser's Copy Image, a Finder copy. /// `takeOver`'s shape with a payload: the counter advances and our own type goes, because that is /// what `clearContents()` does to it. func seed(_ payloads: [(type: String, data: Data)]) { takeOver() foreign = payloads } /// A Finder copy of one or more **files** — every URL as its own pasteboard item, exactly as a /// real multi-select copy is, with `public.file-url` reported through `availableTypes()` (the /// first-item read the image branch's precedence classifies) so `carriesFileURL` sees it. `also` /// seeds types riding beside the file URL on that same first item — an image flavor, for the /// precedence tests where a Finder-copied image file carries both. func seedFileURLs(_ urls: [URL], also: [(type: String, data: Data)] = []) { takeOver() seededFileURLs = urls foreign = urls.isEmpty ? also : [(UTType.fileURL.identifier, Data())] + also } /// A foreign type layered over whatever this pasteboard already holds, counter bumped but nothing /// cleared — a combination `write()` alone can never produce (a real copy's `clearContents()` /// takes everything with it), but exactly what "the app's own type wins outright" needs to /// construct to prove the guard actually fires rather than merely never being exercised /// (`PastedImageClassificationTests.boardItemsWin`'s same synthetic shape, one layer up). func layerForeign(_ payloads: [(type: String, data: Data)]) { changeCount += 1 foreign = payloads } } // MARK: - Fixtures let clipboardLane1 = ItemID(rawValue: Ident.lane1) let clipboardLane2 = ItemID(rawValue: Ident.lane2) let clipboardCard1 = ItemID(rawValue: Ident.card1) let clipboardCard2 = ItemID(rawValue: Ident.card2) let clipboardCard3 = ItemID(rawValue: Ident.card3) let clipboardCard4 = ItemID(rawValue: Ident.card4) /// The trash's **lane row** in the harness below — the opaque unit ⌘X restores (03-board-ui.md § /// Trash, lanes rejoined 2026-07-29). let clipboardTrashedLane = ItemID(rawValue: Ident.lane3) /// An ordinary card body, for the trash's resident. func trashResidentItem(order: String, title: String) -> String { """ --- schema: 1 title: \(title) order: \(order) created: 2026-01-01T09:00:00Z --- \(title) body. """ } /// Two lanes: `lane1` holds two cards, `lane2` holds a single card, and one card sits in the trash. /// `card1` carries two attachments, which is what makes "the snapshot travels whole" and "the /// fallback lost exactly two files" both assertable. @MainActor func makeClipboardBoard() throws -> WriterFixture { let fixture = try WriterFixture() try fixture.item("", Item.board) try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First")) try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/photo.png", Data("png bytes".utf8)) try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/notes.txt", Data("notes".utf8)) try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second")) try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing")) try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "1024", title: "Fourth")) try fixture.item(".trash/\(Ident.card3)", trashResidentItem(order: "1024", title: "Trashed")) return fixture } /// A store plus the two seams, torn down together. @MainActor struct ClipboardHarness { let fixture: WriterFixture let staging: URL let pasteboard: FakePasteboard let clipboard: ClipboardStore let store: BoardStore init(fixture: WriterFixture) throws { self.fixture = fixture staging = FileManager.default.temporaryDirectory .appendingPathComponent("ClipboardTests-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true) pasteboard = FakePasteboard() clipboard = ClipboardStore(pasteboard: pasteboard, stagingRoot: staging, observesActivation: false) store = try BoardStore(rootURL: fixture.root) } func tearDown() { try? FileManager.default.removeItem(at: staging) fixture.tearDown() } /// The staged copy directories, sorted — "at most the current copy" is a claim about this list. /// /// Hidden entries are excluded because the sweep keeps its own bookkeeping folder among them /// (`ClipboardStore.prune`'s claim-then-delete). A staged copy is never hidden — its name is a /// lowercased UUID. func stagedCopyIDs() throws -> [String] { try FileManager.default.contentsOfDirectory(atPath: staging.path) .filter { !$0.hasPrefix(".") } .sorted() } } @MainActor func makeClipboardHarness() throws -> ClipboardHarness { try ClipboardHarness(fixture: try makeClipboardBoard()) } /// The same board with a **lane row in its trash**, carrying one card — the clipboard's other trash /// subject (04-interactions.md ▸ The trash: "⌘X works … a trashed lane pastes after the anchor /// lane"). Its own fixture rather than a line in `makeClipboardBoard`, so every suite that counts the /// trash's cards keeps counting exactly what it did. @MainActor func makeTrashedLaneHarness() throws -> ClipboardHarness { let fixture = try makeClipboardBoard() try fixture.item( ".trash/\(Ident.lane3)", "---\nschema: 1\ntitle: Done\norder: 512\nkind: lane\nproject: lanework\n---\nDone body.\n" ) try fixture.item("\(".trash/\(Ident.lane3)")/\(Ident.indexless)", Item.rich(order: "1024", title: "Freight")) return try ClipboardHarness(fixture: fixture) } // MARK: - The manifest @Suite("ClipboardManifest") struct ClipboardManifestTests { private func entry(_ id: String) -> ClipboardManifest.Entry { ClipboardManifest.Entry( id: id, folder: id, title: "First", index: "---\nschema: 1\n---\nbody\n", attachmentCount: 2 ) } @Test("A manifest round-trips through JSON") func roundTrip() throws { let manifest = ClipboardManifest( copyID: "abc", boardRoot: URL(fileURLWithPath: "/tmp/Board.kanban", isDirectory: true), kind: .card, container: .board, entries: [entry(Ident.card1)] ) let data = try #require(manifest.encoded()) #expect(ClipboardManifest(data: data) == manifest) } @Test("A manifest from a future version is refused rather than half-read") func futureVersion() throws { var manifest = ClipboardManifest( copyID: "abc", boardRoot: URL(fileURLWithPath: "/tmp/B", isDirectory: true), kind: .card, container: .board, entries: [entry(Ident.card1)] ) manifest.version = ClipboardManifest.currentVersion + 1 let data = try #require(manifest.encoded()) #expect(ClipboardManifest(data: data) == nil) } @Test("An entryless manifest is nothing to paste") func empty() throws { let manifest = ClipboardManifest( copyID: "abc", boardRoot: URL(fileURLWithPath: "/tmp/B", isDirectory: true), kind: .card, container: .board, entries: [] ) let data = try #require(manifest.encoded()) #expect(ClipboardManifest(data: data) == nil) } @Test("A lane entry's attachment count totals its cards'") func totalAttachments() { let lane = ClipboardManifest.Entry( id: Ident.lane1, folder: Ident.lane1, title: "Todo", index: "---\nschema: 1\n---\n", attachmentCount: 0, cards: [ .init(id: Ident.card1, title: "First", index: "a", attachmentCount: 2), .init(id: Ident.card2, title: "Second", index: "b", attachmentCount: 1), ] ) #expect(lane.totalAttachmentCount == 3) } @Test("The plain-text rendering is the titles, untitled items rendered as the board renders them") func plainText() { var titled = entry(Ident.card1) var untitled = entry(Ident.card2) untitled.title = nil titled.title = "First" let manifest = ClipboardManifest( copyID: "abc", boardRoot: URL(fileURLWithPath: "/tmp/B", isDirectory: true), kind: .card, container: .board, entries: [titled, untitled] ) #expect(manifest.plainText == "First\nUntitled") } // MARK: - pasteMenuTitle private func manifest(kind: SelectionKind, entryCount: Int) -> ClipboardManifest { ClipboardManifest( copyID: "abc", boardRoot: URL(fileURLWithPath: "/tmp/B", isDirectory: true), kind: kind, container: .board, entries: (0.. BoardModel { try BoardLoader.load(boardRoot: fixture.root).model } @Test("A card payload lands after the anchor card") func afterTheAnchorCard() throws { let fixture = try makeClipboardBoard() defer { fixture.tearDown() } let model = try snapshot(fixture) let target = PasteTarget.cards( selection: ItemReferenceSet(ids: [clipboardCard1], container: .board), lastActiveLaneID: nil, snapshot: model ) #expect(target == PasteTarget.Cards(laneID: clipboardLane1, index: 1)) } @Test("A selected lane appends to its bottom") func appendsToASelectedLane() throws { let fixture = try makeClipboardBoard() defer { fixture.tearDown() } let model = try snapshot(fixture) let target = PasteTarget.cards( selection: ItemReferenceSet(ids: [clipboardLane1], container: .board), lastActiveLaneID: nil, snapshot: model ) // Two rendered cards. #expect(target == PasteTarget.Cards(laneID: clipboardLane1, index: 2)) } @Test("A multi-selection anchors at its last member in flatten order") func flattenOrderAnchor() throws { let fixture = try makeClipboardBoard() defer { fixture.tearDown() } let model = try snapshot(fixture) let target = PasteTarget.cards( selection: ItemReferenceSet(ids: [clipboardCard4, clipboardCard1], container: .board), lastActiveLaneID: nil, snapshot: model ) // `card4` is in the second lane, so it is last in flatten order however the set is spelled. #expect(target == PasteTarget.Cards(laneID: clipboardLane2, index: 1)) } @Test("A trash selection never anchors: it behaves as nothing selected") func trashSelectionNeverAnchors() throws { let fixture = try makeClipboardBoard() defer { fixture.tearDown() } let model = try snapshot(fixture) let target = PasteTarget.cards( selection: ItemReferenceSet(ids: [clipboardCard3], container: .trash), lastActiveLaneID: clipboardLane2, snapshot: model ) // The last-active lane, appended — the trash is never the destination. #expect(target == PasteTarget.Cards(laneID: clipboardLane2, index: 1)) } @Test("Nothing selected and no last-active lane falls back to the first lane") func firstLaneFallback() throws { let fixture = try makeClipboardBoard() defer { fixture.tearDown() } let model = try snapshot(fixture) let target = PasteTarget.cards(selection: .empty, lastActiveLaneID: nil, snapshot: model) #expect(target == PasteTarget.Cards(laneID: clipboardLane1, index: 2)) } @Test("A zero-lane board has no card target at all") func zeroLaneBoardHasNoCardTarget() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", Item.board) let model = try snapshot(fixture) #expect(PasteTarget.cards(selection: .empty, lastActiveLaneID: nil, snapshot: model) == nil) } @Test("A lane payload lands after the anchor lane") func afterTheAnchorLane() throws { let fixture = try makeClipboardBoard() defer { fixture.tearDown() } let model = try snapshot(fixture) #expect(PasteTarget.lanes( selection: ItemReferenceSet(ids: [clipboardLane1], container: .board), snapshot: model ) == 1) } @Test("A selected card names its lane for a lane paste") func aSelectedCardNamesItsLane() throws { let fixture = try makeClipboardBoard() defer { fixture.tearDown() } let model = try snapshot(fixture) #expect(PasteTarget.lanes( selection: ItemReferenceSet(ids: [clipboardCard1], container: .board), snapshot: model ) == 1) } @Test("Nothing (or a trash selection) lands a lane at the board's right end") func rightEnd() throws { let fixture = try makeClipboardBoard() defer { fixture.tearDown() } let model = try snapshot(fixture) #expect(PasteTarget.lanes(selection: .empty, snapshot: model) == 2) #expect(PasteTarget.lanes( selection: ItemReferenceSet(ids: [clipboardCard3], container: .trash), snapshot: model ) == 2) } @Test("A zero-lane board still has a lane slot — position zero") func zeroLaneBoardStillTakesALane() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", Item.board) let model = try BoardLoader.load(boardRoot: fixture.root).model #expect(PasteTarget.lanes(selection: .empty, snapshot: model) == 0) } } // MARK: - The refused paste's phrasing /// **04-interactions.md ▸ Clipboard, re-ruled 2026-07-29** — refuse, never degrade: /// /// > A paste whose staged snapshot is missing or unreadable refuses loudly — never degrades … /// > the paste produces **nothing**, and a one-shot failure banner names it from the manifest's /// > metadata ("Couldn't paste 'Fix login' — the copied content is gone"). /// /// The retired suite these replace pinned `degradedPasteMessage(for:)` and its loss row — "Pasted /// 'Fix login' without its 3 attachments". Both are gone with the degraded materialization: nothing /// arrives, so there is no partial arrival to account for. @Suite("BannerCenter ▸ refused paste") struct RefusedPasteBannerTests { /// 04's own example sentence, composed the way every failure headline is: the action clause the /// banner owns, an em dash, the cause. @Test("04's own example sentence") @MainActor func theExampleSentence() { let center = BannerCenter() center.postRefusedPaste(title: "Fix login", stagedAt: "/tmp/staging/abc") #expect(center.oneShots.count == 1) let headline = try? #require(center.oneShots.first).error #expect(headline.map(BannerCenter.headline(for:)) == "Couldn't paste 'Fix login' — the copied content is gone") } /// "Untitled" is a rendering, never a value (03-board-ui.md § Card face), so an untitled entry is /// "the item" — `actionPhrase`'s standing convention for a failure with no title to quote. @Test("An untitled entry is 'the item', never the Untitled rendering") @MainActor func untitled() { let center = BannerCenter() center.postRefusedPaste(title: nil, stagedAt: "/tmp/staging/abc") let error = try? #require(center.oneShots.first).error #expect(error.map(BannerCenter.headline(for:)) == "Couldn't paste the item — the copied content is gone") } /// **The pivot, stated as a class change**: the degraded paste was a loss row because the items /// landed and only their attachments did not. A refusal is *a write that did not happen*, which is /// 02-architecture.md's own definition of a one-shot — so it ranks with the true failures, carries /// the error tone, and posts no loss row at all. @Test("A refused paste is an error-tone one-shot, not a loss row") @MainActor func refusalIsAOneShotNotALossRow() { let center = BannerCenter() center.postRefusedPaste(title: "Fix login", stagedAt: "/tmp/staging/abc") #expect(center.losses.isEmpty, "the degraded paste's loss row is retired") #expect(center.signposts.isEmpty) let rows = BannerCenter.rows( lock: nil, breakage: nil, oneShots: center.oneShots, losses: [], suspension: nil, operations: [] ) #expect(rows.count == 1) #expect(rows[0].tone == .error) #expect(rows[0].dismissID == center.oneShots.first?.id) } /// The staging path is what the error names, so a bug report about a refusal has something to go /// on — the file that was not there. @Test("The refusal names the staged path it could not find") @MainActor func namesTheStagedPath() { let center = BannerCenter() center.postRefusedPaste(title: "Fix login", stagedAt: "/tmp/staging/abc") #expect(center.oneShots.first?.error.path == "/tmp/staging/abc") #expect(center.oneShots.first?.error.reason == .clipboardContentGone) #expect(center.oneShots.first?.error.operation == .paste(title: "Fix login")) } /// **The loss class survives the retirement** — 02's warning-tone class still has live producers /// (a Finder drop that skipped folders, the app's own relocation and repair notices); only the /// degraded-paste row left it. @Test("The loss class still has its other producers") @MainActor func theLossClassSurvives() { let center = BannerCenter() center.postSkippedFolders(count: 2) #expect(center.losses.count == 1) #expect(center.losses.first?.message == "Folders can't be attached — 2 skipped") } }