import Foundation import Testing @testable import Kanban /// The card window's sidebar sections, reduced to the rules a unit test can hold (05-card-window.md /// ▸ The attributes sidebar). /// /// The views are not the point and are not tested. What is tested is the three seams under them, /// each of which fails *silently* — the failure mode this file exists for: /// /// - **Details** shows frontmatter the app deliberately does not understand, so nothing downstream /// can notice when a key goes missing, arrives out of order, or renders as a YAML indicator. The /// only check on it is a test that reads a file and says what the rows must be. /// - **Style** embeds a shared, selection-aware component in a window that has no selection. An /// anchor wired to the wrong target would look completely normal until it restyled something else. /// - **The sidebar's geometry** is a grid of fixed-size wells in a column sized from font metrics: /// at the wrong column count the last well in each row is simply unreachable, at no text size /// anyone tests by eye. // MARK: - Details ▸ which keys @Suite("Card details ▸ keys") struct CardDetailsKeyTests { /// One card carrying every kind of key at once: the schema's own, an agent overlay, four /// reserved enhanced-schema names, and a key written twice. private static let card = """ --- schema: 1 title: Fix login order: 1024 project: lanework # agent overlay labels: [a, b, c] assignees: - ada - grace due: 2026-08-01 remote: {name: origin, branch: main} created: 2026-01-01T09:00:00Z modified: 2026-02-02T09:00:00Z modified-by: claude background: {color: mint} icon: flag iconColor: carnation project: overlay-rewritten --- Body text. """ @Test("Every unknown key appears, in file order") func unknownKeysInFileOrder() throws { let rows = CardDetails.rows(of: try FrontmatterDocument.parse(Self.card)) // File order, verbatim — 01-storage-format.md preserves it and the sidebar honors it. The // twice-written `project` reads once, and it reads where its *winning* occurrence sits: the // effective view a duplicate collapses to (`FrontmatterDocument.parse`, last-wins), which is // also the order the file itself takes the moment anything rewrites that key. #expect(rows.map(\.key) == ["labels", "assignees", "due", "remote", "project"]) } @Test("Reserved enhanced-schema keys are ordinary unknown keys in this version") func reservedKeysAreShown() throws { let rows = CardDetails.rows(of: try FrontmatterDocument.parse(Self.card)) // "`labels`, `assignees`, `due`, `remote`, … are ordinary unknown keys in this version and // appear here like any other — no special rendering" (05 ▸ Details). The day they gain // meaning they leave this section for a control; until then, hiding them would hide data the // file plainly has. for reserved in ["labels", "assignees", "due", "remote"] { #expect(rows.contains { $0.key == reserved }, "\(reserved) belongs in Details") } } @Test("Every schema-owned key is excluded — including the ones the sidebar shows elsewhere") func schemaKeysAreExcluded() throws { let rows = CardDetails.rows(of: try FrontmatterDocument.parse(Self.card)) let keys = Set(rows.map(\.key)) // The eleven the app owns. `title`, `created`/`modified`/`modified-by` and the three style // keys have their own surfaces in this very window (the title field, the date line, the Style // section), and `schema`/`order`/`width`/`deleted` are structure — a Details row for any of // them would be the same fact stated twice, in a section whose whole premise is "keys the app // does not own". #expect(keys.isDisjoint(with: FrontmatterKeys.schemaOwned)) #expect(keys == ["labels", "assignees", "due", "remote", "project"]) } @Test("A card with no unknown keys has no section at all") func theSectionDisappearsWithNothingToShow() throws { // The visibility rule, as the seam states it: "shown only when any exist" (05 ▸ Details). // Empty rows are the view's whole condition, so this is that condition. let plain = "---\nschema: 1\ntitle: Plain\norder: 1024\n---\nBody.\n" #expect(CardDetails.rows(of: try FrontmatterDocument.parse(plain)).isEmpty) // Style keys and stamps are not "details" either — a styled, stamped card still has none. let styled = """ --- schema: 1 title: Styled order: 1024 background: {color: mint} icon: flag iconColor: carnation created: 2026-01-01T09:00:00Z modified: 2026-02-02T09:00:00Z modified-by: claude width: 2 deleted: 2026-03-03T09:00:00Z --- """ #expect(CardDetails.rows(of: try FrontmatterDocument.parse(styled)).isEmpty) } @Test("The rows come off the snapshot's own card, unknown keys and order intact") func theSnapshotCarriesTheKeys() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", Item.board) try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) try fixture.item("\(Ident.lane1)/\(Ident.card1)", Self.card) // Nothing was added to the loader for this section: `Card.document` is the whole parsed // `index.md` and has been since the model existed, so the section reads what the last reload // read — no second parse, no directory walk, no freshness rule of its own. let model = try BoardLoader.load(boardRoot: fixture.root).model let card = try #require(model.lanes.first?.cards.first) #expect(CardDetails.rows(of: card.document).map(\.key) == ["labels", "assignees", "due", "remote", "project"]) #expect(CardDetails.rows(of: card.document).first?.value == "[a, b, c]") } } // MARK: - Details ▸ which text @Suite("Card details ▸ values") struct CardDetailsValueTests { private func rows(_ frontmatter: String) throws -> [String: String] { let text = "---\nschema: 1\norder: 1024\n\(frontmatter)---\nBody.\n" return Dictionary(uniqueKeysWithValues: CardDetails.rows(of: try FrontmatterDocument.parse(text)) .map { ($0.key, $0.value) }) } @Test("A one-line value renders as the author's own bytes") func singleLineValuesAreVerbatim() throws { let values = try rows(""" plain: lanework flow-map: {name: origin, branch: main} flow-seq: [a, b, c] hex: "#FF8800" quoted: "a # b" commented: kept # this comment is the line's, not the value's stamp: 2026-08-01T09:00:00Z number: 2048 """) // The rawest honest form: quotes, braces, brackets, a hash inside a quoted scalar — all of it // exactly as typed, because a section that exists to show what the app did *not* interpret // must not quietly re-serialize it. #expect(values["plain"] == "lanework") #expect(values["flow-map"] == "{name: origin, branch: main}") #expect(values["flow-seq"] == "[a, b, c]") #expect(values["hex"] == "\"#FF8800\"") #expect(values["quoted"] == "\"a # b\"") #expect(values["stamp"] == "2026-08-01T09:00:00Z") #expect(values["number"] == "2048") // A trailing comment is the *line's*, and dropping it is the engine's own read-side rule — // the bytes on disk keep it. #expect(values["commented"] == "kept") } @Test("A value spanning several lines reads as its text, not as its YAML syntax") func multiLineValuesFallBackToTheParsedReading() throws { let values = try rows(""" notes: | first line second line folded: >- wrapped prose block-seq: - ada - grace block-map: name: origin branch: main wrapped-flow: [one, two] """) // The raw span of a block scalar carries `|`, `>-` and the continuation indent — YAML syntax // the *value* does not have. So these fall back to the engine's own reading, which is the // text the author meant rather than the punctuation that encoded it. #expect(values["notes"] == "first line\nsecond line") #expect(values["folded"] == "wrapped prose") #expect(values["block-seq"] == "[ada, grace]") #expect(values["block-map"] == "{name: origin, branch: main}") #expect(values["wrapped-flow"] == "[one, two]") } @Test("A key with no value reads as null rather than as a blank") func emptyValuesReadAsNull() throws { let values = try rows(""" sphere: explicit: null tilde: ~ empty-string: "" only-a-comment: # nothing but this space-after: " " """) // A row showing a key and nothing beside it reads as a bug in the app; YAML's own word for // the value is the honest thing to draw. An explicitly *empty string* is a different value // and keeps its own bytes. #expect(values["sphere"] == "null") #expect(values["explicit"] == "null") #expect(values["tilde"] == "~") #expect(values["only-a-comment"] == "null") #expect(values["empty-string"] == "\"\"") #expect(values["space-after"] == "\" \"") } @Test("A duplicated key reads once, with the winning occurrence's value") func duplicateKeysCollapseLastWins() throws { let document = try FrontmatterDocument.parse(""" --- schema: 1 order: 1024 project: first sphere: home project: second --- Body. """) let rows = CardDetails.rows(of: document) // 01-storage-format.md's deliberate divergence from strict YAML, surfaced: the section shows // what the app *reads*, and the app reads the last occurrence. Two rows for one key would say // the card has a value it does not have. #expect(rows.map(\.key) == ["sphere", "project"]) #expect(rows.map(\.value) == ["home", "second"]) } @Test("Frontmatter the editor cannot address still renders — readable-but-uneditable") func anUneditableDocumentStillShowsItsKeys() throws { // A whole-frontmatter flow mapping: no key has a line of its own, so `rawValue` has no span // to read and every value falls back to the parsed reading. The document refuses *writes* // (`uneditableShape`) — this section only reads, so it shows the keys like any other card's. let document = try FrontmatterDocument.parse(""" --- {schema: 1, order: 1024, project: lanework, labels: [a, b]} --- Body. """) #expect(document.uneditableShape != nil) #expect(CardDetails.rows(of: document).map(\.key) == ["project", "labels"]) #expect(CardDetails.rows(of: document).map(\.value) == ["lanework", "[a, b]"]) } @Test("Nothing a parsed document can hold makes a row throw or vanish") func exoticShapesAreLenient() throws { // "Values render as plain text, leniently — exotic YAML shapes display best-effort, never // error" (05 ▸ Details). Every shape here has *some* row, and none of them is empty: an // unreadable value must still say that the key is there, since the raw source outlet is the // only way to fix it and the user has to know to go there. let values = try rows(""" nested: {a: {b: [1, 2, {c: d}]}} booleans: [true, false, yes, no] unicode: "日本語 — ✂ \\u00e9" anchored: &a value aliased: *a colon-in-value: "key: not a key" tabbed: "a\\tb" big: 123456789012345678901234567890 exponent: 1.2e+34 infinity: .inf not-a-number: .nan dashes: "- not a list" """) #expect(values.count == 12) for (key, value) in values { #expect(!value.isEmpty, "\(key) rendered as nothing at all") } // An alias resolves to `.null` in the snapshot's value view, but its *source span* is what // the row shows — so an aliased value states the alias rather than lying about being empty. #expect(values["aliased"] == "*a") #expect(values["infinity"] == ".inf") #expect(values["not-a-number"] == ".nan") #expect(values["colon-in-value"] == "\"key: not a key\"") } } // MARK: - Style ▸ the anchor @Suite("Card sidebar ▸ style anchoring") struct CardStyleAnchorTests { @Test("The section's target is this window's card, and only this window's card") func theTargetIsTheWindowsCard() { let card = ItemID(rawValue: Ident.card1) // The card window has no selection and inherits none: "the two embedded anchors need none of // this and get none — the card sidebar dismisses with its card's window" (`StyleEditorSession`). // A target derived from anything that moves is the one way this anchor could restyle // something the user is not looking at. #expect(CardStyleSection.target(forCard: card) == .items([card])) // Never the board — the fallback the *selection-aware* anchor takes with nothing selected, // and the one this anchor must never reach: a card window styling the whole board would // repaint every lane behind it. #expect(CardStyleSection.target(forCard: card) != .board) } @Test("A card respelled in caps is the same target") func theTargetIsKeyedByIdentityNotSpelling() { // The window's own key rule (`CardWindowRef`), so the section cannot disagree with the window // it sits in about which card it is aimed at. let lower = ItemID(rawValue: Ident.card1) let upper = ItemID(rawValue: Ident.card1.uppercased()) #expect(CardStyleSection.target(forCard: lower) == CardStyleSection.target(forCard: upper)) } } // MARK: - Style ▸ the anchor's geometry @Suite("Card sidebar ▸ style editor layout") struct StyleEditorLayoutTests { /// How wide `columns` wells and the gaps between them actually draw, at a given text size. private func gridWidth(columns: Int, bodyPointSize: CGFloat) -> CGFloat { CGFloat(columns) * StyleEditorLayout.wellSide(bodyPointSize: bodyPointSize) + CGFloat(columns - 1) * StyleEditorLayout.wellSpacing(bodyPointSize: bodyPointSize) } @Test("The popover anchor keeps its settled geometry at the standard text size") func thePopoverKeepsItsSettledGeometry() { // 268 points and 7 + 6 background wells are 03-board-ui.md's own numbers ("narrow enough to // sit beside a card"). Making the anchor font-derived (10-accessibility.md ▸ Text scaling) // must not have moved them at the size they were settled for — the point of the multiples is // that the default text size renders exactly what it always did. let popover = StyleEditorLayout.popover(bodyPointSize: 13) #expect(popover.width == 268) #expect(popover.padding == 14) #expect(popover.backgroundColumns == 7) #expect(popover.symbolColumns == 8) #expect(popover.symbolGridMaximumHeight == 168) #expect(popover.wellSide == 20) #expect(popover.wellSpacing == 6) } /// The other half of the same claim: **the popover grows with the text**, so its seven wells /// still fit at a large system text size instead of overflowing a frame frozen at 268 points. @Test("The popover's frame grows with the text, and its wells keep fitting inside it") func thePopoverScales() { var previousWidth: CGFloat = 0 for size in [11.0, 13.0, 16.0, 18.0, 24.0, 36.0] as [CGFloat] { let popover = StyleEditorLayout.popover(bodyPointSize: size) #expect(popover.width! > previousWidth, "the popover must widen with the text at \(size)pt") previousWidth = popover.width! // The column counts are the design's and never move; what has to hold is that they // still fit, insets included. #expect(popover.backgroundColumns == 7) let content = popover.width! - 2 * popover.padding #expect(gridWidth(columns: 7, bodyPointSize: size) <= content, "7 wells overflow at \(size)pt") #expect(gridWidth(columns: 8, bodyPointSize: size) <= content, "8 wells overflow at \(size)pt") } } @Test("The sidebar anchor takes the column it is given and adds nothing to it") func theSidebarBringsNoGeometryOfItsOwn() { let layout = StyleEditorLayout.sidebar(contentWidth: 169, bodyPointSize: 13) // No width: the sidebar's is `CardWindowMetrics`' one decision. No padding: the section stack // is already inset by a gutter, and insetting twice would narrow the grids for nothing. #expect(layout.width == nil) #expect(layout.padding == 0) // No inner scroller: the sidebar is already a scroll view, and a scroll view inside a scroll // view is a scroll view that fights (`CardWindowView`'s rule). #expect(layout.symbolGridMaximumHeight == nil) } @Test("The grids fit the sidebar at every text size, and waste no room doing it") func theGridsFitTheSidebar() { for size in [11.0, 13.0, 16.0, 18.0, 24.0, 36.0] as [CGFloat] { let available = CardWindowMetrics.sidebarContentWidth(bodyPointSize: size) let layout = StyleEditorLayout.sidebar(contentWidth: available, bodyPointSize: size) #expect(layout.backgroundColumns == layout.symbolColumns, "one column count for one column") // Fits — a grid wider than its column puts the last well of every row out of reach of // the pointer, at a text size nobody checks by eye. #expect(gridWidth(columns: layout.backgroundColumns, bodyPointSize: size) <= available, "overflows at \(size)pt") // And is maximal: one more well would not have fitted, so the wells are as large a set as // the column can show rather than an arbitrary count that happened to be safe. #expect(gridWidth(columns: layout.backgroundColumns + 1, bodyPointSize: size) > available, "under-packed at \(size)pt") // And the count is *stable* across text sizes, because the column and the wells scale on // the same ruler — the sidebar is 26 body characters wide and a well is 1.55 body ems, // and neither of those ratios moves. Without that the grid would collapse to a strip at // a large text size, which is a palette the user can no longer scan. #expect(layout.backgroundColumns >= 4, "the grid collapsed to a strip at \(size)pt") } } @Test("The sidebar's grids are narrower than the popover's, at the standard text size") func theSidebarIsTheNarrowerAnchor() { // Which is the entire reason this type exists: the popover's 7 wells across do not fit a // 26-character column, so an editor with one hard-coded frame could not have both anchors. let layout = StyleEditorLayout.sidebar( contentWidth: CardWindowMetrics.sidebarContentWidth(bodyPointSize: 13), bodyPointSize: 13 ) #expect(layout.backgroundColumns < StyleEditorLayout.popover(bodyPointSize: 13).backgroundColumns) #expect(layout.backgroundColumns >= 4, "a grid this narrow would be a strip, not a palette") } @Test("A column too narrow for even one well still asks for one") func theFitRuleIsTotal() { // Total over any width, because the caller is a layout system: a zero proposal during a // window's first frame must not produce a grid of zero columns, which is a division by zero // waiting in `LazyVGrid`. #expect(StyleEditorLayout.columns(fitting: 0, bodyPointSize: 13) == 1) #expect(StyleEditorLayout.columns(fitting: -100, bodyPointSize: 13) == 1) #expect(StyleEditorLayout.columns(fitting: StyleEditorLayout.wellSide(bodyPointSize: 13), bodyPointSize: 13) == 1) } @Test("The sidebar's content width is the column minus its two gutters") func theContentWidthIsTheColumnMinusItsGutters() { for size in [11.0, 13.0, 24.0] as [CGFloat] { #expect( CardWindowMetrics.sidebarContentWidth(bodyPointSize: size) == CardWindowMetrics.sidebarWidth(bodyPointSize: size) - 2 * CardWindowMetrics.gutter(bodyPointSize: size) ) } // 26 characters at half an em: 26 × 0.5 × 16 = 208, the text width the column was sized for. #expect(CardWindowMetrics.sidebarContentWidth(bodyPointSize: 16) == 208) } }