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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user