From aa8c54fc7a5a56e33599b2584b8fbaff3dc2ae0c Mon Sep 17 00:00:00 2001 From: rzen Date: Sun, 9 Aug 2026 10:12:06 -0400 Subject: [PATCH] =?UTF-8?q?Choose=20Image=E2=80=A6=20lets=20the=20Style=20?= =?UTF-8?q?editor=20point=20a=20board=20at=20an=20arbitrary=20picture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A "Choose Image…" row joins "Other…" beside the background palette, live only when the Style… popover is aimed at the board (background.image is a board-root field — lanes and cards carry no such key to write). A standard, image-restricted NSOpenPanel hands the pick to BoardStore.applyChosenBackground, which copies the bytes into .backgrounds/ under the file's own name — Finder- laddered on collision, overwritten in place on a repeat pick — points background.image at the copy, and leaves background.color exactly as it was, the same posture Paste as Board Background already carries. The existing repoint tidy trims a superseded .backgrounds/ file automatically; nothing about it needed to change. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy --- Kanban/LiveStore/BoardStore.swift | 108 ++++++ Kanban/UI/StyleEditor.swift | 84 ++++- KanbanTests/ChooseBackgroundImageTests.swift | 332 +++++++++++++++++++ 3 files changed, 520 insertions(+), 4 deletions(-) create mode 100644 KanbanTests/ChooseBackgroundImageTests.swift diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index 192dab8..659d3f8 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -2530,6 +2530,114 @@ public final class BoardStore: HealHost { document.setBackgroundImage(name) } + // MARK: - Chosen background + + /// **Applies a user-picked picture as this board's backdrop** — the Style editor's **Choose + /// Image…** row (03-board-ui.md § Styling ▸ Controls; ruled 2026-08-09, reversing + /// BackgroundField.swift's original "there is no image picker and none is planned"). + /// + /// `applyPastedBackground` line for line — the same two-writes-one-bracket ordering (picture + /// first, so a failure never leaves the board naming a file that is not there), the same + /// `.setBoardBackground` operation, the same swallowed failure, the same restyle step, the same + /// **colour-left-untouched** posture (an arbitrary picture off Finder carries no ground colour any + /// more than one off the pasteboard does — `setBackgroundImage`'s per-subkey contract) — with one + /// difference from every other producer: + /// + /// ### The name is the picked file's own + /// + /// The generator writes a synthetic `facets.png`; a paste has no name at all and mints "Pasted + /// Background"; **a Choose Image… pick already has a name**, the one the user found it under in + /// Finder, and throwing it away in favour of another synthetic stem would make `.backgrounds/` + /// less legible about what it holds, not more. So `baseName` — the caller's + /// `URL.lastPathComponent`, never opened by this method — is what `boardImageName(base: + /// replacing:inRoot:)` ladders and echoes against, exactly as `facets.png` and `Pasted + /// Background.png` already are: a first pick of `sunset.jpg` lands at `.backgrounds/sunset.jpg`, a + /// second pick of a same-named file steps aside to `sunset 2.jpg` (`BoardWriter.freshName`, + /// re-picking the file this board already shows overwrites it in place, exactly as re-pasting + /// does), and every other rule `boardImageName` already enforces — the foreign-file step-aside, the + /// legacy-reference-is-not-ours reading, the fast-repeat echo — composes for free. + /// + /// ### The bytes travel verbatim + /// + /// Unlike a paste, which re-encodes an interchange bitmap to PNG because TIFF is not a format + /// anyone keeps a file in, a chosen file is *already* a file on the user's disk — decoding and + /// re-encoding it here would cost fidelity (and, for an animated GIF, the animation) for a picture + /// that needed neither. `BoardBackdrop.decode`'s 3072px ceiling governs what the app *renders* on + /// window resize, off ImageIO's thumbnail path — it is not a write-time re-encode, and this method + /// does not decode the picture at all: `data` is copied through `BoardWriter.writeBoardImage` + /// exactly as the caller read it. + /// + /// ### The prior `.backgrounds/` file is trimmed on the same terms as every other producer + /// + /// Once both writes land, `snapshot`'s prior image is trimmed when it names a file of ours other + /// than the one this write just landed on (`tidyReplacedBackgroundImage` — `applyGeneratedBackground`'s + /// own note carries the full reasoning, including the echo-window staleness gate). Composed, not + /// reimplemented: this method calls the very same static helper every other producer calls. + /// + /// ### The undo restores the field, not the bytes + /// + /// `applyGeneratedBackground`'s own note, unchanged and for its reason: an undo across a re-pick of + /// the *same* file overwrites pixels nothing kept a copy of. A copy this gesture wrote and an undo + /// then walks away from is exactly what the open-time sweep exists to find (`tidyBackgroundImages`) + /// — this method adds no heal of its own because none is needed. + /// + /// - Parameter data: the picked file's bytes, read by the caller and handed over unchanged. + /// - Parameter baseName: the picked file's own name (`URL.lastPathComponent`) — never a path; + /// `BoardWriter.writeBoardImage`'s own guard refuses anything else. + /// - Returns: whether bytes reached disk, which is the same question as "is an echo reload coming" + /// (`applyPastedBackground`'s own rule). + @discardableResult + public func applyChosenBackground(data: Data, baseName: String) -> Bool { + let root = rootURL + let priorImage = snapshot.backgroundImage + let target = boardImageName(base: baseName, replacing: priorImage.value, inRoot: root) + let backgroundsFolder = root.appendingPathComponent( + BoardBackdrop.backgroundsFolderName, isDirectory: true + ) + + let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in + try BoardWriter.writeBoardImage( + data: data, named: target.bareName, inRoot: backgroundsFolder, operation: .setBoardBackground + ) + try BoardWriter.updateIndex( + inItemFolder: root, kind: .board, operation: .setBoardBackground + ) { document in + document.setBackgroundImage(target.reference) + } + // The replace-in-place half of the orphan tidy (ruled 2026-08-09) — see + // `applyGeneratedBackground`'s own note. + Self.tidyReplacedBackgroundImage( + priorImage: priorImage.value, newReference: target.reference, inFolder: backgroundsFolder + ) + } + guard landed != nil else { return false } + generatedBackgroundEcho = ( + name: target.reference, bareName: target.bareName, base: baseName, reloads: landedReloads + ) + + // restyle → prior image (13-native-undo.md ▸ Rules). The board's own stack, never a window's: + // there is no card here to have a session. Only the image subkey is declared, exactly as the + // paste's own step declares it — this gesture never writes a colour. + registerStep( + HistoryPhrase.name(.restyle, kind: .board), + undoExpects: [.present(root, .backgroundImage(target.reference))], + redoExpects: [.present(root, .backgroundImage(priorImage.value))] + ) { _ in + try BoardWriter.updateIndex( + inItemFolder: root, kind: .board, operation: .setBoardBackground + ) { document in + document.setBackgroundImage(priorImage.value) + } + } redo: { _ in + try BoardWriter.updateIndex( + inItemFolder: root, kind: .board, operation: .setBoardBackground + ) { document in + document.setBackgroundImage(target.reference) + } + } + return true + } + // MARK: - Creation /// Creates a lane at the board's right end — File ▸ New Lane ⇧⌘N (11-command-nexus.md). diff --git a/Kanban/UI/StyleEditor.swift b/Kanban/UI/StyleEditor.swift index ccf586f..3de8e97 100644 --- a/Kanban/UI/StyleEditor.swift +++ b/Kanban/UI/StyleEditor.swift @@ -1,5 +1,6 @@ import AppKit import SwiftUI +import UniformTypeIdentifiers /// **The one style editor** — "a background palette grid and a curated symbol grid — presented from /// three anchors" (03-board-ui.md § Styling ▸ Controls), plus the two small surfaces that stand @@ -397,16 +398,27 @@ struct StyleEditorView: View { // MARK: - Background /// The palette wells and their leading None (03-board-ui.md § Styling ▸ Controls: "palette-only - /// in-app … plus a leading **None** well that removes the `background` key"), and — since - /// 2026-08-09 — an **Other…** row onto the system Colors panel. + /// in-app … plus a leading **None** well that removes the `background` key"), an **Other…** row + /// onto the system Colors panel (2026-08-09), and — on the board target alone — a **Choose + /// Image…** row onto a standard, image-restricted `NSOpenPanel` (ruled 2026-08-09, reversing + /// BackgroundField.swift's original "there is no image picker and none is planned"). /// /// **"Palette-only in-app" is the sentence that changed**, and it changed before this card: /// `ColorComboView` shipped an **Other…** row of its own, so the card window's sidebar could /// already write an arbitrary hex while the Style… popover — the *primary* styling surface — /// could not. This closes that gap rather than opening a new one. Seventeen wells at seven /// columns is three rows where the old twelve made two, which is the other half of the same change. + /// + /// **Choose Image… is absent, not disabled, off the board.** `background.image` is a board-root + /// field alone — a lane or a card has a colour well and nothing else to point an image subkey at + /// (`BoardModel.backgroundImage`; `Lane`/`Card` carry no such field) — so an item-targeted editor + /// has no key this row could write, the same reasoning `showsSymbols`/`showsBackground` already + /// apply to their own anchors. `store.styleLevel(of: target) == .board` is the same predicate the + /// symbol section already computes for its own leading well's default, asked here for the row + /// instead. private func backgroundSection(_ state: StyleFieldState, layout: StyleEditorLayout) -> some View { - VStack(alignment: .leading, spacing: layout.wellSpacing) { + let isBoardTarget = store.styleLevel(of: target) == .board + return VStack(alignment: .leading, spacing: layout.wellSpacing) { sectionHeader("Background", current: backgroundCurrent(state), layout: layout) StyleWellGrid( wells: backgroundWells(state), @@ -416,8 +428,13 @@ struct StyleEditorView: View { StyleCommand.apply(background: change, to: target, in: store, recents: recents, on: undo) } ) - HStack(spacing: 0) { + HStack(spacing: layout.wellSpacing) { Spacer(minLength: 0) + if isBoardTarget { + Button("Choose Image…") { chooseBackgroundImage() } + .buttonStyle(.link) + .font(.caption) + } Button("Other…") { openBackgroundPanel(state) } .buttonStyle(.link) .font(.caption) @@ -445,6 +462,14 @@ struct StyleEditorView: View { } } + /// **Choose Image…** — opens the panel and, on a pick, hands the URL to `ChosenBoardBackground`, + /// the whole of this row's non-panel logic. Split out so a modal `NSOpenPanel.runModal()` is the + /// only thing this method does that a test cannot drive. + private func chooseBackgroundImage() { + guard let url = BoardBackgroundImagePanel.chooseImage() else { return } + ChosenBoardBackground.apply(from: url, to: store) + } + private func backgroundWells(_ state: StyleFieldState) -> [StyleWell] { var wells = [StyleWell(id: 0, face: .noValue, label: "None", change: .remove, isSelected: state == .unset)] for (index, color) in Palette.backgrounds.enumerated() { @@ -565,6 +590,57 @@ struct StyleEditorView: View { } } +// MARK: - Choose Image… + +/// The panel behind **Choose Image…** — a single-selection, image-restricted `NSOpenPanel` +/// (03-board-ui.md § Styling ▸ Controls, ruled 2026-08-09) — the same open-panel shape +/// `AttachmentPanel` (CardAttachments.swift) uses for attachments, narrowed to what `UTType.image` +/// claims. +/// +/// **Restricted, unlike the attachments panel's "every file type"**: a background is a picture and +/// nothing else, so `allowedContentTypes` states that up front — a filesystem provider that honours +/// it dims or refuses everything else before the panel ever returns. That is convenience, not the +/// boundary: `ChosenBoardBackground.apply(from:to:)` re-checks the pick regardless, because a drag +/// onto an open panel is not gated the same way a click on a listed row is. +@MainActor +enum BoardBackgroundImagePanel { + static func chooseImage() -> URL? { + let panel = NSOpenPanel() + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = false + panel.resolvesAliases = true + panel.allowedContentTypes = [.image] + panel.prompt = "Choose" + panel.message = "Choose an image for the board background." + guard panel.runModal() == .OK else { return nil } + return panel.urls.first + } +} + +/// **Choose Image…'s whole non-panel logic** — split out from `StyleEditorView.chooseBackgroundImage` +/// so the guard and the data flow are testable without driving a modal `NSOpenPanel`, the way +/// `PastedImage`'s pure classification is tested apart from `NSPasteboard`. +/// +/// Three steps, none of them decoding the picture: the cheap guard first (`PastedImage.isImageName` +/// — the panel's own filter, restated for a bypass such as a drag onto the panel), the sandboxed read +/// second (security-scoped exactly as `CardAttachments.add()` opens one), and +/// `BoardStore.applyChosenBackground` last, which owns the naming, the one write bracket, the orphan +/// tidy's composition and the undo step. +enum ChosenBoardBackground { + @discardableResult + @MainActor + static func apply(from url: URL, to store: BoardStore) -> Bool { + guard PastedImage.isImageName(url.lastPathComponent) else { return false } + + let scoped = url.startAccessingSecurityScopedResource() + defer { if scoped { url.stopAccessingSecurityScopedResource() } } + guard let data = try? Data(contentsOf: url) else { return false } + + return store.applyChosenBackground(data: data, baseName: url.lastPathComponent) + } +} + // MARK: - Current value /// The current-value chip beside a section title: the one place an off-palette value is stated diff --git a/KanbanTests/ChooseBackgroundImageTests.swift b/KanbanTests/ChooseBackgroundImageTests.swift new file mode 100644 index 0000000..5441215 --- /dev/null +++ b/KanbanTests/ChooseBackgroundImageTests.swift @@ -0,0 +1,332 @@ +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)) + } +}