Files
lanework/KanbanTests/CardHeroTests.swift
T
rzen ce92c24190 Hero image for cards — one of the card's own attachments, banded across its face
A card whose `hero:` names one of its own attachments draws that picture as a
banner across the full width of its plate, above the icon-and-title row,
aspect-fill cropped into a fixed 2.75 em band — 36pt at the standard body, and
em-scaled like every other figure the board draws, so it grows with the system
text size and with the board's zoom rather than shrinking against a title twice
its usual size. The figure sits deliberately under the 44pt a plain one-line
card is tall: a hero card should read as a card with a picture on it rather than
a picture with a caption, which is 03's standing rule that the title dominates.

The key's grammar is a **bare filename**, and that is what separates it from the
board background's `image` subkey rather than a nervousness about paths. A board
names a file anywhere under its root, so a path is that key's reading and where
it leads is the renderer's question. A card names one of the files it already
owns — the flat `attachments/` folder the app lists, relocates into, and carries
through every move, copy, trash and restore — so `hero: art/sketch.png` is not an
awkward spelling of a hero image, it is a value the key cannot mean. It therefore
has no reading at all: a value carrying a separator, or spelling `.`/`..`, or
empty, is malformed at the document layer, which renders it as absent and leaves
the coerce tier's trace, exactly as `width: 1.5` does. The bytes stay as written,
the resolver re-checks containment anyway, and the whole degrade family below
that — a name pointing at a missing file, an unreadable one, or one that is not
an image — ends the same way: no banner, no defect, nothing written.

That last promise is about *height* as much as about ink, so the band is given no
height at all until a picture has actually decoded. A card whose hero cannot be
drawn lays out identically to a card with no key, structurally rather than by a
branch somebody has to remember; the price is one settle per hero as a board
opens, and none after that. Everything else the face draws is attached outside
the new stack and is untouched by it — the accent stripe still runs the plate's
full leading edge across the band's corner, the selection and file-hover strokes
still ring the whole plate, the cut and drag dims still cover it, and the drop
model still registers the plate's real height, so a hero card is simply a taller
card the masonry already understands. The trash draws it too, by the one-face
rule.

Decoding is ImageIO's downsampling path off the main actor at a quarter of the
backdrop's pixel budget (`BoardBackdrop.decode` gained the limit as a parameter
rather than being copied), and the results live in one app-wide, deliberately
non-observable cache keyed on path plus the file's date and size. Non-observable
because a tracked write there would invalidate every hero face on the board,
which is the O(board) invalidation this view was rebuilt once already to shed;
each face holds its own picture in view state and seeds it from the cache, which
is also what lets the drag replica — whose preview builder is non-escaping and
cannot await anything — carry the band at the face's real height. Taking a stamp
twice from one URL value turned out to answer with the first read's date and size
however many times the bytes had changed, so `stamp(of:)` now drops its cached
resource values first; noticing a replacement is the only thing a stamp is for.

The face takes the resolved URL as a compared input rather than resolving it, for
selected-ness's reason one axis over: resolving needs the card's folder, which a
face does not know, and finding it from the snapshot would be a board walk per
face. The lane and the trash column each know their own container and compute it
once for the whole strip.

There is no in-app setter this version — the key is written by hand or by an
agent, which is why the guide bumps to v13 with a clause spelling the grammar out
beside the other card keys, and why `attachments/` gets the one-line pointer an
agent that has just written `![](attachments/x.png)` will need. "Set as Hero"
from the attachment row is future work, as is the card window and print, which
draw the same model and show no banner today.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-08 23:41:15 -04:00

279 lines
13 KiB
Swift

import CoreGraphics
import Foundation
import ImageIO
import Testing
@testable import Kanban
/// **The card face's hero image** (03-board-ui.md § Card face ▸ Hero image; the card's `hero` key,
/// 01-storage-format.md § Frontmatter).
///
/// The *reading* lives with its siblings in `FrontmatterTests` and the *metric* with the rest of the
/// board's geometry in `VisualAccommodationsTests`/`BoardZoomTests`; what is here is everything new:
/// where a name resolves (`CardHero`), what the band can actually draw (the decode), and the shared
/// cache the drag replica depends on being able to read synchronously (`CardHeroCache`).
// MARK: - Fixtures
/// A card folder with an `attachments/` inside it, under a temp root.
private struct CardFixture {
let root: URL
let lane = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
let card = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
var cardFolder: URL {
root
.appendingPathComponent(lane, isDirectory: true)
.appendingPathComponent(card, isDirectory: true)
}
var attachments: URL {
cardFolder.appendingPathComponent("attachments", isDirectory: true)
}
init() throws {
root = FileManager.default.temporaryDirectory
.appendingPathComponent("CardHeroTests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: attachments, withIntermediateDirectories: true)
}
func tearDown() {
try? FileManager.default.removeItem(at: root)
}
}
/// A real PNG of the given pixel size — the only way to test a decode honestly, since the whole
/// question is what ImageIO makes of actual bytes.
@discardableResult
private func writePNG(at url: URL, width: Int, height: Int) throws -> URL {
let context = try #require(CGContext(
data: nil,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: 0,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
))
context.setFillColor(CGColor(red: 0.2, green: 0.4, blue: 0.8, alpha: 1))
context.fill(CGRect(x: 0, y: 0, width: width, height: height))
let image = try #require(context.makeImage())
let destination = try #require(
CGImageDestinationCreateWithURL(url as CFURL, "public.png" as CFString, 1, nil))
CGImageDestinationAddImage(destination, image, nil)
#expect(CGImageDestinationFinalize(destination))
return url
}
/// The card as the loader reads it — the model value the face is handed, rather than one assembled
/// by hand, so the carry-through and the resolution are exercised against the same thing.
private func loadedCard(_ fixture: CardFixture, frontmatter: String) throws -> Card {
func index(_ folder: URL, _ text: String) throws {
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
try "---\n\(text)---\n"
.write(to: folder.appendingPathComponent("index.md"), atomically: true, encoding: .utf8)
}
try index(fixture.root, "schema: 1\n")
try index(fixture.root.appendingPathComponent(fixture.lane, isDirectory: true), "schema: 1\norder: 1024\n")
try index(fixture.cardFolder, frontmatter)
let model = try BoardLoader.load(boardRoot: fixture.root).model
return try #require(model.lanes.first?.cards.first)
}
// MARK: - Where a name resolves
@Suite("Card hero ▸ the name resolves inside the card's own attachments")
struct CardHeroResolutionTests {
/// The whole grammar: a bare filename lands directly in this card's `attachments/`, which is what
/// makes a hero survive every move, copy, trash and restore the card takes — the folder travels
/// with it and nothing has to be rewritten.
@Test("A bare filename lands in this card's attachments folder")
func aBareNameResolves() throws {
let fixture = try CardFixture()
defer { fixture.tearDown() }
#expect(CardHero.imageURL(named: "sketch.png", inCardFolder: fixture.cardFolder)?.path
== fixture.attachments.appendingPathComponent("sketch.png").path)
// Nothing about the name is policed here beyond the shape: a dot file, a tilde and spaces are
// all filenames, and one naming nothing simply decodes to nothing later.
#expect(CardHero.imageURL(named: ".hidden.png", inCardFolder: fixture.cardFolder) != nil)
#expect(CardHero.imageURL(named: "~a shot 2.png", inCardFolder: fixture.cardFolder) != nil)
}
/// **Belt over the reading's braces.** `FrontmatterDocument.hero` already refuses a path, so
/// nothing pathy should ever reach here — but a lenient reading must never be the only thing
/// standing between a value and the filesystem, and this is the layer that builds the URL.
///
/// The last two are what makes this stricter than `BoardBackdrop`'s check: "inside the folder"
/// and "directly inside the folder" are different promises, and an attachment is the second.
@Test("Anything that is not a name resolves nowhere")
func pathsResolveNowhere() throws {
let fixture = try CardFixture()
defer { fixture.tearDown() }
#expect(CardHero.imageURL(named: "", inCardFolder: fixture.cardFolder) == nil)
#expect(CardHero.imageURL(named: "art/sketch.png", inCardFolder: fixture.cardFolder) == nil)
#expect(CardHero.imageURL(named: "../sketch.png", inCardFolder: fixture.cardFolder) == nil)
#expect(CardHero.imageURL(named: "/etc/passwd", inCardFolder: fixture.cardFolder) == nil)
#expect(CardHero.imageURL(named: "attachments/sketch.png", inCardFolder: fixture.cardFolder) == nil)
#expect(CardHero.imageURL(named: ".", inCardFolder: fixture.cardFolder) == nil)
#expect(CardHero.imageURL(named: "..", inCardFolder: fixture.cardFolder) == nil)
}
/// The card-level entry point, on the board side: the container is the lane folder, and the card
/// folder is appended here so the face never has to know its own path.
@Test("A card's hero resolves under its container")
func aCardResolvesUnderItsContainer() throws {
let fixture = try CardFixture()
defer { fixture.tearDown() }
let card = try loadedCard(fixture, frontmatter: "schema: 1\nkind: card\norder: 1024\nhero: sketch.png\n")
let lane = fixture.root.appendingPathComponent(fixture.lane, isDirectory: true)
#expect(CardHero.imageURL(for: card, inContainer: lane)?.path
== fixture.attachments.appendingPathComponent("sketch.png").path)
}
/// **The no-key-no-change identity** — the card every board is made of. No key means no reading,
/// no URL, no band, no trace, and bytes that come back exactly as they went in.
@Test("A card with no hero key resolves to nothing and changes nothing")
func noKeyIsNoChange() throws {
let fixture = try CardFixture()
defer { fixture.tearDown() }
let text = "schema: 1\nkind: card\norder: 1024\ntitle: Plain\n"
let card = try loadedCard(fixture, frontmatter: text)
let lane = fixture.root.appendingPathComponent(fixture.lane, isDirectory: true)
#expect(card.hero == .missing)
#expect(CardHero.imageURL(for: card, inContainer: lane) == nil)
#expect(card.document.coercedFields.isEmpty)
#expect(card.document.serialized() == "---\n\(text)---\n")
}
/// A malformed key is the same absence with a trace behind it: the face draws no band, and the
/// value the author wrote is still on disk for them to fix.
@Test("A pathy hero resolves to nothing, and the bytes stay as written")
func aMalformedKeyResolvesToNothing() throws {
let fixture = try CardFixture()
defer { fixture.tearDown() }
let text = "schema: 1\nkind: card\norder: 1024\nhero: art/sketch.png\n"
let card = try loadedCard(fixture, frontmatter: text)
let lane = fixture.root.appendingPathComponent(fixture.lane, isDirectory: true)
#expect(card.hero == .malformed(raw: "art/sketch.png"))
#expect(CardHero.imageURL(for: card, inContainer: lane) == nil)
#expect(card.document.serialized() == "---\n\(text)---\n")
}
}
// MARK: - What the band can draw
@Suite("Card hero ▸ what the band can draw")
struct CardHeroDecodeTests {
/// A hero is decoded **downsampled**, at the card's own limit rather than the backdrop's: a board
/// has one backdrop and may have hundreds of hero cards, so the figure is a per-card memory cost
/// as much as a decode cost.
@Test("An image decodes downsampled to the hero's own limit")
func anImageDecodesDownsampled() throws {
let fixture = try CardFixture()
defer { fixture.tearDown() }
let url = try writePNG(
at: fixture.attachments.appendingPathComponent("sketch.png"), width: 2400, height: 1200)
let decoded = try #require(BoardBackdrop.decode(url, limit: CardHero.maximumPixelSize))
#expect(decoded.width == CardHero.maximumPixelSize)
#expect(decoded.height == CardHero.maximumPixelSize / 2)
// The parameter is the whole reason the backdrop's decode was widened rather than copied.
#expect(CardHero.maximumPixelSize < BoardBackdrop.maximumPixelSize)
}
/// **Missing, unreadable, and not-an-image all end the same way** — no picture, so no band, so a
/// face that renders exactly as one with no key (the ruling). No defect, no badge, and nothing
/// written.
@Test("A missing or non-image file decodes to nothing")
func nonImagesDecodeToNothing() throws {
let fixture = try CardFixture()
defer { fixture.tearDown() }
let missing = fixture.attachments.appendingPathComponent("gone.png")
#expect(BoardBackdrop.decode(missing, limit: CardHero.maximumPixelSize) == nil)
let notes = fixture.attachments.appendingPathComponent("notes.txt")
try "not a picture".write(to: notes, atomically: true, encoding: .utf8)
#expect(BoardBackdrop.decode(notes, limit: CardHero.maximumPixelSize) == nil)
// The liar: an image extension over text. The extension is not what is read.
let liar = fixture.attachments.appendingPathComponent("fake.png")
try "not a picture either".write(to: liar, atomically: true, encoding: .utf8)
#expect(BoardBackdrop.decode(liar, limit: CardHero.maximumPixelSize) == nil)
}
}
// MARK: - The shared cache
/// Serialized because the cache is app-wide by design (see `CardHeroCache`) — one suite's fixtures
/// must not decide another's hits.
@MainActor
@Suite("Card hero ▸ the shared cache", .serialized)
struct CardHeroCacheTests {
/// **The claim the drag replica rests on**: a picture already on screen can be read back
/// *synchronously*, with no `stat` and no decode. A preview builder is non-escaping — it runs
/// while the body does — so a replica that had to await anything would lift a card with a hole
/// where its banner is.
@Test("A loaded hero reads back synchronously")
func aLoadedHeroReadsBackSynchronously() async throws {
let fixture = try CardFixture()
defer { fixture.tearDown() }
CardHeroCache.removeAll()
let url = try writePNG(
at: fixture.attachments.appendingPathComponent("sketch.png"), width: 800, height: 400)
#expect(CardHeroCache.image(forFileAt: url) == nil)
let loaded = await CardHeroCache.load(url)
#expect(loaded != nil)
#expect(CardHeroCache.image(forFileAt: url) != nil)
}
/// A file that is not an image caches nothing — and, crucially, is not *remembered* as an image
/// either: the synchronous read stays empty, so the replica draws the same hero-less face the
/// board does.
@Test("A non-image caches nothing")
func aNonImageCachesNothing() async throws {
let fixture = try CardFixture()
defer { fixture.tearDown() }
CardHeroCache.removeAll()
let notes = fixture.attachments.appendingPathComponent("notes.txt")
try "not a picture".write(to: notes, atomically: true, encoding: .utf8)
#expect(await CardHeroCache.load(notes) == nil)
#expect(CardHeroCache.image(forFileAt: notes) == nil)
}
/// **Replaced bytes key differently** — the freshness posture `AttachmentThumbnailCache` already
/// takes, and the reason there is no invalidation path to keep honest: the stamp is part of the
/// key, so a rewritten file cannot be answered with the old picture.
@Test("A replaced file keys differently")
func replacedBytesKeyDifferently() async throws {
let fixture = try CardFixture()
defer { fixture.tearDown() }
CardHeroCache.removeAll()
// Both are wider than the decode limit, so both come back at it — the *shape* is what tells
// them apart, which is exactly what a stale entry could not fake.
let url = fixture.attachments.appendingPathComponent("sketch.png")
try writePNG(at: url, width: 2048, height: 1024)
let first = await CardHeroCache.load(url)
#expect(first?.height == CardHero.maximumPixelSize / 2)
try FileManager.default.removeItem(at: url)
try writePNG(at: url, width: 2048, height: 512)
let second = await CardHeroCache.load(url)
#expect(second?.height == CardHero.maximumPixelSize / 4)
}
}