import Foundation import Testing @testable import Kanban /// **Choose Image…** — the Style editor's board-targeted affordance onto a picked file /// (03-board-ui.md § Styling ▸ Controls; ruled 2026-08-09, reversing BackgroundField.swift's original /// "there is no image picker and none is planned"): `BoardStore.applyChosenBackground` and /// `ChosenBoardBackground.apply(from:to:)`, the panel's own non-modal logic. /// /// Like `GeneratedBackgroundTests.swift` and `PasteImageTests.swift`'s background suites, these drive /// a real store over a real temp board and read the **bytes on disk** back — the claims are about the /// file, never the app's own read path. `WriterFixture`, `Ident` and `Item` come from /// `WriterTestSupport.swift`. // MARK: - Fixtures 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("")) } /// A real, tiny opaque bitmap — `PasteImageTests.encodedImage`'s reason, restated: the write path /// never decodes what it copies, but a fixture built from real bytes keeps every test honest about /// what it is actually pinning. private let photo = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x01, 0x02, 0x03]) private let otherPhoto = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x09, 0x09]) /// A temp file this test can hand `ChosenBoardBackground.apply` — the stand-in for what /// `BoardBackgroundImagePanel.chooseImage()` would have returned, since a modal `NSOpenPanel` cannot /// be driven headlessly. private func tempFile(named name: String, _ bytes: Data) throws -> URL { let url = FileManager.default.temporaryDirectory .appendingPathComponent("ChooseBackgroundImageTests-\(UUID().uuidString)", isDirectory: true) .appendingPathComponent(name) try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) try bytes.write(to: url) return url } // MARK: - BoardStore.applyChosenBackground ▸ naming, write shape, the lock @MainActor @Suite("BoardStore ▸ applyChosenBackground") struct ChosenBackgroundWriteTests { @Test("The picture lands under its own name in .backgrounds/, and background.image names it") func writesTheFileUnderItsOwnName() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, _) = try makeStore(fixture) #expect(store.applyChosenBackground(data: photo, baseName: "sunset.jpg")) #expect(try fixture.data(".backgrounds/sunset.jpg") == photo) let after = try document(fixture) #expect(after.backgroundImage == .valid(".backgrounds/sunset.jpg")) #expect(store.banners.oneShots.isEmpty) } /// The pasted-background precedent, on this producer: an arbitrary picked file carries no ground /// colour, so the colour subkey — present or absent — is not this gesture's to touch. @Test("The colour subkey is left exactly as it was") func theColourSurvives() throws { let fixture = try makeBoard(background: "{color: fern}") defer { fixture.tearDown() } let (store, _) = try makeStore(fixture) store.applyChosenBackground(data: photo, baseName: "sunset.jpg") let after = try document(fixture) #expect(after.background == .valid("fern")) #expect(after.backgroundImage == .valid(".backgrounds/sunset.jpg")) } @Test("A board with no colour at all still gets none") func noColourStaysAbsent() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, _) = try makeStore(fixture) store.applyChosenBackground(data: photo, baseName: "sunset.jpg") #expect(try document(fixture).background == .missing) } /// The bytes travel verbatim — no decode, no re-encode, whatever the caller hands over. @Test("Bytes are copied exactly as given, not re-encoded") func bytesAreVerbatim() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, _) = try makeStore(fixture) let notReallyAnImage = Data("not actually image bytes".utf8) store.applyChosenBackground(data: notReallyAnImage, baseName: "whatever.png") #expect(try fixture.data(".backgrounds/whatever.png") == notReallyAnImage) } /// Finder-ladder rename on collision — a hand-placed or previously chosen file already holding the /// name is never written through, the same rule `applyGeneratedBackground`/`applyPastedBackground` /// already follow (`BoardWriter.freshName`). @Test("A file already holding the name is stepped around") func collisionLaddersToTheNextName() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let mine = Data("hand placed".utf8) try fixture.file(".backgrounds/sunset.jpg", mine) let (store, _) = try makeStore(fixture) store.applyChosenBackground(data: photo, baseName: "sunset.jpg") #expect(try fixture.data(".backgrounds/sunset.jpg") == mine, "the existing file is untouched") #expect(try fixture.data(".backgrounds/sunset 2.jpg") == photo) #expect(try document(fixture).backgroundImage == .valid(".backgrounds/sunset 2.jpg")) } /// Re-picking the exact file this board already shows overwrites it in place — the same /// regenerate/re-paste posture, applied to a repeat Choose Image…. @Test("Re-choosing the board's own current file overwrites it in place") func rechoosingTheCurrentFileOverwrites() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, _) = try makeStore(fixture) store.applyChosenBackground(data: photo, baseName: "sunset.jpg") await reload(store) store.applyChosenBackground(data: otherPhoto, baseName: "sunset.jpg") #expect(try fixture.data(".backgrounds/sunset.jpg") == otherPhoto) #expect(try fixture.entryNames(".backgrounds") == ["sunset.jpg"], "no ladder onto 'sunset 2.jpg'") } @Test("One bracket for both files") 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.applyChosenBackground(data: photo, baseName: "sunset.jpg") #expect(begins == 1) #expect(ends == 1) } @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.applyChosenBackground(data: photo, baseName: "sunset.jpg") == false) #expect(!fixture.exists(".backgrounds/sunset.jpg")) #expect(try document(fixture).backgroundImage == .missing) } } // MARK: - The orphan tidy composes, unmodified /// **"Verify that composes rather than reimplementing it"** — `applyChosenBackground` calls the very /// same `tidyReplacedBackgroundImage`/`tidyBackgroundImages` every other producer calls; nothing about /// the tidy itself changed for this card, so what is pinned here is the *composition*, not a new rule. @MainActor @Suite("BoardStore ▸ applyChosenBackground composes with the orphan tidy") struct ChosenBackgroundOrphanTidyTests { /// The replace-in-place trim: once a prior chosen picture has settled, repointing away from it /// trims the old file as part of the very same write bracket — `BackgroundReplaceInPlaceTrimTests`' /// own shape, for this producer. @Test("A settled repoint trims the previously chosen file") func trimsThePreviouslyChosenFile() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, _) = try makeStore(fixture) store.applyChosenBackground(data: photo, baseName: "sunset.jpg") await reload(store) store.applyChosenBackground(data: otherPhoto, baseName: "mountains.png") #expect(!fixture.exists(".backgrounds/sunset.jpg"), "trimmed by the repoint that superseded it") #expect(try fixture.data(".backgrounds/mountains.png") == otherPhoto) #expect(try document(fixture).backgroundImage == .valid(".backgrounds/mountains.png")) #expect(store.banners.losses.isEmpty, "the in-flow trim is silent") } /// The reverse direction: a chosen picture landing after a settled generated one trims the /// generator's file — the two producers meet through the same tidy either way. @Test("Choosing an image after a settled generated background trims it") func trimsASettledGeneratedFile() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, _) = try makeStore(fixture) store.applyGeneratedBackground(png: photo, colorHex: "#445566") await reload(store) store.applyChosenBackground(data: otherPhoto, baseName: "sunset.jpg") #expect(!fixture.exists(".backgrounds/\(FacetsGenerator.fileName)"), "trimmed by the settled choose") #expect(try document(fixture).backgroundImage == .valid(".backgrounds/sunset.jpg")) } /// **The undo posture, and what it leaves behind**: ⌘Z restores the field alone — the bytes this /// gesture wrote stay on disk, exactly as every other producer's undo already behaves — and the /// copy an undo walks away from is precisely what the open-time heal sweep exists to find. @Test("Undo restores the field; the file it leaves behind is exactly what the open-time heal sweeps") func undoLeavesAFileTheHealSweeps() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) #expect(store.applyChosenBackground(data: photo, baseName: "sunset.jpg")) #expect(history.undoActionName == "Restyle Board") history.undo() let undone = try document(fixture) #expect(undone.backgroundImage == .missing) // The undo restores the field, not the bytes — the file is still exactly there. #expect(fixture.exists(".backgrounds/sunset.jpg")) // Nothing in `background.image` names it any more, so the open-time sweep — not this test // reaching in by hand — is what removes it, composing without any new machinery. store.tidyBackgroundImages() #expect(!fixture.exists(".backgrounds/sunset.jpg"), "swept as an orphan once nothing names it") history.redo() #expect(try document(fixture).backgroundImage == .valid(".backgrounds/sunset.jpg")) } } // MARK: - ChosenBoardBackground.apply ▸ the guard and the data flow /// `ChosenBoardBackground.apply(from:to:)` — the picker's non-panel logic, exercised against a real /// temp file standing in for what `BoardBackgroundImagePanel.chooseImage()` would have returned (a /// modal `NSOpenPanel` cannot be driven headlessly). @MainActor @Suite("ChosenBoardBackground ▸ apply(from:to:)") struct ChosenBoardBackgroundApplyTests { @Test("A picked file lands in .backgrounds/ under its own name") func landsUnderItsOwnName() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, _) = try makeStore(fixture) let picked = try tempFile(named: "sunset.jpg", photo) defer { try? FileManager.default.removeItem(at: picked.deletingLastPathComponent()) } #expect(ChosenBoardBackground.apply(from: picked, to: store)) #expect(try fixture.data(".backgrounds/sunset.jpg") == photo) #expect(try document(fixture).backgroundImage == .valid(".backgrounds/sunset.jpg")) } /// **The cheap guard**: the panel's own `allowedContentTypes` filter is the first line, but a drag /// onto an open panel is not gated the same way a click on a listed row is — this is the second, /// re-checked at the point bytes would otherwise be copied into the board. @Test("A non-image file is refused before anything is written") func refusesANonImageFile() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, _) = try makeStore(fixture) let picked = try tempFile(named: "notes.txt", Data("plain text".utf8)) defer { try? FileManager.default.removeItem(at: picked.deletingLastPathComponent()) } #expect(!ChosenBoardBackground.apply(from: picked, to: store)) #expect(try fixture.entryNames("") == [Ident.lane1, "index.md"], "nothing landed, not even the folder") #expect(try document(fixture).backgroundImage == .missing) } @Test("An extension-less file is refused the same way") func refusesAnExtensionlessFile() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, _) = try makeStore(fixture) let picked = try tempFile(named: "README", Data("nothing to read here".utf8)) defer { try? FileManager.default.removeItem(at: picked.deletingLastPathComponent()) } #expect(!ChosenBoardBackground.apply(from: picked, to: store)) #expect(try document(fixture).backgroundImage == .missing) } @Test("A file that vanished before the read is refused, not crashed on") func refusesAVanishedFile() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, _) = try makeStore(fixture) let picked = try tempFile(named: "sunset.jpg", photo) try FileManager.default.removeItem(at: picked) defer { try? FileManager.default.removeItem(at: picked.deletingLastPathComponent()) } #expect(!ChosenBoardBackground.apply(from: picked, to: store)) } }