Files
lanework/KanbanTests/PasteImageTests.swift
T
rzen 200fbce276 The card window's ⌘V drops its picture branch — the attachments header now offers one instead
Raw image data on a card window's ⌘V was a keyboard shortcut with no visible
trigger; the sidebar's Attachments header now grows a quiet control — beside
the existing add affordance, present only while the pasteboard holds a
picture this card could take — that pastes it through the exact seam the
retired branch used (ClipboardStore.pasteImage(intoCard:in:), the board's
"Paste Image into Card" row's own call). The file-URL branch stays on ⌘V; a
Finder copy is still unambiguous. CardBodyTextView's paste-yield mechanism
needed no change at all — it forwards by capability, not by picture-specific
logic, so a screenshot ⌘V with the body editor focused is now a genuine
no-op there, served by the new control instead.

The pasteboard's re-read gains a fourth checkpoint — a window becoming key —
alongside menu-tracking, ⌘-down and app activation: a persistent visible
control has to read true continuously while its window is frontmost, not
only at the instant a menu or chord probes it.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-09 10:37:55 -04:00

1045 lines
47 KiB
Swift

import AppKit
import CoreGraphics
import Foundation
import ImageIO
import Testing
import UniformTypeIdentifiers
@testable import Kanban
/// **Pasting a picture** (04-interactions.md ▸ Clipboard's image-data branch, ruled 2026-08-09) —
/// the pasteboard classification and its precedence, the format rule, the Finder-style name and its
/// ladder, the hero key's write, and the three surfaces' validation.
///
/// 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` and the board fixture come from `ClipboardTests.swift`.
// MARK: - Making pictures
/// A tiny opaque bitmap, encoded under `type` — real bytes, because the format rule's whole claim is
/// about what ImageIO can and cannot read, and a `Data("png".utf8)` stand-in would make the
/// conversion tests vacuous.
func encodedImage(_ type: UTType, side: Int = 4) -> Data {
let space = CGColorSpaceCreateDeviceRGB()
let context = CGContext(
data: nil,
width: side,
height: side,
bitsPerComponent: 8,
bytesPerRow: 0,
space: space,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
)!
context.setFillColor(CGColor(red: 0.2, green: 0.6, blue: 0.9, alpha: 1))
context.fill(CGRect(x: 0, y: 0, width: side, height: side))
let image = context.makeImage()!
let output = NSMutableData()
let destination = CGImageDestinationCreateWithData(output, type.identifier as CFString, 1, nil)!
CGImageDestinationAddImage(destination, image, nil)
_ = CGImageDestinationFinalize(destination)
return output as Data
}
/// The type identifier of whatever `data` actually is, as ImageIO reads it — the only honest way to
/// assert "the bytes on disk are still a PNG".
func imageType(of data: Data) -> String? {
guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { return nil }
return CGImageSourceGetType(source) as String?
}
/// One app-mediated reload landed — `GeneratedBackgroundTests`' own helper, because the echo-window
/// tests here are about exactly the same memo and have to open and close that window the same way.
@MainActor
private func settle(_ store: BoardStore) async {
store.handleWatcherEvent(.treeChanged(.appMediated))
await store.awaitQuiescence()
}
// MARK: - Classification
@Suite("PastedImage ▸ classification")
struct PastedImageClassificationTests {
@Test("The app's own clipboard type wins outright — a picture beside it never diverts ⌘V")
func boardItemsWin() {
let flavor = PastedImage.flavor(
hasBoardItems: true,
types: [UTType.laneworkClipboard.identifier, UTType.png.identifier, UTType.tiff.identifier]
)
#expect(flavor == nil)
}
/// The ruling's own parenthesis: "IMAGE DATA (no file URL)". A Finder copy of a PNG puts a file
/// URL down, often with an image flavor beside it, and that is a different gesture's payload.
@Test("A file URL suppresses the branch, whatever else rides beside it")
func fileURLsSuppress() {
#expect(PastedImage.flavor(
hasBoardItems: false,
types: [UTType.fileURL.identifier, UTType.png.identifier]
) == nil)
// Conformance, not equality: a subtype of `public.file-url` is still a file reference.
#expect(PastedImage.carriesFileURL([UTType.fileURL.identifier]))
#expect(!PastedImage.carriesFileURL([UTType.png.identifier, UTType.tiff.identifier]))
// A type the system does not know is not a file URL — the optimistic reading a drag's
// unknown types get.
#expect(!PastedImage.carriesFileURL(["com.example.nothing-at-all"]))
}
@Test("Raw image data is the fallback, and an empty pasteboard offers nothing")
func imageDataIsTheFallback() throws {
let flavor = try #require(PastedImage.flavor(hasBoardItems: false, types: [UTType.png.identifier]))
#expect(flavor.type == UTType.png.identifier)
#expect(PastedImage.flavor(hasBoardItems: false, types: []) == nil)
#expect(PastedImage.flavor(hasBoardItems: false, types: [UTType.plainText.identifier]) == nil)
}
/// The screenshot's exact pasteboard: PNG and TIFF together. Our order wins over the
/// pasteboard's, so the common paste costs no decode and no re-encode at all.
@Test("PNG beats TIFF however the pasteboard orders them")
func pngBeatsTIFF() throws {
for types in [
[UTType.tiff.identifier, UTType.png.identifier],
[UTType.png.identifier, UTType.tiff.identifier],
] {
let flavor = try #require(PastedImage.flavor(hasBoardItems: false, types: types))
#expect(flavor.type == UTType.png.identifier)
#expect(!flavor.convertsToPNG)
#expect(flavor.fileName == "Pasted Image.png")
}
}
@Test("A file-shaped flavor keeps its own extension; TIFF and BMP become PNG")
func theFormatRule() throws {
let jpeg = try #require(PastedImage.flavor(hasBoardItems: false, types: [UTType.jpeg.identifier]))
#expect(!jpeg.convertsToPNG)
#expect(jpeg.fileExtension == UTType.jpeg.preferredFilenameExtension)
let gif = try #require(PastedImage.flavor(hasBoardItems: false, types: [UTType.gif.identifier]))
#expect(!gif.convertsToPNG, "a GIF's animation does not survive a single-frame decode")
#expect(gif.fileName == "Pasted Image.gif")
for interchange in [UTType.tiff, UTType.bmp] {
let flavor = try #require(
PastedImage.flavor(hasBoardItems: false, types: [interchange.identifier])
)
#expect(flavor.convertsToPNG)
#expect(flavor.fileExtension == "png")
#expect(flavor.type == interchange.identifier, "the bytes still come from the offered type")
}
}
@Test("The two names are the two folders' — a card's attachment and a board's backdrop")
func theTwoNames() throws {
let flavor = try #require(PastedImage.flavor(hasBoardItems: false, types: [UTType.jpeg.identifier]))
#expect(flavor.fileName == "Pasted Image.jpeg")
#expect(flavor.backgroundFileName == "Pasted Background.jpeg")
}
}
// MARK: - The bytes
@Suite("PastedImage ▸ encoding")
struct PastedImageEncodingTests {
@Test("A verbatim flavor is handed back byte for byte — no decode, no re-encode")
func verbatimIsUntouched() throws {
let png = encodedImage(.png)
let flavor = try #require(PastedImage.flavor(hasBoardItems: false, types: [UTType.png.identifier]))
#expect(PastedImage.encode(png, as: flavor) == png)
}
@Test("TIFF is re-encoded, and what comes out really is a PNG")
func tiffBecomesPNG() throws {
let tiff = encodedImage(.tiff)
let flavor = try #require(PastedImage.flavor(hasBoardItems: false, types: [UTType.tiff.identifier]))
let encoded = try #require(PastedImage.encode(tiff, as: flavor))
#expect(encoded != tiff)
#expect(imageType(of: encoded) == UTType.png.identifier)
}
/// A pasteboard that declares a flavor it cannot back up. Nothing is written, which is the honest
/// outcome — see `ClipboardStore.pasteImage(intoCard:in:)`.
@Test("Bytes that are not an image encode to nothing rather than to a broken file")
func undecodableBytesRefuse() throws {
let tiff = try #require(PastedImage.flavor(hasBoardItems: false, types: [UTType.tiff.identifier]))
#expect(PastedImage.encode(Data("not a picture".utf8), as: tiff) == nil)
let png = try #require(PastedImage.flavor(hasBoardItems: false, types: [UTType.png.identifier]))
#expect(PastedImage.encode(Data(), as: png) == nil, "an empty payload is not a file")
}
}
// MARK: - Which attachments can be a hero
@Suite("PastedImage ▸ image names")
struct PastedImageNameTests {
@Test("An image is decided by extension, and anything else is not one")
func imageNames() {
#expect(PastedImage.isImageName("Pasted Image.png"))
#expect(PastedImage.isImageName("photo.JPEG"), "extensions are case-insensitive")
#expect(PastedImage.isImageName("clip.gif"))
#expect(!PastedImage.isImageName("notes.txt"))
#expect(!PastedImage.isImageName("archive.zip"))
#expect(!PastedImage.isImageName("README"), "no extension, no reading")
#expect(!PastedImage.isImageName(""))
}
}
// MARK: - Where a picture lands on the board
@MainActor
@Suite("PasteTarget ▸ the image branch")
struct PasteImageTargetTests {
private func snapshot() throws -> BoardModel {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
return try BoardLoader.load(boardRoot: fixture.root).model
}
@Test("The anchor card, which is the last selected in flatten order")
func theAnchorCard() throws {
let model = try snapshot()
#expect(PasteTarget.card(
selection: ItemReferenceSet(ids: [clipboardCard1], container: .board),
snapshot: model
) == clipboardCard1)
// Two selected: the last in flatten order, the shared anchor rule.
#expect(PasteTarget.card(
selection: ItemReferenceSet(ids: [clipboardCard1, clipboardCard4], container: .board),
snapshot: model
) == clipboardCard4)
}
/// A picture has to land in *some* card's `attachments/`, and there is no card the app could pick
/// without inventing one — so these three all answer nothing and the menu greys out.
@Test("A lane, an empty and a trash selection all anchor no card")
func nothingToAnchorOn() throws {
let model = try snapshot()
#expect(PasteTarget.card(
selection: ItemReferenceSet(ids: [clipboardLane1], container: .board),
snapshot: model
) == nil)
#expect(PasteTarget.card(selection: .empty, snapshot: model) == nil)
#expect(PasteTarget.card(
selection: ItemReferenceSet(ids: [clipboardCard3], container: .trash),
snapshot: model
) == nil)
}
}
// MARK: - The paste itself
@MainActor
@Suite("Paste ▸ an image into a card")
struct PasteImageWriteTests {
/// Attachment names as the loader sees them — never the store's snapshot, which a write
/// deliberately does not touch (the one-way flow).
private func attachments(_ 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 []
}
@Test("A screenshot lands as 'Pasted Image.png', byte for byte, in the anchor card")
func aScreenshotLands() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let png = encodedImage(.png)
harness.pasteboard.seed([
(UTType.png.identifier, png),
(UTType.tiff.identifier, encodedImage(.tiff)),
])
harness.store.select([clipboardCard2], in: .board)
#expect(harness.clipboard.pasteImage(into: harness.store))
#expect(try attachments(Ident.card2, in: harness.fixture) == ["Pasted Image.png"])
#expect(try harness.fixture.data("\(Ident.lane1)/\(Ident.card2)/attachments/Pasted Image.png") == png)
#expect(harness.store.banners.oneShots.isEmpty)
}
@Test("A second paste climbs the Finder ladder rather than overwriting")
func theFinderLadder() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.pasteboard.seed([(UTType.png.identifier, encodedImage(.png))])
harness.store.select([clipboardCard2], in: .board)
#expect(harness.clipboard.pasteImage(into: harness.store))
#expect(harness.clipboard.pasteImage(into: harness.store))
#expect(harness.clipboard.pasteImage(into: harness.store))
#expect(try attachments(Ident.card2, in: harness.fixture)
== ["Pasted Image 2.png", "Pasted Image 3.png", "Pasted Image.png"])
}
/// The name is minted against what is on disk, so a hand-placed file of the same name is the
/// user's and is never written through — `BoardWriter.freshName`'s rule, inherited whole.
@Test("A file already holding the name is stepped around, never overwritten")
func anExistingNameIsRespected() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let mine = Data("hand placed".utf8)
try harness.fixture.file("\(Ident.lane1)/\(Ident.card2)/attachments/Pasted Image.png", mine)
harness.pasteboard.seed([(UTType.png.identifier, encodedImage(.png))])
harness.store.select([clipboardCard2], in: .board)
#expect(harness.clipboard.pasteImage(into: harness.store))
#expect(try harness.fixture.data("\(Ident.lane1)/\(Ident.card2)/attachments/Pasted Image.png") == mine)
#expect(try attachments(Ident.card2, in: harness.fixture) == ["Pasted Image 2.png", "Pasted Image.png"])
}
@Test("A TIFF-only pasteboard lands a PNG")
func tiffLandsAsPNG() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.pasteboard.seed([(UTType.tiff.identifier, encodedImage(.tiff))])
harness.store.select([clipboardCard2], in: .board)
#expect(harness.clipboard.pasteImage(into: harness.store))
#expect(try attachments(Ident.card2, in: harness.fixture) == ["Pasted Image.png"])
let landed = try harness.fixture.data("\(Ident.lane1)/\(Ident.card2)/attachments/Pasted Image.png")
#expect(imageType(of: landed) == UTType.png.identifier)
}
@Test("A pasteboard that declared a flavor it cannot back up writes nothing at all")
func aLyingPasteboardWritesNothing() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.pasteboard.seed([(UTType.tiff.identifier, Data("not a picture".utf8))])
harness.store.select([clipboardCard2], in: .board)
#expect(!harness.clipboard.pasteImage(into: harness.store))
#expect(try attachments(Ident.card2, in: harness.fixture).isEmpty)
#expect(harness.store.banners.oneShots.isEmpty)
}
/// The card window's branch: the target is the window's own card, whatever the board's selection
/// happens to be.
@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() }
harness.pasteboard.seed([(UTType.png.identifier, encodedImage(.png))])
harness.store.select([clipboardCard2], in: .board)
#expect(harness.clipboard.pasteImage(intoCard: clipboardCard4, in: harness.store))
#expect(try attachments(Ident.card4, in: harness.fixture) == ["Pasted Image.png"])
#expect(try attachments(Ident.card2, in: harness.fixture).isEmpty)
}
@Test("A board payload on the pasteboard is never diverted into an attachment")
func aBoardPayloadStillPastesAsCards() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1], in: .board)
harness.clipboard.copy(from: harness.store)
#expect(harness.clipboard.imagePayload == nil)
#expect(!harness.clipboard.canPasteImage(into: harness.store))
#expect(harness.clipboard.canPaste(into: harness.store))
}
}
// MARK: - Validation
@MainActor
@Suite("Paste ▸ image menu validation")
struct PasteImageValidationTests {
@Test("The board branch needs a picture and a card to put it on")
func theBoardBranchsClauses() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1], in: .board)
#expect(!harness.clipboard.canPasteImage(into: harness.store), "nothing on the pasteboard")
harness.pasteboard.seed([(UTType.png.identifier, encodedImage(.png))])
harness.clipboard.refresh()
#expect(harness.clipboard.canPasteImage(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.canPasteImage(into: harness.store))
#expect(!harness.clipboard.pasteImage(into: harness.store))
harness.store.clearSelection()
#expect(!harness.clipboard.canPasteImage(into: harness.store))
}
@Test("A file URL on the pasteboard offers nothing to any of the three surfaces")
func fileURLsOfferNothing() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.pasteboard.seed([
(UTType.fileURL.identifier, Data("file:///tmp/shot.png".utf8)),
(UTType.png.identifier, encodedImage(.png)),
])
harness.clipboard.refresh()
harness.store.select([clipboardCard1], in: .board)
#expect(harness.clipboard.imagePayload == nil)
#expect(!harness.clipboard.canPasteImage(into: harness.store))
#expect(!harness.clipboard.canPasteImage(intoCard: clipboardCard1, in: harness.store))
#expect(!harness.clipboard.canPasteBoardBackground(into: harness.store))
}
/// The board's backdrop has no target to resolve, which is what makes it the one image-paste
/// surface that stays live with nothing selected.
@Test("The backdrop row needs only a picture and a writable board")
func theBackgroundRowsClauses() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.pasteboard.seed([(UTType.png.identifier, encodedImage(.png))])
harness.clipboard.refresh()
harness.store.clearSelection()
#expect(harness.clipboard.canPasteBoardBackground(into: harness.store))
#expect(!harness.clipboard.canPasteImage(into: harness.store), "no card selected")
}
@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() }
harness.pasteboard.seed([(UTType.png.identifier, encodedImage(.png))])
harness.clipboard.refresh()
#expect(harness.clipboard.canPasteImage(intoCard: clipboardCard1, in: harness.store))
#expect(!harness.clipboard.canPasteImage(intoCard: clipboardCard3, in: harness.store), "trashed")
#expect(!harness.clipboard.canPasteImage(intoCard: clipboardLane1, in: harness.store), "a lane")
}
}
// MARK: - Set as Hero
@MainActor
@Suite("The hero key ▸ set and remove")
struct SetAsHeroTests {
private func heroKey(of card: String, lane: String, in fixture: WriterFixture) throws -> FieldValue<String> {
try FrontmatterDocument.parse(fixture.indexText("\(lane)/\(card)")).hero
}
/// The reload between the two halves is not ceremony: the no-op guard reads the **snapshot**,
/// which is one reload behind every write the app makes (the one-way flow), exactly as
/// `applyStyle`'s `effective(_:against:)` does. The two surfaces stay consistent because they read
/// the same snapshot — while it has not caught up, the row is still offering *Set* as Hero, so
/// there is no Remove for a user to press.
@Test("Set as Hero writes the bare filename; Remove Hero takes the key away")
func setThenRemove() async throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
#expect(store.setHero("photo.png", onCard: clipboardCard1))
#expect(try heroKey(of: Ident.card1, lane: Ident.lane1, in: fixture).value == "photo.png")
await settle(store)
#expect(store.setHero(nil, onCard: clipboardCard1))
#expect(try heroKey(of: Ident.card1, lane: Ident.lane1, in: fixture).isMissing)
#expect(store.banners.oneShots.isEmpty)
}
/// One hero per card, and the user picked a different picture: Set replaces rather than refusing,
/// so no Remove-then-Set dance.
@Test("Set as Hero on a card that already has one replaces it")
func setReplaces() async throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
#expect(store.setHero("photo.png", onCard: clipboardCard1))
await settle(store)
#expect(store.setHero("notes.txt", onCard: clipboardCard1))
#expect(try heroKey(of: Ident.card1, lane: Ident.lane1, in: fixture).value == "notes.txt")
}
@Test("Writing the hero a card already has is a no-op — no write, no step")
func redundantSetsAreFree() async throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let history = NativeHistoryProvider()
store.history = history
// Removing a hero that was never there changes nothing.
#expect(!store.setHero(nil, onCard: clipboardCard1))
#expect(!history.canUndo, "nothing happened, so nothing is on the stack")
#expect(store.setHero("photo.png", onCard: clipboardCard1))
await settle(store)
#expect(!store.setHero("photo.png", onCard: clipboardCard1), "already says exactly this")
}
@Test("The other keys and the body ride through untouched")
func everythingElseSurvives() throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
#expect(store.setHero("photo.png", onCard: clipboardCard1))
let document = try FrontmatterDocument.parse(fixture.indexText("\(Ident.lane1)/\(Ident.card1)"))
#expect(document.title.value == "First")
#expect(document.value(for: "project") != nil, "an unknown key is round-tripped")
#expect(document.body.contains("First body"))
}
@Test("A trashed card and a lane are both refused")
func containerGuards() throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
#expect(!store.setHero("photo.png", onCard: clipboardCard3), "in the trash")
#expect(!store.setHero("photo.png", onCard: clipboardLane1), "a lane has no hero")
#expect(try FrontmatterDocument.parse(fixture.indexText(".trash/\(Ident.card3)")).hero.isMissing)
}
@Test("⌘Z puts the key back exactly as it was, under the restyle phrase")
func undoRestoresTheKey() throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let history = NativeHistoryProvider()
store.history = history
#expect(store.setHero("photo.png", onCard: clipboardCard1))
#expect(try heroKey(of: Ident.card1, lane: Ident.lane1, in: fixture).value == "photo.png")
#expect(history.undoActionName == "Restyle Card")
history.undo()
#expect(try heroKey(of: Ident.card1, lane: Ident.lane1, in: fixture).isMissing)
history.redo()
#expect(try heroKey(of: Ident.card1, lane: Ident.lane1, in: fixture).value == "photo.png")
}
/// A prior hero comes back as itself rather than as an absence — `applyStyle`'s inverse reading.
@Test("Undoing a replacement restores the hero that was there")
func undoRestoresAPriorHero() async throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let history = NativeHistoryProvider()
store.history = history
#expect(store.setHero("photo.png", onCard: clipboardCard1))
await settle(store)
#expect(store.setHero("notes.txt", onCard: clipboardCard1))
history.undo()
#expect(try heroKey(of: Ident.card1, lane: Ident.lane1, in: fixture).value == "photo.png")
}
/// The wiring, driven through the real `configureAttachments` so the test breaks if the seam is
/// ever crossed or the id captured from the wrong place.
@Test("The attachment row's seam writes the window's own card")
func theRowsSeam() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let attachments = CardAttachments()
CardWindowHost.configureAttachments(
attachments, store: harness.store, cardID: clipboardCard1, undo: CardWindowUndo(),
clipboard: harness.clipboard
)
attachments.setHeroFile?("photo.png")
#expect(try heroKey(of: Ident.card1, lane: Ident.lane1, in: harness.fixture).value == "photo.png")
}
}
// MARK: - The card window's paste-image affordance
/// **The header's control, wired through `configureAttachments`** — replaces the retired ⌘V
/// image-data branch (04-interactions.md ▸ Clipboard, re-ruled 2026-08-09). Driven through the real
/// wiring, `theRowsSeam`'s own reason: the test breaks if the seam is ever crossed or the id captured
/// from the wrong place.
@MainActor
@Suite("Paste ▸ the card window's paste-image affordance")
struct PasteImageAffordanceTests {
private func attachments(_ 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 []
}
@Test("The button's write lands the pasteboard's picture on the window's own card")
func theButtonsWrite() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.pasteboard.seed([(UTType.png.identifier, encodedImage(.png))])
harness.clipboard.refresh()
let attachments = CardAttachments()
CardWindowHost.configureAttachments(
attachments, store: harness.store, cardID: clipboardCard4, undo: CardWindowUndo(),
clipboard: harness.clipboard
)
#expect(attachments.canPasteImage?() == true)
attachments.pasteImage?()
#expect(try self.attachments(Ident.card4, in: harness.fixture) == ["Pasted Image.png"])
}
@Test("The button's visibility clears once the pasteboard no longer offers a picture")
func visibilityFollowsThePasteboard() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let attachments = CardAttachments()
CardWindowHost.configureAttachments(
attachments, store: harness.store, cardID: clipboardCard1, undo: CardWindowUndo(),
clipboard: harness.clipboard
)
#expect(attachments.canPasteImage?() == false, "nothing on the pasteboard yet")
harness.pasteboard.seed([(UTType.png.identifier, encodedImage(.png))])
harness.clipboard.refresh()
#expect(attachments.canPasteImage?() == true)
// A Finder-copied file outranks the picture riding beside it — the same precedence ⌘V's own
// file branch reads (`PastedImage.flavor`'s clause order) — so the button goes quiet exactly
// where the file branch lights up instead.
harness.pasteboard.seedFileURLs(
[URL(fileURLWithPath: "/tmp/shot.png")], also: [(UTType.png.identifier, encodedImage(.png))]
)
harness.clipboard.refresh()
#expect(attachments.canPasteImage?() == false, "a file URL wins the precedence")
}
@Test("The button is absent under the read-only lock")
func lockedBoardsOfferNothing() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.pasteboard.seed([(UTType.png.identifier, encodedImage(.png))])
harness.clipboard.refresh()
harness.store.enterVanishedRootLock()
let attachments = CardAttachments()
CardWindowHost.configureAttachments(
attachments, store: harness.store, cardID: clipboardCard1, undo: CardWindowUndo(),
clipboard: harness.clipboard
)
#expect(attachments.canPasteImage?() == false)
}
}
// MARK: - The card window's ⌘V routing, image data retired
/// **`CardWindowPasteRouting.action` keeps one clause** (04-interactions.md ▸ Clipboard, re-ruled
/// 2026-08-09: "instead of a special ⌘V handler at card window level … add a control"). Driven
/// directly rather than through `canPasteFiles`/`canPasteImage` on their own, because those two
/// predicates staying correct says nothing about whether the *composition* still offers the retired
/// branch — which is exactly the regression this suite exists to catch.
@MainActor
@Suite("Paste ▸ the card window's ⌘V, image data retired")
struct CardWindowPasteRoutingTests {
private func attachmentNames(_ 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 []
}
@Test("Raw image data alone offers the card window's ⌘V nothing")
func imageDataAloneOffersNothing() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.pasteboard.seed([(UTType.png.identifier, encodedImage(.png))])
harness.clipboard.refresh()
#expect(
harness.clipboard.canPasteImage(intoCard: clipboardCard4, in: harness.store),
"the affordance would still show"
)
let action = CardWindowPasteRouting.action(
store: harness.store, cardID: clipboardCard4, clipboard: harness.clipboard
)
#expect(action == nil)
#expect(try attachmentNames(Ident.card4, in: harness.fixture).isEmpty)
}
@Test("A Finder-copied file still routes through the card window's ⌘V")
func fileURLsStillRoute() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let source = try WriterFixture()
defer { source.tearDown() }
let shot = try source.file("shot.png", Data([0x89, 0x50]))
harness.pasteboard.seedFileURLs([shot])
harness.clipboard.refresh()
let action = CardWindowPasteRouting.action(
store: harness.store, cardID: clipboardCard4, clipboard: harness.clipboard
)
#expect(action != nil)
action?()
#expect(try attachmentNames(Ident.card4, in: harness.fixture) == ["shot.png"])
}
}
// MARK: - The hero rows' menu rules
@Suite("The hero rows ▸ menu validation")
struct HeroMenuRulesTests {
private let names = ["photo.png", "notes.txt", "shot.jpeg"]
@Test("Set as Hero is offered on an image row that is not already the hero")
func setIsOffered() {
#expect(CardAttachments.canSetHero("photo.png", hero: nil, names: names, isEditable: true))
#expect(CardAttachments.canSetHero("shot.jpeg", hero: "photo.png", names: names, isEditable: true))
}
@Test("It is absent on the current hero's row, which shows Remove Hero instead")
func theCurrentHerosRow() {
#expect(!CardAttachments.canSetHero("photo.png", hero: "photo.png", names: names, isEditable: true))
#expect(CardAttachments.canRemoveHero("photo.png", hero: "photo.png", isEditable: true))
#expect(!CardAttachments.canRemoveHero("shot.jpeg", hero: "photo.png", isEditable: true))
}
/// Offering the row on a `.zip` would let a user set a hero that can never draw.
@Test("A non-image row is never offered Set as Hero")
func nonImageRows() {
#expect(!CardAttachments.canSetHero("notes.txt", hero: nil, names: names, isEditable: true))
}
/// A hero somebody hand-wrote to a non-image file is exactly the state Remove Hero exists to get
/// out of, so that row does *not* repeat the image test.
@Test("Remove Hero is offered even where Set as Hero would not be")
func removeDoesNotRepeatTheImageTest() {
#expect(CardAttachments.canRemoveHero("notes.txt", hero: "notes.txt", isEditable: true))
}
@Test("The lock closes both rows, and a row not in the listing offers nothing")
func theLockAndTheListing() {
#expect(!CardAttachments.canSetHero("photo.png", hero: nil, names: names, isEditable: false))
#expect(!CardAttachments.canRemoveHero("photo.png", hero: "photo.png", isEditable: false))
#expect(!CardAttachments.canSetHero("gone.png", hero: nil, names: names, isEditable: true))
}
}
// MARK: - The menu-bar twins
/// The two File rows the context entry's twin contract requires (11-command-nexus.md: "no function's
/// only home"), validated as values rather than by driving a menu — `AddAttachmentCommand`'s own
/// shape, for its reason.
@MainActor
@Suite("File ▸ Set as Hero / Remove Hero")
struct HeroCommandTests {
private func section(selected: String?, hero: String?) -> CardAttachments {
let attachments = CardAttachments()
attachments.names = ["photo.png", "notes.txt", "shot.jpeg"]
attachments.hero = hero
attachments.selected = selected
attachments.isEditable = true
return attachments
}
@Test("They read the section's selected row, and are dead with nothing selected")
func theyFollowTheSelection() {
#expect(SetAsHeroCommand.isEnabled(section(selected: "photo.png", hero: nil)))
#expect(!SetAsHeroCommand.isEnabled(section(selected: nil, hero: nil)))
#expect(!RemoveHeroCommand.isEnabled(section(selected: nil, hero: "photo.png")))
#expect(!SetAsHeroCommand.isEnabled(nil), "no card window in front, no row")
#expect(!RemoveHeroCommand.isEnabled(nil))
}
/// Exactly one of the pair is ever live on a given row, which is what makes them read as one
/// gesture with two directions rather than as two independent commands.
@Test("Only one of the pair is live on any row")
func onlyOneIsLive() {
let onTheHero = section(selected: "photo.png", hero: "photo.png")
#expect(!SetAsHeroCommand.isEnabled(onTheHero))
#expect(RemoveHeroCommand.isEnabled(onTheHero))
let onAnother = section(selected: "shot.jpeg", hero: "photo.png")
#expect(SetAsHeroCommand.isEnabled(onAnother))
#expect(!RemoveHeroCommand.isEnabled(onAnother))
let onAFile = section(selected: "notes.txt", hero: "photo.png")
#expect(!SetAsHeroCommand.isEnabled(onAFile))
#expect(!RemoveHeroCommand.isEnabled(onAFile))
}
@Test("The lock closes both rows")
func theLock() {
let locked = section(selected: "photo.png", hero: "photo.png")
locked.isEditable = false
#expect(!SetAsHeroCommand.isEnabled(locked))
#expect(!RemoveHeroCommand.isEnabled(locked))
}
}
// MARK: - Paste as Board Background
@MainActor
@Suite("Paste ▸ as board background")
struct PasteBoardBackgroundTests {
private func background(_ fixture: WriterFixture) throws -> FrontmatterDocument {
try FrontmatterDocument.parse(fixture.indexText(""))
}
@Test("The picture lands in .backgrounds/ and `background.image` names it")
func thePictureLands() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let png = encodedImage(.png)
harness.pasteboard.seed([(UTType.png.identifier, png)])
#expect(harness.clipboard.pasteBoardBackground(into: harness.store))
#expect(try harness.fixture.data(".backgrounds/Pasted Background.png") == png)
#expect(try background(harness.fixture).backgroundImage.value == ".backgrounds/Pasted Background.png")
#expect(harness.store.banners.oneShots.isEmpty)
}
/// A picture off the pasteboard carries no ground colour, so the gesture writes none — and a
/// colour the board already had survives, which is `setBackgroundImage`'s per-subkey contract.
@Test("The colour subkey is left exactly as it was")
func theColourSurvives() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
#expect(harness.store.applySolidBackground(colorHex: "#112233"))
await settle(harness.store)
harness.pasteboard.seed([(UTType.png.identifier, encodedImage(.png))])
#expect(harness.clipboard.pasteBoardBackground(into: harness.store))
let document = try background(harness.fixture)
#expect(document.background.value == "#112233")
#expect(document.backgroundImage.value == ".backgrounds/Pasted Background.png")
}
/// The generator's overwrite-in-place rule, inherited: re-pasting must not leave a folder full of
/// abandoned pictures.
@Test("Re-pasting overwrites the board's own file rather than climbing the ladder")
func rePastingOverwrites() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.pasteboard.seed([(UTType.png.identifier, encodedImage(.png, side: 4))])
#expect(harness.clipboard.pasteBoardBackground(into: harness.store))
await settle(harness.store)
let second = encodedImage(.png, side: 8)
harness.pasteboard.seed([(UTType.png.identifier, second)])
#expect(harness.clipboard.pasteBoardBackground(into: harness.store))
#expect(try harness.fixture.data(".backgrounds/Pasted Background.png") == second)
#expect(!harness.fixture.exists(".backgrounds/Pasted Background 2.png"))
}
/// A hand-placed file of that name **inside `.backgrounds/`** is the user's, and is never written
/// through — the ladder's rule, the same one the generator follows. A file of that name at board
/// root would not collide at all any more: it simply is not where this write ever looks.
@Test("A file already holding the name is stepped around")
func anExistingNameIsRespected() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let mine = Data("hand placed".utf8)
try harness.fixture.file(".backgrounds/Pasted Background.png", mine)
harness.pasteboard.seed([(UTType.png.identifier, encodedImage(.png))])
#expect(harness.clipboard.pasteBoardBackground(into: harness.store))
#expect(try harness.fixture.data(".backgrounds/Pasted Background.png") == mine)
#expect(try background(harness.fixture).backgroundImage.value == ".backgrounds/Pasted Background 2.png")
}
/// The two producers must not read each other's echo: a paste landing inside the reroll's window
/// must not overwrite `facets.png`.
@Test("A paste right after a generated background writes its own file")
func theTwoProducersStayApart() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let generated = encodedImage(.png, side: 4)
#expect(harness.store.applyGeneratedBackground(png: generated, colorHex: "#445566"))
// Deliberately *no* reload — this is the echo window the generator's memo exists for.
harness.pasteboard.seed([(UTType.png.identifier, encodedImage(.png, side: 8))])
#expect(harness.clipboard.pasteBoardBackground(into: harness.store))
#expect(try harness.fixture.data(".backgrounds/\(FacetsGenerator.fileName)") == generated, "untouched")
#expect(try background(harness.fixture).backgroundImage.value == ".backgrounds/Pasted Background.png")
}
/// `theTwoProducersStayApart`'s settled counterpart — the orphan tidy's replace-in-place trim
/// (ruled 2026-08-09): once the generate has *settled* (a reload landed and `snapshot` caught up
/// with it), a paste that repoints away from it trims the generated file, on the same terms
/// `GeneratedBackgroundTests.BackgroundReplaceInPlaceTrimTests` pins for the reverse direction.
@Test("A paste after a settled generated background trims it")
func aSettledPasteTrimsTheGeneratedFile() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let generated = encodedImage(.png, side: 4)
#expect(harness.store.applyGeneratedBackground(png: generated, colorHex: "#445566"))
await settle(harness.store)
harness.pasteboard.seed([(UTType.png.identifier, encodedImage(.png, side: 8))])
#expect(harness.clipboard.pasteBoardBackground(into: harness.store))
#expect(!harness.fixture.exists(".backgrounds/\(FacetsGenerator.fileName)"), "trimmed by the settled paste")
#expect(try background(harness.fixture).backgroundImage.value == ".backgrounds/Pasted Background.png")
#expect(harness.store.banners.losses.isEmpty, "the in-flow trim is silent")
}
@Test("⌘Z puts the image subkey back and leaves the colour alone")
func undoRestoresTheSubkey() throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let history = NativeHistoryProvider()
store.history = history
#expect(store.applyPastedBackground(data: encodedImage(.png), fileExtension: "png"))
#expect(try FrontmatterDocument.parse(fixture.indexText("")).backgroundImage.value
== ".backgrounds/Pasted Background.png")
#expect(history.undoActionName == "Restyle Board")
history.undo()
#expect(try FrontmatterDocument.parse(fixture.indexText("")).backgroundImage.isMissing)
// The file survives the undo — "the undo restores the field, not the bytes".
#expect(fixture.exists(".backgrounds/Pasted Background.png"))
history.redo()
#expect(try FrontmatterDocument.parse(fixture.indexText("")).backgroundImage.value
== ".backgrounds/Pasted Background.png")
}
}
// MARK: - The body editor's paste yield
/// The responder behind the editor in these tests — stands where the card window's
/// `cardWindowPaste` bridge stands in the app, and only counts.
@MainActor
private final class PasteCatcher: NSView {
var pastes = 0
@objc func paste(_ sender: Any?) { pastes += 1 }
}
/// **`CardBodyTextView` yields a paste it cannot read** (05-card-window.md ▸ Attachments, ruled
/// 2026-08-09) — a screenshot pasteboard reaches *whatever responds behind the editor* even while
/// the editor holds the keyboard, and a text paste never leaves the editor. The mechanism is
/// capability-based (`readablePasteboardTypes`), not a picture-specific rule, which is exactly what
/// lets `CardWindowPasteRouting.action`'s image branch retire without touching this file at all
/// (re-ruled 2026-08-09 — 04-interactions.md ▸ Clipboard): in the real app today nothing answers
/// `paste:` behind the editor for an image-only pasteboard any more, so a screenshot ⌘V with the
/// body editor focused is a no-op, served instead by the attachments header's control
/// (`CardPasteImageAffordance`). `PasteCatcher` here stands for "something behind the editor still
/// takes it" in the general case, which is the claim these tests actually pin.
///
/// The pasteboard is a private named one through the view's `yieldPasteboard` seam, so the suite
/// never reads the machine's — except through `super.paste`, which is AppKit's own and is exactly
/// why the text-path test asserts the catcher stayed silent rather than what landed in the view.
@MainActor
@Suite("Paste yield ▸ the body editor")
struct PasteYieldTests {
private func makeEditor(behind catcher: PasteCatcher? = nil) -> (CardBodyTextView, NSPasteboard) {
let pasteboard = NSPasteboard(name: NSPasteboard.Name("test-yield-\(UUID().uuidString)"))
pasteboard.clearContents()
let editor = CardBodyTextView(frame: .zero)
editor.isRichText = false
editor.isEditable = true
editor.yieldPasteboard = pasteboard
catcher?.addSubview(editor)
return (editor, pasteboard)
}
private var pasteItem: NSMenuItem {
NSMenuItem(title: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "")
}
@Test("An image-only pasteboard validates through the editor and forwards to the responder behind")
func imageOnlyYields() {
let catcher = PasteCatcher(frame: .zero)
let (editor, pasteboard) = makeEditor(behind: catcher)
defer { pasteboard.releaseGlobally() }
pasteboard.setData(encodedImage(.png), forType: .png)
#expect(editor.validateUserInterfaceItem(pasteItem))
editor.paste(nil)
#expect(catcher.pastes == 1)
}
/// **The file-URL branch's own yield** — a Finder copy reaches the window's attachment branch
/// exactly as a screenshot does, `readablePasteboardTypes`'s explicit exclusion of `.fileURL`
/// (`CardBodyTextView`'s override) proven rather than assumed: `importsGraphics` being `false` is
/// an AppKit default this pins down as a contract.
@Test("A file-URL-only pasteboard validates through the editor and forwards to the responder behind")
func fileURLOnlyYields() throws {
let catcher = PasteCatcher(frame: .zero)
let (editor, pasteboard) = makeEditor(behind: catcher)
defer { pasteboard.releaseGlobally() }
let fileURL = FileManager.default.temporaryDirectory
.appendingPathComponent("paste-yield-\(UUID().uuidString).png")
try Data([0x01]).write(to: fileURL)
defer { try? FileManager.default.removeItem(at: fileURL) }
pasteboard.writeObjects([fileURL as NSURL])
#expect(editor.validateUserInterfaceItem(pasteItem))
editor.paste(nil)
#expect(catcher.pastes == 1)
}
/// No expectation on `validateUserInterfaceItem` here: a readable pasteboard routes validation
/// to `super`, and `NSTextView`'s own answer reads the *machine's* general pasteboard — asserting
/// it would tie the test to whatever the host's clipboard happens to hold. The claim under test
/// is the routing: a text flavor means the paste is the editor's, so nothing is forwarded.
@Test("A text flavor keeps the paste in the editor — riding image or not")
func textStaysTheEditors() {
let catcher = PasteCatcher(frame: .zero)
let (editor, pasteboard) = makeEditor(behind: catcher)
defer { pasteboard.releaseGlobally() }
pasteboard.setString("plain words", forType: .string)
pasteboard.setData(encodedImage(.png), forType: .png)
editor.paste(nil)
#expect(catcher.pastes == 0)
}
@Test("Preview mode takes no paste at all, so the window's branch owns it outright")
func previewYieldsEverything() {
let catcher = PasteCatcher(frame: .zero)
let (editor, pasteboard) = makeEditor(behind: catcher)
defer { pasteboard.releaseGlobally() }
editor.isEditable = false
pasteboard.setData(encodedImage(.png), forType: .png)
#expect(editor.validateUserInterfaceItem(pasteItem))
editor.paste(nil)
#expect(catcher.pastes == 1)
}
@Test("Nothing behind to take it means a disabled row, not a swallowed gesture")
func noTargetDisables() {
let (editor, pasteboard) = makeEditor()
defer { pasteboard.releaseGlobally() }
pasteboard.setData(encodedImage(.png), forType: .png)
#expect(!editor.validateUserInterfaceItem(pasteItem))
}
}