import Foundation import Testing import UniformTypeIdentifiers @testable import Kanban /// **Pasting a file URL as an attachment** — the image-data branch's reserved clause 2, filled /// (04-interactions.md ▸ Clipboard, ruled 2026-08-09; follow-up to the paste-image card, 92a9ea6a). /// A Finder copy of one or more files pastes them into the anchor card's `attachments/` through the /// same `BoardStore.importAttachments` path a Finder drop takes: one collision ladder, one folder /// refusal (via `FinderDrop.partition`), one set of banners. /// /// Like every other write suite here the landing tests drive a **real store over a real temp board** /// and read back through the loader or the raw bytes, never through a snapshot the store handed out. /// `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`; `FakePasteboard`, /// `ClipboardHarness`, `makeClipboardHarness` and the board fixture come from `ClipboardTests.swift`; /// `encodedImage` comes from `PasteImageTests.swift`. // MARK: - Sources on disk /// Real files (and folders) **outside the board** for a fake pasteboard's file URLs to point at — a /// file URL is a reference to something the filesystem actually has, unlike the image branch's raw /// bytes, and `FinderDrop.partition`'s directory check reads the real filesystem rather than a /// declared type. `DropSources`' own shape, one file over (`FileDropWriteTests.swift`). private struct PasteFileSources { let root: URL init() throws { root = FileManager.default.temporaryDirectory .appendingPathComponent("PasteFileTests-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) } func tearDown() { try? FileManager.default.removeItem(at: root) } @discardableResult func file(_ relativePath: String, _ bytes: Data = Data([0x01])) throws -> URL { let url = root.appendingPathComponent(relativePath) try FileManager.default.createDirectory( at: url.deletingLastPathComponent(), withIntermediateDirectories: true ) try bytes.write(to: url) return url } @discardableResult func folder(_ relativePath: String) throws -> URL { let url = root.appendingPathComponent(relativePath, isDirectory: true) try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) return url } } /// Attachment names as the loader sees them — never the store's snapshot, which a write deliberately /// does not touch (the one-way flow). `PasteImageWriteTests`' own helper, one file over. @MainActor private func fileTestAttachments(_ card: String, in fixture: WriterFixture) throws -> [String] { let model = try BoardLoader.load(boardRoot: fixture.root).model for lane in model.lanes { if let match = lane.cards.first(where: { $0.id.rawValue == card }) { return match.attachments } } return [] } // MARK: - Classification @Suite("PastedImage ▸ carriesFileURL, a web URL is not a file") struct FileURLPresenceTests { /// `public.url` is the conforms-*from* direction — every file URL is one, not the reverse — so a /// browser's Copy Link never trips the branch a Finder copy does. @Test("A plain web URL does not conform to public.file-url") func webURLsDoNotCount() { #expect(!PastedImage.carriesFileURL([UTType.url.identifier])) } } // MARK: - Where a file lands on the board @MainActor @Suite("Paste ▸ files into a card") struct PasteFileWriteTests { @Test("A Finder-copied file lands under its own name in the anchor card") func aFileLands() throws { let harness = try makeClipboardHarness() defer { harness.tearDown() } let sources = try PasteFileSources() defer { sources.tearDown() } let shot = try sources.file("shot.png", Data([0x89, 0x50])) harness.pasteboard.seedFileURLs([shot]) harness.store.select([clipboardCard2], in: .board) #expect(harness.clipboard.pasteFiles(into: harness.store)) #expect(try fileTestAttachments(Ident.card2, in: harness.fixture) == ["shot.png"]) #expect(try harness.fixture.data("\(Ident.lane1)/\(Ident.card2)/attachments/shot.png") == Data([0x89, 0x50])) #expect(try Data(contentsOf: shot) == Data([0x89, 0x50]), "a copy's origin, never its casualty") #expect(harness.store.banners.oneShots.isEmpty) #expect(harness.store.banners.losses.isEmpty) } @Test("A multi-file Finder copy lands every file, in pasteboard order") func multipleFilesLand() throws { let harness = try makeClipboardHarness() defer { harness.tearDown() } let sources = try PasteFileSources() defer { sources.tearDown() } let shot = try sources.file("shot.png", Data([0x89, 0x50])) let notes = try sources.file("notes.txt", Data([0x41, 0x42])) harness.pasteboard.seedFileURLs([shot, notes]) harness.store.select([clipboardCard2], in: .board) #expect(harness.clipboard.pasteFiles(into: harness.store)) #expect(try fileTestAttachments(Ident.card2, in: harness.fixture) == ["notes.txt", "shot.png"]) #expect(try harness.fixture.data("\(Ident.lane1)/\(Ident.card2)/attachments/shot.png") == Data([0x89, 0x50])) #expect(try harness.fixture.data("\(Ident.lane1)/\(Ident.card2)/attachments/notes.txt") == Data([0x41, 0x42])) #expect(harness.store.banners.oneShots.isEmpty) } @Test("A name already taken climbs the Finder ladder rather than overwriting") func collisionsRename() throws { let harness = try makeClipboardHarness() defer { harness.tearDown() } let sources = try PasteFileSources() defer { sources.tearDown() } let mine = Data("hand placed".utf8) try harness.fixture.file("\(Ident.lane1)/\(Ident.card2)/attachments/shot.png", mine) let shot = try sources.file("shot.png", Data([0x02])) harness.pasteboard.seedFileURLs([shot]) harness.store.select([clipboardCard2], in: .board) #expect(harness.clipboard.pasteFiles(into: harness.store)) #expect(try harness.fixture.data("\(Ident.lane1)/\(Ident.card2)/attachments/shot.png") == mine) #expect(try fileTestAttachments(Ident.card2, in: harness.fixture) == ["shot 2.png", "shot.png"]) } @Test("A mixed paste imports its files and names the folder it skipped") func mixedPasteSkipsFolders() throws { let harness = try makeClipboardHarness() defer { harness.tearDown() } let sources = try PasteFileSources() defer { sources.tearDown() } let shot = try sources.file("shot.png", Data([0x89, 0x50])) let directory = try sources.folder("Project") harness.pasteboard.seedFileURLs([shot, directory]) harness.store.select([clipboardCard2], in: .board) #expect(harness.clipboard.pasteFiles(into: harness.store)) #expect(try fileTestAttachments(Ident.card2, in: harness.fixture) == ["shot.png"]) #expect(!harness.fixture.exists("\(Ident.lane1)/\(Ident.card2)/attachments/Project")) #expect(harness.store.banners.oneShots.isEmpty, "nothing failed: the folder was never attempted") #expect(harness.store.banners.losses.map(\.message) == ["Folders can't be attached — 1 skipped"]) } @Test("A folders-only paste writes nothing at all and only names the loss") func foldersOnlyPasteWritesNothing() throws { let harness = try makeClipboardHarness() defer { harness.tearDown() } let sources = try PasteFileSources() defer { sources.tearDown() } let directory = try sources.folder("Project") harness.pasteboard.seedFileURLs([directory]) harness.store.select([clipboardCard2], in: .board) #expect(!harness.clipboard.pasteFiles(into: harness.store)) #expect(try fileTestAttachments(Ident.card2, in: harness.fixture).isEmpty) #expect(harness.store.banners.oneShots.isEmpty) #expect(harness.store.banners.losses.map(\.message) == ["Folders can't be attached — 1 skipped"]) } /// The pasteboard declared `public.file-url` (so `canPasteFiles` reads true), but produced no /// actual URL objects when asked — the honest outcome is the one where the card is untouched, /// same as the image branch's lying-pasteboard case. @Test("A pasteboard that declares a file URL but resolves none writes nothing at all") func aLyingPasteboardWritesNothing() throws { let harness = try makeClipboardHarness() defer { harness.tearDown() } harness.pasteboard.seed([(UTType.fileURL.identifier, Data())]) harness.clipboard.refresh() harness.store.select([clipboardCard2], in: .board) #expect(harness.clipboard.canPasteFiles(into: harness.store)) #expect(!harness.clipboard.pasteFiles(into: harness.store)) #expect(try fileTestAttachments(Ident.card2, in: harness.fixture).isEmpty) #expect(harness.store.banners.oneShots.isEmpty) #expect(harness.store.banners.losses.isEmpty) } /// The card window's branch: the target is the window's own card, whatever the board's selection /// happens to be — `PasteImageWriteTests.theCardWindowsOwnCard`'s own shape. @Test("The card window pastes onto its own card, not onto the board's selection") func theCardWindowsOwnCard() throws { let harness = try makeClipboardHarness() defer { harness.tearDown() } let sources = try PasteFileSources() defer { sources.tearDown() } let shot = try sources.file("shot.png") harness.pasteboard.seedFileURLs([shot]) harness.store.select([clipboardCard2], in: .board) #expect(harness.clipboard.pasteFiles(intoCard: clipboardCard4, in: harness.store)) #expect(try fileTestAttachments(Ident.card4, in: harness.fixture) == ["shot.png"]) #expect(try fileTestAttachments(Ident.card2, in: harness.fixture).isEmpty) } /// **The recorded precedence call**: a Finder-copied image file carries both a file URL and an /// image flavor on the same pasteboard. The file branch wins — the actual file lands, byte for /// byte, rather than a re-encoded "Pasted Image.png" beside it — which is `imagePayload == nil` /// whenever `fileURLPayload` is true, `refresh()`'s own reading of `PastedImage.flavor`'s clause /// order. @Test("A file URL outranks image data riding beside it — the actual file lands, not a picture copy") func fileURLOutranksImageData() throws { let harness = try makeClipboardHarness() defer { harness.tearDown() } let sources = try PasteFileSources() defer { sources.tearDown() } let png = encodedImage(.png) let photo = try sources.file("photo.png", png) harness.pasteboard.seedFileURLs([photo], also: [(UTType.png.identifier, png)]) harness.clipboard.refresh() harness.store.select([clipboardCard2], in: .board) #expect(harness.clipboard.fileURLPayload) #expect(harness.clipboard.imagePayload == nil, "the image branch defers to the file URL") #expect(harness.clipboard.canPasteFiles(into: harness.store)) #expect(!harness.clipboard.canPasteImage(into: harness.store)) #expect(harness.clipboard.pasteFiles(into: harness.store)) #expect(try fileTestAttachments(Ident.card2, in: harness.fixture) == ["photo.png"]) #expect(try harness.fixture.data("\(Ident.lane1)/\(Ident.card2)/attachments/photo.png") == png) } } // MARK: - Validation @MainActor @Suite("Paste ▸ file menu validation") struct PasteFileValidationTests { @Test("The board branch needs a file URL and a card to put it on") func theBoardBranchsClauses() throws { let harness = try makeClipboardHarness() defer { harness.tearDown() } let sources = try PasteFileSources() defer { sources.tearDown() } let shot = try sources.file("shot.png") harness.store.select([clipboardCard1], in: .board) #expect(!harness.clipboard.canPasteFiles(into: harness.store), "nothing on the pasteboard") harness.pasteboard.seedFileURLs([shot]) harness.clipboard.refresh() #expect(harness.clipboard.canPasteFiles(into: harness.store)) // A lane anchors no card, so the row greys out — and so does the paste. harness.store.select([clipboardLane1], in: .board) #expect(!harness.clipboard.canPasteFiles(into: harness.store)) #expect(!harness.clipboard.pasteFiles(into: harness.store)) harness.store.clearSelection() #expect(!harness.clipboard.canPasteFiles(into: harness.store)) } @Test("A file URL offers the file branch even though it silences the picture branch") func fileURLOffersItsOwnBranch() throws { let harness = try makeClipboardHarness() defer { harness.tearDown() } let sources = try PasteFileSources() defer { sources.tearDown() } let shot = try sources.file("shot.png") harness.pasteboard.seedFileURLs([shot], also: [(UTType.png.identifier, encodedImage(.png))]) harness.clipboard.refresh() harness.store.select([clipboardCard1], in: .board) #expect(harness.clipboard.fileURLPayload) #expect(harness.clipboard.imagePayload == nil) #expect(harness.clipboard.canPasteFiles(into: harness.store)) #expect(!harness.clipboard.canPasteImage(into: harness.store)) #expect(!harness.clipboard.canPasteBoardBackground(into: harness.store), "a file URL suppresses this too") } @Test("The card-window branch refuses a card that is not on the board side") func theCardWindowBranchsClauses() throws { let harness = try makeClipboardHarness() defer { harness.tearDown() } let sources = try PasteFileSources() defer { sources.tearDown() } let shot = try sources.file("shot.png") harness.pasteboard.seedFileURLs([shot]) harness.clipboard.refresh() #expect(harness.clipboard.canPasteFiles(intoCard: clipboardCard1, in: harness.store)) #expect(!harness.clipboard.canPasteFiles(intoCard: clipboardCard3, in: harness.store), "trashed") #expect(!harness.clipboard.canPasteFiles(intoCard: clipboardLane1, in: harness.store), "a lane") } /// Clause 1 of the precedence, proven rather than assumed — `PastedImageClassificationTests /// .boardItemsWin`'s own claim, one surface over. Synthetic (`layerForeign`'s doc), because a real /// copy's `clearContents()` would never leave a file URL beside the app's own type in the first /// place — the guard exists so that if it ever did, the board payload would still win. @Test("A board payload on the pasteboard outranks a file URL riding beside it") func boardPayloadOutranksFileURL() throws { let harness = try makeClipboardHarness() defer { harness.tearDown() } harness.store.select([clipboardCard1], in: .board) harness.clipboard.copy(from: harness.store) harness.pasteboard.layerForeign([(UTType.fileURL.identifier, Data("file:///tmp/shot.png".utf8))]) harness.clipboard.refresh() #expect(!harness.clipboard.fileURLPayload) #expect(harness.clipboard.canPaste(into: harness.store)) #expect(!harness.clipboard.canPasteFiles(into: harness.store)) } @Test("A non-file URL (a browser's Copy Link) offers nothing to either attachment branch") func nonFileURLsOfferNothing() throws { let harness = try makeClipboardHarness() defer { harness.tearDown() } harness.pasteboard.seed([(UTType.url.identifier, Data("https://example.com".utf8))]) harness.clipboard.refresh() harness.store.select([clipboardCard1], in: .board) #expect(!harness.clipboard.fileURLPayload) #expect(!harness.clipboard.canPasteFiles(into: harness.store)) #expect(!harness.clipboard.canPasteImage(into: harness.store)) } }