The Background tab fills in — facets rendered to order, eight hues in a carousel
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -0,0 +1,386 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// The write half of generated board backgrounds: `BoardWriter.writeBoardImage` and
|
||||
/// `BoardStore.applyGeneratedBackground` (03-board-ui.md § Styling ▸ Capabilities;
|
||||
/// DESIGN/explorations/board-backgrounds.md).
|
||||
///
|
||||
/// Like every other write suite here these drive a real writer or a real store over a real temp
|
||||
/// board and read the **bytes on disk** back rather than the app's own read path: the claims are
|
||||
/// about the file — which name the picture landed under, what the frontmatter says afterwards, and
|
||||
/// what an undo leaves behind. `WriterFixture`, `Ident` and `Item` come from
|
||||
/// `WriterTestSupport.swift`.
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
/// A board root carrying whatever background the test needs, plus the unowned baggage every write
|
||||
/// has to leave alone.
|
||||
private func boardIndex(background: String? = nil) -> String {
|
||||
let line = background.map { "background: \($0)\n" } ?? ""
|
||||
return """
|
||||
---
|
||||
schema: 1
|
||||
title: Work
|
||||
\(line)project: lanework # agent overlay
|
||||
created: 2026-01-01T09:00:00Z
|
||||
---
|
||||
Board description.
|
||||
|
||||
"""
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func makeBoard(background: String? = nil) throws -> WriterFixture {
|
||||
let fixture = try WriterFixture()
|
||||
try fixture.item("", boardIndex(background: background))
|
||||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
|
||||
return fixture
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func makeStore(_ fixture: WriterFixture) throws -> (store: BoardStore, history: NativeHistoryProvider) {
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let history = NativeHistoryProvider()
|
||||
store.history = history
|
||||
return (store, history)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func reload(_ store: BoardStore) async {
|
||||
store.handleWatcherEvent(.treeChanged(.appMediated))
|
||||
await store.awaitQuiescence()
|
||||
}
|
||||
|
||||
private func document(_ fixture: WriterFixture) throws -> FrontmatterDocument {
|
||||
try FrontmatterDocument.parse(fixture.indexText(""))
|
||||
}
|
||||
|
||||
/// Bytes that are not an image and do not need to be: nothing in the write path decodes them, which
|
||||
/// is itself worth pinning — the Writer moves a payload, it does not validate artwork.
|
||||
private let png = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x01, 0x02, 0x03])
|
||||
private let otherPNG = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x09, 0x09])
|
||||
|
||||
// MARK: - The writer primitive
|
||||
|
||||
@MainActor
|
||||
@Suite("BoardWriter ▸ writeBoardImage")
|
||||
struct WriteBoardImageTests {
|
||||
|
||||
@Test("The bytes land under the given name, and the name comes back")
|
||||
func writesTheBytes() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
|
||||
let name = try BoardWriter.writeBoardImage(
|
||||
data: png, named: "facets.png", inRoot: fixture.root, operation: .setBoardBackground
|
||||
)
|
||||
|
||||
#expect(name == "facets.png")
|
||||
#expect(try fixture.data("facets.png") == png)
|
||||
}
|
||||
|
||||
/// The caller decides the name, so the writer's own contract is simply that the same name is
|
||||
/// replaced rather than laddered — one board, one generated picture.
|
||||
@Test("A second write to the same name replaces it in place")
|
||||
func overwritesInPlace() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
|
||||
try BoardWriter.writeBoardImage(
|
||||
data: png, named: "facets.png", inRoot: fixture.root, operation: .setBoardBackground
|
||||
)
|
||||
try BoardWriter.writeBoardImage(
|
||||
data: otherPNG, named: "facets.png", inRoot: fixture.root, operation: .setBoardBackground
|
||||
)
|
||||
|
||||
#expect(try fixture.data("facets.png") == otherPNG)
|
||||
#expect(try fixture.entryNames("") == [Ident.lane1, "facets.png", "index.md"])
|
||||
}
|
||||
|
||||
/// **Atomic, and no residue**: the temp file is hidden and in the same folder, so a listing that
|
||||
/// sees hidden entries is what proves the rename left nothing behind.
|
||||
@Test("No temp file survives the write")
|
||||
func leavesNoResidue() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
|
||||
try BoardWriter.writeBoardImage(
|
||||
data: png, named: "facets.png", inRoot: fixture.root, operation: .setBoardBackground
|
||||
)
|
||||
|
||||
#expect(try !fixture.entryNames("").contains { $0.hasPrefix(".") })
|
||||
}
|
||||
|
||||
/// The mirror of the read side's containment rule (`BoardBackdrop.imageURL(named:inBoardRoot:)`):
|
||||
/// a background that could be written outside the board folder is not a background.
|
||||
@Test("A path, an empty name and the dot names are refused", arguments: ["", "art/x.png", "../x.png", ".", ".."])
|
||||
func refusesAnythingThatIsNotABareName(name: String) throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
|
||||
#expect(throws: BoardWriteError.self) {
|
||||
try BoardWriter.writeBoardImage(
|
||||
data: png, named: name, inRoot: fixture.root, operation: .setBoardBackground
|
||||
)
|
||||
}
|
||||
#expect(try fixture.entryNames("") == [Ident.lane1, "index.md"])
|
||||
}
|
||||
|
||||
/// The receipt: without one the churn the write produces classifies as somebody else's, and the
|
||||
/// auto-committer would name the commit for a foreign edit.
|
||||
@Test("The write leaves a content receipt in the ledger")
|
||||
func dropsAReceipt() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let ledger = EchoLedger()
|
||||
|
||||
EchoLedger.$current.withValue(ledger) {
|
||||
try? BoardWriter.writeBoardImage(
|
||||
data: png, named: "facets.png", inRoot: fixture.root, operation: .setBoardBackground
|
||||
)
|
||||
}
|
||||
|
||||
let receipts = ledger.outstandingEntries()
|
||||
#expect(receipts.contains { $0.key.hasSuffix("/facets.png") })
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The gesture
|
||||
|
||||
@MainActor
|
||||
@Suite("BoardStore ▸ applyGeneratedBackground")
|
||||
struct GeneratedBackgroundWriteTests {
|
||||
|
||||
@Test("The picture lands in the folder and both subkeys point at it")
|
||||
func writesTheFileAndTheFields() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (store, _) = try makeStore(fixture)
|
||||
|
||||
#expect(store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB"))
|
||||
|
||||
#expect(try fixture.data("facets.png") == png)
|
||||
let after = try document(fixture)
|
||||
#expect(after.background == .valid("#E0E5EB"))
|
||||
#expect(after.backgroundImage == .valid("facets.png"))
|
||||
#expect(try fixture.indexText("").contains("background: {color: \"#E0E5EB\", image: \"facets.png\"}"))
|
||||
#expect(store.banners.oneShots.isEmpty)
|
||||
}
|
||||
|
||||
/// One gesture, one bracket — the style batch's rule, which is what makes one reroll one
|
||||
/// app-mediated reload and one commit on a git board, though it writes two files.
|
||||
@Test("Two files, one bracket")
|
||||
func oneBracketForBothFiles() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (store, _) = try makeStore(fixture)
|
||||
var begins = 0
|
||||
var ends = 0
|
||||
store.watcherBrackets = (begin: { begins += 1 }, end: { ends += 1 })
|
||||
|
||||
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
|
||||
|
||||
#expect(begins == 1)
|
||||
#expect(ends == 1)
|
||||
}
|
||||
|
||||
/// A colour the wells wrote is replaced, and everything the app does not own comes through
|
||||
/// untouched — the unknown key with its comment, `created`, the body.
|
||||
@Test("An existing colour is replaced and the rest of the file survives")
|
||||
func replacesAnExistingColour() throws {
|
||||
let fixture = try makeBoard(background: "{color: fern}")
|
||||
defer { fixture.tearDown() }
|
||||
let (store, _) = try makeStore(fixture)
|
||||
|
||||
store.applyGeneratedBackground(png: png, colorHex: "#513D1A")
|
||||
|
||||
let text = try fixture.indexText("")
|
||||
#expect(text.contains("background: {color: \"#513D1A\", image: \"facets.png\"}"))
|
||||
#expect(text.contains("project: lanework # agent overlay"))
|
||||
#expect(text.contains("created: 2026-01-01T09:00:00Z"))
|
||||
#expect(text.contains("Board description."))
|
||||
}
|
||||
|
||||
/// **Regenerating overwrites**: the whole reason the name is fixed rather than minted. The reload
|
||||
/// between the two rolls is the ordinary case — the snapshot has caught up and names the file.
|
||||
@Test("A second generation replaces the same file")
|
||||
func secondGenerationOverwrites() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (store, _) = try makeStore(fixture)
|
||||
|
||||
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
|
||||
await reload(store)
|
||||
store.applyGeneratedBackground(png: otherPNG, colorHex: "#513D1A")
|
||||
|
||||
#expect(try fixture.data("facets.png") == otherPNG)
|
||||
#expect(try document(fixture).backgroundImage == .valid("facets.png"))
|
||||
// No ladder: the reload in between also seeds this board's `.gitignore`, so the listing is
|
||||
// filtered to the pictures rather than compared whole.
|
||||
#expect(try fixture.entryNames("").filter { $0.hasSuffix(".png") } == ["facets.png"])
|
||||
}
|
||||
|
||||
/// **The reroll's echo**: rolling again before the watcher has rounded the first write back must
|
||||
/// not ladder onto `facets 2.png`, because a fast reroll is the expected gesture and a folder of
|
||||
/// abandoned pictures is what the fixed name exists to prevent.
|
||||
@Test("A reroll before the reload lands still overwrites")
|
||||
func rerollBeforeTheReloadOverwrites() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (store, _) = try makeStore(fixture)
|
||||
|
||||
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
|
||||
store.applyGeneratedBackground(png: otherPNG, colorHex: "#513D1A")
|
||||
|
||||
#expect(try fixture.data("facets.png") == otherPNG)
|
||||
#expect(try fixture.entryNames("") == [Ident.lane1, "facets.png", "index.md"])
|
||||
}
|
||||
|
||||
/// **Somebody else's `facets.png` is never written through** — a file the user put in the board
|
||||
/// folder is theirs, and the Finder ladder is how the app steps aside from a name it does not own.
|
||||
@Test("A foreign file on the name pushes the generation to 'facets 2.png'")
|
||||
func stepsAsideFromAForeignFile() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let mine = Data("not the app's".utf8)
|
||||
try fixture.file("facets.png", mine)
|
||||
let (store, _) = try makeStore(fixture)
|
||||
|
||||
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
|
||||
|
||||
#expect(try fixture.data("facets.png") == mine, "the user's file is untouched")
|
||||
#expect(try fixture.data("facets 2.png") == png)
|
||||
#expect(try document(fixture).backgroundImage == .valid("facets 2.png"))
|
||||
}
|
||||
|
||||
/// The same ladder when the board already names a *different* image: the hand-written path is
|
||||
/// the escape hatch and stays on disk, and the generation lands beside it.
|
||||
@Test("A board naming another image keeps it and generates alongside")
|
||||
func keepsAHandWrittenImage() throws {
|
||||
let fixture = try makeBoard(background: "{image: sunset.jpg}")
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.file("sunset.jpg", Data("photo".utf8))
|
||||
try fixture.file("facets.png", Data("someone else's".utf8))
|
||||
let (store, _) = try makeStore(fixture)
|
||||
|
||||
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
|
||||
|
||||
#expect(try fixture.data("sunset.jpg") == Data("photo".utf8))
|
||||
#expect(try document(fixture).backgroundImage == .valid("facets 2.png"))
|
||||
}
|
||||
|
||||
/// A board whose generated file was deleted in Finder is a board with a broken backdrop, and
|
||||
/// regenerating is exactly the repair — so the name is reused rather than laddered.
|
||||
@Test("A missing file under our own name is rewritten, not laddered")
|
||||
func rewritesAMissingFile() throws {
|
||||
let fixture = try makeBoard(background: "{color: fern, image: facets.png}")
|
||||
defer { fixture.tearDown() }
|
||||
let (store, _) = try makeStore(fixture)
|
||||
|
||||
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
|
||||
|
||||
#expect(try fixture.data("facets.png") == png)
|
||||
#expect(try document(fixture).backgroundImage == .valid("facets.png"))
|
||||
}
|
||||
|
||||
/// A locked board writes nothing at all — not the picture, not the fields.
|
||||
@Test("A read-only board refuses before anything is written")
|
||||
func refusesUnderTheLock() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (store, _) = try makeStore(fixture)
|
||||
store.enterVanishedRootLock()
|
||||
|
||||
#expect(store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB") == false)
|
||||
#expect(!fixture.exists("facets.png"))
|
||||
#expect(try document(fixture).backgroundImage == .missing)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Undo
|
||||
|
||||
@MainActor
|
||||
@Suite("Undo ▸ generated background")
|
||||
struct GeneratedBackgroundUndoTests {
|
||||
|
||||
/// The first generation's undo is a clean return: the board had no background, and afterwards it
|
||||
/// has none again. (The PNG stays in the folder — nothing in the app deletes the user's files —
|
||||
/// and nothing points at it.)
|
||||
@Test("Undo removes both subkeys and redo puts them back")
|
||||
func roundTrip() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (store, history) = try makeStore(fixture)
|
||||
|
||||
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
|
||||
#expect(history.undoActionName == "Restyle Board")
|
||||
|
||||
history.undo()
|
||||
let undone = try document(fixture)
|
||||
#expect(undone.background == .missing)
|
||||
#expect(undone.backgroundImage == .missing)
|
||||
#expect(!undone.contains(FrontmatterKeys.background))
|
||||
|
||||
history.redo()
|
||||
let redone = try document(fixture)
|
||||
#expect(redone.background == .valid("#E0E5EB"))
|
||||
#expect(redone.backgroundImage == .valid("facets.png"))
|
||||
}
|
||||
|
||||
/// A prior colour comes back as itself rather than as an absence — the same reading `applyStyle`'s
|
||||
/// inverse has.
|
||||
@Test("A prior colour and image are restored, not removed")
|
||||
func priorValuesComeBack() throws {
|
||||
let fixture = try makeBoard(background: "{color: fern, image: sunset.jpg}")
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.file("sunset.jpg", Data("photo".utf8))
|
||||
let (store, history) = try makeStore(fixture)
|
||||
|
||||
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
|
||||
history.undo()
|
||||
|
||||
let undone = try document(fixture)
|
||||
#expect(undone.background == .valid("fern"))
|
||||
#expect(undone.backgroundImage == .valid("sunset.jpg"))
|
||||
}
|
||||
|
||||
/// **The undo restores fields, never bytes** — stated as a test so the limit is visible rather
|
||||
/// than folklore: regenerating over the app's own output leaves the second picture on disk, and
|
||||
/// ⌘Z points the (unchanged) name back at it.
|
||||
@Test("Undo does not bring the overwritten pixels back")
|
||||
func undoDoesNotRestoreBytes() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (store, history) = try makeStore(fixture)
|
||||
|
||||
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
|
||||
await reload(store)
|
||||
store.applyGeneratedBackground(png: otherPNG, colorHex: "#513D1A")
|
||||
|
||||
history.undo()
|
||||
#expect(try document(fixture).background == .valid("#E0E5EB"))
|
||||
#expect(try document(fixture).backgroundImage == .valid("facets.png"))
|
||||
#expect(try fixture.data("facets.png") == otherPNG, "the first generation's bytes are gone")
|
||||
}
|
||||
|
||||
/// A foreign edit to the field the step wrote stales it — the field-level predicate, applied to
|
||||
/// the subkey this gesture owns.
|
||||
@Test("A foreign edit to the image subkey skips the undo")
|
||||
func foreignEditStalesTheStep() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (store, history) = try makeStore(fixture)
|
||||
|
||||
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
|
||||
|
||||
var foreign = try document(fixture)
|
||||
foreign.setBackgroundImage("elsewhere.png")
|
||||
try fixture.item("", foreign.serialized())
|
||||
|
||||
history.undo()
|
||||
#expect(try document(fixture).backgroundImage == .valid("elsewhere.png"))
|
||||
#expect(store.banners.signposts.isEmpty == false, "the skip says so on the strip")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user