Orphaned .backgrounds/ files get tidied — a repoint trims the one it leaves behind, and every open sweeps what got away
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
This commit is contained in:
@@ -0,0 +1,362 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// The open-time half of the orphan tidy — `BoardStore.tidyBackgroundImages` (01-storage-format.md §
|
||||
/// Validation and healing, ruled 2026-08-09: "any file in `.backgrounds/` not referenced by the
|
||||
/// board's current `background.image` is swept"). Its sibling, the replace-in-place trim a background
|
||||
/// apply makes on its own prior file, is pinned in `GeneratedBackgroundTests.swift`
|
||||
/// (`BackgroundReplaceInPlaceTrimTests`); this file is the safety net behind it — the scheduled heal
|
||||
/// that catches whatever the trim declines (the echo window, a foreign repoint, a trim that failed and
|
||||
/// was swallowed) on the next open.
|
||||
///
|
||||
/// Like the loose-file relocation suite this one is modeled on (`LooseFileRelocationTests.swift`), the
|
||||
/// board here carries a **current agent guide and a seeded `.gitignore`** — what any board the app has
|
||||
/// opened once looks like — so the store's *other* scheduled heals do not open brackets or write files
|
||||
/// of their own and confuse the bracket counts and banner rows these tests read.
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
private func makeBoard(background: String? = nil) throws -> WriterFixture {
|
||||
let fixture = try WriterFixture()
|
||||
let line = background.map { "background: \($0)\n" } ?? ""
|
||||
try fixture.item("", "---\nschema: 1\ntitle: Work\n\(line)---\nBoard description.\n")
|
||||
try fixture.file(AgentGuide.filename, Data(AgentGuide.content.utf8))
|
||||
try fixture.file(AgentGuide.agentsFilename, Data(AgentGuide.content.utf8))
|
||||
try fixture.file(IntegrityRules.gitignoreFileName, Data(BoardWriter.gitignoreSeed.utf8))
|
||||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||
return fixture
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func makeStore(_ fixture: WriterFixture) throws -> BoardStore {
|
||||
try BoardStore(rootURL: fixture.root)
|
||||
}
|
||||
|
||||
/// Counts the bracket calls a store makes — `LooseFileRelocationTests.RelocationBracketLog`'s twin,
|
||||
/// redeclared here since that one is private to its own file.
|
||||
@MainActor
|
||||
private final class TidyBracketLog {
|
||||
private(set) var begins = 0
|
||||
|
||||
func attach(to store: BoardStore) {
|
||||
store.watcherBrackets = (begin: { self.begins += 1 }, end: {})
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The sweep
|
||||
|
||||
@MainActor
|
||||
@Suite("Background images ▸ the open-time sweep")
|
||||
struct BackgroundImageSweepTests {
|
||||
|
||||
@Test("An unreferenced .backgrounds/ file is removed and the referenced one is spared")
|
||||
func removesUnreferencedAndSparesReferenced() throws {
|
||||
let fixture = try makeBoard(background: "{image: .backgrounds/facets.png}")
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.file(".backgrounds/facets.png", Data("current".utf8))
|
||||
try fixture.file(".backgrounds/Pasted Background.png", Data("orphan".utf8))
|
||||
let store = try makeStore(fixture)
|
||||
|
||||
store.tidyBackgroundImages()
|
||||
|
||||
#expect(fixture.exists(".backgrounds/facets.png"), "still named by background.image")
|
||||
#expect(try fixture.data(".backgrounds/facets.png") == Data("current".utf8))
|
||||
#expect(!fixture.exists(".backgrounds/Pasted Background.png"), "nothing named it any more")
|
||||
#expect(store.banners.losses.map(\.message)
|
||||
== ["Removed 'Pasted Background.png' — it was no longer the board's background"])
|
||||
#expect(store.banners.oneShots.isEmpty)
|
||||
}
|
||||
|
||||
@Test("A board with no background field sweeps everything .backgrounds/ holds")
|
||||
func noReferenceSweepsEverything() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.file(".backgrounds/facets.png", Data("a".utf8))
|
||||
try fixture.file(".backgrounds/Pasted Background.png", Data("b".utf8))
|
||||
let store = try makeStore(fixture)
|
||||
|
||||
store.tidyBackgroundImages()
|
||||
|
||||
#expect(try fixture.entryNames(".backgrounds").isEmpty)
|
||||
#expect(store.banners.losses.map(\.message) == ["Removed 2 background images — they were no longer the board's background"])
|
||||
}
|
||||
|
||||
/// **Legacy scope, pinned at the sweep too**: a bare root-level reference is never even a
|
||||
/// candidate — the sweep only ever looks inside `.backgrounds/` — so it survives untouched
|
||||
/// whether or not it happens to share a name with something the sweep does remove.
|
||||
@Test("A legacy root-level file is never swept, referenced or not")
|
||||
func legacyRootLevelFileIsNeverSwept() throws {
|
||||
let fixture = try makeBoard(background: "{image: facets.png}")
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.file("facets.png", Data("legacy".utf8))
|
||||
try fixture.file(".backgrounds/facets.png", Data("orphan".utf8))
|
||||
let store = try makeStore(fixture)
|
||||
|
||||
store.tidyBackgroundImages()
|
||||
|
||||
#expect(try fixture.data("facets.png") == Data("legacy".utf8), "outside .backgrounds/ — never a candidate")
|
||||
#expect(!fixture.exists(".backgrounds/facets.png"), "unreferenced — the legacy spelling does not protect it")
|
||||
}
|
||||
|
||||
@Test("A missing .backgrounds/ folder is an ordinary resting state")
|
||||
func missingFolderIsResting() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try makeStore(fixture)
|
||||
let brackets = TidyBracketLog()
|
||||
brackets.attach(to: store)
|
||||
|
||||
store.tidyBackgroundImages()
|
||||
|
||||
#expect(brackets.begins == 0)
|
||||
#expect(store.banners.losses.isEmpty)
|
||||
#expect(store.banners.oneShots.isEmpty)
|
||||
}
|
||||
|
||||
@Test("A board with nothing orphaned writes nothing and says nothing")
|
||||
func nothingOrphanedIsSilent() throws {
|
||||
let fixture = try makeBoard(background: "{image: .backgrounds/facets.png}")
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.file(".backgrounds/facets.png", Data("current".utf8))
|
||||
let store = try makeStore(fixture)
|
||||
let brackets = TidyBracketLog()
|
||||
brackets.attach(to: store)
|
||||
|
||||
store.tidyBackgroundImages()
|
||||
|
||||
#expect(brackets.begins == 0)
|
||||
#expect(store.banners.losses.isEmpty)
|
||||
#expect(fixture.exists(".backgrounds/facets.png"))
|
||||
}
|
||||
|
||||
/// `.skipsHiddenFiles` doing its job: the app's own crashed-write residue and Finder's litter are
|
||||
/// never proposed as candidates, exactly as `BoardLoader.directoryCandidates` already treats them
|
||||
/// everywhere else.
|
||||
@Test("Hidden entries inside .backgrounds/ are never candidates")
|
||||
func hiddenEntriesAreNeverCandidates() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.file(".backgrounds/.DS_Store", Data("finder".utf8))
|
||||
try fixture.file(".backgrounds/.facets.png.lanework-1234", Data("residue".utf8))
|
||||
let store = try makeStore(fixture)
|
||||
let brackets = TidyBracketLog()
|
||||
brackets.attach(to: store)
|
||||
|
||||
store.tidyBackgroundImages()
|
||||
|
||||
#expect(brackets.begins == 0)
|
||||
#expect(fixture.exists(".backgrounds/.DS_Store"))
|
||||
#expect(fixture.exists(".backgrounds/.facets.png.lanework-1234"))
|
||||
}
|
||||
|
||||
/// A sub-folder of `.backgrounds/` is content the sweep leaves alone — the same "only this level's
|
||||
/// own contents" scope `relocateLooseFiles` gives a card's `attachments/`.
|
||||
@Test("A sub-folder inside .backgrounds/ is not a candidate")
|
||||
func subfoldersAreNotCandidates() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.file(".backgrounds/art/sunset.png", Data("nested".utf8))
|
||||
let store = try makeStore(fixture)
|
||||
|
||||
store.tidyBackgroundImages()
|
||||
|
||||
#expect(fixture.exists(".backgrounds/art/sunset.png"))
|
||||
#expect(store.banners.losses.isEmpty)
|
||||
}
|
||||
|
||||
/// A locked board defers — the loose-file relocation heal's own posture, restated: strays (here,
|
||||
/// orphans) stay tolerated until the lock clears.
|
||||
@Test("A read-only board sweeps nothing, and sweeps once the lock clears")
|
||||
func readOnlyBoardDefers() async throws {
|
||||
let fixture = try makeBoard(background: "{image: .backgrounds/facets.png}")
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.file(".backgrounds/facets.png", Data("current".utf8))
|
||||
try fixture.file(".backgrounds/Pasted Background.png", Data("orphan".utf8))
|
||||
let store = try makeStore(fixture)
|
||||
let brackets = TidyBracketLog()
|
||||
brackets.attach(to: store)
|
||||
store.enterUnwritableLock(.permissionDenied)
|
||||
|
||||
store.tidyBackgroundImages()
|
||||
|
||||
#expect(fixture.exists(".backgrounds/Pasted Background.png"), "tolerated under the lock")
|
||||
#expect(store.banners.losses.isEmpty)
|
||||
#expect(brackets.begins == 0)
|
||||
#expect(store.isReadOnly)
|
||||
|
||||
// A reconciling reload re-probes writability, the lock clears — and the same reload sweeps
|
||||
// the orphan it had been holding back.
|
||||
store.handleWatcherEvent(.treeChanged(.reconciling))
|
||||
await store.awaitQuiescence()
|
||||
|
||||
#expect(!store.isReadOnly)
|
||||
#expect(!fixture.exists(".backgrounds/Pasted Background.png"))
|
||||
#expect(store.banners.losses.map(\.message)
|
||||
== ["Removed 'Pasted Background.png' — it was no longer the board's background"])
|
||||
}
|
||||
|
||||
/// The loop the guard exists for: a sweep that fails leaves the same file on disk, so the next
|
||||
/// walk hands back the same work — one failure, one row, then silence.
|
||||
@Test("A failing sweep is attempted once, not forever")
|
||||
func repeatedFailureDoesNotHotLoop() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.file(".backgrounds/facets.png", Data("orphan".utf8))
|
||||
let orphan = fixture.root.appendingPathComponent(".backgrounds/facets.png")
|
||||
try FileManager.default.setAttributes([.immutable: true], ofItemAtPath: orphan.path)
|
||||
defer { try? FileManager.default.setAttributes([.immutable: false], ofItemAtPath: orphan.path) }
|
||||
let store = try makeStore(fixture)
|
||||
let brackets = TidyBracketLog()
|
||||
brackets.attach(to: store)
|
||||
|
||||
store.tidyBackgroundImages()
|
||||
#expect(store.banners.oneShots.count == 1)
|
||||
#expect(store.banners.oneShots.first?.error.operation == .tidyBackgroundImage(filename: "facets.png"))
|
||||
#expect(store.banners.losses.isEmpty)
|
||||
#expect(brackets.begins == 1)
|
||||
|
||||
for _ in 0 ..< 3 {
|
||||
store.handleWatcherEvent(.treeChanged(.foreign))
|
||||
await store.awaitQuiescence()
|
||||
}
|
||||
|
||||
// Same picture on disk, so no second attempt and no second row.
|
||||
#expect(store.banners.oneShots.count == 1)
|
||||
#expect(brackets.begins == 1)
|
||||
|
||||
// A picture that actually changed is a fresh attempt.
|
||||
try FileManager.default.setAttributes([.immutable: false], ofItemAtPath: orphan.path)
|
||||
try fixture.file(".backgrounds/second.png", Data("also orphan".utf8))
|
||||
store.handleWatcherEvent(.treeChanged(.foreign))
|
||||
await store.awaitQuiescence()
|
||||
|
||||
#expect(brackets.begins == 2)
|
||||
#expect(!fixture.exists(".backgrounds/facets.png"))
|
||||
#expect(!fixture.exists(".backgrounds/second.png"))
|
||||
}
|
||||
|
||||
@Test("runScheduledHeals reaches the sweep")
|
||||
func runScheduledHealsReachesTheSweep() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.file(".backgrounds/facets.png", Data("orphan".utf8))
|
||||
let store = try makeStore(fixture)
|
||||
|
||||
store.runScheduledHeals()
|
||||
|
||||
#expect(!fixture.exists(".backgrounds/facets.png"))
|
||||
#expect(store.banners.losses.map(\.message)
|
||||
== ["Removed 'facets.png' — it was no longer the board's background"])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The candidate listing (pure)
|
||||
|
||||
@Suite("Background images ▸ orphaned-file listing")
|
||||
struct OrphanedBackgroundFileNamesTests {
|
||||
|
||||
private func fixture() throws -> WriterFixture { try WriterFixture() }
|
||||
|
||||
@Test("A missing folder lists as empty")
|
||||
func missingFolderListsEmpty() throws {
|
||||
let fixture = try fixture()
|
||||
defer { fixture.tearDown() }
|
||||
|
||||
#expect(BoardStore.orphanedBackgroundFileNames(
|
||||
inFolder: fixture.url(".backgrounds"), keeping: nil
|
||||
) == [])
|
||||
}
|
||||
|
||||
@Test("Every file lists when nothing is kept, sorted")
|
||||
func everyFileListsWhenNothingIsKept() throws {
|
||||
let fixture = try fixture()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.file(".backgrounds/b.png", Data())
|
||||
try fixture.file(".backgrounds/a.png", Data())
|
||||
|
||||
#expect(BoardStore.orphanedBackgroundFileNames(
|
||||
inFolder: fixture.url(".backgrounds"), keeping: nil
|
||||
) == ["a.png", "b.png"])
|
||||
}
|
||||
|
||||
@Test("The kept name is excluded, everything else lists")
|
||||
func theKeptNameIsExcluded() throws {
|
||||
let fixture = try fixture()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.file(".backgrounds/facets.png", Data())
|
||||
try fixture.file(".backgrounds/Pasted Background.png", Data())
|
||||
|
||||
#expect(BoardStore.orphanedBackgroundFileNames(
|
||||
inFolder: fixture.url(".backgrounds"), keeping: "facets.png"
|
||||
) == ["Pasted Background.png"])
|
||||
}
|
||||
|
||||
@Test("Hidden entries and sub-folders are excluded")
|
||||
func hiddenAndNestedAreExcluded() throws {
|
||||
let fixture = try fixture()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.file(".backgrounds/facets.png", Data())
|
||||
try fixture.file(".backgrounds/.DS_Store", Data())
|
||||
try fixture.file(".backgrounds/art/sunset.png", Data())
|
||||
|
||||
#expect(BoardStore.orphanedBackgroundFileNames(
|
||||
inFolder: fixture.url(".backgrounds"), keeping: nil
|
||||
) == ["facets.png"])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - qualifiedBareName (pure)
|
||||
|
||||
@Suite("Background images ▸ qualifiedBareName")
|
||||
struct QualifiedBareNameTests {
|
||||
|
||||
@Test("A qualified reference answers its bare name")
|
||||
func qualifiedAnswersBareName() {
|
||||
#expect(BoardBackdrop.qualifiedBareName(of: ".backgrounds/facets.png") == "facets.png")
|
||||
#expect(BoardBackdrop.qualifiedBareName(of: ".backgrounds/Pasted Background.png") == "Pasted Background.png")
|
||||
}
|
||||
|
||||
@Test("Everything that is not a direct .backgrounds/ child answers nil", arguments: [
|
||||
nil, "facets.png", "art/sunset.jpg", ".backgrounds/art/sunset.png", ".backgrounds/", ".backgrounds",
|
||||
"other/.backgrounds/facets.png",
|
||||
] as [String?])
|
||||
func everythingElseAnswersNil(_ reference: String?) {
|
||||
#expect(BoardBackdrop.qualifiedBareName(of: reference) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Phrasing (BannerCenter owns every word)
|
||||
|
||||
@Suite("Background images ▸ phrasing")
|
||||
struct BackgroundTidyMessageTests {
|
||||
|
||||
@Test("One file names it — the relocation family's own sole-item shape")
|
||||
func oneFile() {
|
||||
#expect(BannerCenter.tidiedBackgroundImagesMessage(for: ["facets 2.png"])
|
||||
== "Removed 'facets 2.png' — it was no longer the board's background")
|
||||
}
|
||||
|
||||
@Test("Several files fold to a count")
|
||||
func severalFiles() {
|
||||
#expect(BannerCenter.tidiedBackgroundImagesMessage(for: ["a.png", "b.png", "c.png"])
|
||||
== "Removed 3 background images — they were no longer the board's background")
|
||||
}
|
||||
|
||||
@Test("Nothing removed says nothing")
|
||||
func nothingRemoved() {
|
||||
#expect(BannerCenter.tidiedBackgroundImagesMessage(for: []) == nil)
|
||||
}
|
||||
|
||||
/// A failed sweep is a one-shot write failure, and the banner owns its words too — the successful
|
||||
/// notice's verb, negated, `.relocateLooseFile`'s own precedent.
|
||||
@Test("A failed removal says so in the sweep's own verb")
|
||||
func failureHeadline() {
|
||||
let error = BoardWriteError(
|
||||
operation: .tidyBackgroundImage(filename: "facets.png"),
|
||||
path: "/tmp/board/.backgrounds/facets.png",
|
||||
reason: .io(message: "disk full")
|
||||
)
|
||||
#expect(BannerCenter.headline(for: error) == "Couldn't remove 'facets.png' — disk full")
|
||||
}
|
||||
}
|
||||
@@ -477,12 +477,12 @@ struct SolidBackgroundWriteTests {
|
||||
#expect(after.backgroundImage == .missing)
|
||||
}
|
||||
|
||||
/// **The deliberate half of the contract**: choosing a solid colour over a generated background
|
||||
/// does not delete the picture on disk — only the field that pointed at it. Undo has to have
|
||||
/// something to point back to (`SolidBackgroundUndoTests.restoresAPriorGeneratedImage`), and even
|
||||
/// without undo the file is the user's now, not litter the app cleans up on its own.
|
||||
@Test(".backgrounds/facets.png stays on disk when the board had one")
|
||||
func leavesTheGeneratedFileOnDisk() throws {
|
||||
/// **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)
|
||||
@@ -550,9 +550,11 @@ struct SolidBackgroundUndoTests {
|
||||
}
|
||||
|
||||
/// **Both prior fields come back** — the colour a board had before, and the generated image the
|
||||
/// solid choice pointed away from — which is what makes the file surviving on disk
|
||||
/// (`SolidBackgroundWriteTests.leavesTheGeneratedFileOnDisk`) worth doing: an undo with nothing to
|
||||
/// point back at would make the surviving bytes an orphan from the moment they landed.
|
||||
/// 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}")
|
||||
@@ -589,3 +591,113 @@ struct SolidBackgroundUndoTests {
|
||||
#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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -748,6 +748,26 @@ struct PasteBoardBackgroundTests {
|
||||
#expect(try background(harness.fixture).backgroundImage.value == ".backgrounds/Pasted Background.png")
|
||||
}
|
||||
|
||||
/// `theTwoProducersStayApart`'s settled counterpart — the orphan tidy's replace-in-place trim
|
||||
/// (ruled 2026-08-09): once the generate has *settled* (a reload landed and `snapshot` caught up
|
||||
/// with it), a paste that repoints away from it trims the generated file, on the same terms
|
||||
/// `GeneratedBackgroundTests.BackgroundReplaceInPlaceTrimTests` pins for the reverse direction.
|
||||
@Test("A paste after a settled generated background trims it")
|
||||
func aSettledPasteTrimsTheGeneratedFile() async throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
let generated = encodedImage(.png, side: 4)
|
||||
#expect(harness.store.applyGeneratedBackground(png: generated, colorHex: "#445566"))
|
||||
await settle(harness.store)
|
||||
harness.pasteboard.seed([(UTType.png.identifier, encodedImage(.png, side: 8))])
|
||||
|
||||
#expect(harness.clipboard.pasteBoardBackground(into: harness.store))
|
||||
|
||||
#expect(!harness.fixture.exists(".backgrounds/\(FacetsGenerator.fileName)"), "trimmed by the settled paste")
|
||||
#expect(try background(harness.fixture).backgroundImage.value == ".backgrounds/Pasted Background.png")
|
||||
#expect(harness.store.banners.losses.isEmpty, "the in-flow trim is silent")
|
||||
}
|
||||
|
||||
@Test("⌘Z puts the image subkey back and leaves the colour alone")
|
||||
func undoRestoresTheSubkey() throws {
|
||||
let fixture = try makeClipboardBoard()
|
||||
|
||||
Reference in New Issue
Block a user