Choose Image… lets the Style editor point a board at an arbitrary picture
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
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user