background is {color:, image:} and only a mapping at every level; the board's image paints the full window under a transparent title bar, with a thin-material frost strip keeping the chrome legible and the standard accommodations intact.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
292 lines
14 KiB
Swift
292 lines
14 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import Kanban
|
|
|
|
/// The board background's **mapping form** — the write side that has to preserve what the app has no
|
|
/// control over, and the path rule that decides where an `image:` may point (01-storage-format.md §
|
|
/// Frontmatter; 03-board-ui.md § Styling ▸ Capabilities).
|
|
///
|
|
/// The *readings* live with their siblings in `FrontmatterTests`; what is here is everything that is
|
|
/// new machinery rather than a new value: `FrontmatterDocument.setStyleValue` (BackgroundField.swift)
|
|
/// and `BoardBackdrop.imageURL(named:inBoardRoot:)`.
|
|
|
|
// MARK: - Fixtures
|
|
|
|
private func document(_ frontmatter: String) throws -> FrontmatterDocument {
|
|
try FrontmatterDocument.parse("---\n\(frontmatter)\n---\nbody\n")
|
|
}
|
|
|
|
/// The `background:` line as it now reads on disk, or `nil` when the key is gone.
|
|
private func backgroundLine(_ document: FrontmatterDocument) -> String? {
|
|
document.serialized()
|
|
.split(separator: "\n", omittingEmptySubsequences: false)
|
|
.first { $0.hasPrefix("background:") }
|
|
.map(String.init)
|
|
}
|
|
|
|
// MARK: - Writing into the mapping
|
|
|
|
@Suite("Board background ▸ the write preserves the mapping")
|
|
struct BackgroundWriteTests {
|
|
|
|
/// The whole point of the field: the app owns the colour well and nothing else, so a colour
|
|
/// change on a board carrying an image has to come back still carrying it.
|
|
@Test("Setting a colour replaces the subkey and keeps the image")
|
|
func setKeepsTheImage() throws {
|
|
var document = try document("background: {color: fern, image: sunset.jpg}")
|
|
document.setStyleValue("dark-teal", for: FrontmatterKeys.background)
|
|
|
|
#expect(document.background == .valid("dark-teal"))
|
|
#expect(document.backgroundImage == .valid("sunset.jpg"))
|
|
#expect(backgroundLine(document) == "background: {color: \"dark-teal\", image: \"sunset.jpg\"}")
|
|
}
|
|
|
|
/// A colour written into a mapping that had none joins it rather than replacing it — the
|
|
/// image-only board is exactly the board the style editor is most likely to be opened on.
|
|
@Test("Setting a colour on an image-only background adds the subkey")
|
|
func setAddsTheSubkeyToAnImageOnlyMapping() throws {
|
|
var document = try document("background: {image: sunset.jpg}")
|
|
document.setStyleValue("chalk", for: FrontmatterKeys.background)
|
|
|
|
#expect(document.background == .valid("chalk"))
|
|
#expect(document.backgroundImage == .valid("sunset.jpg"))
|
|
}
|
|
|
|
/// Unknown subkeys ride along like unknown keys do — nothing in the app knows what `blend:`
|
|
/// means and nothing in the app is entitled to drop it.
|
|
@Test("Unknown subkeys survive a colour change, in their own positions")
|
|
func setPreservesUnknownSubkeys() throws {
|
|
var document = try document("background: {blend: multiply, color: fern, opacity: 0.5, tags: [a, b]}")
|
|
document.setStyleValue("obsidian", for: FrontmatterKeys.background)
|
|
|
|
#expect(backgroundLine(document)
|
|
== "background: {blend: \"multiply\", color: \"obsidian\", opacity: 0.5, tags: [\"a\", \"b\"]}")
|
|
}
|
|
|
|
/// The None well removes the *colour*, not the background: an image the user never chose in the
|
|
/// app must not disappear because they cleared a colour (03-board-ui.md § Styling ▸ Controls).
|
|
@Test("The None well drops the colour subkey alone")
|
|
func removeDropsOnlyTheColour() throws {
|
|
var document = try document("background: {color: fern, image: sunset.jpg}")
|
|
document.setStyleValue(nil, for: FrontmatterKeys.background)
|
|
|
|
#expect(document.background == .missing)
|
|
#expect(document.backgroundImage == .valid("sunset.jpg"))
|
|
#expect(backgroundLine(document) == "background: {image: \"sunset.jpg\"}")
|
|
}
|
|
|
|
/// A mapping the removal empties takes the key with it — `background: {}` is a key that says
|
|
/// nothing, and the removal's contract is that the field is gone.
|
|
@Test("A mapping emptied by the removal takes the key with it")
|
|
func removeDropsAnEmptiedKey() throws {
|
|
var document = try document("background: {color: fern}")
|
|
document.setStyleValue(nil, for: FrontmatterKeys.background)
|
|
|
|
#expect(document.background == .missing)
|
|
#expect(!document.contains(FrontmatterKeys.background))
|
|
#expect(backgroundLine(document) == nil)
|
|
}
|
|
|
|
/// A block mapping is the same value as a flow one and is merged the same way. The spelling is
|
|
/// what does not survive — the span editor rewrites a key's value as one line — which is the
|
|
/// documented limit of the verbatim promise on the one key being written.
|
|
@Test("A block mapping merges, collapsing to flow form")
|
|
func blockMappingCollapsesToFlow() throws {
|
|
var document = try FrontmatterDocument.parse(
|
|
"---\nschema: 1\nbackground:\n color: fern\n image: sunset.jpg\nicon: tray\n---\nbody\n"
|
|
)
|
|
document.setStyleValue("chalk", for: FrontmatterKeys.background)
|
|
|
|
#expect(document.background == .valid("chalk"))
|
|
#expect(document.backgroundImage == .valid("sunset.jpg"))
|
|
#expect(document.serialized() == """
|
|
---
|
|
schema: 1
|
|
background: {color: "chalk", image: "sunset.jpg"}
|
|
icon: tray
|
|
---
|
|
body
|
|
|
|
""")
|
|
}
|
|
|
|
/// **The app always writes the mapping** (01-storage-format.md § Frontmatter): a key that was
|
|
/// absent gets one, and a key holding a shape the schema cannot read — the retired scalar, a
|
|
/// sequence — is *replaced* by one, which is the malformed-value-cleared posture ("choosing any
|
|
/// well replaces it").
|
|
@Test("A colour written onto an absent or unreadable key lands as a mapping")
|
|
func alwaysWritesTheMapping() throws {
|
|
var absent = try document("schema: 1")
|
|
absent.setStyleValue("chalk", for: FrontmatterKeys.background)
|
|
#expect(backgroundLine(absent) == "background: {color: \"chalk\"}")
|
|
#expect(absent.background == .valid("chalk"))
|
|
|
|
var scalar = try document("background: fern")
|
|
scalar.setStyleValue("chalk", for: FrontmatterKeys.background)
|
|
#expect(backgroundLine(scalar) == "background: {color: \"chalk\"}")
|
|
|
|
var sequence = try document("background: [a, b]")
|
|
sequence.setStyleValue("chalk", for: FrontmatterKeys.background)
|
|
#expect(backgroundLine(sequence) == "background: {color: \"chalk\"}")
|
|
}
|
|
|
|
/// A removal over a shape with no subkeys to keep is simply a removal — there is no mapping to
|
|
/// preserve half of, and nothing was readable to begin with.
|
|
@Test("A removal over a scalar or an absent key just removes it")
|
|
func removalOverANonMappingRemovesTheKey() throws {
|
|
var scalar = try document("background: fern")
|
|
scalar.setStyleValue(nil, for: FrontmatterKeys.background)
|
|
#expect(!scalar.contains(FrontmatterKeys.background))
|
|
|
|
var absent = try document("schema: 1")
|
|
absent.setStyleValue(nil, for: FrontmatterKeys.background)
|
|
#expect(!absent.contains(FrontmatterKeys.background))
|
|
#expect(absent.serialized() == "---\nschema: 1\n---\nbody\n")
|
|
}
|
|
|
|
/// The other style keys are untouched by any of this: their values *are* strings, and a
|
|
/// hand-written `icon: {a: 1}` is a malformed value the write exists to clear — never something
|
|
/// to merge a `color:` subkey into.
|
|
@Test("Title and icon keep the plain scalar path")
|
|
func otherKeysStayScalar() throws {
|
|
var titled = try document("title: Old")
|
|
titled.setStyleValue("New", for: FrontmatterKeys.title)
|
|
#expect(titled.title == .valid("New"))
|
|
#expect(titled.serialized() == "---\ntitle: New\n---\nbody\n")
|
|
|
|
var mappedIcon = try document("icon: {a: 1}")
|
|
mappedIcon.setStyleValue("tray", for: FrontmatterKeys.icon)
|
|
#expect(mappedIcon.icon == .valid("tray"))
|
|
#expect(mappedIcon.serialized() == "---\nicon: tray\n---\nbody\n")
|
|
}
|
|
|
|
/// The emitted mapping is read back by the very reader the app uses, values with YAML-significant
|
|
/// characters included — the reason strings are always quoted in flow context.
|
|
@Test("An awkward colour and path round-trip through the emitted mapping")
|
|
func awkwardValuesRoundTrip() throws {
|
|
var document = try document("background: {image: \"a, b}.jpg\"}")
|
|
document.setStyleValue("#ff8800", for: FrontmatterKeys.background)
|
|
|
|
let reparsed = try FrontmatterDocument.parse(document.serialized())
|
|
#expect(reparsed.background == .valid("#ff8800"))
|
|
#expect(reparsed.backgroundImage == .valid("a, b}.jpg"))
|
|
}
|
|
}
|
|
|
|
// MARK: - Where an image may point
|
|
|
|
@Suite("Board background ▸ the image path stays inside the board")
|
|
struct BackgroundImagePathTests {
|
|
|
|
private let root = URL(fileURLWithPath: "/Users/someone/Boards/Work.kanban", isDirectory: true)
|
|
|
|
@Test("A plain name and a nested path resolve inside the board")
|
|
func resolvesRelativePaths() {
|
|
#expect(BoardBackdrop.imageURL(named: "sunset.jpg", inBoardRoot: root)?.path
|
|
== "/Users/someone/Boards/Work.kanban/sunset.jpg")
|
|
#expect(BoardBackdrop.imageURL(named: "art/backdrops/sunset.png", inBoardRoot: root)?.path
|
|
== "/Users/someone/Boards/Work.kanban/art/backdrops/sunset.png")
|
|
}
|
|
|
|
/// The check is about where the path *ends up*, not how it is spelled: a climb that lands back
|
|
/// inside the board is an ordinary file in it.
|
|
@Test("A path that climbs and returns is still inside")
|
|
func resolvesPathsThatStandardizeBackInside() {
|
|
#expect(BoardBackdrop.imageURL(named: "art/../sunset.jpg", inBoardRoot: root)?.path
|
|
== "/Users/someone/Boards/Work.kanban/sunset.jpg")
|
|
#expect(BoardBackdrop.imageURL(named: "./sunset.jpg", inBoardRoot: root)?.path
|
|
== "/Users/someone/Boards/Work.kanban/sunset.jpg")
|
|
}
|
|
|
|
/// A `.kanban` folder is a document — it gets copied, zipped and handed to somebody else — so a
|
|
/// background that only works on the Mac it was written on resolves to nothing at all.
|
|
@Test("Escapes and absolute paths resolve to nothing")
|
|
func rejectsEscapes() {
|
|
#expect(BoardBackdrop.imageURL(named: "../escape.jpg", inBoardRoot: root) == nil)
|
|
#expect(BoardBackdrop.imageURL(named: "art/../../escape.jpg", inBoardRoot: root) == nil)
|
|
#expect(BoardBackdrop.imageURL(named: "/etc/passwd", inBoardRoot: root) == nil)
|
|
// Appended rather than rejected, an absolute path would land as `<root>/etc/passwd` and pass
|
|
// containment while naming a file the author plainly did not mean.
|
|
#expect(BoardBackdrop.imageURL(named: "/sunset.jpg", inBoardRoot: root) == nil)
|
|
#expect(BoardBackdrop.imageURL(named: "", inBoardRoot: root) == nil)
|
|
#expect(BoardBackdrop.imageURL(named: ".", inBoardRoot: root) == nil)
|
|
}
|
|
|
|
/// The trailing separator in the containment test, doing its job: a sibling whose name merely
|
|
/// starts with this board's is a different board.
|
|
@Test("A sibling folder with a prefixed name is not inside")
|
|
func rejectsAPrefixedSibling() {
|
|
#expect(BoardBackdrop.imageURL(named: "../Work.kanban.backup/sunset.jpg", inBoardRoot: root) == nil)
|
|
}
|
|
|
|
/// `~` is not expanded and is not special: only a shell ever meant a home folder by it, and a
|
|
/// file honestly named that way sits in the board like any other.
|
|
@Test("A tilde is an ordinary character in a file name")
|
|
func treatsTildeAsAnOrdinaryCharacter() {
|
|
#expect(BoardBackdrop.imageURL(named: "~notes.png", inBoardRoot: root)?.path
|
|
== "/Users/someone/Boards/Work.kanban/~notes.png")
|
|
}
|
|
|
|
/// The whole-model reading, which is what the view and the window chrome ask: no key, an
|
|
/// unresolvable one, and a good one.
|
|
@Test("The board-level reading follows the field and the path rule")
|
|
func readsTheBoardsOwnField() throws {
|
|
func board(_ frontmatter: String) throws -> BoardModel {
|
|
BoardModel(
|
|
rootURL: root,
|
|
schema: 1,
|
|
title: .missing,
|
|
created: .missing,
|
|
modified: .missing,
|
|
modifiedBy: .missing,
|
|
deleted: .missing,
|
|
background: .missing,
|
|
backgroundImage: try document(frontmatter).backgroundImage,
|
|
icon: .missing,
|
|
iconColor: .missing,
|
|
template: nil,
|
|
lanes: [],
|
|
document: try document(frontmatter)
|
|
)
|
|
}
|
|
|
|
#expect(BoardBackdrop.imageURL(for: try board("background: {color: fern}"), root: root) == nil)
|
|
#expect(BoardBackdrop.imageURL(for: try board("background: {image: ../x.jpg}"), root: root) == nil)
|
|
#expect(BoardBackdrop.imageURL(for: try board("background: {image: sunset.jpg}"), root: root)?.path
|
|
== "/Users/someone/Boards/Work.kanban/sunset.jpg")
|
|
}
|
|
|
|
/// **The window-chrome predicate** (`BoardWindowHost`): a board paints a background of its own
|
|
/// when a colour resolves or an image path lands inside the board — a path that could never
|
|
/// paint anything leaves the standard chrome alone.
|
|
@Test("The chrome predicate answers for colour, image, both and neither")
|
|
func answersTheChromePredicate() throws {
|
|
func board(_ frontmatter: String) throws -> BoardModel {
|
|
let parsed = try document(frontmatter)
|
|
return BoardModel(
|
|
rootURL: root,
|
|
schema: 1,
|
|
title: .missing,
|
|
created: .missing,
|
|
modified: .missing,
|
|
modifiedBy: .missing,
|
|
deleted: .missing,
|
|
background: parsed.background,
|
|
backgroundImage: parsed.backgroundImage,
|
|
icon: .missing,
|
|
iconColor: .missing,
|
|
template: nil,
|
|
lanes: [],
|
|
document: parsed
|
|
)
|
|
}
|
|
|
|
#expect(BoardBackdrop.isCustom(try board("schema: 1"), root: root) == false)
|
|
#expect(BoardBackdrop.isCustom(try board("background: {color: fern}"), root: root))
|
|
#expect(BoardBackdrop.isCustom(try board("background: {image: sunset.jpg}"), root: root))
|
|
#expect(BoardBackdrop.isCustom(try board("background: {color: fern, image: sunset.jpg}"), root: root))
|
|
// Neither half resolves: an unrecognized colour name and a path that leaves the board.
|
|
#expect(BoardBackdrop.isCustom(try board("background: {color: mauve, image: /tmp/x.jpg}"), root: root) == false)
|
|
}
|
|
}
|