diff --git a/Kanban/LiveStore/BannerCenter.swift b/Kanban/LiveStore/BannerCenter.swift index 17db028..378ca51 100644 --- a/Kanban/LiveStore/BannerCenter.swift +++ b/Kanban/LiveStore/BannerCenter.swift @@ -602,6 +602,27 @@ public final class BannerCenter { postLoss(message) } + /// **The orphaned background-image sweep** (01-storage-format.md § Validation and healing, ruled + /// 2026-08-09: "the open-time heal posts the calm notice-family banner the loose-file relocation + /// heal uses — state what was tidied"): a file in `.backgrounds/` that nothing on the board named + /// any more was moved to the system Trash, and this is the row that says so. + /// + /// **A loss row, on `postRelocatedLooseFiles`' exact reasoning**: the app removed a file on its own + /// initiative, on a board it opened rather than on a gesture the user made — that must be said out + /// loud, must not evaporate unread, and must not rank as an error, because nothing failed. + /// + /// **Only the sweep posts this.** The in-flow replace-in-place trim a background apply makes on + /// its own prior file is deliberately silent — the ruling's "temp-file posture": the app + /// superseding its own file inside a gesture the user already sees succeed (a fresh picture lands) + /// is not news the way a heal discovered on open is. + /// + /// `names` are the bare filenames the sweep actually removed, in the order it removed them. A + /// sweep that removed nothing posts nothing. + public func postTidiedBackgroundImages(_ names: [String]) { + guard let message = Self.tidiedBackgroundImagesMessage(for: names) else { return } + postLoss(message) + } + /// **The skip notice** (01-storage-format.md § Malformed input, ruled 2026-07-31): the decision /// surface offered Skip on a defect the app has no honest repair for, the user consented, the /// board opened without that item — "the file stays on disk untouched, tolerated-invisible like @@ -973,6 +994,15 @@ public final class BannerCenter { // failure the user did not provoke is exactly the one they have no other way to learn // about. "Couldn't move '\(filename)' into attachments" + case let .tidyBackgroundImage(filename): + // **The successful sweep's own verb, negated** — `postTidiedBackgroundImages`' "Removed + // '\(filename)' …" said the other way round, `.relocateLooseFile`'s own precedent: the + // failure reads as the same sentence's negation rather than a different event. It stays + // in the ordinary one-shot precedence class for `.relocateLooseFile`'s reason too — this + // is work the app started on its own, and a failure the user did not provoke is exactly + // the one they have no other way to learn about. In practice only the sweep's own failure + // ever reaches here; the in-flow trim swallows its own (the ruling's "temp-file posture"). + "Couldn't remove '\(filename)'" case .agentGuide: // **"the agent guide", not "CLAUDE.md"**: the file is the app's, written for agents, and // most users will never have opened it — a filename here would name something they have @@ -1284,6 +1314,24 @@ public final class BannerCenter { return "Repaired duplicate id — \(sole(only))" } + /// The orphaned background-image sweep's line, `migratedTombstonesMessage`'s voice: the act + /// first, the subject after an em dash, plurals folded, a tail naming the cause — without it the + /// sentence would read as something that happened *to* the board rather than the app's own + /// housekeeping. + /// + /// - **One**: "Removed 'facets 2.png' — it was no longer the board's background". + /// - **Several**: "Removed 3 background images — they were no longer the board's background". + /// + /// `nil` when nothing was removed — a sweep that swept nothing is not news. + public nonisolated static func tidiedBackgroundImagesMessage(for names: [String]) -> String? { + guard !names.isEmpty else { return nil } + let subject = names.count == 1 ? "'\(names[0])'" : "\(names.count) background images" + let tail = names.count == 1 + ? "it was no longer the board's background" + : "they were no longer the board's background" + return "Removed \(subject) — \(tail)" + } + /// The lossy export's line, in the relocation family's voice — the act first, the cause after an /// em dash, plurals folded into their counts. /// diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index f105350..716fb4f 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -2056,8 +2056,27 @@ public final class BoardStore: HealHost { /// alternative — a temp copy, a versioned name — buys byte-perfect undo of a picture nobody asked /// to keep at the price of litter in a folder the user owns. /// + /// ### The prior `.backgrounds/` file is trimmed, not just orphaned + /// + /// **The replace-in-place half of the orphan tidy** (01-storage-format.md § Validation and + /// healing, ruled 2026-08-09): once the new picture and the field both land, `snapshot`'s own prior + /// image — read before this write, the same value the undo step above closes over — is trimmed + /// from `.backgrounds/` when it named a file of ours different from the one this write just landed + /// on (`tidyReplacedBackgroundImage`). Switching producers (a settled paste, then a generate) no + /// longer leaves the earlier picture behind forever; a regeneration over the *same* name is + /// unaffected, because there is no "old" file distinct from the new one to trim. + /// + /// **This sharpens the undo note above, not contradicts it**: an undo that restores a prior + /// `.backgrounds/` reference now restores a field that may point at bytes the Trash holds rather + /// than the board folder — the missing-file degrade `BoardBackdropImage` already has for a + /// hand-deleted file, and recoverable exactly as one, on the ruling's own "temp-file posture". A + /// board's own `snapshot` is what decides whether the trim fires at all: back-to-back gestures + /// inside one reload's echo window (`theTwoProducersStayApart`) see a `nil` prior and trim nothing, + /// the same staleness that already gates the echo naming above. + /// /// Failure is `performWrite`'s: the banner is posted before the rethrow, which is swallowed here - /// like every other gesture with no second thing to do about it. + /// like every other gesture with no second thing to do about it. The trim's own failure is + /// swallowed independently and never reaches this banner at all — see `tidyReplacedBackgroundImage`. /// /// - Parameter png: the encoded image, already rendered (`FacetsGenerator.pngData`). /// - Parameter colorHex: the ground colour of that render (`FacetsRecipe.primaryColorHex`) — @@ -2088,6 +2107,10 @@ public final class BoardStore: HealHost { ) { document in Self.pointBackground(at: target.reference, color: colorHex, in: &document) } + // The replace-in-place half of the orphan tidy (ruled 2026-08-09) — see its own note. + Self.tidyReplacedBackgroundImage( + priorImage: priorImage.value, newReference: target.reference, inFolder: backgroundsFolder + ) } guard landed != nil else { return false } generatedBackgroundEcho = ( @@ -2133,15 +2156,21 @@ public final class BoardStore: HealHost { /// undo-restores-both-fields shape, the same `WriteOperation.setBoardBackground`, the same /// swallowed failure. /// - /// ### `facets.png` survives on disk + /// ### `facets.png` is trimmed when the switch has settled /// - /// Choosing a solid colour over a generated background does **not** delete the picture the - /// generator wrote. Undo restores the `image` *field*, and a field cannot point an undo back at - /// bytes this gesture just erased — so the file has to survive for the same reason - /// `applyGeneratedBackground`'s own overwrite-in-place does. A board that regenerates after - /// choosing solid still finds `facets.png` free to overwrite in place; the file becomes an orphan - /// only when nothing in the frontmatter ever points at it again, which is the same quiet leftover - /// a hand-deleted `image:` line already leaves. + /// **Superseded by the ruling below** (01-storage-format.md § Validation and healing, ruled + /// 2026-08-09): earlier, choosing a solid colour over a generated background never deleted the + /// picture the generator wrote, on exactly the reasoning `applyGeneratedBackground`'s own overwrite- + /// in-place note still gives for a same-name regeneration — undo restores the `image` *field*, and + /// a field cannot point an undo back at bytes this gesture erased. The ruling reads "unsets" as one + /// more shape of "repoints away from a `.backgrounds/` file", so this gesture now trims that file + /// too, the same `tidyReplacedBackgroundImage` call `applyGeneratedBackground` and + /// `applyPastedBackground` make — see that method's own note, including the accepted cost: an undo + /// back to the picture this gesture unset now restores a field that may point at bytes the Trash + /// holds rather than the board folder, gracefully, the same missing-file degrade a hand-deleted file + /// already has. The trim only fires once `snapshot` has caught up with the write it is trimming — + /// two solid-colour gestures back to back inside one reload's echo window trim nothing on the + /// second, `applyGeneratedBackground`'s own staleness gate, restated here. /// /// - Parameter colorHex: the solid colour to write, `#RRGGBB` — one of the Theme tab's Solid color /// swatches, which read `FacetsRecipe.primaryColorHex` at the filters' tone/saturation level. @@ -2152,6 +2181,9 @@ public final class BoardStore: HealHost { let root = rootURL let priorImage = snapshot.backgroundImage let priorColor = snapshot.background + let backgroundsFolder = root.appendingPathComponent( + BoardBackdrop.backgroundsFolderName, isDirectory: true + ) let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in try BoardWriter.updateIndex( @@ -2159,6 +2191,11 @@ public final class BoardStore: HealHost { ) { document in Self.pointSolidBackground(color: colorHex, in: &document) } + // The replace-in-place half of the orphan tidy (ruled 2026-08-09) — see this method's own + // note. `newReference: nil`, the unset case: solid never writes an `image` subkey. + Self.tidyReplacedBackgroundImage( + priorImage: priorImage.value, newReference: nil, inFolder: backgroundsFolder + ) } guard landed != nil else { return false } @@ -2257,6 +2294,140 @@ public final class BoardStore: HealHost { return (fresh, "\(BoardBackdrop.backgroundsFolderName)/\(fresh)") } + // MARK: - The orphan tidy ▸ replace-in-place + + /// **Trims the `.backgrounds/` file a background apply just repointed or unset away from** — the + /// replace-in-place half of the orphan tidy (01-storage-format.md § Validation and healing, ruled + /// 2026-08-09: "when the app's own write repoints or unsets the board background away from a + /// `.backgrounds/` file, remove the now-unreferenced old file as part of the operation"), shared by + /// every gesture that can move `background.image` off one of the app's own files — + /// `applyGeneratedBackground`, `applyPastedBackground`, `applySolidBackground` — each of which calls + /// this as the last line inside its own write bracket, after both of its own writes have landed. + /// + /// ### Silent and best-effort, the ruling's own "temp-file posture" + /// + /// This rides inside a bracket whose actual job is landing the *new* state, so a failure to trim + /// the *old* file must never fail that write or surface a banner about it — the user pressed + /// Generate, Paste, or a colour well, and what failed, as far as they are concerned, is whichever of + /// those the surrounding bracket's own banner already names. `try?` swallows whatever + /// `BoardWriter.trashBoardImage` throws; a file that could not be trimmed is simply left for the + /// scheduled sweep (`tidyBackgroundImages`) to find on the next open — the same self-healing shape + /// every heal in this file has when a single attempt does not land. + /// + /// `healMarked: false`: this is not a heal discovering work on its own, it is part of the very + /// gesture the user is watching succeed — `relocateLooseFiles`' own distinction between its + /// scheduled caller and its inline one, applied here. + /// + /// ### A no-op in three shapes, each deliberate + /// + /// - `priorImage` is `nil` — no field to have named an old file, or (`theTwoProducersStayApart`'s + /// echo window) `snapshot` has not yet caught up with the very write this trim would be reacting + /// to. + /// - `priorImage` names the file the write just landed on (`newReference` unchanged) — + /// `applyGeneratedBackground`'s own overwrite-in-place case; there is no "old" file distinct from + /// the new one. + /// - `BoardBackdrop.qualifiedBareName(of:)` answers `nil` — `priorImage` is not one of the app's own + /// `.backgrounds/` files at all: unqualified (a legacy reference, ruled "stays where it is — no + /// migration"), a hand-placed image elsewhere in the board, or a path this tidy is not scoped to + /// touch (01's own "Tidy scopes to `.backgrounds/` ONLY"). + private static func tidyReplacedBackgroundImage( + priorImage: String?, newReference: String?, inFolder backgroundsFolder: URL + ) { + guard let priorImage, priorImage != newReference, + let bareName = BoardBackdrop.qualifiedBareName(of: priorImage) + else { return } + try? BoardWriter.trashBoardImage( + named: bareName, inFolder: backgroundsFolder, healMarked: false, + operation: .tidyBackgroundImage(filename: bareName) + ) + } + + // MARK: - The orphan tidy ▸ the open-time sweep + + /// **Sweeps `.backgrounds/` for a file the board's current background no longer names** — the + /// open-time half of the orphan tidy (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"), the safety net behind the replace-in-place trim + /// (`tidyReplacedBackgroundImage`): every case that trim declines — the echo window, a foreign + /// write that repointed the field outside the app, a trim that failed and was swallowed — leaves + /// exactly the picture this sweep is built to notice and clean up on the next open. + /// + /// **Scheduling is the engine's** (`HealScheduler`), on every other scheduled heal's own terms: the + /// resting-clear, the lock-and-writability gate, the signature compare, the armed-before-attempt + /// memo, the one bracket, the banner posture and the clear-on-success are its six steps, and this + /// method is only what is genuinely this heal's — which files are candidates, and what the notice + /// names. + /// + /// **No `Defect` case** (`IntegrityRules.Defect.Class.orphanedBackgroundImages`'s own note): what is + /// orphaned is two board-root readings taken together at the moment of healing, not something a + /// tree walk over lanes and cards could carry forward — `seedGitignore`'s and `refreshAgentGuide`'s + /// shape, not `relocateLooseCardFiles`'s. + /// + /// **Announced, unlike the trim** (`HealScheduler.HealNotice.tidiedBackgroundImages`): a heal + /// discovered on open is news the ruling asks to be said out loud, the loose-file relocation's own + /// posture — "state what was tidied" — where the in-flow trim rides silently inside a gesture the + /// user already watches succeed. + /// + /// **The write half re-verifies against disk**: `BoardWriter.trashBoardImage` re-checks each name's + /// existence at write time and no-ops when it is gone, so a file that vanished under the write + /// (a hand delete, a losing race with a foreign write) contributes no line to the notice. + public func tidyBackgroundImages() { + let root = rootURL + let backgroundsFolder = root.appendingPathComponent( + BoardBackdrop.backgroundsFolderName, isDirectory: true + ) + let referenced = BoardBackdrop.qualifiedBareName(of: snapshot.backgroundImage.value) + let candidates = Self.orphanedBackgroundFileNames(inFolder: backgroundsFolder, keeping: referenced) + + var tidied: [String] = [] + heals.run( + .orphanedBackgroundImages, + signature: Set(candidates.map { "background:\($0)" }), + on: self + ) { () throws(BoardWriteError) -> Void in + for name in candidates { + let landed = try BoardWriter.trashBoardImage( + named: name, inFolder: backgroundsFolder, healMarked: true, + operation: .tidyBackgroundImage(filename: name) + ) + // Vanished under the write — the reload is the authority on what is there, and this + // contributes no line, `relocateLooseCardFiles`'s own rule. + guard landed != nil else { continue } + tidied.append(name) + } + } posting: { + .tidiedBackgroundImages(tidied) + } + } + + /// The `.backgrounds/` files nothing currently names — `tidyBackgroundImages`'s candidate list, read + /// fresh at the moment of healing rather than carried from a tree walk (see that method's own + /// note). + /// + /// A **missing** `.backgrounds/` lists as `[]`, not an error: nothing has ever been written there, + /// an ordinary state on a board that has never generated or pasted a background. `.skipsHiddenFiles` + /// is what keeps this from ever proposing the app's own crashed-write residue + /// (`.facets.png.lanework-`, `BoardWriter.atomicWrite`'s dot-temp) or a stray `.DS_Store` — + /// the same hidden-entry exclusion `BoardLoader.directoryCandidates` uses at every other level. + /// Sub-folders are excluded the same way `relocateLooseFiles` excludes them from a card: this sweep + /// only ever considers `.backgrounds/`'s own direct, regular-file contents, never anything nested. + /// + /// - Parameter keeping: the bare name `background.image` currently resolves to inside this folder + /// (`BoardBackdrop.qualifiedBareName(of:)`), `nil` when the board's current reference is not one + /// of ours — every file in the folder is then a candidate. + nonisolated static func orphanedBackgroundFileNames(inFolder backgroundsFolder: URL, keeping referenced: String?) -> [String] { + guard let entries = try? FileManager.default.contentsOfDirectory( + at: backgroundsFolder, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles] + ) else { return [] } + return entries + .filter { url in + url.lastPathComponent != referenced + && (try? url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true + } + .map(\.lastPathComponent) + .sorted() + } + // MARK: - Pasted background /// **Applies a pasted picture as this board's backdrop** — Edit ▸ Paste as Board Background @@ -2275,15 +2446,22 @@ public final class BoardStore: HealHost { /// other subkey survives untouched, which is `setBackgroundImage`'s whole per-subkey contract. /// - **The name carries the payload's own extension** (`Pasted Background.png`, `.jpeg`, …), /// because the format rule keeps a file-shaped flavor verbatim (`PastedImage`). A board pasted - /// twice in two formats therefore leaves the first file behind — the same quiet leftover - /// choosing a solid colour over a generated background already leaves, and for the same reason: - /// undo restores the *field*, and a field cannot point an undo back at bytes this gesture - /// deleted. + /// twice in two formats no longer leaves the first file behind once `snapshot` has caught up + /// with the first paste — see the trim note below, which is `applyGeneratedBackground`'s own, + /// restated. /// /// **The undo restores the field, not the bytes** — `applyGeneratedBackground`'s own note, /// unchanged and for its reason: a re-paste over this board's own `.backgrounds/Pasted /// Background.png` overwrites pixels nothing kept a copy of. /// + /// **The prior `.backgrounds/` file is trimmed once landed, on the same terms** — the + /// replace-in-place half of the orphan tidy (ruled 2026-08-09): once both writes land, + /// `tidyReplacedBackgroundImage` trims `snapshot`'s prior image when it names a file of ours other + /// than the one this write just landed on. `theTwoProducersStayApart`'s echo window is unaffected + /// by construction — a `nil` prior (the snapshot has not caught up with the write it would be + /// trimming) trims nothing, so a paste landing right after a generate leaves that generate's file + /// exactly where `applyGeneratedBackground`'s own memo says it should stay findable. + /// /// - Returns: whether bytes reached disk, which is the same question as "is an echo reload /// coming". @discardableResult @@ -2305,6 +2483,11 @@ public final class BoardStore: HealHost { ) { document in document.setBackgroundImage(target.reference) } + // The replace-in-place half of the orphan tidy (ruled 2026-08-09) — see this method's own + // note. + Self.tidyReplacedBackgroundImage( + priorImage: priorImage.value, newReference: target.reference, inFolder: backgroundsFolder + ) } guard landed != nil else { return false } generatedBackgroundEcho = ( @@ -4952,6 +5135,9 @@ public final class BoardStore: HealHost { /// The rest of the order is immaterial: they touch disjoint files (a card's loose files, a /// `deleted:` key inside an `index.md`, `CLAUDE.md`), each opens its own bracket, and each is /// re-armed by the reload the others' writes produce, so none can see another's work half-done. + /// **The background-image sweep joins this immaterial tail** (ruled 2026-08-09): it reads + /// `background.image` and writes only inside `.backgrounds/`, a file and a folder none of the + /// other five ever touch, so it has nothing to race and nothing to be raced by. public func runScheduledHeals() { displaceClaimedNames() seedGitignore() @@ -4959,6 +5145,7 @@ public final class BoardStore: HealHost { migrateLegacyTombstones() remintDuplicateIdentities() refreshAgentGuide() + tidyBackgroundImages() } // MARK: - Selection (delegated) diff --git a/Kanban/LiveStore/HealScheduler.swift b/Kanban/LiveStore/HealScheduler.swift index 68a94da..b9538aa 100644 --- a/Kanban/LiveStore/HealScheduler.swift +++ b/Kanban/LiveStore/HealScheduler.swift @@ -171,6 +171,11 @@ public final class HealScheduler { /// This row is what replaced the retired user-gated Repair banner: the same information, none /// of the waiting. case remintedDuplicateIDs(titles: [String?]) + /// Loss row, warning tone: `.backgrounds/` files nothing on the board named any more were + /// moved to the system Trash, named by filename (ruled 2026-08-09 — the open-time heal half + /// of the orphan tidy; the in-flow trim rides silently inside the gesture that triggers it and + /// posts nothing here). + case tidiedBackgroundImages([String]) } private static func post(_ notice: HealNotice, to banners: BannerCenter) { @@ -185,6 +190,8 @@ public final class HealScheduler { banners.postDisplacedClaimedNames(displacements) case let .remintedDuplicateIDs(titles): banners.postRemintedDuplicateIDs(titles) + case let .tidiedBackgroundImages(names): + banners.postTidiedBackgroundImages(names) } } diff --git a/Kanban/Storage/BoardWriter.swift b/Kanban/Storage/BoardWriter.swift index e9d87f7..01832ef 100644 --- a/Kanban/Storage/BoardWriter.swift +++ b/Kanban/Storage/BoardWriter.swift @@ -2767,6 +2767,58 @@ public enum BoardWriter: Sendable { return trashedURL as URL? } + /// Moves a file out of `.backgrounds/` to the **system** Trash — `trashAttachment`'s body one + /// folder over, and the shared primitive both orphan-tidy mechanisms use (01-storage-format.md § + /// Validation and healing, ruled 2026-08-09): the replace-in-place trim a background apply makes + /// on the file it just repointed or unset away from (`BoardStore.applyGeneratedBackground`, + /// `applyPastedBackground`, `applySolidBackground`), and the open-time sweep of whatever + /// `.backgrounds/` holds that the board's current `background.image` no longer names + /// (`BoardStore.tidyBackgroundImages`). + /// + /// **`FileManager.trashItem`, never `removeItem`** — `trashAttachment`'s own reasoning, applied to + /// a file the app rather than the user put there: an app-written background is recoverable exactly + /// the way a trashed attachment is, by the user, in Finder, with no help from this app, which is + /// the ruling's own "removal via `FileManager.trashItem` (recoverable, matches + /// attachment-removal precedent)". + /// + /// **A name no longer present in `backgroundsFolder` is not a failure** — `trashAttachment`'s rule + /// again: the reload (or, for the in-flow caller, the disk itself a moment later) is the authority + /// on what is there, and a file that vanished between the scan and the write — a hand delete, a + /// losing race with a foreign write — contributes nothing to report. + /// + /// - Parameter healMarked: whether the receipt this drops is marked as a heal's + /// (06-history-undo.md ▸ Commit messages; `relocateLooseFiles`' own parameter, restated here). + /// `true` for the scheduled sweep, which is app-initiated work discovered on its own; `false` for + /// the in-flow trim, which rides inside the gesture that triggered it rather than a heal's own + /// bracket. + /// - Returns: where the file now sits inside the Trash, or `nil` when there was nothing to move. + @discardableResult + static func trashBoardImage( + named name: String, + inFolder backgroundsFolder: URL, + healMarked: Bool, + operation: WriteOperation + ) throws(BoardWriteError) -> URL? { + let fileURL = backgroundsFolder.appendingPathComponent(name) + guard FileManager.default.fileExists(atPath: fileURL.path) else { return nil } + + var trashedURL: NSURL? + do { + try FileManager.default.trashItem(at: fileURL, resultingItemURL: &trashedURL) + EchoLedger.current?.recordDeletion(at: fileURL) + if healMarked { + EchoLedger.current?.markHeal(at: fileURL) + } + } catch { + throw BoardWriteError( + operation: operation, + path: fileURL.path, + reason: .io(message: "could not move file to the Trash: \(error.localizedDescription)") + ) + } + return trashedURL as URL? + } + // MARK: - Move/copy pre-flight /// The rank a moved or copied root lands on: the caller's explicit value — a drop between @@ -3084,6 +3136,22 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible { /// never the Finder-renamed one it would have landed under. case relocateLooseFile(filename: String) + /// A `.backgrounds/` file being moved to the **system** Trash because nothing names it any more — + /// the orphan tidy's write, both mechanisms (01-storage-format.md § Validation and healing, ruled + /// 2026-08-09): the replace-in-place trim a background apply makes on the file it just repointed + /// or unset away from, and the open-time sweep of whatever `.backgrounds/` holds that the board's + /// current `background.image` no longer names. + /// + /// Its own case on `.relocateLooseFile`'s reasoning, doubled: this is work the *app* started on + /// its own, on a file the user never named, and a banner saying the app "couldn't move 'facets + /// 2.png' to the Trash" without this case would borrow `.removeAttachment`'s sentence for a + /// gesture — a click on a Remove row — the user never made. In practice only the sweep's own + /// failure ever reaches a banner: the in-flow trim swallows whatever this throws (`try?`, the + /// ruling's own "temp-file posture"), so this case's phrasing exists for the sweep and for the + /// developer-facing `description` either mechanism's failure would log. `filename` is the bare + /// name inside `.backgrounds/`, the one the sweep's own successful notice would have used. + case tidyBackgroundImage(filename: String) + /// The board-root `CLAUDE.md` being written or upgraded — and the rescue move that precedes it /// when a markerless one has to be displaced to `CLAUDE.user.md` (08-agent-integration.md ▸ The /// agent guide; `AgentGuide`). @@ -3275,8 +3343,8 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible { // the name the user is looking at and the only one its banner should say — and there is no // document to enrich from anyway, since the failure it describes is the read of one. case .createBoard, .createLane, .createCard, .importAttachment, .importBoard, .listAttachments, - .removeAttachment, .renumberChildren, .relocateLooseFile, .agentGuide, .seedGitignore, - .mintBoardIndex, .stampSchema, .setBoardBackground, + .removeAttachment, .renumberChildren, .relocateLooseFile, .tidyBackgroundImage, .agentGuide, + .seedGitignore, .mintBoardIndex, .stampSchema, .setBoardBackground, .displaceClaimedName, .repairDuplicateID, .saveCommentDraft, .postComment, .editComment, .deleteComment, .purgeCommentTrash: self @@ -3345,8 +3413,8 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible { .style, .resize, .collapse, .expand, .rename, .duplicateBoard, .saveAsTemplate, .shareBoard, .paste, .exportBoard, .importBoard, .importAttachment, - .listAttachments, .removeAttachment, .relocateLooseFile, .agentGuide, .seedGitignore, - .mintBoardIndex, .stampSchema, .setBoardBackground, + .listAttachments, .removeAttachment, .relocateLooseFile, .tidyBackgroundImage, .agentGuide, + .seedGitignore, .mintBoardIndex, .stampSchema, .setBoardBackground, .displaceClaimedName, .repairDuplicateID, .toggleTask, .editBody, .rawSource, .saveCommentDraft, .postComment, .editComment, .deleteComment, .purgeCommentTrash: @@ -3388,6 +3456,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible { case let .removeAttachment(filename): "move attachment '\(filename)' to the Trash" case .renumberChildren: "renumber children" case let .relocateLooseFile(filename): "relocate loose file '\(filename)'" + case let .tidyBackgroundImage(filename): "tidy background image '\(filename)'" case .agentGuide: "update the agent guide" case .seedGitignore: "seed the board's .gitignore" case .mintBoardIndex: "create this board's index.md" diff --git a/Kanban/Storage/IntegrityRules.swift b/Kanban/Storage/IntegrityRules.swift index ebae97c..768b76f 100644 --- a/Kanban/Storage/IntegrityRules.swift +++ b/Kanban/Storage/IntegrityRules.swift @@ -778,6 +778,12 @@ public enum IntegrityRules: Sendable { /// card window closes; crash residue sweeps at the next card-window open, armed-then-cleared /// like every heal memo"). The board walk never opens a thread, so no tree walk could report /// it. + /// + /// `orphanedBackgroundImages` joins this no-`Defect`-case group for the identical reason: what + /// is orphaned is a property of **two** board-root readings taken together at the moment of + /// healing — the `.backgrounds/` folder's current listing and `background.image`'s current + /// value (`BoardStore.tidyBackgroundImages`, ruled 2026-08-09) — not something a tree walk over + /// lanes and cards could carry forward to the heal that acts on it. public enum Class: Sendable, Equatable, Hashable, CaseIterable { case looseCardFiles case legacyTombstone @@ -786,6 +792,7 @@ public enum IntegrityRules: Sendable { case staleAgentGuide case missingGitignore case commentTrashResidue + case orphanedBackgroundImages } /// The scheduled-heal class this defect belongs to, or **`nil` where there is no heal** — the diff --git a/Kanban/UI/Board/BoardBackdropImage.swift b/Kanban/UI/Board/BoardBackdropImage.swift index 1f38af5..0e3b4e4 100644 --- a/Kanban/UI/Board/BoardBackdropImage.swift +++ b/Kanban/UI/Board/BoardBackdropImage.swift @@ -96,6 +96,26 @@ enum BoardBackdrop { /// leftover an overwritten `facets.png` already could. static let backgroundsFolderName = ".backgrounds" + /// **The bare filename `reference` names, when it is a direct child of `.backgrounds/`** — the + /// read the orphan tidy needs (01-storage-format.md § Validation and healing, ruled 2026-08-09: + /// "Tidy scopes to `.backgrounds/` ONLY … app-written by construction") to tell "one of ours, + /// still in service" from everything else a `background.image` could name. + /// + /// `nil` for every reading that is not that one shape, folded together on purpose — a missing + /// reference, a legacy bare name at board root, a hand-placed image in a subfolder of its own, a + /// reference naming a grandchild of `.backgrounds/` (`.backgrounds/art/x.png`) — because every one + /// of those means the same thing to a caller asking "which file in `.backgrounds/` is spoken for + /// right now": none of them are, so every file the folder holds is a candidate. The shared + /// prefix-and-single-component test is exactly `boardImageName`'s own "only the qualified form + /// reads as ours" rule, restated as a pure function so both tidy mechanisms — the in-flow trim and + /// the open-time sweep — read it the same way without either owning the other's copy. + static func qualifiedBareName(of reference: String?) -> String? { + guard let reference, reference.hasPrefix("\(backgroundsFolderName)/") else { return nil } + let rest = reference.dropFirst(backgroundsFolderName.count + 1) + guard !rest.isEmpty, !rest.contains("/") else { return nil } + return String(rest) + } + /// Whether this board paints a background of its own — **the window-chrome predicate** /// (`BoardWindowHost`, `HostedWindowController.setExtendsContentUnderTitlebar`): a board with one /// runs its content under a transparent title bar, and a board without one keeps the standard diff --git a/KanbanTests/BackgroundImageTidyTests.swift b/KanbanTests/BackgroundImageTidyTests.swift new file mode 100644 index 0000000..934b309 --- /dev/null +++ b/KanbanTests/BackgroundImageTidyTests.swift @@ -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") + } +} diff --git a/KanbanTests/GeneratedBackgroundTests.swift b/KanbanTests/GeneratedBackgroundTests.swift index b6036b3..3026714 100644 --- a/KanbanTests/GeneratedBackgroundTests.swift +++ b/KanbanTests/GeneratedBackgroundTests.swift @@ -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) + } +} diff --git a/KanbanTests/PasteImageTests.swift b/KanbanTests/PasteImageTests.swift index 0b39204..f082073 100644 --- a/KanbanTests/PasteImageTests.swift +++ b/KanbanTests/PasteImageTests.swift @@ -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()