A picture off the pasteboard becomes a card's file, its hero, or the board's backdrop

⌘V grows an image-data branch, below the app's own clipboard format and refused
outright while a file URL is on the pasteboard: a screenshot or a browser's Copy
Image lands as "Pasted Image.png" in the anchor card's attachments/, through the
very import path Finder file drops and ⇧⌘A take — one bracket, one Finder-style
collision ladder, one set of banners, and the same silence a drop's arrival has.
A card window's ⌘V pastes onto its own card; a focused text field still wins the
selector natively. A file-shaped flavor travels byte for byte, PNG preferred when
several are offered; TIFF and BMP are re-encoded to PNG, being interchange
encodings rather than files anyone wants in a folder.

The hero key gets the setter it was born owing: "Set as Hero" on any image row of
the attachment list, "Remove Hero" on the row that holds it, with menu-bar twins
so the context entry is nobody's only home. It writes as a restyle — one key, one
bracket, one invertible step on the window's own stack — and replaces rather than
refusing, because a card has one hero and the row that has it says Remove instead.

Edit ▸ Paste as Board Background is the same payload's other destination, taking
the existing background.image convention at its word: the picture into the board
folder as "Pasted Background.png", the colour subkey untouched, the generator's
overwrite-our-own-name rule inherited and its echo memo taught to tell the two
producers apart.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-09 02:43:53 -04:00
parent b18f7ca609
commit ca5d45156b
21 changed files with 1792 additions and 42 deletions
+3 -3
View File
@@ -248,7 +248,7 @@ struct AddAttachmentTargetTests {
let store = try BoardStore(rootURL: fixture.root)
let attachments = CardAttachments()
CardWindowHost.configureAttachments(attachments, store: store, cardID: card1)
CardWindowHost.configureAttachments(attachments, store: store, cardID: card1, undo: CardWindowUndo())
attachments.importFiles?([shot])
#expect(try fixture.data("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png") == Data([0x89, 0x50]))
@@ -266,7 +266,7 @@ struct AddAttachmentTargetTests {
let store = try BoardStore(rootURL: fixture.root)
let attachments = CardAttachments()
CardWindowHost.configureAttachments(attachments, store: store, cardID: card1)
CardWindowHost.configureAttachments(attachments, store: store, cardID: card1, undo: CardWindowUndo())
attachments.removeFile?(doomed)
#expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card1)/attachments").isEmpty)
@@ -285,7 +285,7 @@ struct AddAttachmentTargetTests {
let store = try BoardStore(rootURL: fixture.root)
let attachments = CardAttachments()
CardWindowHost.configureAttachments(attachments, store: store, cardID: card1)
CardWindowHost.configureAttachments(attachments, store: store, cardID: card1, undo: CardWindowUndo())
attachments.cardFolder = cardFolder(fixture)
attachments.names = ["shot.png"]
+27
View File
@@ -1,5 +1,6 @@
import Foundation
import Testing
import UniformTypeIdentifiers
@testable import Kanban
/// `ClipboardStore`'s own machinery the manifest, the staging lifecycle, the sweep, the
@@ -25,6 +26,12 @@ final class FakePasteboard: ClipboardPasteboard {
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)] = []
func manifestData() -> Data? { data }
@discardableResult
@@ -32,14 +39,34 @@ final class FakePasteboard: ClipboardPasteboard {
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
}
/// Another app copied: ownership moves, our type is gone, the counter advanced.
func takeOver() {
changeCount += 1
data = nil
text = nil
foreign = []
}
/// 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
}
}
+771
View File
@@ -0,0 +1,771 @@
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 fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let attachments = CardAttachments()
CardWindowHost.configureAttachments(
attachments, store: store, cardID: clipboardCard1, undo: CardWindowUndo()
)
attachments.setHeroFile?("photo.png")
#expect(try heroKey(of: Ident.card1, lane: Ident.lane1, in: fixture).value == "photo.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 the board folder 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("Pasted Background.png") == png)
#expect(try background(harness.fixture).backgroundImage.value == "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 == "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("Pasted Background.png") == second)
#expect(!harness.fixture.exists("Pasted Background 2.png"))
}
/// A hand-placed file of that name is the user's, and is never written through the ladder's
/// rule, the same one the generator follows.
@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("Pasted Background.png", mine)
harness.pasteboard.seed([(UTType.png.identifier, encodedImage(.png))])
#expect(harness.clipboard.pasteBoardBackground(into: harness.store))
#expect(try harness.fixture.data("Pasted Background.png") == mine)
#expect(try background(harness.fixture).backgroundImage.value == "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(FacetsGenerator.fileName) == generated, "untouched")
#expect(try background(harness.fixture).backgroundImage.value == "Pasted Background.png")
}
@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
== "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("Pasted Background.png"))
history.redo()
#expect(try FrontmatterDocument.parse(fixture.indexText("")).backgroundImage.value
== "Pasted Background.png")
}
}