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") } }