Follow-up to d0c5461's .backgrounds/ folder: generating, pasting, or choosing solid now trims the app's own prior file in .backgrounds/ as part of the same write when it repoints or unsets background.image away from it — silent, best-effort, never blocking the gesture that triggered it. A scheduled heal at every board open sweeps whatever that trim declined or missed: any .backgrounds/ file the board's current background.image no longer names, announced with a loss-row notice in the loose-file relocation's own voice. Legacy root-level references are untouched by both paths — the tidy scopes to .backgrounds/ only, since that is the one folder the app can prove it wrote into. Removal is via FileManager.trashItem, matching the attachment-removal precedent (recoverable, never a hard delete). Supersedes applySolidBackground's earlier "facets.png survives on disk" contract for the settled case: the ruling reads "unsets" as one more shape of "repoints away from a .backgrounds/ file", so a settled solid choice now trims the generated picture it displaces, same as switching producers does. The superseded test and doc comment are updated to the new behavior; the echo-window (no-reload-yet) case is unaffected and still leaves the file in place. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
704 lines
32 KiB
Swift
704 lines
32 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import Kanban
|
|
|
|
/// The write half of the Theme tab's two picture-adjacent gestures: `BoardWriter.writeBoardImage` and
|
|
/// `BoardStore.applyGeneratedBackground` for Pattern, `BoardStore.applySolidBackground` for Solid
|
|
/// color (03-board-ui.md § Board popover ▸ Theme tab; 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
|
|
/// announcer — or any future ledger consumer — would attribute the app's own write to a foreign
|
|
/// editor.
|
|
@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") })
|
|
}
|
|
|
|
/// **`.backgrounds/` did not exist before this ruling** (2026-08-09), so the first board on a
|
|
/// Mac to ever generate or paste a background hands this call a folder nothing has made yet — the
|
|
/// call has to make it rather than fail, or every board's very first background write would.
|
|
@Test("The destination folder is created when it does not exist yet")
|
|
func createsTheDestinationFolderWhenMissing() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let backgroundsFolder = fixture.root.appendingPathComponent(
|
|
BoardBackdrop.backgroundsFolderName, isDirectory: true
|
|
)
|
|
|
|
let name = try BoardWriter.writeBoardImage(
|
|
data: png, named: "facets.png", inRoot: backgroundsFolder, operation: .setBoardBackground
|
|
)
|
|
|
|
#expect(name == "facets.png")
|
|
#expect(try fixture.data("\(BoardBackdrop.backgroundsFolderName)/facets.png") == png)
|
|
}
|
|
}
|
|
|
|
// MARK: - The gesture
|
|
|
|
@MainActor
|
|
@Suite("BoardStore ▸ applyGeneratedBackground")
|
|
struct GeneratedBackgroundWriteTests {
|
|
|
|
@Test("The picture lands in .backgrounds/ 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(".backgrounds/facets.png") == png)
|
|
let after = try document(fixture)
|
|
#expect(after.background == .valid("#E0E5EB"))
|
|
#expect(after.backgroundImage == .valid(".backgrounds/facets.png"))
|
|
#expect(try fixture.indexText("")
|
|
.contains("background: {color: \"#E0E5EB\", image: \".backgrounds/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, 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: \".backgrounds/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(".backgrounds/facets.png") == otherPNG)
|
|
#expect(try document(fixture).backgroundImage == .valid(".backgrounds/facets.png"))
|
|
// No ladder: `.backgrounds/` holds exactly the one picture this store ever wrote to it.
|
|
#expect(try fixture.entryNames(".backgrounds") == ["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(".backgrounds/facets.png") == otherPNG)
|
|
#expect(try fixture.entryNames("") == [".backgrounds", Ident.lane1, "index.md"])
|
|
}
|
|
|
|
/// **Somebody else's `.backgrounds/facets.png` is never written through** — a file the user put in
|
|
/// that folder is theirs, and the Finder ladder is how the app steps aside from a name it does not
|
|
/// own. A same-named file at board root — the legacy location — is a different question entirely
|
|
/// (`aLegacyBareReferenceIsNotOverwrittenInPlace`, below): it no longer sits anywhere this write
|
|
/// ever looks.
|
|
@Test("A foreign file on the name pushes the generation to '.backgrounds/facets 2.png'")
|
|
func stepsAsideFromAForeignFile() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let mine = Data("not the app's".utf8)
|
|
try fixture.file(".backgrounds/facets.png", mine)
|
|
let (store, _) = try makeStore(fixture)
|
|
|
|
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
|
|
|
|
#expect(try fixture.data(".backgrounds/facets.png") == mine, "the user's file is untouched")
|
|
#expect(try fixture.data(".backgrounds/facets 2.png") == png)
|
|
#expect(try document(fixture).backgroundImage == .valid(".backgrounds/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 — inside `.backgrounds/`,
|
|
/// composing with the collision-ladder test above.
|
|
@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(".backgrounds/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(".backgrounds/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, **when the
|
|
/// reference is already the qualified `.backgrounds/` one this scheme writes**.
|
|
@Test("A missing file under our own qualified name is rewritten, not laddered")
|
|
func rewritesAMissingFile() throws {
|
|
let fixture = try makeBoard(background: "{color: fern, image: .backgrounds/facets.png}")
|
|
defer { fixture.tearDown() }
|
|
let (store, _) = try makeStore(fixture)
|
|
|
|
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
|
|
|
|
#expect(try fixture.data(".backgrounds/facets.png") == png)
|
|
#expect(try document(fixture).backgroundImage == .valid(".backgrounds/facets.png"))
|
|
}
|
|
|
|
/// **Legacy stays where it is — no migration** (ruled 2026-08-09). A board whose `background.image`
|
|
/// still names a bare `facets.png` at board root — the shape every board carried before this
|
|
/// ruling — does not read as "ours to overwrite in place": only the qualified `.backgrounds/`
|
|
/// spelling does (`rewritesAMissingFile`, above). So a regeneration on a legacy board writes a
|
|
/// fresh `.backgrounds/facets.png` rather than touching the root-level file the field used to name
|
|
/// — even though nothing is actually there to protect in this case (the field names a file that
|
|
/// was never created), the point is the *reference's shape* decides, not disk contents.
|
|
@Test("A legacy bare reference is left alone; the regeneration writes a fresh .backgrounds/ file")
|
|
func aLegacyBareReferenceIsNotOverwrittenInPlace() 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(".backgrounds/facets.png") == png)
|
|
#expect(try document(fixture).backgroundImage == .valid(".backgrounds/facets.png"))
|
|
#expect(!fixture.exists("facets.png"), "no legacy file was ever created — nothing to leave behind")
|
|
}
|
|
|
|
/// 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(".backgrounds/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(".backgrounds/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(".backgrounds/facets.png"))
|
|
#expect(try fixture.data(".backgrounds/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")
|
|
}
|
|
}
|
|
|
|
// MARK: - Solid background
|
|
|
|
/// The Theme tab's Solid color half: `BoardStore.applySolidBackground` (03-board-ui.md § Board
|
|
/// popover ▸ Theme tab; `BoardThemeTabView.applySolid`). Modeled line-for-line on
|
|
/// `applyGeneratedBackground` minus the file write, so these suites mirror the write and undo suites
|
|
/// above with the one difference the method itself has: no picture, and `facets.png` — when there is
|
|
/// one — is deliberately left on disk rather than deleted.
|
|
@MainActor
|
|
@Suite("BoardStore ▸ applySolidBackground")
|
|
struct SolidBackgroundWriteTests {
|
|
|
|
@Test("The colour lands and there is no image subkey to point anywhere")
|
|
func writesTheColourAloneOnAPlainBoard() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let (store, _) = try makeStore(fixture)
|
|
|
|
#expect(store.applySolidBackground(colorHex: "#E0E5EB"))
|
|
|
|
let after = try document(fixture)
|
|
#expect(after.background == .valid("#E0E5EB"))
|
|
#expect(after.backgroundImage == .missing)
|
|
#expect(try fixture.indexText("").contains("background: {color: \"#E0E5EB\"}"))
|
|
#expect(store.banners.oneShots.isEmpty)
|
|
}
|
|
|
|
/// The write's whole point on a board that already carries a generated picture: the colour
|
|
/// changes, the `image` subkey goes, and every other subkey the app does not own rides through
|
|
/// untouched.
|
|
@Test("An existing image subkey is removed and unrelated subkeys survive")
|
|
func removesTheImageSubkeyAndKeepsForeignOnes() throws {
|
|
let fixture = try makeBoard(background: "{blend: multiply, color: fern, image: facets.png, opacity: 0.5}")
|
|
defer { fixture.tearDown() }
|
|
let (store, _) = try makeStore(fixture)
|
|
|
|
store.applySolidBackground(colorHex: "#513D1A")
|
|
|
|
let text = try fixture.indexText("")
|
|
#expect(text.contains("blend: \"multiply\""))
|
|
#expect(text.contains("color: \"#513D1A\""))
|
|
#expect(text.contains("opacity: 0.5"))
|
|
#expect(!text.contains("image:"))
|
|
let after = try document(fixture)
|
|
#expect(after.background == .valid("#513D1A"))
|
|
#expect(after.backgroundImage == .missing)
|
|
}
|
|
|
|
/// **The echo-window case**: back to back with no reload in between, `snapshot` has not caught up
|
|
/// with the generation this solid choice is about to unset, so the trim it would otherwise make
|
|
/// (`SolidBackgroundOrphanTrimTests.trimsTheGeneratedFileOnceSettled`) sees a `nil` prior and does
|
|
/// nothing — the same staleness gate `theTwoProducersStayApart` pins for the paste path.
|
|
@Test(".backgrounds/facets.png survives an unsettled solid choice")
|
|
func leavesTheGeneratedFileOnDiskBeforeTheReloadLands() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let (store, _) = try makeStore(fixture)
|
|
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
|
|
|
|
store.applySolidBackground(colorHex: "#513D1A")
|
|
|
|
#expect(try fixture.data(".backgrounds/facets.png") == png, "the bytes are untouched")
|
|
#expect(try document(fixture).backgroundImage == .missing, "only the field is gone")
|
|
}
|
|
|
|
@Test("One bracket for the one file it touches")
|
|
func oneBracketForTheWrite() 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.applySolidBackground(colorHex: "#E0E5EB")
|
|
|
|
#expect(begins == 1)
|
|
#expect(ends == 1)
|
|
}
|
|
|
|
/// A locked board writes nothing at all.
|
|
@Test("A read-only board refuses before anything is written")
|
|
func refusesUnderTheLock() throws {
|
|
let fixture = try makeBoard(background: "{color: fern}")
|
|
defer { fixture.tearDown() }
|
|
let (store, _) = try makeStore(fixture)
|
|
store.enterVanishedRootLock()
|
|
|
|
#expect(store.applySolidBackground(colorHex: "#E0E5EB") == false)
|
|
#expect(try document(fixture).background == .valid("fern"))
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
@Suite("Undo ▸ solid background")
|
|
struct SolidBackgroundUndoTests {
|
|
|
|
/// The first solid colour's undo is a clean return: the board had no background, and afterwards
|
|
/// it has none again.
|
|
@Test("Undo removes the colour and redo puts it back")
|
|
func roundTrip() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let (store, history) = try makeStore(fixture)
|
|
|
|
store.applySolidBackground(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 == .missing)
|
|
}
|
|
|
|
/// **Both prior fields come back** — the colour a board had before, and the generated image the
|
|
/// solid choice pointed away from. `facets.png` here is a **legacy** bare reference at board root
|
|
/// (`aLegacyBareReferenceIsNotOverwrittenInPlace`'s own shape), which the orphan tidy never
|
|
/// touches — "Tidy scopes to `.backgrounds/` ONLY" — so the file surviving on disk is guaranteed
|
|
/// rather than merely likely, and an undo with nothing to point back at would make the surviving
|
|
/// bytes an orphan from the moment they landed.
|
|
@Test("A prior colour and generated image are both restored")
|
|
func restoresAPriorGeneratedImage() throws {
|
|
let fixture = try makeBoard(background: "{color: fern, image: facets.png}")
|
|
defer { fixture.tearDown() }
|
|
try fixture.file("facets.png", png)
|
|
let (store, history) = try makeStore(fixture)
|
|
|
|
store.applySolidBackground(colorHex: "#E0E5EB")
|
|
history.undo()
|
|
|
|
let undone = try document(fixture)
|
|
#expect(undone.background == .valid("fern"))
|
|
#expect(undone.backgroundImage == .valid("facets.png"))
|
|
#expect(try fixture.data("facets.png") == png, "undo restored the field, not new bytes")
|
|
}
|
|
|
|
/// A foreign edit to the field the step wrote stales it — the same field-level predicate
|
|
/// `GeneratedBackgroundUndoTests.foreignEditStalesTheStep` pins for the generated path, applied to
|
|
/// the colour subkey this gesture owns.
|
|
@Test("A foreign edit to the colour subkey skips the undo")
|
|
func foreignEditStalesTheStep() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let (store, history) = try makeStore(fixture)
|
|
|
|
store.applySolidBackground(colorHex: "#E0E5EB")
|
|
|
|
var foreign = try document(fixture)
|
|
foreign.setStyleValue("obsidian", for: FrontmatterKeys.background)
|
|
try fixture.item("", foreign.serialized())
|
|
|
|
history.undo()
|
|
#expect(try document(fixture).background == .valid("obsidian"))
|
|
#expect(store.banners.signposts.isEmpty == false, "the skip says so on the strip")
|
|
}
|
|
}
|
|
|
|
// MARK: - The orphan tidy ▸ replace-in-place (ruled 2026-08-09)
|
|
|
|
/// The replace-in-place half of the orphan tidy: once a background apply's own writes have landed, a
|
|
/// **settled** prior `.backgrounds/` file it repointed or unset away from is trimmed as part of the
|
|
/// same bracket (`BoardStore.tidyReplacedBackgroundImage`). "Settled" is the operative word throughout
|
|
/// — every unsettled (no-reload-yet) counterpart already lives beside its sibling test:
|
|
/// `leavesTheGeneratedFileOnDiskBeforeTheReloadLands` (solid) and
|
|
/// `PasteBoardBackgroundTests.theTwoProducersStayApart` (paste after generate).
|
|
@MainActor
|
|
@Suite("BoardStore ▸ the orphan tidy's replace-in-place trim")
|
|
struct BackgroundReplaceInPlaceTrimTests {
|
|
|
|
/// The ruling's own headline case: choosing a solid colour over a *settled* generated background
|
|
/// now trims the picture the generator wrote, superseding the earlier "facets.png survives"
|
|
/// contract (`applySolidBackground`'s own doc comment carries the history).
|
|
@Test(".backgrounds/facets.png is trimmed once a solid choice has settled")
|
|
func trimsTheGeneratedFileOnceSettled() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let (store, _) = try makeStore(fixture)
|
|
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
|
|
await reload(store)
|
|
|
|
store.applySolidBackground(colorHex: "#513D1A")
|
|
|
|
#expect(!fixture.exists(".backgrounds/facets.png"), "trimmed as part of the solid write")
|
|
#expect(try document(fixture).backgroundImage == .missing)
|
|
#expect(store.banners.losses.isEmpty, "the in-flow trim is silent — the ruling's temp-file posture")
|
|
#expect(store.banners.oneShots.isEmpty)
|
|
}
|
|
|
|
/// Switching producers the other way — a settled generate landing after a settled paste — trims
|
|
/// the pasted file, `PasteBoardBackgroundTests.theTwoProducersStayApart`'s settled counterpart.
|
|
@Test("A settled generate trims a differently-named prior .backgrounds/ file")
|
|
func trimsAPriorFileOfADifferentName() async throws {
|
|
let fixture = try makeBoard(background: "{image: .backgrounds/Pasted Background.png}")
|
|
defer { fixture.tearDown() }
|
|
try fixture.file(".backgrounds/Pasted Background.png", Data("pasted".utf8))
|
|
let (store, _) = try makeStore(fixture)
|
|
await reload(store)
|
|
|
|
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
|
|
|
|
#expect(!fixture.exists(".backgrounds/Pasted Background.png"), "trimmed by the generate that superseded it")
|
|
#expect(try fixture.data(".backgrounds/facets.png") == png)
|
|
#expect(try document(fixture).backgroundImage == .valid(".backgrounds/facets.png"))
|
|
#expect(store.banners.losses.isEmpty)
|
|
}
|
|
|
|
/// **A regeneration over the app's own fixed name never trims anything** — there is no "old" file
|
|
/// distinct from the new one; `writeBoardImage`'s overwrite-in-place already lands the new bytes on
|
|
/// the very name the trim would otherwise have removed.
|
|
@Test("Regenerating the same name trims nothing")
|
|
func regeneratingTheSameNameTrimsNothing() 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.entryNames(".backgrounds") == ["facets.png"], "one file, the same name, overwritten")
|
|
#expect(try fixture.data(".backgrounds/facets.png") == otherPNG)
|
|
}
|
|
|
|
/// **Legacy stays where it is, settled or not** — "Tidy scopes to `.backgrounds/` ONLY", so a bare
|
|
/// root-level reference this tidy cannot prove is app-written survives every gesture that repoints
|
|
/// or unsets it, whether or not a reload landed first.
|
|
@Test("A legacy root-level prior survives a settled repoint")
|
|
func aLegacyPriorSurvivesASettledRepoint() async throws {
|
|
let fixture = try makeBoard(background: "{image: facets.png}")
|
|
defer { fixture.tearDown() }
|
|
try fixture.file("facets.png", Data("legacy".utf8))
|
|
let (store, _) = try makeStore(fixture)
|
|
await reload(store)
|
|
|
|
store.applySolidBackground(colorHex: "#E0E5EB")
|
|
|
|
#expect(try fixture.data("facets.png") == Data("legacy".utf8), "never touched — not app-written by construction")
|
|
}
|
|
|
|
/// The ruling's "temp-file posture", proven rather than merely swallowed: an old file the app
|
|
/// cannot remove (here, one the filesystem itself refuses to touch) refuses the trim, and the
|
|
/// gesture the user actually asked for — landing the new background — still succeeds with no
|
|
/// banner naming a file the user never saw.
|
|
@Test("A trim that cannot land does not fail or announce the gesture that triggered it")
|
|
func aFailedTrimDoesNotSurface() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let (store, _) = try makeStore(fixture)
|
|
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
|
|
await reload(store)
|
|
let oldFile = fixture.root.appendingPathComponent(".backgrounds/facets.png")
|
|
// `uchg` — the immutable flag — makes even the owner unable to rename or remove this one
|
|
// file, while the folder around it stays perfectly writable for the new picture landing in it.
|
|
try FileManager.default.setAttributes([.immutable: true], ofItemAtPath: oldFile.path)
|
|
defer { try? FileManager.default.setAttributes([.immutable: false], ofItemAtPath: oldFile.path) }
|
|
|
|
let landed = store.applyPastedBackground(data: otherPNG, fileExtension: "png")
|
|
|
|
#expect(landed, "the pasted picture and the field both still land")
|
|
#expect(try fixture.data(".backgrounds/Pasted Background.png") == otherPNG)
|
|
#expect(try document(fixture).backgroundImage == .valid(".backgrounds/Pasted Background.png"))
|
|
#expect(fixture.exists(".backgrounds/facets.png"), "the immutable file could not be trimmed")
|
|
#expect(store.banners.oneShots.isEmpty, "a swallowed trim failure names nothing")
|
|
#expect(store.banners.losses.isEmpty)
|
|
}
|
|
}
|