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 { 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") } }