The editor holds the keyboard from the moment the card window opens, and NSTextView answers an image-only pasteboard with a disabled Paste row — so the window's image branch sat one responder below the keyboard and could never be reached by it. The yield is by capability (readablePasteboardTypes), the same shape as the editor's existing refusal of file drops: any text flavor keeps the paste in the editor, and a pasteboard the editor has no reading of passes paste: to the responder behind it. Live-probed with real window-server ⌘V chords in both windows. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
860 lines
38 KiB
Swift
860 lines
38 KiB
Swift
import AppKit
|
|
import CoreGraphics
|
|
import Foundation
|
|
import ImageIO
|
|
import Testing
|
|
import UniformTypeIdentifiers
|
|
@testable import Kanban
|
|
|
|
/// **Pasting a picture** (04-interactions.md ▸ Clipboard's image-data branch, ruled 2026-08-09) —
|
|
/// the pasteboard classification and its precedence, the format rule, the Finder-style name and its
|
|
/// ladder, the hero key's write, and the three surfaces' validation.
|
|
///
|
|
/// Like every other write suite here the landing tests drive a **real store over a real temp board**
|
|
/// and read back through the loader or the raw bytes, never through a snapshot the store handed out.
|
|
/// `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`; `FakePasteboard`,
|
|
/// `ClipboardHarness` and the board fixture come from `ClipboardTests.swift`.
|
|
|
|
// MARK: - Making pictures
|
|
|
|
/// A tiny opaque bitmap, encoded under `type` — real bytes, because the format rule's whole claim is
|
|
/// about what ImageIO can and cannot read, and a `Data("png".utf8)` stand-in would make the
|
|
/// conversion tests vacuous.
|
|
func encodedImage(_ type: UTType, side: Int = 4) -> Data {
|
|
let space = CGColorSpaceCreateDeviceRGB()
|
|
let context = CGContext(
|
|
data: nil,
|
|
width: side,
|
|
height: side,
|
|
bitsPerComponent: 8,
|
|
bytesPerRow: 0,
|
|
space: space,
|
|
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
|
)!
|
|
context.setFillColor(CGColor(red: 0.2, green: 0.6, blue: 0.9, alpha: 1))
|
|
context.fill(CGRect(x: 0, y: 0, width: side, height: side))
|
|
let image = context.makeImage()!
|
|
|
|
let output = NSMutableData()
|
|
let destination = CGImageDestinationCreateWithData(output, type.identifier as CFString, 1, nil)!
|
|
CGImageDestinationAddImage(destination, image, nil)
|
|
_ = CGImageDestinationFinalize(destination)
|
|
return output as Data
|
|
}
|
|
|
|
/// The type identifier of whatever `data` actually is, as ImageIO reads it — the only honest way to
|
|
/// assert "the bytes on disk are still a PNG".
|
|
func imageType(of data: Data) -> String? {
|
|
guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { return nil }
|
|
return CGImageSourceGetType(source) as String?
|
|
}
|
|
|
|
/// One app-mediated reload landed — `GeneratedBackgroundTests`' own helper, because the echo-window
|
|
/// tests here are about exactly the same memo and have to open and close that window the same way.
|
|
@MainActor
|
|
private func settle(_ store: BoardStore) async {
|
|
store.handleWatcherEvent(.treeChanged(.appMediated))
|
|
await store.awaitQuiescence()
|
|
}
|
|
|
|
// MARK: - Classification
|
|
|
|
@Suite("PastedImage ▸ classification")
|
|
struct PastedImageClassificationTests {
|
|
|
|
@Test("The app's own clipboard type wins outright — a picture beside it never diverts ⌘V")
|
|
func boardItemsWin() {
|
|
let flavor = PastedImage.flavor(
|
|
hasBoardItems: true,
|
|
types: [UTType.laneworkClipboard.identifier, UTType.png.identifier, UTType.tiff.identifier]
|
|
)
|
|
#expect(flavor == nil)
|
|
}
|
|
|
|
/// The ruling's own parenthesis: "IMAGE DATA (no file URL)". A Finder copy of a PNG puts a file
|
|
/// URL down, often with an image flavor beside it, and that is a different gesture's payload.
|
|
@Test("A file URL suppresses the branch, whatever else rides beside it")
|
|
func fileURLsSuppress() {
|
|
#expect(PastedImage.flavor(
|
|
hasBoardItems: false,
|
|
types: [UTType.fileURL.identifier, UTType.png.identifier]
|
|
) == nil)
|
|
// Conformance, not equality: a subtype of `public.file-url` is still a file reference.
|
|
#expect(PastedImage.carriesFileURL([UTType.fileURL.identifier]))
|
|
#expect(!PastedImage.carriesFileURL([UTType.png.identifier, UTType.tiff.identifier]))
|
|
// A type the system does not know is not a file URL — the optimistic reading a drag's
|
|
// unknown types get.
|
|
#expect(!PastedImage.carriesFileURL(["com.example.nothing-at-all"]))
|
|
}
|
|
|
|
@Test("Raw image data is the fallback, and an empty pasteboard offers nothing")
|
|
func imageDataIsTheFallback() throws {
|
|
let flavor = try #require(PastedImage.flavor(hasBoardItems: false, types: [UTType.png.identifier]))
|
|
#expect(flavor.type == UTType.png.identifier)
|
|
#expect(PastedImage.flavor(hasBoardItems: false, types: []) == nil)
|
|
#expect(PastedImage.flavor(hasBoardItems: false, types: [UTType.plainText.identifier]) == nil)
|
|
}
|
|
|
|
/// The screenshot's exact pasteboard: PNG and TIFF together. Our order wins over the
|
|
/// pasteboard's, so the common paste costs no decode and no re-encode at all.
|
|
@Test("PNG beats TIFF however the pasteboard orders them")
|
|
func pngBeatsTIFF() throws {
|
|
for types in [
|
|
[UTType.tiff.identifier, UTType.png.identifier],
|
|
[UTType.png.identifier, UTType.tiff.identifier],
|
|
] {
|
|
let flavor = try #require(PastedImage.flavor(hasBoardItems: false, types: types))
|
|
#expect(flavor.type == UTType.png.identifier)
|
|
#expect(!flavor.convertsToPNG)
|
|
#expect(flavor.fileName == "Pasted Image.png")
|
|
}
|
|
}
|
|
|
|
@Test("A file-shaped flavor keeps its own extension; TIFF and BMP become PNG")
|
|
func theFormatRule() throws {
|
|
let jpeg = try #require(PastedImage.flavor(hasBoardItems: false, types: [UTType.jpeg.identifier]))
|
|
#expect(!jpeg.convertsToPNG)
|
|
#expect(jpeg.fileExtension == UTType.jpeg.preferredFilenameExtension)
|
|
|
|
let gif = try #require(PastedImage.flavor(hasBoardItems: false, types: [UTType.gif.identifier]))
|
|
#expect(!gif.convertsToPNG, "a GIF's animation does not survive a single-frame decode")
|
|
#expect(gif.fileName == "Pasted Image.gif")
|
|
|
|
for interchange in [UTType.tiff, UTType.bmp] {
|
|
let flavor = try #require(
|
|
PastedImage.flavor(hasBoardItems: false, types: [interchange.identifier])
|
|
)
|
|
#expect(flavor.convertsToPNG)
|
|
#expect(flavor.fileExtension == "png")
|
|
#expect(flavor.type == interchange.identifier, "the bytes still come from the offered type")
|
|
}
|
|
}
|
|
|
|
@Test("The two names are the two folders' — a card's attachment and a board's backdrop")
|
|
func theTwoNames() throws {
|
|
let flavor = try #require(PastedImage.flavor(hasBoardItems: false, types: [UTType.jpeg.identifier]))
|
|
#expect(flavor.fileName == "Pasted Image.jpeg")
|
|
#expect(flavor.backgroundFileName == "Pasted Background.jpeg")
|
|
}
|
|
}
|
|
|
|
// MARK: - The bytes
|
|
|
|
@Suite("PastedImage ▸ encoding")
|
|
struct PastedImageEncodingTests {
|
|
|
|
@Test("A verbatim flavor is handed back byte for byte — no decode, no re-encode")
|
|
func verbatimIsUntouched() throws {
|
|
let png = encodedImage(.png)
|
|
let flavor = try #require(PastedImage.flavor(hasBoardItems: false, types: [UTType.png.identifier]))
|
|
#expect(PastedImage.encode(png, as: flavor) == png)
|
|
}
|
|
|
|
@Test("TIFF is re-encoded, and what comes out really is a PNG")
|
|
func tiffBecomesPNG() throws {
|
|
let tiff = encodedImage(.tiff)
|
|
let flavor = try #require(PastedImage.flavor(hasBoardItems: false, types: [UTType.tiff.identifier]))
|
|
let encoded = try #require(PastedImage.encode(tiff, as: flavor))
|
|
#expect(encoded != tiff)
|
|
#expect(imageType(of: encoded) == UTType.png.identifier)
|
|
}
|
|
|
|
/// A pasteboard that declares a flavor it cannot back up. Nothing is written, which is the honest
|
|
/// outcome — see `ClipboardStore.pasteImage(intoCard:in:)`.
|
|
@Test("Bytes that are not an image encode to nothing rather than to a broken file")
|
|
func undecodableBytesRefuse() throws {
|
|
let tiff = try #require(PastedImage.flavor(hasBoardItems: false, types: [UTType.tiff.identifier]))
|
|
#expect(PastedImage.encode(Data("not a picture".utf8), as: tiff) == nil)
|
|
|
|
let png = try #require(PastedImage.flavor(hasBoardItems: false, types: [UTType.png.identifier]))
|
|
#expect(PastedImage.encode(Data(), as: png) == nil, "an empty payload is not a file")
|
|
}
|
|
}
|
|
|
|
// MARK: - Which attachments can be a hero
|
|
|
|
@Suite("PastedImage ▸ image names")
|
|
struct PastedImageNameTests {
|
|
|
|
@Test("An image is decided by extension, and anything else is not one")
|
|
func imageNames() {
|
|
#expect(PastedImage.isImageName("Pasted Image.png"))
|
|
#expect(PastedImage.isImageName("photo.JPEG"), "extensions are case-insensitive")
|
|
#expect(PastedImage.isImageName("clip.gif"))
|
|
#expect(!PastedImage.isImageName("notes.txt"))
|
|
#expect(!PastedImage.isImageName("archive.zip"))
|
|
#expect(!PastedImage.isImageName("README"), "no extension, no reading")
|
|
#expect(!PastedImage.isImageName(""))
|
|
}
|
|
}
|
|
|
|
// MARK: - Where a picture lands on the board
|
|
|
|
@MainActor
|
|
@Suite("PasteTarget ▸ the image branch")
|
|
struct PasteImageTargetTests {
|
|
|
|
private func snapshot() throws -> BoardModel {
|
|
let fixture = try makeClipboardBoard()
|
|
defer { fixture.tearDown() }
|
|
return try BoardLoader.load(boardRoot: fixture.root).model
|
|
}
|
|
|
|
@Test("The anchor card, which is the last selected in flatten order")
|
|
func theAnchorCard() throws {
|
|
let model = try snapshot()
|
|
#expect(PasteTarget.card(
|
|
selection: ItemReferenceSet(ids: [clipboardCard1], container: .board),
|
|
snapshot: model
|
|
) == clipboardCard1)
|
|
// Two selected: the last in flatten order, the shared anchor rule.
|
|
#expect(PasteTarget.card(
|
|
selection: ItemReferenceSet(ids: [clipboardCard1, clipboardCard4], container: .board),
|
|
snapshot: model
|
|
) == clipboardCard4)
|
|
}
|
|
|
|
/// A picture has to land in *some* card's `attachments/`, and there is no card the app could pick
|
|
/// without inventing one — so these three all answer nothing and the menu greys out.
|
|
@Test("A lane, an empty and a trash selection all anchor no card")
|
|
func nothingToAnchorOn() throws {
|
|
let model = try snapshot()
|
|
#expect(PasteTarget.card(
|
|
selection: ItemReferenceSet(ids: [clipboardLane1], container: .board),
|
|
snapshot: model
|
|
) == nil)
|
|
#expect(PasteTarget.card(selection: .empty, snapshot: model) == nil)
|
|
#expect(PasteTarget.card(
|
|
selection: ItemReferenceSet(ids: [clipboardCard3], container: .trash),
|
|
snapshot: model
|
|
) == nil)
|
|
}
|
|
}
|
|
|
|
// MARK: - The paste itself
|
|
|
|
@MainActor
|
|
@Suite("Paste ▸ an image into a card")
|
|
struct PasteImageWriteTests {
|
|
|
|
/// Attachment names as the loader sees them — never the store's snapshot, which a write
|
|
/// deliberately does not touch (the one-way flow).
|
|
private func attachments(_ card: String, in fixture: WriterFixture) throws -> [String] {
|
|
let model = try BoardLoader.load(boardRoot: fixture.root).model
|
|
for lane in model.lanes {
|
|
if let match = lane.cards.first(where: { $0.id.rawValue == card }) { return match.attachments }
|
|
}
|
|
return []
|
|
}
|
|
|
|
@Test("A screenshot lands as 'Pasted Image.png', byte for byte, in the anchor card")
|
|
func aScreenshotLands() throws {
|
|
let harness = try makeClipboardHarness()
|
|
defer { harness.tearDown() }
|
|
let png = encodedImage(.png)
|
|
harness.pasteboard.seed([
|
|
(UTType.png.identifier, png),
|
|
(UTType.tiff.identifier, encodedImage(.tiff)),
|
|
])
|
|
harness.store.select([clipboardCard2], in: .board)
|
|
|
|
#expect(harness.clipboard.pasteImage(into: harness.store))
|
|
|
|
#expect(try attachments(Ident.card2, in: harness.fixture) == ["Pasted Image.png"])
|
|
#expect(try harness.fixture.data("\(Ident.lane1)/\(Ident.card2)/attachments/Pasted Image.png") == png)
|
|
#expect(harness.store.banners.oneShots.isEmpty)
|
|
}
|
|
|
|
@Test("A second paste climbs the Finder ladder rather than overwriting")
|
|
func theFinderLadder() throws {
|
|
let harness = try makeClipboardHarness()
|
|
defer { harness.tearDown() }
|
|
harness.pasteboard.seed([(UTType.png.identifier, encodedImage(.png))])
|
|
harness.store.select([clipboardCard2], in: .board)
|
|
|
|
#expect(harness.clipboard.pasteImage(into: harness.store))
|
|
#expect(harness.clipboard.pasteImage(into: harness.store))
|
|
#expect(harness.clipboard.pasteImage(into: harness.store))
|
|
|
|
#expect(try attachments(Ident.card2, in: harness.fixture)
|
|
== ["Pasted Image 2.png", "Pasted Image 3.png", "Pasted Image.png"])
|
|
}
|
|
|
|
/// The name is minted against what is on disk, so a hand-placed file of the same name is the
|
|
/// user's and is never written through — `BoardWriter.freshName`'s rule, inherited whole.
|
|
@Test("A file already holding the name is stepped around, never overwritten")
|
|
func anExistingNameIsRespected() throws {
|
|
let harness = try makeClipboardHarness()
|
|
defer { harness.tearDown() }
|
|
let mine = Data("hand placed".utf8)
|
|
try harness.fixture.file("\(Ident.lane1)/\(Ident.card2)/attachments/Pasted Image.png", mine)
|
|
harness.pasteboard.seed([(UTType.png.identifier, encodedImage(.png))])
|
|
harness.store.select([clipboardCard2], in: .board)
|
|
|
|
#expect(harness.clipboard.pasteImage(into: harness.store))
|
|
|
|
#expect(try harness.fixture.data("\(Ident.lane1)/\(Ident.card2)/attachments/Pasted Image.png") == mine)
|
|
#expect(try attachments(Ident.card2, in: harness.fixture) == ["Pasted Image 2.png", "Pasted Image.png"])
|
|
}
|
|
|
|
@Test("A TIFF-only pasteboard lands a PNG")
|
|
func tiffLandsAsPNG() throws {
|
|
let harness = try makeClipboardHarness()
|
|
defer { harness.tearDown() }
|
|
harness.pasteboard.seed([(UTType.tiff.identifier, encodedImage(.tiff))])
|
|
harness.store.select([clipboardCard2], in: .board)
|
|
|
|
#expect(harness.clipboard.pasteImage(into: harness.store))
|
|
|
|
#expect(try attachments(Ident.card2, in: harness.fixture) == ["Pasted Image.png"])
|
|
let landed = try harness.fixture.data("\(Ident.lane1)/\(Ident.card2)/attachments/Pasted Image.png")
|
|
#expect(imageType(of: landed) == UTType.png.identifier)
|
|
}
|
|
|
|
@Test("A pasteboard that declared a flavor it cannot back up writes nothing at all")
|
|
func aLyingPasteboardWritesNothing() throws {
|
|
let harness = try makeClipboardHarness()
|
|
defer { harness.tearDown() }
|
|
harness.pasteboard.seed([(UTType.tiff.identifier, Data("not a picture".utf8))])
|
|
harness.store.select([clipboardCard2], in: .board)
|
|
|
|
#expect(!harness.clipboard.pasteImage(into: harness.store))
|
|
#expect(try attachments(Ident.card2, in: harness.fixture).isEmpty)
|
|
#expect(harness.store.banners.oneShots.isEmpty)
|
|
}
|
|
|
|
/// The card window's branch: the target is the window's own card, whatever the board's selection
|
|
/// happens to be.
|
|
@Test("The card window pastes onto its own card, not onto the board's selection")
|
|
func theCardWindowsOwnCard() throws {
|
|
let harness = try makeClipboardHarness()
|
|
defer { harness.tearDown() }
|
|
harness.pasteboard.seed([(UTType.png.identifier, encodedImage(.png))])
|
|
harness.store.select([clipboardCard2], in: .board)
|
|
|
|
#expect(harness.clipboard.pasteImage(intoCard: clipboardCard4, in: harness.store))
|
|
|
|
#expect(try attachments(Ident.card4, in: harness.fixture) == ["Pasted Image.png"])
|
|
#expect(try attachments(Ident.card2, in: harness.fixture).isEmpty)
|
|
}
|
|
|
|
@Test("A board payload on the pasteboard is never diverted into an attachment")
|
|
func aBoardPayloadStillPastesAsCards() throws {
|
|
let harness = try makeClipboardHarness()
|
|
defer { harness.tearDown() }
|
|
harness.store.select([clipboardCard1], in: .board)
|
|
harness.clipboard.copy(from: harness.store)
|
|
|
|
#expect(harness.clipboard.imagePayload == nil)
|
|
#expect(!harness.clipboard.canPasteImage(into: harness.store))
|
|
#expect(harness.clipboard.canPaste(into: harness.store))
|
|
}
|
|
}
|
|
|
|
// MARK: - Validation
|
|
|
|
@MainActor
|
|
@Suite("Paste ▸ image menu validation")
|
|
struct PasteImageValidationTests {
|
|
|
|
@Test("The board branch needs a picture and a card to put it on")
|
|
func theBoardBranchsClauses() throws {
|
|
let harness = try makeClipboardHarness()
|
|
defer { harness.tearDown() }
|
|
|
|
harness.store.select([clipboardCard1], in: .board)
|
|
#expect(!harness.clipboard.canPasteImage(into: harness.store), "nothing on the pasteboard")
|
|
|
|
harness.pasteboard.seed([(UTType.png.identifier, encodedImage(.png))])
|
|
harness.clipboard.refresh()
|
|
#expect(harness.clipboard.canPasteImage(into: harness.store))
|
|
|
|
// A lane anchors no card, so the row greys out — and so does the paste.
|
|
harness.store.select([clipboardLane1], in: .board)
|
|
#expect(!harness.clipboard.canPasteImage(into: harness.store))
|
|
#expect(!harness.clipboard.pasteImage(into: harness.store))
|
|
|
|
harness.store.clearSelection()
|
|
#expect(!harness.clipboard.canPasteImage(into: harness.store))
|
|
}
|
|
|
|
@Test("A file URL on the pasteboard offers nothing to any of the three surfaces")
|
|
func fileURLsOfferNothing() throws {
|
|
let harness = try makeClipboardHarness()
|
|
defer { harness.tearDown() }
|
|
harness.pasteboard.seed([
|
|
(UTType.fileURL.identifier, Data("file:///tmp/shot.png".utf8)),
|
|
(UTType.png.identifier, encodedImage(.png)),
|
|
])
|
|
harness.clipboard.refresh()
|
|
harness.store.select([clipboardCard1], in: .board)
|
|
|
|
#expect(harness.clipboard.imagePayload == nil)
|
|
#expect(!harness.clipboard.canPasteImage(into: harness.store))
|
|
#expect(!harness.clipboard.canPasteImage(intoCard: clipboardCard1, in: harness.store))
|
|
#expect(!harness.clipboard.canPasteBoardBackground(into: harness.store))
|
|
}
|
|
|
|
/// The board's backdrop has no target to resolve, which is what makes it the one image-paste
|
|
/// surface that stays live with nothing selected.
|
|
@Test("The backdrop row needs only a picture and a writable board")
|
|
func theBackgroundRowsClauses() throws {
|
|
let harness = try makeClipboardHarness()
|
|
defer { harness.tearDown() }
|
|
harness.pasteboard.seed([(UTType.png.identifier, encodedImage(.png))])
|
|
harness.clipboard.refresh()
|
|
|
|
harness.store.clearSelection()
|
|
#expect(harness.clipboard.canPasteBoardBackground(into: harness.store))
|
|
#expect(!harness.clipboard.canPasteImage(into: harness.store), "no card selected")
|
|
}
|
|
|
|
@Test("The card-window branch refuses a card that is not on the board side")
|
|
func theCardWindowBranchsClauses() throws {
|
|
let harness = try makeClipboardHarness()
|
|
defer { harness.tearDown() }
|
|
harness.pasteboard.seed([(UTType.png.identifier, encodedImage(.png))])
|
|
harness.clipboard.refresh()
|
|
|
|
#expect(harness.clipboard.canPasteImage(intoCard: clipboardCard1, in: harness.store))
|
|
#expect(!harness.clipboard.canPasteImage(intoCard: clipboardCard3, in: harness.store), "trashed")
|
|
#expect(!harness.clipboard.canPasteImage(intoCard: clipboardLane1, in: harness.store), "a lane")
|
|
}
|
|
}
|
|
|
|
// MARK: - Set as Hero
|
|
|
|
@MainActor
|
|
@Suite("The hero key ▸ set and remove")
|
|
struct SetAsHeroTests {
|
|
|
|
private func heroKey(of card: String, lane: String, in fixture: WriterFixture) throws -> FieldValue<String> {
|
|
try FrontmatterDocument.parse(fixture.indexText("\(lane)/\(card)")).hero
|
|
}
|
|
|
|
/// The reload between the two halves is not ceremony: the no-op guard reads the **snapshot**,
|
|
/// which is one reload behind every write the app makes (the one-way flow), exactly as
|
|
/// `applyStyle`'s `effective(_:against:)` does. The two surfaces stay consistent because they read
|
|
/// the same snapshot — while it has not caught up, the row is still offering *Set* as Hero, so
|
|
/// there is no Remove for a user to press.
|
|
@Test("Set as Hero writes the bare filename; Remove Hero takes the key away")
|
|
func setThenRemove() async throws {
|
|
let fixture = try makeClipboardBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
#expect(store.setHero("photo.png", onCard: clipboardCard1))
|
|
#expect(try heroKey(of: Ident.card1, lane: Ident.lane1, in: fixture).value == "photo.png")
|
|
|
|
await settle(store)
|
|
#expect(store.setHero(nil, onCard: clipboardCard1))
|
|
#expect(try heroKey(of: Ident.card1, lane: Ident.lane1, in: fixture).isMissing)
|
|
#expect(store.banners.oneShots.isEmpty)
|
|
}
|
|
|
|
/// One hero per card, and the user picked a different picture: Set replaces rather than refusing,
|
|
/// so no Remove-then-Set dance.
|
|
@Test("Set as Hero on a card that already has one replaces it")
|
|
func setReplaces() async throws {
|
|
let fixture = try makeClipboardBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
#expect(store.setHero("photo.png", onCard: clipboardCard1))
|
|
await settle(store)
|
|
#expect(store.setHero("notes.txt", onCard: clipboardCard1))
|
|
#expect(try heroKey(of: Ident.card1, lane: Ident.lane1, in: fixture).value == "notes.txt")
|
|
}
|
|
|
|
@Test("Writing the hero a card already has is a no-op — no write, no step")
|
|
func redundantSetsAreFree() async throws {
|
|
let fixture = try makeClipboardBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let history = NativeHistoryProvider()
|
|
store.history = history
|
|
|
|
// Removing a hero that was never there changes nothing.
|
|
#expect(!store.setHero(nil, onCard: clipboardCard1))
|
|
#expect(!history.canUndo, "nothing happened, so nothing is on the stack")
|
|
|
|
#expect(store.setHero("photo.png", onCard: clipboardCard1))
|
|
await settle(store)
|
|
#expect(!store.setHero("photo.png", onCard: clipboardCard1), "already says exactly this")
|
|
}
|
|
|
|
@Test("The other keys and the body ride through untouched")
|
|
func everythingElseSurvives() throws {
|
|
let fixture = try makeClipboardBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
#expect(store.setHero("photo.png", onCard: clipboardCard1))
|
|
let document = try FrontmatterDocument.parse(fixture.indexText("\(Ident.lane1)/\(Ident.card1)"))
|
|
#expect(document.title.value == "First")
|
|
#expect(document.value(for: "project") != nil, "an unknown key is round-tripped")
|
|
#expect(document.body.contains("First body"))
|
|
}
|
|
|
|
@Test("A trashed card and a lane are both refused")
|
|
func containerGuards() throws {
|
|
let fixture = try makeClipboardBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
#expect(!store.setHero("photo.png", onCard: clipboardCard3), "in the trash")
|
|
#expect(!store.setHero("photo.png", onCard: clipboardLane1), "a lane has no hero")
|
|
#expect(try FrontmatterDocument.parse(fixture.indexText(".trash/\(Ident.card3)")).hero.isMissing)
|
|
}
|
|
|
|
@Test("⌘Z puts the key back exactly as it was, under the restyle phrase")
|
|
func undoRestoresTheKey() throws {
|
|
let fixture = try makeClipboardBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let history = NativeHistoryProvider()
|
|
store.history = history
|
|
|
|
#expect(store.setHero("photo.png", onCard: clipboardCard1))
|
|
#expect(try heroKey(of: Ident.card1, lane: Ident.lane1, in: fixture).value == "photo.png")
|
|
#expect(history.undoActionName == "Restyle Card")
|
|
|
|
history.undo()
|
|
#expect(try heroKey(of: Ident.card1, lane: Ident.lane1, in: fixture).isMissing)
|
|
|
|
history.redo()
|
|
#expect(try heroKey(of: Ident.card1, lane: Ident.lane1, in: fixture).value == "photo.png")
|
|
}
|
|
|
|
/// A prior hero comes back as itself rather than as an absence — `applyStyle`'s inverse reading.
|
|
@Test("Undoing a replacement restores the hero that was there")
|
|
func undoRestoresAPriorHero() async throws {
|
|
let fixture = try makeClipboardBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let history = NativeHistoryProvider()
|
|
store.history = history
|
|
|
|
#expect(store.setHero("photo.png", onCard: clipboardCard1))
|
|
await settle(store)
|
|
#expect(store.setHero("notes.txt", onCard: clipboardCard1))
|
|
|
|
history.undo()
|
|
#expect(try heroKey(of: Ident.card1, lane: Ident.lane1, in: fixture).value == "photo.png")
|
|
}
|
|
|
|
/// The wiring, driven through the real `configureAttachments` so the test breaks if the seam is
|
|
/// ever crossed or the id captured from the wrong place.
|
|
@Test("The attachment row's seam writes the window's own card")
|
|
func theRowsSeam() throws {
|
|
let 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")
|
|
}
|
|
}
|
|
|
|
// MARK: - The body editor's paste yield
|
|
|
|
/// The responder behind the editor in these tests — stands where the card window's
|
|
/// `cardWindowImagePaste` bridge stands in the app, and only counts.
|
|
@MainActor
|
|
private final class PasteCatcher: NSView {
|
|
var pastes = 0
|
|
@objc func paste(_ sender: Any?) { pastes += 1 }
|
|
}
|
|
|
|
/// **`CardBodyTextView` yields a paste it cannot read** (05-card-window.md ▸ Attachments, ruled
|
|
/// 2026-08-09) — the screenshot pasteboard reaches the window's attachment branch even while the
|
|
/// editor holds the keyboard, and a text paste never leaves the editor.
|
|
///
|
|
/// The pasteboard is a private named one through the view's `yieldPasteboard` seam, so the suite
|
|
/// never reads the machine's — except through `super.paste`, which is AppKit's own and is exactly
|
|
/// why the text-path test asserts the catcher stayed silent rather than what landed in the view.
|
|
@MainActor
|
|
@Suite("Paste yield ▸ the body editor")
|
|
struct PasteYieldTests {
|
|
|
|
private func makeEditor(behind catcher: PasteCatcher? = nil) -> (CardBodyTextView, NSPasteboard) {
|
|
let pasteboard = NSPasteboard(name: NSPasteboard.Name("test-yield-\(UUID().uuidString)"))
|
|
pasteboard.clearContents()
|
|
let editor = CardBodyTextView(frame: .zero)
|
|
editor.isRichText = false
|
|
editor.isEditable = true
|
|
editor.yieldPasteboard = pasteboard
|
|
catcher?.addSubview(editor)
|
|
return (editor, pasteboard)
|
|
}
|
|
|
|
private var pasteItem: NSMenuItem {
|
|
NSMenuItem(title: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "")
|
|
}
|
|
|
|
@Test("An image-only pasteboard validates through the editor and forwards to the responder behind")
|
|
func imageOnlyYields() {
|
|
let catcher = PasteCatcher(frame: .zero)
|
|
let (editor, pasteboard) = makeEditor(behind: catcher)
|
|
defer { pasteboard.releaseGlobally() }
|
|
pasteboard.setData(encodedImage(.png), forType: .png)
|
|
|
|
#expect(editor.validateUserInterfaceItem(pasteItem))
|
|
editor.paste(nil)
|
|
#expect(catcher.pastes == 1)
|
|
}
|
|
|
|
/// No expectation on `validateUserInterfaceItem` here: a readable pasteboard routes validation
|
|
/// to `super`, and `NSTextView`'s own answer reads the *machine's* general pasteboard — asserting
|
|
/// it would tie the test to whatever the host's clipboard happens to hold. The claim under test
|
|
/// is the routing: a text flavor means the paste is the editor's, so nothing is forwarded.
|
|
@Test("A text flavor keeps the paste in the editor — riding image or not")
|
|
func textStaysTheEditors() {
|
|
let catcher = PasteCatcher(frame: .zero)
|
|
let (editor, pasteboard) = makeEditor(behind: catcher)
|
|
defer { pasteboard.releaseGlobally() }
|
|
pasteboard.setString("plain words", forType: .string)
|
|
pasteboard.setData(encodedImage(.png), forType: .png)
|
|
|
|
editor.paste(nil)
|
|
#expect(catcher.pastes == 0)
|
|
}
|
|
|
|
@Test("Preview mode takes no paste at all, so the window's branch owns it outright")
|
|
func previewYieldsEverything() {
|
|
let catcher = PasteCatcher(frame: .zero)
|
|
let (editor, pasteboard) = makeEditor(behind: catcher)
|
|
defer { pasteboard.releaseGlobally() }
|
|
editor.isEditable = false
|
|
pasteboard.setData(encodedImage(.png), forType: .png)
|
|
|
|
#expect(editor.validateUserInterfaceItem(pasteItem))
|
|
editor.paste(nil)
|
|
#expect(catcher.pastes == 1)
|
|
}
|
|
|
|
@Test("Nothing behind to take it means a disabled row, not a swallowed gesture")
|
|
func noTargetDisables() {
|
|
let (editor, pasteboard) = makeEditor()
|
|
defer { pasteboard.releaseGlobally() }
|
|
pasteboard.setData(encodedImage(.png), forType: .png)
|
|
|
|
#expect(!editor.validateUserInterfaceItem(pasteItem))
|
|
}
|
|
}
|