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:
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user