import Foundation import Testing @testable import Kanban /// The write half of the Theme tab's two picture-adjacent gestures: `BoardWriter.writeBoardImage` and /// `BoardStore.applyGeneratedBackground` for Pattern, `BoardStore.applySolidBackground` for Solid /// color (03-board-ui.md § Board popover ▸ Theme tab; DESIGN/explorations/board-backgrounds.md). /// /// Like every other write suite here these drive a real writer or a real store over a real temp /// board and read the **bytes on disk** back rather than the app's own read path: the claims are /// about the file — which name the picture landed under, what the frontmatter says afterwards, and /// what an undo leaves behind. `WriterFixture`, `Ident` and `Item` come from /// `WriterTestSupport.swift`. // MARK: - Fixtures /// A board root carrying whatever background the test needs, plus the unowned baggage every write /// has to leave alone. private func boardIndex(background: String? = nil) -> String { let line = background.map { "background: \($0)\n" } ?? "" return """ --- schema: 1 title: Work \(line)project: lanework # agent overlay created: 2026-01-01T09:00:00Z --- Board description. """ } @MainActor private func makeBoard(background: String? = nil) throws -> WriterFixture { let fixture = try WriterFixture() try fixture.item("", boardIndex(background: background)) try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First")) return fixture } @MainActor private func makeStore(_ fixture: WriterFixture) throws -> (store: BoardStore, history: NativeHistoryProvider) { let store = try BoardStore(rootURL: fixture.root) let history = NativeHistoryProvider() store.history = history return (store, history) } @MainActor private func reload(_ store: BoardStore) async { store.handleWatcherEvent(.treeChanged(.appMediated)) await store.awaitQuiescence() } private func document(_ fixture: WriterFixture) throws -> FrontmatterDocument { try FrontmatterDocument.parse(fixture.indexText("")) } /// Bytes that are not an image and do not need to be: nothing in the write path decodes them, which /// is itself worth pinning — the Writer moves a payload, it does not validate artwork. private let png = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x01, 0x02, 0x03]) private let otherPNG = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x09, 0x09]) // MARK: - The writer primitive @MainActor @Suite("BoardWriter ▸ writeBoardImage") struct WriteBoardImageTests { @Test("The bytes land under the given name, and the name comes back") func writesTheBytes() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let name = try BoardWriter.writeBoardImage( data: png, named: "facets.png", inRoot: fixture.root, operation: .setBoardBackground ) #expect(name == "facets.png") #expect(try fixture.data("facets.png") == png) } /// The caller decides the name, so the writer's own contract is simply that the same name is /// replaced rather than laddered — one board, one generated picture. @Test("A second write to the same name replaces it in place") func overwritesInPlace() throws { let fixture = try makeBoard() defer { fixture.tearDown() } try BoardWriter.writeBoardImage( data: png, named: "facets.png", inRoot: fixture.root, operation: .setBoardBackground ) try BoardWriter.writeBoardImage( data: otherPNG, named: "facets.png", inRoot: fixture.root, operation: .setBoardBackground ) #expect(try fixture.data("facets.png") == otherPNG) #expect(try fixture.entryNames("") == [Ident.lane1, "facets.png", "index.md"]) } /// **Atomic, and no residue**: the temp file is hidden and in the same folder, so a listing that /// sees hidden entries is what proves the rename left nothing behind. @Test("No temp file survives the write") func leavesNoResidue() throws { let fixture = try makeBoard() defer { fixture.tearDown() } try BoardWriter.writeBoardImage( data: png, named: "facets.png", inRoot: fixture.root, operation: .setBoardBackground ) #expect(try !fixture.entryNames("").contains { $0.hasPrefix(".") }) } /// The mirror of the read side's containment rule (`BoardBackdrop.imageURL(named:inBoardRoot:)`): /// a background that could be written outside the board folder is not a background. @Test("A path, an empty name and the dot names are refused", arguments: ["", "art/x.png", "../x.png", ".", ".."]) func refusesAnythingThatIsNotABareName(name: String) throws { let fixture = try makeBoard() defer { fixture.tearDown() } #expect(throws: BoardWriteError.self) { try BoardWriter.writeBoardImage( data: png, named: name, inRoot: fixture.root, operation: .setBoardBackground ) } #expect(try fixture.entryNames("") == [Ident.lane1, "index.md"]) } /// The receipt: without one the churn the write produces classifies as somebody else's, and the /// announcer — or any future ledger consumer — would attribute the app's own write to a foreign /// editor. @Test("The write leaves a content receipt in the ledger") func dropsAReceipt() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let ledger = EchoLedger() EchoLedger.$current.withValue(ledger) { try? BoardWriter.writeBoardImage( data: png, named: "facets.png", inRoot: fixture.root, operation: .setBoardBackground ) } let receipts = ledger.outstandingEntries() #expect(receipts.contains { $0.key.hasSuffix("/facets.png") }) } } // MARK: - The gesture @MainActor @Suite("BoardStore ▸ applyGeneratedBackground") struct GeneratedBackgroundWriteTests { @Test("The picture lands in the folder and both subkeys point at it") func writesTheFileAndTheFields() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, _) = try makeStore(fixture) #expect(store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")) #expect(try fixture.data("facets.png") == png) let after = try document(fixture) #expect(after.background == .valid("#E0E5EB")) #expect(after.backgroundImage == .valid("facets.png")) #expect(try fixture.indexText("").contains("background: {color: \"#E0E5EB\", image: \"facets.png\"}")) #expect(store.banners.oneShots.isEmpty) } /// One gesture, one bracket — the style batch's rule, which is what makes one reroll one /// app-mediated reload, though it writes two files. @Test("Two files, one bracket") func oneBracketForBothFiles() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, _) = try makeStore(fixture) var begins = 0 var ends = 0 store.watcherBrackets = (begin: { begins += 1 }, end: { ends += 1 }) store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB") #expect(begins == 1) #expect(ends == 1) } /// A colour the wells wrote is replaced, and everything the app does not own comes through /// untouched — the unknown key with its comment, `created`, the body. @Test("An existing colour is replaced and the rest of the file survives") func replacesAnExistingColour() throws { let fixture = try makeBoard(background: "{color: fern}") defer { fixture.tearDown() } let (store, _) = try makeStore(fixture) store.applyGeneratedBackground(png: png, colorHex: "#513D1A") let text = try fixture.indexText("") #expect(text.contains("background: {color: \"#513D1A\", image: \"facets.png\"}")) #expect(text.contains("project: lanework # agent overlay")) #expect(text.contains("created: 2026-01-01T09:00:00Z")) #expect(text.contains("Board description.")) } /// **Regenerating overwrites**: the whole reason the name is fixed rather than minted. The reload /// between the two rolls is the ordinary case — the snapshot has caught up and names the file. @Test("A second generation replaces the same file") func secondGenerationOverwrites() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, _) = try makeStore(fixture) store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB") await reload(store) store.applyGeneratedBackground(png: otherPNG, colorHex: "#513D1A") #expect(try fixture.data("facets.png") == otherPNG) #expect(try document(fixture).backgroundImage == .valid("facets.png")) // No ladder: the reload in between also seeds this board's `.gitignore`, so the listing is // filtered to the pictures rather than compared whole. #expect(try fixture.entryNames("").filter { $0.hasSuffix(".png") } == ["facets.png"]) } /// **The reroll's echo**: rolling again before the watcher has rounded the first write back must /// not ladder onto `facets 2.png`, because a fast reroll is the expected gesture and a folder of /// abandoned pictures is what the fixed name exists to prevent. @Test("A reroll before the reload lands still overwrites") func rerollBeforeTheReloadOverwrites() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, _) = try makeStore(fixture) store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB") store.applyGeneratedBackground(png: otherPNG, colorHex: "#513D1A") #expect(try fixture.data("facets.png") == otherPNG) #expect(try fixture.entryNames("") == [Ident.lane1, "facets.png", "index.md"]) } /// **Somebody else's `facets.png` is never written through** — a file the user put in the board /// folder is theirs, and the Finder ladder is how the app steps aside from a name it does not own. @Test("A foreign file on the name pushes the generation to 'facets 2.png'") func stepsAsideFromAForeignFile() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let mine = Data("not the app's".utf8) try fixture.file("facets.png", mine) let (store, _) = try makeStore(fixture) store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB") #expect(try fixture.data("facets.png") == mine, "the user's file is untouched") #expect(try fixture.data("facets 2.png") == png) #expect(try document(fixture).backgroundImage == .valid("facets 2.png")) } /// The same ladder when the board already names a *different* image: the hand-written path is /// the escape hatch and stays on disk, and the generation lands beside it. @Test("A board naming another image keeps it and generates alongside") func keepsAHandWrittenImage() throws { let fixture = try makeBoard(background: "{image: sunset.jpg}") defer { fixture.tearDown() } try fixture.file("sunset.jpg", Data("photo".utf8)) try fixture.file("facets.png", Data("someone else's".utf8)) let (store, _) = try makeStore(fixture) store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB") #expect(try fixture.data("sunset.jpg") == Data("photo".utf8)) #expect(try document(fixture).backgroundImage == .valid("facets 2.png")) } /// A board whose generated file was deleted in Finder is a board with a broken backdrop, and /// regenerating is exactly the repair — so the name is reused rather than laddered. @Test("A missing file under our own name is rewritten, not laddered") func rewritesAMissingFile() throws { let fixture = try makeBoard(background: "{color: fern, image: facets.png}") defer { fixture.tearDown() } let (store, _) = try makeStore(fixture) store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB") #expect(try fixture.data("facets.png") == png) #expect(try document(fixture).backgroundImage == .valid("facets.png")) } /// A locked board writes nothing at all — not the picture, not the fields. @Test("A read-only board refuses before anything is written") func refusesUnderTheLock() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, _) = try makeStore(fixture) store.enterVanishedRootLock() #expect(store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB") == false) #expect(!fixture.exists("facets.png")) #expect(try document(fixture).backgroundImage == .missing) } } // MARK: - Undo @MainActor @Suite("Undo ▸ generated background") struct GeneratedBackgroundUndoTests { /// The first generation's undo is a clean return: the board had no background, and afterwards it /// has none again. (The PNG stays in the folder — nothing in the app deletes the user's files — /// and nothing points at it.) @Test("Undo removes both subkeys and redo puts them back") func roundTrip() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB") #expect(history.undoActionName == "Restyle Board") history.undo() let undone = try document(fixture) #expect(undone.background == .missing) #expect(undone.backgroundImage == .missing) #expect(!undone.contains(FrontmatterKeys.background)) history.redo() let redone = try document(fixture) #expect(redone.background == .valid("#E0E5EB")) #expect(redone.backgroundImage == .valid("facets.png")) } /// A prior colour comes back as itself rather than as an absence — the same reading `applyStyle`'s /// inverse has. @Test("A prior colour and image are restored, not removed") func priorValuesComeBack() throws { let fixture = try makeBoard(background: "{color: fern, image: sunset.jpg}") defer { fixture.tearDown() } try fixture.file("sunset.jpg", Data("photo".utf8)) let (store, history) = try makeStore(fixture) store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB") history.undo() let undone = try document(fixture) #expect(undone.background == .valid("fern")) #expect(undone.backgroundImage == .valid("sunset.jpg")) } /// **The undo restores fields, never bytes** — stated as a test so the limit is visible rather /// than folklore: regenerating over the app's own output leaves the second picture on disk, and /// ⌘Z points the (unchanged) name back at it. @Test("Undo does not bring the overwritten pixels back") func undoDoesNotRestoreBytes() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB") await reload(store) store.applyGeneratedBackground(png: otherPNG, colorHex: "#513D1A") history.undo() #expect(try document(fixture).background == .valid("#E0E5EB")) #expect(try document(fixture).backgroundImage == .valid("facets.png")) #expect(try fixture.data("facets.png") == otherPNG, "the first generation's bytes are gone") } /// A foreign edit to the field the step wrote stales it — the field-level predicate, applied to /// the subkey this gesture owns. @Test("A foreign edit to the image subkey skips the undo") func foreignEditStalesTheStep() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB") var foreign = try document(fixture) foreign.setBackgroundImage("elsewhere.png") try fixture.item("", foreign.serialized()) history.undo() #expect(try document(fixture).backgroundImage == .valid("elsewhere.png")) #expect(store.banners.signposts.isEmpty == false, "the skip says so on the strip") } } // MARK: - Solid background /// The Theme tab's Solid color half: `BoardStore.applySolidBackground` (03-board-ui.md § Board /// popover ▸ Theme tab; `BoardThemeTabView.applySolid`). Modeled line-for-line on /// `applyGeneratedBackground` minus the file write, so these suites mirror the write and undo suites /// above with the one difference the method itself has: no picture, and `facets.png` — when there is /// one — is deliberately left on disk rather than deleted. @MainActor @Suite("BoardStore ▸ applySolidBackground") struct SolidBackgroundWriteTests { @Test("The colour lands and there is no image subkey to point anywhere") func writesTheColourAloneOnAPlainBoard() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, _) = try makeStore(fixture) #expect(store.applySolidBackground(colorHex: "#E0E5EB")) let after = try document(fixture) #expect(after.background == .valid("#E0E5EB")) #expect(after.backgroundImage == .missing) #expect(try fixture.indexText("").contains("background: {color: \"#E0E5EB\"}")) #expect(store.banners.oneShots.isEmpty) } /// The write's whole point on a board that already carries a generated picture: the colour /// changes, the `image` subkey goes, and every other subkey the app does not own rides through /// untouched. @Test("An existing image subkey is removed and unrelated subkeys survive") func removesTheImageSubkeyAndKeepsForeignOnes() throws { let fixture = try makeBoard(background: "{blend: multiply, color: fern, image: facets.png, opacity: 0.5}") defer { fixture.tearDown() } let (store, _) = try makeStore(fixture) store.applySolidBackground(colorHex: "#513D1A") let text = try fixture.indexText("") #expect(text.contains("blend: \"multiply\"")) #expect(text.contains("color: \"#513D1A\"")) #expect(text.contains("opacity: 0.5")) #expect(!text.contains("image:")) let after = try document(fixture) #expect(after.background == .valid("#513D1A")) #expect(after.backgroundImage == .missing) } /// **The 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("facets.png stays on disk when the board had one") func leavesTheGeneratedFileOnDisk() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, _) = try makeStore(fixture) store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB") store.applySolidBackground(colorHex: "#513D1A") #expect(try fixture.data("facets.png") == png, "the bytes are untouched") #expect(try document(fixture).backgroundImage == .missing, "only the field is gone") } @Test("One bracket for the one file it touches") func oneBracketForTheWrite() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, _) = try makeStore(fixture) var begins = 0 var ends = 0 store.watcherBrackets = (begin: { begins += 1 }, end: { ends += 1 }) store.applySolidBackground(colorHex: "#E0E5EB") #expect(begins == 1) #expect(ends == 1) } /// A locked board writes nothing at all. @Test("A read-only board refuses before anything is written") func refusesUnderTheLock() throws { let fixture = try makeBoard(background: "{color: fern}") defer { fixture.tearDown() } let (store, _) = try makeStore(fixture) store.enterVanishedRootLock() #expect(store.applySolidBackground(colorHex: "#E0E5EB") == false) #expect(try document(fixture).background == .valid("fern")) } } @MainActor @Suite("Undo ▸ solid background") struct SolidBackgroundUndoTests { /// The first solid colour's undo is a clean return: the board had no background, and afterwards /// it has none again. @Test("Undo removes the colour and redo puts it back") func roundTrip() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.applySolidBackground(colorHex: "#E0E5EB") #expect(history.undoActionName == "Restyle Board") history.undo() let undone = try document(fixture) #expect(undone.background == .missing) #expect(undone.backgroundImage == .missing) #expect(!undone.contains(FrontmatterKeys.background)) history.redo() let redone = try document(fixture) #expect(redone.background == .valid("#E0E5EB")) #expect(redone.backgroundImage == .missing) } /// **Both prior fields come back** — the colour a board had before, and the generated image the /// solid choice pointed away from — 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. @Test("A prior colour and generated image are both restored") func restoresAPriorGeneratedImage() throws { let fixture = try makeBoard(background: "{color: fern, image: facets.png}") defer { fixture.tearDown() } try fixture.file("facets.png", png) let (store, history) = try makeStore(fixture) store.applySolidBackground(colorHex: "#E0E5EB") history.undo() let undone = try document(fixture) #expect(undone.background == .valid("fern")) #expect(undone.backgroundImage == .valid("facets.png")) #expect(try fixture.data("facets.png") == png, "undo restored the field, not new bytes") } /// A foreign edit to the field the step wrote stales it — the same field-level predicate /// `GeneratedBackgroundUndoTests.foreignEditStalesTheStep` pins for the generated path, applied to /// the colour subkey this gesture owns. @Test("A foreign edit to the colour subkey skips the undo") func foreignEditStalesTheStep() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.applySolidBackground(colorHex: "#E0E5EB") var foreign = try document(fixture) foreign.setStyleValue("obsidian", for: FrontmatterKeys.background) try fixture.item("", foreign.serialized()) history.undo() #expect(try document(fixture).background == .valid("obsidian")) #expect(store.banners.signposts.isEmpty == false, "the skip says so on the strip") } }