New app-written background images (Theme tab ▸ Pattern, and Edit ▸ Paste as Board Background) now land in a hidden `.backgrounds/` folder at board root instead of beside index.md, matching the `.trash/` app-managed pattern. `background.image` stores the qualified relative reference (`.backgrounds/facets.png`); the resolver needed no change at all, since it already accepted any relative path inside the board root — the same mechanism that already resolved `art/backdrops/sunset.png` resolves the new location for free. `BoardWriter.writeBoardImage` now creates its destination folder if missing, since `.backgrounds/` won't exist until a board's first generated or pasted background. The Finder collision-ladder (`BoardStore.boardImageName`) is rescoped to `.backgrounds/`'s own contents, and its overwrite-in-place check now recognizes only the qualified form as "ours" — a legacy bare `background.image: facets.png` from before this change is read as a foreign reference rather than migrated, so a regeneration writes a fresh `.backgrounds/` file and orphans the old one in place, per the no-migration ruling. The Theme tab's Pattern/Solid mode-detection was updated to recognize both the legacy and current spellings as the generator's own output. The board loader needed no change: `.backgrounds/` is a hidden, non-UUID-shaped name, and `.skipsHiddenFiles` already keeps every hidden entry off the lane walk before any name-based exclusion is consulted — pinned with a new loader test. Deliberately did not add `.backgrounds` to IntegrityRules' claimed-name/squatter-displacement table: that table mirrors a specific existing DESIGN ruling this card doesn't amend. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
861 lines
38 KiB
Swift
861 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 .backgrounds/ and `background.image` names it")
|
|
func thePictureLands() throws {
|
|
let harness = try makeClipboardHarness()
|
|
defer { harness.tearDown() }
|
|
let png = encodedImage(.png)
|
|
harness.pasteboard.seed([(UTType.png.identifier, png)])
|
|
|
|
#expect(harness.clipboard.pasteBoardBackground(into: harness.store))
|
|
|
|
#expect(try harness.fixture.data(".backgrounds/Pasted Background.png") == png)
|
|
#expect(try background(harness.fixture).backgroundImage.value == ".backgrounds/Pasted Background.png")
|
|
#expect(harness.store.banners.oneShots.isEmpty)
|
|
}
|
|
|
|
/// A picture off the pasteboard carries no ground colour, so the gesture writes none — and a
|
|
/// colour the board already had survives, which is `setBackgroundImage`'s per-subkey contract.
|
|
@Test("The colour subkey is left exactly as it was")
|
|
func theColourSurvives() async throws {
|
|
let harness = try makeClipboardHarness()
|
|
defer { harness.tearDown() }
|
|
#expect(harness.store.applySolidBackground(colorHex: "#112233"))
|
|
await settle(harness.store)
|
|
harness.pasteboard.seed([(UTType.png.identifier, encodedImage(.png))])
|
|
|
|
#expect(harness.clipboard.pasteBoardBackground(into: harness.store))
|
|
|
|
let document = try background(harness.fixture)
|
|
#expect(document.background.value == "#112233")
|
|
#expect(document.backgroundImage.value == ".backgrounds/Pasted Background.png")
|
|
}
|
|
|
|
/// The generator's overwrite-in-place rule, inherited: re-pasting must not leave a folder full of
|
|
/// abandoned pictures.
|
|
@Test("Re-pasting overwrites the board's own file rather than climbing the ladder")
|
|
func rePastingOverwrites() async throws {
|
|
let harness = try makeClipboardHarness()
|
|
defer { harness.tearDown() }
|
|
harness.pasteboard.seed([(UTType.png.identifier, encodedImage(.png, side: 4))])
|
|
#expect(harness.clipboard.pasteBoardBackground(into: harness.store))
|
|
await settle(harness.store)
|
|
|
|
let second = encodedImage(.png, side: 8)
|
|
harness.pasteboard.seed([(UTType.png.identifier, second)])
|
|
#expect(harness.clipboard.pasteBoardBackground(into: harness.store))
|
|
|
|
#expect(try harness.fixture.data(".backgrounds/Pasted Background.png") == second)
|
|
#expect(!harness.fixture.exists(".backgrounds/Pasted Background 2.png"))
|
|
}
|
|
|
|
/// A hand-placed file of that name **inside `.backgrounds/`** is the user's, and is never written
|
|
/// through — the ladder's rule, the same one the generator follows. A file of that name at board
|
|
/// root would not collide at all any more: it simply is not where this write ever looks.
|
|
@Test("A file already holding the name is stepped around")
|
|
func anExistingNameIsRespected() throws {
|
|
let harness = try makeClipboardHarness()
|
|
defer { harness.tearDown() }
|
|
let mine = Data("hand placed".utf8)
|
|
try harness.fixture.file(".backgrounds/Pasted Background.png", mine)
|
|
harness.pasteboard.seed([(UTType.png.identifier, encodedImage(.png))])
|
|
|
|
#expect(harness.clipboard.pasteBoardBackground(into: harness.store))
|
|
|
|
#expect(try harness.fixture.data(".backgrounds/Pasted Background.png") == mine)
|
|
#expect(try background(harness.fixture).backgroundImage.value == ".backgrounds/Pasted Background 2.png")
|
|
}
|
|
|
|
/// The two producers must not read each other's echo: a paste landing inside the reroll's window
|
|
/// must not overwrite `facets.png`.
|
|
@Test("A paste right after a generated background writes its own file")
|
|
func theTwoProducersStayApart() throws {
|
|
let harness = try makeClipboardHarness()
|
|
defer { harness.tearDown() }
|
|
let generated = encodedImage(.png, side: 4)
|
|
#expect(harness.store.applyGeneratedBackground(png: generated, colorHex: "#445566"))
|
|
// Deliberately *no* reload — this is the echo window the generator's memo exists for.
|
|
harness.pasteboard.seed([(UTType.png.identifier, encodedImage(.png, side: 8))])
|
|
|
|
#expect(harness.clipboard.pasteBoardBackground(into: harness.store))
|
|
|
|
#expect(try harness.fixture.data(".backgrounds/\(FacetsGenerator.fileName)") == generated, "untouched")
|
|
#expect(try background(harness.fixture).backgroundImage.value == ".backgrounds/Pasted Background.png")
|
|
}
|
|
|
|
@Test("⌘Z puts the image subkey back and leaves the colour alone")
|
|
func undoRestoresTheSubkey() throws {
|
|
let fixture = try makeClipboardBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let history = NativeHistoryProvider()
|
|
store.history = history
|
|
|
|
#expect(store.applyPastedBackground(data: encodedImage(.png), fileExtension: "png"))
|
|
#expect(try FrontmatterDocument.parse(fixture.indexText("")).backgroundImage.value
|
|
== ".backgrounds/Pasted Background.png")
|
|
#expect(history.undoActionName == "Restyle Board")
|
|
|
|
history.undo()
|
|
#expect(try FrontmatterDocument.parse(fixture.indexText("")).backgroundImage.isMissing)
|
|
// The file survives the undo — "the undo restores the field, not the bytes".
|
|
#expect(fixture.exists(".backgrounds/Pasted Background.png"))
|
|
|
|
history.redo()
|
|
#expect(try FrontmatterDocument.parse(fixture.indexText("")).backgroundImage.value
|
|
== ".backgrounds/Pasted Background.png")
|
|
}
|
|
}
|
|
|
|
// MARK: - The body editor's paste yield
|
|
|
|
/// The responder behind the editor in these tests — stands where the card window's
|
|
/// `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))
|
|
}
|
|
}
|