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:
@@ -2530,6 +2530,114 @@ public final class BoardStore: HealHost {
|
|||||||
document.setBackgroundImage(name)
|
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
|
// MARK: - Creation
|
||||||
|
|
||||||
/// Creates a lane at the board's right end — File ▸ New Lane ⇧⌘N (11-command-nexus.md).
|
/// Creates a lane at the board's right end — File ▸ New Lane ⇧⌘N (11-command-nexus.md).
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
/// **The one style editor** — "a background palette grid and a curated symbol grid — presented from
|
/// **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
|
/// three anchors" (03-board-ui.md § Styling ▸ Controls), plus the two small surfaces that stand
|
||||||
@@ -397,16 +398,27 @@ struct StyleEditorView: View {
|
|||||||
// MARK: - Background
|
// MARK: - Background
|
||||||
|
|
||||||
/// The palette wells and their leading None (03-board-ui.md § Styling ▸ Controls: "palette-only
|
/// 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
|
/// in-app … plus a leading **None** well that removes the `background` key"), an **Other…** row
|
||||||
/// 2026-08-09 — an **Other…** row onto the system Colors panel.
|
/// 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:
|
/// **"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
|
/// `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 —
|
/// 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
|
/// 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.
|
/// 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 {
|
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)
|
sectionHeader("Background", current: backgroundCurrent(state), layout: layout)
|
||||||
StyleWellGrid(
|
StyleWellGrid(
|
||||||
wells: backgroundWells(state),
|
wells: backgroundWells(state),
|
||||||
@@ -416,8 +428,13 @@ struct StyleEditorView: View {
|
|||||||
StyleCommand.apply(background: change, to: target, in: store, recents: recents, on: undo)
|
StyleCommand.apply(background: change, to: target, in: store, recents: recents, on: undo)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: layout.wellSpacing) {
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
|
if isBoardTarget {
|
||||||
|
Button("Choose Image…") { chooseBackgroundImage() }
|
||||||
|
.buttonStyle(.link)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
Button("Other…") { openBackgroundPanel(state) }
|
Button("Other…") { openBackgroundPanel(state) }
|
||||||
.buttonStyle(.link)
|
.buttonStyle(.link)
|
||||||
.font(.caption)
|
.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] {
|
private func backgroundWells(_ state: StyleFieldState) -> [StyleWell] {
|
||||||
var wells = [StyleWell(id: 0, face: .noValue, label: "None", change: .remove, isSelected: state == .unset)]
|
var wells = [StyleWell(id: 0, face: .noValue, label: "None", change: .remove, isSelected: state == .unset)]
|
||||||
for (index, color) in Palette.backgrounds.enumerated() {
|
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
|
// MARK: - Current value
|
||||||
|
|
||||||
/// The current-value chip beside a section title: the one place an off-palette value is stated
|
/// The current-value chip beside a section title: the one place an off-palette value is stated
|
||||||
|
|||||||
@@ -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