Build card faces with edge-accent styling
The card face becomes real: leading SF Symbol (card default doc.text, tinted by a valid hand-written iconColor — schema yes, control no), title or the quiet untitled placeholder, and a quiet paperclip when the card has attachments — title-only by design, no body excerpt. Color is the settled K1 edge accent, not a fill: background paints a 4pt stripe down the left edge, resolved through the ported pathfinder palette (12 icon tints + 12 backgrounds carried over verbatim, plus raw #RRGGBB[AA]); anything unresolvable paints nothing and stays on disk exactly as written. The snapshot now carries each card's flat attachment names — the loader's one read inside a card folder, shared with the Writer's listing so the m5 carousel and m6 sidebar can never disagree on order (Finder order, the Writer's existing comparator). The face keeps its top-aligned structure so the sole-selection carousel can expand inside the card without moving masonry neighbors. 18 new tests. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -390,9 +390,10 @@ struct BoardLoaderNonUUIDStrayTests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reserved card children are covered "by construction" now: `attachments/` and
|
||||
/// `comments/` are non-UUID-shaped, and this loader never scans a card folder's contents
|
||||
/// anyway (cards are leaves) — either way, they must never surface a warning.
|
||||
/// Reserved card children are covered "by construction": `attachments/` and `comments/` are
|
||||
/// non-UUID-shaped, and the *level walk* stops at depth 2 — so neither can ever be mistaken
|
||||
/// for an item, and neither may surface a warning. (`attachments/` is read for its file
|
||||
/// names, which is a listing, not a descent — see `CardAttachmentListingTests` below.)
|
||||
@Test func reservedAttachmentsAndCommentsUnderCardProduceNoWarning() throws {
|
||||
let fixture = try BoardFixture()
|
||||
defer { fixture.tearDown() }
|
||||
@@ -401,7 +402,7 @@ struct BoardLoaderNonUUIDStrayTests {
|
||||
let card = uuidFolderName()
|
||||
|
||||
try fixture.index("", "schema: 1\n")
|
||||
try fixture.index(lane, "schema: 1\norder: 1024\n")
|
||||
try fixture.index("\(lane)", "schema: 1\norder: 1024\n")
|
||||
try fixture.index("\(lane)/\(card)", "schema: 1\norder: 1024\n")
|
||||
try fixture.strayFile("\(lane)/\(card)/attachments/sketch.png")
|
||||
try fixture.strayFile("\(lane)/\(card)/comments/whatever.md")
|
||||
@@ -412,6 +413,130 @@ struct BoardLoaderNonUUIDStrayTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Card attachments (01-storage-format.md § Attachments)
|
||||
|
||||
/// `Card.attachments` — the loader's one read *inside* a card folder. The golden-fixture suite
|
||||
/// pins the everyday shapes on real committed trees (`FixtureBoardTests`); these cover what a git
|
||||
/// fixture can't carry (a symlink) and what only a synthetic tree can arrange (a card folder whose
|
||||
/// `attachments` is a *file*, an empty folder, Finder's numeric ordering).
|
||||
struct CardAttachmentListingTests {
|
||||
|
||||
/// Builds a one-card board and returns that card, so each test below is one arrangement plus
|
||||
/// one assertion.
|
||||
private func card(in fixture: BoardFixture) throws -> Card {
|
||||
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||
return try #require(result.model.lanes.first?.cards.first)
|
||||
}
|
||||
|
||||
private func boardWithOneCard(_ fixture: BoardFixture) throws -> String {
|
||||
let lane = "10000000-0000-4000-8000-000000000001"
|
||||
let cardID = "20000000-0000-4000-8000-000000000002"
|
||||
try fixture.index("", "schema: 1\n")
|
||||
try fixture.index(lane, "schema: 1\norder: 1024\n")
|
||||
try fixture.index("\(lane)/\(cardID)", "schema: 1\norder: 1024\n")
|
||||
return "\(lane)/\(cardID)"
|
||||
}
|
||||
|
||||
/// **Symlinks are not surfaced** — the same never-resolve stance the level walk takes
|
||||
/// (`directoryCandidates`), so a link into another volume or a cycle can't turn a listing
|
||||
/// into a traversal. The link itself stays on disk untouched; it just isn't an attachment.
|
||||
@Test func symlinksInAttachmentsAreNotSurfaced() throws {
|
||||
let fixture = try BoardFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let cardPath = try boardWithOneCard(fixture)
|
||||
|
||||
try fixture.strayFile("\(cardPath)/attachments/real.png")
|
||||
try fixture.strayFile("outside.png")
|
||||
let attachments = fixture.root.appendingPathComponent("\(cardPath)/attachments", isDirectory: true)
|
||||
try FileManager.default.createSymbolicLink(
|
||||
at: attachments.appendingPathComponent("link-to-file.png"),
|
||||
withDestinationURL: fixture.root.appendingPathComponent("outside.png")
|
||||
)
|
||||
try FileManager.default.createSymbolicLink(
|
||||
at: attachments.appendingPathComponent("link-to-folder"),
|
||||
withDestinationURL: fixture.root
|
||||
)
|
||||
|
||||
#expect(try card(in: fixture).attachments == ["real.png"])
|
||||
}
|
||||
|
||||
/// The four shapes in one folder — the fixture board's assertion restated synthetically, so
|
||||
/// the rule is pinned even if the bundled fixture tree ever loses a file to a copy phase.
|
||||
@Test func onlyTopLevelNonHiddenRegularFilesAreListed() throws {
|
||||
let fixture = try BoardFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let cardPath = try boardWithOneCard(fixture)
|
||||
|
||||
try fixture.strayFile("\(cardPath)/attachments/sketch.png")
|
||||
try fixture.strayFile("\(cardPath)/attachments/notes.txt")
|
||||
try fixture.strayFile("\(cardPath)/attachments/.DS_Store")
|
||||
try fixture.strayFile("\(cardPath)/attachments/sub/nested.txt")
|
||||
|
||||
#expect(try card(in: fixture).attachments == ["notes.txt", "sketch.png"])
|
||||
}
|
||||
|
||||
/// **Finder order** (`localizedStandardCompare`), not plain lexicographic — digits inside a
|
||||
/// name *count* rather than collate, so the run `importAttachments` itself produces on a
|
||||
/// collision (`shot.png` → `shot 2.png` → `shot 10.png`) pages 2-before-10, and the face's
|
||||
/// carousel matches the card window sidebar's listing, which answers through this same
|
||||
/// enumeration. (`shot.png` trailing its own numbered copies is Finder's own ordering of a
|
||||
/// space against a dot, not a quirk of ours.)
|
||||
@Test func namesSortInFinderOrderSoNumbersCountRatherThanCollate() throws {
|
||||
let fixture = try BoardFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let cardPath = try boardWithOneCard(fixture)
|
||||
|
||||
for name in ["shot 10.png", "shot 2.png", "shot.png", "page-10.txt", "page-2.txt"] {
|
||||
try fixture.strayFile("\(cardPath)/attachments/\(name)")
|
||||
}
|
||||
|
||||
#expect(try card(in: fixture).attachments == [
|
||||
"page-2.txt", "page-10.txt", "shot 2.png", "shot 10.png", "shot.png",
|
||||
])
|
||||
}
|
||||
|
||||
@Test func anEmptyAttachmentsFolderListsNothing() throws {
|
||||
let fixture = try BoardFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let cardPath = try boardWithOneCard(fixture)
|
||||
try fixture.emptyFolder("\(cardPath)/attachments")
|
||||
|
||||
#expect(try card(in: fixture).attachments.isEmpty)
|
||||
}
|
||||
|
||||
/// A listing that *cannot* be made degrades to `[]` — here because a hand-editor left a
|
||||
/// `attachments` **file** where the folder would be. Fail-fast is reserved for structure
|
||||
/// (01-storage-format.md § Malformed input); a cosmetic field must never be why a board
|
||||
/// refuses to open, and the file itself is preserved verbatim like any other stray.
|
||||
@Test func anAttachmentsThatIsNotADirectoryDegradesToAnEmptyListing() throws {
|
||||
let fixture = try BoardFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let cardPath = try boardWithOneCard(fixture)
|
||||
try fixture.strayFile("\(cardPath)/attachments", contents: "not a folder")
|
||||
|
||||
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||
#expect(result.model.lanes.first?.cards.first?.attachments.isEmpty == true)
|
||||
#expect(result.warnings.isEmpty)
|
||||
}
|
||||
|
||||
/// `BoardWriter.listAttachments` — the card window sidebar's authoritative listing — and
|
||||
/// `Card.attachments` are **one enumeration**, so a sidebar and a face looking at the same
|
||||
/// card can never disagree about its files or their order.
|
||||
@Test func theSnapshotsListingAndTheWritersAreTheSameAnswer() throws {
|
||||
let fixture = try BoardFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let cardPath = try boardWithOneCard(fixture)
|
||||
|
||||
for name in ["shot 10.png", "shot 2.png", "shot.png", ".hidden"] {
|
||||
try fixture.strayFile("\(cardPath)/attachments/\(name)")
|
||||
}
|
||||
try fixture.strayFile("\(cardPath)/attachments/sub/nested.txt")
|
||||
|
||||
let cardFolder = fixture.root.appendingPathComponent(cardPath, isDirectory: true)
|
||||
#expect(try card(in: fixture).attachments == BoardWriter.listAttachments(ofCard: cardFolder))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ItemID value semantics (01-storage-format.md § Fractal layout ▸ Rules, "Identity
|
||||
// comparison is UUID-value equality, never string equality")
|
||||
|
||||
|
||||
@@ -117,6 +117,38 @@ struct FixtureRichBoardTests {
|
||||
#expect(done.cards[0].title.value == "Ship v1")
|
||||
}
|
||||
|
||||
/// `attachments/` is **flat** (01-storage-format.md § Attachments): the card's listing is its
|
||||
/// top-level regular files and nothing else. This card's folder holds all four shapes on real
|
||||
/// disk — two ordinary files, a hidden one, and a subfolder with a file in it — so the rule is
|
||||
/// asserted against a filesystem rather than against a mock.
|
||||
///
|
||||
/// The excluded three are excluded for three different reasons and only one of them is stated
|
||||
/// in the design doc: subfolders are "tolerated, preserved verbatim … and not surfaced"; the
|
||||
/// hidden file is the loader's uniform `.skipsHiddenFiles` stance (a `.DS_Store` is not
|
||||
/// anyone's attachment); symlinks are the loader's never-resolve stance, covered in
|
||||
/// `BoardLoaderTests` because git cannot carry that shape into a fixture reliably.
|
||||
@Test func aCardListsOnlyTheTopLevelFilesOfItsAttachmentsFolder() throws {
|
||||
let result = try loadFixture("Valid/rich-board.kanban")
|
||||
let doing = try #require(result.model.lanes.first { $0.id.rawValue == RichBoard.laneDoing })
|
||||
let taxonomy = try #require(doing.cards.first { $0.id.rawValue == RichBoard.cardTaxonomy })
|
||||
|
||||
#expect(taxonomy.attachments == ["notes.txt", "sketch.png"])
|
||||
}
|
||||
|
||||
/// The overwhelmingly common shape: no `attachments/` folder at all. It reads as an empty
|
||||
/// listing, never as a warning or a failure — nothing has been attached yet is an ordinary
|
||||
/// state, and it is what makes the face's paperclip indicator absent by default.
|
||||
@Test func cardsWithoutAnAttachmentsFolderListNothing() throws {
|
||||
let result = try loadFixture("Valid/rich-board.kanban")
|
||||
let doing = try #require(result.model.lanes.first { $0.id.rawValue == RichBoard.laneDoing })
|
||||
let second = try #require(doing.cards.first { $0.id.rawValue == RichBoard.cardSecond })
|
||||
let done = try #require(result.model.lanes.first { $0.id.rawValue == RichBoard.laneDone })
|
||||
|
||||
#expect(second.attachments.isEmpty)
|
||||
#expect(done.cards.map(\.attachments) == [[]])
|
||||
#expect(result.warnings.isEmpty)
|
||||
}
|
||||
|
||||
@Test func boardUnknownAndReservedKeysPreserveOrder() throws {
|
||||
let result = try loadFixture("Valid/rich-board.kanban")
|
||||
// schema-owned keys (schema, title, created, modified, modified-by, background, icon,
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import AppKit
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// The colour vocabulary `background` and `iconColor` are written in (03-board-ui.md § Styling ▸
|
||||
/// Capabilities): a kebab-case palette name, or a `#RRGGBB[AA]` hex — and **nil for everything
|
||||
/// else**, which is a rendering instruction, never an error.
|
||||
///
|
||||
/// The palette tables themselves are pinned by name *and* hex rather than merely counted: they
|
||||
/// are a carried-over design artefact ("the pathfinder's palettes … carry over as the starting
|
||||
/// point"), and their names are what users hand-write into files — a silent rename or a shifted
|
||||
/// hex would change what an existing board renders as.
|
||||
|
||||
// MARK: - The tables
|
||||
|
||||
struct PaletteTableTests {
|
||||
|
||||
@Test func bothTablesHoldTheTwelveNamedColoursTheDesignCarriesOver() {
|
||||
#expect(Palette.foregrounds.map(\.name) == [
|
||||
"obsidian", "aluminum", "soapstone", "chalk",
|
||||
"carnation", "rich-grapefruit", "smokey-tangerine", "fern",
|
||||
"light-teal", "deep-sky-blue", "pale-violet", "deep-cool-granite",
|
||||
])
|
||||
#expect(Palette.backgrounds.map(\.name) == [
|
||||
"obsidian", "shale", "aluminum", "chalk",
|
||||
"light-cayenne", "light-mocha", "smokey-mocha", "smokey-fern",
|
||||
"dark-teal", "smokey-ocean", "smokey-rich-eggplant", "intense-cool-shale",
|
||||
])
|
||||
}
|
||||
|
||||
@Test func everyPaletteNameResolvesToItsOwnHex() throws {
|
||||
for entry in Palette.foregrounds + Palette.backgrounds {
|
||||
let byName = try #require(
|
||||
Palette.nsColor(for: entry.name),
|
||||
"palette name '\(entry.name)' did not resolve"
|
||||
)
|
||||
let byHex = try #require(
|
||||
NSColor(paletteHex: entry.hex),
|
||||
"palette hex '\(entry.hex)' for '\(entry.name)' is not parseable"
|
||||
)
|
||||
#expect(byName == byHex, "'\(entry.name)' resolved to something other than \(entry.hex)")
|
||||
}
|
||||
}
|
||||
|
||||
/// The three names appearing in both tables are the same colour in each — `obsidian`,
|
||||
/// `aluminum` and `chalk` are one colour with one hex, listed twice because both *pickers*
|
||||
/// offer them. Resolution searches foregrounds first, so a drift would make the same written
|
||||
/// name mean two different things depending on which field it landed in.
|
||||
@Test func namesSharedByBothTablesCarryOneHex() {
|
||||
for name in ["obsidian", "aluminum", "chalk"] {
|
||||
let foreground = Palette.foregrounds.first { $0.name == name }?.hex
|
||||
let background = Palette.backgrounds.first { $0.name == name }?.hex
|
||||
#expect(foreground != nil && foreground == background, "'\(name)' differs between the two tables")
|
||||
}
|
||||
}
|
||||
|
||||
/// Both tables answer either field: the foreground/background split is what each picker
|
||||
/// offers, not a namespace (`Palette.nsColor(for:)`). A hand-written `background: carnation`
|
||||
/// — a name only the icon-tint table lists — must resolve, not read as garbage.
|
||||
@Test func aNameFromEitherTableResolvesRegardlessOfWhichFieldItCameFrom() {
|
||||
#expect(Palette.nsColor(for: "carnation") != nil) // foregrounds only
|
||||
#expect(Palette.nsColor(for: "intense-cool-shale") != nil) // backgrounds only
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Name matching
|
||||
|
||||
struct PaletteNameMatchingTests {
|
||||
|
||||
/// Names are matched **exactly**, kebab-case as the tables spell them. A near-miss degrades
|
||||
/// like any other unknown value rather than being guessed at — the same posture `ItemSymbol`
|
||||
/// takes towards a typo'd symbol name.
|
||||
@Test func nameMatchingIsCaseSensitiveAndExact() {
|
||||
#expect(Palette.nsColor(for: "deep-sky-blue") != nil)
|
||||
#expect(Palette.nsColor(for: "Deep-Sky-Blue") == nil)
|
||||
#expect(Palette.nsColor(for: "DEEP-SKY-BLUE") == nil)
|
||||
#expect(Palette.nsColor(for: "deep sky blue") == nil)
|
||||
#expect(Palette.nsColor(for: "deepskyblue") == nil)
|
||||
#expect(Palette.nsColor(for: " fern") == nil)
|
||||
#expect(Palette.nsColor(for: "fern ") == nil)
|
||||
}
|
||||
|
||||
/// `color(for:)` folds all three `FieldValue` shapes into one answer, exactly as
|
||||
/// `ItemSymbol.name(_:fallback:)` does: to a renderer, a missing key, a malformed one, and a
|
||||
/// valid-but-unknown name are one case — *there is no colour, so use your default*.
|
||||
@Test func everyUnusableFieldShapeReadsAsNoColour() {
|
||||
#expect(Palette.color(for: .valid("fern")) != nil)
|
||||
#expect(Palette.color(for: .valid("#FF0000")) != nil)
|
||||
#expect(Palette.color(for: FieldValue<String>.missing) == nil)
|
||||
#expect(Palette.color(for: .malformed(raw: "[a, b]")) == nil)
|
||||
#expect(Palette.color(for: .valid("chartreuse")) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hex parsing
|
||||
|
||||
/// `#RRGGBB[AA]` in **sRGB** — the colour space the digits name, so a value sampled from a
|
||||
/// screenshot renders as the colour the author sampled.
|
||||
struct PaletteHexTests {
|
||||
|
||||
private func components(_ hex: String) throws -> (CGFloat, CGFloat, CGFloat, CGFloat) {
|
||||
let color = try #require(NSColor(paletteHex: hex), "'\(hex)' did not parse")
|
||||
let srgb = try #require(color.usingColorSpace(.sRGB), "'\(hex)' is not sRGB-convertible")
|
||||
return (srgb.redComponent, srgb.greenComponent, srgb.blueComponent, srgb.alphaComponent)
|
||||
}
|
||||
|
||||
private func expectComponents(
|
||||
_ hex: String,
|
||||
_ expected: (red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat)
|
||||
) throws {
|
||||
let (red, green, blue, alpha) = try components(hex)
|
||||
let tolerance: CGFloat = 0.001
|
||||
#expect(abs(red - expected.red) < tolerance, "\(hex): red \(red) != \(expected.red)")
|
||||
#expect(abs(green - expected.green) < tolerance, "\(hex): green \(green) != \(expected.green)")
|
||||
#expect(abs(blue - expected.blue) < tolerance, "\(hex): blue \(blue) != \(expected.blue)")
|
||||
#expect(abs(alpha - expected.alpha) < tolerance, "\(hex): alpha \(alpha) != \(expected.alpha)")
|
||||
}
|
||||
|
||||
@Test func sixDigitHexParsesToItsSRGBComponentsAtFullOpacity() throws {
|
||||
try expectComponents("#FF0000", (red: 1, green: 0, blue: 0, alpha: 1))
|
||||
try expectComponents("#00FF00", (red: 0, green: 1, blue: 0, alpha: 1))
|
||||
try expectComponents("#0000FF", (red: 0, green: 0, blue: 1, alpha: 1))
|
||||
try expectComponents("#000000", (red: 0, green: 0, blue: 0, alpha: 1))
|
||||
try expectComponents("#FFFFFF", (red: 1, green: 1, blue: 1, alpha: 1))
|
||||
// The rich-board fixture's board background — an ordinary hand-written value.
|
||||
try expectComponents("#1E1E1E", (red: 30 / 255, green: 30 / 255, blue: 30 / 255, alpha: 1))
|
||||
}
|
||||
|
||||
@Test func eightDigitHexParsesItsTrailingPairAsAlpha() throws {
|
||||
try expectComponents("#FF000080", (red: 1, green: 0, blue: 0, alpha: 128 / 255))
|
||||
try expectComponents("#00FF00FF", (red: 0, green: 1, blue: 0, alpha: 1))
|
||||
try expectComponents("#0000FF00", (red: 0, green: 0, blue: 1, alpha: 0))
|
||||
}
|
||||
|
||||
/// Hex digits are case-insensitive — unlike palette *names*. Two different vocabularies with
|
||||
/// two different rules, deliberately: `#ff0000` is the same number as `#FF0000`, where
|
||||
/// `Fern` is simply not a name the palette has.
|
||||
@Test func hexDigitsAreCaseInsensitive() throws {
|
||||
let lower = try #require(NSColor(paletteHex: "#a1b2c3"))
|
||||
let upper = try #require(NSColor(paletteHex: "#A1B2C3"))
|
||||
#expect(lower == upper)
|
||||
}
|
||||
|
||||
/// Garbage resolves to nothing — no throw, no default colour, no partial read of a truncated
|
||||
/// value. Every one of these leaves the bytes on disk untouched and the surface undecorated.
|
||||
@Test func malformedAndUnknownValuesResolveToNil() {
|
||||
for value in [
|
||||
"", // the empty string
|
||||
"#", // a lone marker
|
||||
"#12", // too short
|
||||
"#12345", // five digits
|
||||
"#1234567", // seven — neither RGB nor RGBA
|
||||
"#123456789", // nine
|
||||
"#GGGGGG", // right length, not hex
|
||||
"#12345G", // one bad digit
|
||||
"not-a-color", // kebab-case, but not a name the palette has
|
||||
"FF0000", // hex digits without the '#' read as a name, and no name matches
|
||||
"rgb(255,0,0)", // a different colour vocabulary entirely
|
||||
] {
|
||||
#expect(Palette.nsColor(for: value) == nil, "'\(value)' should not resolve")
|
||||
#expect(Palette.color(named: value) == nil, "'\(value)' should not resolve to a SwiftUI Color")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user