The Background tab becomes Theme — solid colors or patterns, presets only, chevron-paged
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -1,68 +0,0 @@
|
||||
import SwiftUI
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// **`BoardBackgroundFilters`** — the Background tab's generated picker: the default-tone rule and
|
||||
/// the pure filters → `FacetsRecipe` assembly, pinned the way the popover's other pure seams are
|
||||
/// (`BoardInfoMetrics`, `BoardDiskFootprint` in `BoardInfoTabTests`). Everything else about the
|
||||
/// carousel — the strip's layout, the placeholder chip, the apply gesture — is SwiftUI and
|
||||
/// deliberately untested; `FacetsGeneratorTests` and `GeneratedBackgroundTests` already cover the
|
||||
/// generator and the write path this feeds.
|
||||
@Suite("Board popover ▸ Background tab filters")
|
||||
struct BoardBackgroundFiltersTests {
|
||||
|
||||
@Test("Dark system appearance opens on Tone Dark, light opens on Tone Light")
|
||||
func defaultToneFollowsTheSystem() {
|
||||
#expect(BoardBackgroundFilters.defaultTone(colorScheme: .dark) == .dark)
|
||||
#expect(BoardBackgroundFilters.defaultTone(colorScheme: .light) == .light)
|
||||
}
|
||||
|
||||
@Test("The opening state is mono, medium, mid — only tone varies with the system")
|
||||
func initialStateIsTheReviewedDefaults() {
|
||||
let light = BoardBackgroundFilters.initial(colorScheme: .light)
|
||||
#expect(light.tone == .light)
|
||||
#expect(light.colors == .mono)
|
||||
#expect(light.mesh == .medium)
|
||||
#expect(light.saturation == .mid)
|
||||
|
||||
let dark = BoardBackgroundFilters.initial(colorScheme: .dark)
|
||||
#expect(dark.tone == .dark)
|
||||
#expect(dark.colors == .mono)
|
||||
#expect(dark.mesh == .medium)
|
||||
#expect(dark.saturation == .mid)
|
||||
}
|
||||
|
||||
@Test("A hue and a seed assemble the exact recipe the filters describe")
|
||||
func recipeAssemblesEveryAxis() {
|
||||
let filters = BoardBackgroundFilters(tone: .dark, colors: .trio, mesh: .fine, saturation: .rich)
|
||||
let recipe = filters.recipe(hue: .iris, seed: 0x5EED)
|
||||
|
||||
#expect(recipe.hue == .iris)
|
||||
#expect(recipe.strategy == .trio)
|
||||
#expect(recipe.density == .fine)
|
||||
#expect(recipe.tone == .dark)
|
||||
#expect(recipe.saturation == .rich)
|
||||
#expect(recipe.seed == 0x5EED)
|
||||
}
|
||||
|
||||
@Test("Two hues under the same filters and seed differ only in hue")
|
||||
func onlyHueChangesAcrossTheWheel() {
|
||||
let filters = BoardBackgroundFilters.initial(colorScheme: .light)
|
||||
let sky = filters.recipe(hue: .sky, seed: 42)
|
||||
let rose = filters.recipe(hue: .rose, seed: 42)
|
||||
|
||||
#expect(sky.hue == .sky)
|
||||
#expect(rose.hue == .rose)
|
||||
#expect(sky.strategy == rose.strategy)
|
||||
#expect(sky.density == rose.density)
|
||||
#expect(sky.tone == rose.tone)
|
||||
#expect(sky.saturation == rose.saturation)
|
||||
#expect(sky.seed == rose.seed)
|
||||
}
|
||||
|
||||
@Test("The same filters and seed recipe identically — the preview/apply agreement the carousel depends on")
|
||||
func sameInputsRecipeIdentically() {
|
||||
let filters = BoardBackgroundFilters(tone: .light, colors: .duo, mesh: .coarse, saturation: .soft)
|
||||
#expect(filters.recipe(hue: .forest, seed: 7) == filters.recipe(hue: .forest, seed: 7))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import SwiftUI
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// **`BoardThemeFilters`** and **`BoardThemeMode`** — the Theme tab's shared filter state and its
|
||||
/// Solid color / Pattern picker: the default-tone rule, the pure filters → `FacetsRecipe` assembly,
|
||||
/// and solid colour's seed-independence, pinned the way the popover's other pure seams are
|
||||
/// (`BoardInfoMetrics`, `BoardDiskFootprint` in `BoardInfoTabTests`). Everything else about the
|
||||
/// carousel — the strip's layout, the placeholder chip, the chevron paging, the apply gesture — is
|
||||
/// SwiftUI and deliberately untested; `FacetsGeneratorTests` and `GeneratedBackgroundTests` already
|
||||
/// cover the generator and the write paths this feeds.
|
||||
@Suite("Board popover ▸ Theme tab filters")
|
||||
struct BoardThemeFiltersTests {
|
||||
|
||||
@Test("Dark system appearance opens on Tone Dark, light opens on Tone Light")
|
||||
func defaultToneFollowsTheSystem() {
|
||||
#expect(BoardThemeFilters.defaultTone(colorScheme: .dark) == .dark)
|
||||
#expect(BoardThemeFilters.defaultTone(colorScheme: .light) == .light)
|
||||
}
|
||||
|
||||
@Test("The opening state is mono, medium, mid — only tone varies with the system")
|
||||
func initialStateIsTheReviewedDefaults() {
|
||||
let light = BoardThemeFilters.initial(colorScheme: .light)
|
||||
#expect(light.tone == .light)
|
||||
#expect(light.colors == .mono)
|
||||
#expect(light.mesh == .medium)
|
||||
#expect(light.saturation == .mid)
|
||||
|
||||
let dark = BoardThemeFilters.initial(colorScheme: .dark)
|
||||
#expect(dark.tone == .dark)
|
||||
#expect(dark.colors == .mono)
|
||||
#expect(dark.mesh == .medium)
|
||||
#expect(dark.saturation == .mid)
|
||||
}
|
||||
|
||||
@Test("A hue and a seed assemble the exact recipe the filters describe")
|
||||
func recipeAssemblesEveryAxis() {
|
||||
let filters = BoardThemeFilters(tone: .dark, colors: .trio, mesh: .fine, saturation: .rich)
|
||||
let recipe = filters.recipe(hue: .iris, seed: 0x5EED)
|
||||
|
||||
#expect(recipe.hue == .iris)
|
||||
#expect(recipe.strategy == .trio)
|
||||
#expect(recipe.density == .fine)
|
||||
#expect(recipe.tone == .dark)
|
||||
#expect(recipe.saturation == .rich)
|
||||
#expect(recipe.seed == 0x5EED)
|
||||
}
|
||||
|
||||
@Test("Two hues under the same filters and seed differ only in hue")
|
||||
func onlyHueChangesAcrossTheWheel() {
|
||||
let filters = BoardThemeFilters.initial(colorScheme: .light)
|
||||
let sky = filters.recipe(hue: .sky, seed: 42)
|
||||
let rose = filters.recipe(hue: .rose, seed: 42)
|
||||
|
||||
#expect(sky.hue == .sky)
|
||||
#expect(rose.hue == .rose)
|
||||
#expect(sky.strategy == rose.strategy)
|
||||
#expect(sky.density == rose.density)
|
||||
#expect(sky.tone == rose.tone)
|
||||
#expect(sky.saturation == rose.saturation)
|
||||
#expect(sky.seed == rose.seed)
|
||||
}
|
||||
|
||||
@Test("The same filters and seed recipe identically — the preview/apply agreement the carousel depends on")
|
||||
func sameInputsRecipeIdentically() {
|
||||
let filters = BoardThemeFilters(tone: .light, colors: .duo, mesh: .coarse, saturation: .soft)
|
||||
#expect(filters.recipe(hue: .forest, seed: 7) == filters.recipe(hue: .forest, seed: 7))
|
||||
}
|
||||
|
||||
// MARK: - Mode
|
||||
|
||||
@Test("Solid color and Pattern are the picker's two cases, labeled as the segmented control shows them")
|
||||
func modeCasesAreTheSegmentedLabels() {
|
||||
#expect(BoardThemeMode.allCases == [.solid, .pattern])
|
||||
#expect(BoardThemeMode.solid.rawValue == "Solid color")
|
||||
#expect(BoardThemeMode.pattern.rawValue == "Pattern")
|
||||
#expect(BoardThemeMode.solid.id == .solid)
|
||||
#expect(BoardThemeMode.pattern.id == .pattern)
|
||||
}
|
||||
|
||||
// MARK: - Solid colour derivation
|
||||
|
||||
/// The Solid color swatch's fill (`BoardThemeTabView.solidColor(_:)`): the filters' own
|
||||
/// tone/saturation level at a hue, un-jittered — the same thing a Pattern swatch's ground colour
|
||||
/// is (`FacetsRecipe.primaryColor`), read directly rather than through a mesh.
|
||||
@Test("The same filters and hue always yield the same solid colour")
|
||||
func solidColorIsDeterministic() {
|
||||
let filters = BoardThemeFilters(tone: .dark, colors: .duo, mesh: .fine, saturation: .rich)
|
||||
let first = filters.recipe(hue: .teal, seed: 0).primaryColor
|
||||
let second = filters.recipe(hue: .teal, seed: 0).primaryColor
|
||||
|
||||
#expect(first == second)
|
||||
}
|
||||
|
||||
/// **Seed-independence**: `primaryColor` never reads `seed`, so a Solid swatch clicked at seed
|
||||
/// `0` (`BoardThemeTabView.applySolid`) is exactly what a Pattern swatch's ground colour would be
|
||||
/// at any seed — the fact that lets Solid color skip minting a seed at all.
|
||||
@Test("primaryColor is the same colour whatever the seed")
|
||||
func primaryColorIsSeedIndependent() {
|
||||
let filters = BoardThemeFilters(tone: .light, colors: .trio, mesh: .coarse, saturation: .soft)
|
||||
let seeds: [UInt64] = [0, 1, 42, .max]
|
||||
|
||||
let colors = seeds.map { filters.recipe(hue: .amber, seed: $0).primaryColor }
|
||||
#expect(Set(colors).count == 1)
|
||||
}
|
||||
|
||||
/// Different hues under the same filters still land on different solid colours — the carousel
|
||||
/// would otherwise show eight identical swatches.
|
||||
@Test("Different hues yield different solid colours under the same filters")
|
||||
func differentHuesYieldDifferentSolidColors() {
|
||||
let filters = BoardThemeFilters.initial(colorScheme: .light)
|
||||
let sky = filters.recipe(hue: .sky, seed: 0).primaryColor
|
||||
let rose = filters.recipe(hue: .rose, seed: 0).primaryColor
|
||||
|
||||
#expect(sky != rose)
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,9 @@ import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// The write half of generated board backgrounds: `BoardWriter.writeBoardImage` and
|
||||
/// `BoardStore.applyGeneratedBackground` (03-board-ui.md § Styling ▸ Capabilities;
|
||||
/// DESIGN/explorations/board-backgrounds.md).
|
||||
/// The write half of the Theme tab's two picture-adjacent gestures: `BoardWriter.writeBoardImage` and
|
||||
/// `BoardStore.applyGeneratedBackground` for Pattern, `BoardStore.applySolidBackground` for Solid
|
||||
/// color (03-board-ui.md § Board popover ▸ Theme tab; DESIGN/explorations/board-backgrounds.md).
|
||||
///
|
||||
/// Like every other write suite here these drive a real writer or a real store over a real temp
|
||||
/// board and read the **bytes on disk** back rather than the app's own read path: the claims are
|
||||
@@ -384,3 +384,163 @@ struct GeneratedBackgroundUndoTests {
|
||||
#expect(store.banners.signposts.isEmpty == false, "the skip says so on the strip")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Solid background
|
||||
|
||||
/// The Theme tab's Solid color half: `BoardStore.applySolidBackground` (03-board-ui.md § Board
|
||||
/// popover ▸ Theme tab; `BoardThemeTabView.applySolid`). Modeled line-for-line on
|
||||
/// `applyGeneratedBackground` minus the file write, so these suites mirror the write and undo suites
|
||||
/// above with the one difference the method itself has: no picture, and `facets.png` — when there is
|
||||
/// one — is deliberately left on disk rather than deleted.
|
||||
@MainActor
|
||||
@Suite("BoardStore ▸ applySolidBackground")
|
||||
struct SolidBackgroundWriteTests {
|
||||
|
||||
@Test("The colour lands and there is no image subkey to point anywhere")
|
||||
func writesTheColourAloneOnAPlainBoard() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (store, _) = try makeStore(fixture)
|
||||
|
||||
#expect(store.applySolidBackground(colorHex: "#E0E5EB"))
|
||||
|
||||
let after = try document(fixture)
|
||||
#expect(after.background == .valid("#E0E5EB"))
|
||||
#expect(after.backgroundImage == .missing)
|
||||
#expect(try fixture.indexText("").contains("background: {color: \"#E0E5EB\"}"))
|
||||
#expect(store.banners.oneShots.isEmpty)
|
||||
}
|
||||
|
||||
/// The write's whole point on a board that already carries a generated picture: the colour
|
||||
/// changes, the `image` subkey goes, and every other subkey the app does not own rides through
|
||||
/// untouched.
|
||||
@Test("An existing image subkey is removed and unrelated subkeys survive")
|
||||
func removesTheImageSubkeyAndKeepsForeignOnes() throws {
|
||||
let fixture = try makeBoard(background: "{blend: multiply, color: fern, image: facets.png, opacity: 0.5}")
|
||||
defer { fixture.tearDown() }
|
||||
let (store, _) = try makeStore(fixture)
|
||||
|
||||
store.applySolidBackground(colorHex: "#513D1A")
|
||||
|
||||
let text = try fixture.indexText("")
|
||||
#expect(text.contains("blend: \"multiply\""))
|
||||
#expect(text.contains("color: \"#513D1A\""))
|
||||
#expect(text.contains("opacity: 0.5"))
|
||||
#expect(!text.contains("image:"))
|
||||
let after = try document(fixture)
|
||||
#expect(after.background == .valid("#513D1A"))
|
||||
#expect(after.backgroundImage == .missing)
|
||||
}
|
||||
|
||||
/// **The deliberate half of the contract**: choosing a solid colour over a generated background
|
||||
/// does not delete the picture on disk — only the field that pointed at it. Undo has to have
|
||||
/// something to point back to (`SolidBackgroundUndoTests.restoresAPriorGeneratedImage`), and even
|
||||
/// without undo the file is the user's now, not litter the app cleans up on its own.
|
||||
@Test("facets.png stays on disk when the board had one")
|
||||
func leavesTheGeneratedFileOnDisk() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (store, _) = try makeStore(fixture)
|
||||
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
|
||||
|
||||
store.applySolidBackground(colorHex: "#513D1A")
|
||||
|
||||
#expect(try fixture.data("facets.png") == png, "the bytes are untouched")
|
||||
#expect(try document(fixture).backgroundImage == .missing, "only the field is gone")
|
||||
}
|
||||
|
||||
@Test("One bracket for the one file it touches")
|
||||
func oneBracketForTheWrite() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (store, _) = try makeStore(fixture)
|
||||
var begins = 0
|
||||
var ends = 0
|
||||
store.watcherBrackets = (begin: { begins += 1 }, end: { ends += 1 })
|
||||
|
||||
store.applySolidBackground(colorHex: "#E0E5EB")
|
||||
|
||||
#expect(begins == 1)
|
||||
#expect(ends == 1)
|
||||
}
|
||||
|
||||
/// A locked board writes nothing at all.
|
||||
@Test("A read-only board refuses before anything is written")
|
||||
func refusesUnderTheLock() throws {
|
||||
let fixture = try makeBoard(background: "{color: fern}")
|
||||
defer { fixture.tearDown() }
|
||||
let (store, _) = try makeStore(fixture)
|
||||
store.enterVanishedRootLock()
|
||||
|
||||
#expect(store.applySolidBackground(colorHex: "#E0E5EB") == false)
|
||||
#expect(try document(fixture).background == .valid("fern"))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Suite("Undo ▸ solid background")
|
||||
struct SolidBackgroundUndoTests {
|
||||
|
||||
/// The first solid colour's undo is a clean return: the board had no background, and afterwards
|
||||
/// it has none again.
|
||||
@Test("Undo removes the colour and redo puts it back")
|
||||
func roundTrip() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (store, history) = try makeStore(fixture)
|
||||
|
||||
store.applySolidBackground(colorHex: "#E0E5EB")
|
||||
#expect(history.undoActionName == "Restyle Board")
|
||||
|
||||
history.undo()
|
||||
let undone = try document(fixture)
|
||||
#expect(undone.background == .missing)
|
||||
#expect(undone.backgroundImage == .missing)
|
||||
#expect(!undone.contains(FrontmatterKeys.background))
|
||||
|
||||
history.redo()
|
||||
let redone = try document(fixture)
|
||||
#expect(redone.background == .valid("#E0E5EB"))
|
||||
#expect(redone.backgroundImage == .missing)
|
||||
}
|
||||
|
||||
/// **Both prior fields come back** — the colour a board had before, and the generated image the
|
||||
/// solid choice pointed away from — which is what makes the file surviving on disk
|
||||
/// (`SolidBackgroundWriteTests.leavesTheGeneratedFileOnDisk`) worth doing: an undo with nothing to
|
||||
/// point back at would make the surviving bytes an orphan from the moment they landed.
|
||||
@Test("A prior colour and generated image are both restored")
|
||||
func restoresAPriorGeneratedImage() throws {
|
||||
let fixture = try makeBoard(background: "{color: fern, image: facets.png}")
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.file("facets.png", png)
|
||||
let (store, history) = try makeStore(fixture)
|
||||
|
||||
store.applySolidBackground(colorHex: "#E0E5EB")
|
||||
history.undo()
|
||||
|
||||
let undone = try document(fixture)
|
||||
#expect(undone.background == .valid("fern"))
|
||||
#expect(undone.backgroundImage == .valid("facets.png"))
|
||||
#expect(try fixture.data("facets.png") == png, "undo restored the field, not new bytes")
|
||||
}
|
||||
|
||||
/// A foreign edit to the field the step wrote stales it — the same field-level predicate
|
||||
/// `GeneratedBackgroundUndoTests.foreignEditStalesTheStep` pins for the generated path, applied to
|
||||
/// the colour subkey this gesture owns.
|
||||
@Test("A foreign edit to the colour subkey skips the undo")
|
||||
func foreignEditStalesTheStep() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (store, history) = try makeStore(fixture)
|
||||
|
||||
store.applySolidBackground(colorHex: "#E0E5EB")
|
||||
|
||||
var foreign = try document(fixture)
|
||||
foreign.setStyleValue("obsidian", for: FrontmatterKeys.background)
|
||||
try fixture.item("", foreign.serialized())
|
||||
|
||||
history.undo()
|
||||
#expect(try document(fixture).background == .valid("obsidian"))
|
||||
#expect(store.banners.signposts.isEmpty == false, "the skip says so on the strip")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user