import Foundation import Testing @testable import Kanban /// The styling system's rules that need neither a screen nor (mostly) a disk: the per-dimension /// mixed state the editor displays, the Style… popover's live target set, the quick-style recents /// list, and the curated symbol grid (03-board-ui.md § Styling). /// /// The popover half drives a **real store over a real temp board** for `TransientBoardStateTests`' /// reason — the lifecycle's contract includes being re-resolved by the store's reload path, and a /// suite that only called `resolved(against:)` by hand could pass with that wire cut. // MARK: - Fixtures private func tombstoned(order: String, title: String) -> String { """ --- schema: 1 title: \(title) order: \(order) deleted: 2026-03-03T09:00:00Z --- \(title) body. """ } @MainActor private func makeBoard() throws -> WriterFixture { let fixture = try WriterFixture() try fixture.item("", Item.board) try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First")) try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second")) try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing")) try fixture.item("\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Third")) return fixture } private let lane1 = ItemID(rawValue: Ident.lane1) private let lane2 = ItemID(rawValue: Ident.lane2) private let card1 = ItemID(rawValue: Ident.card1) private let card2 = ItemID(rawValue: Ident.card2) private let card3 = ItemID(rawValue: Ident.card3) private let card4 = ItemID(rawValue: Ident.card4) @MainActor private func reload(_ store: BoardStore) async { store.handleWatcherEvent(.treeChanged(.foreign)) await store.awaitQuiescence() } // MARK: - Mixed state @Suite("Styling ▸ mixed state") struct StyleFieldStateTests { @Test("Agreement reads uniform, disagreement reads mixed, absence reads unset") func theThreeStates() { #expect(StyleFieldState.resolve([]) == .unset) #expect(StyleFieldState.resolve([.missing, .missing]) == .unset) #expect(StyleFieldState.resolve([.valid("fern"), .valid("fern")]) == .uniform("fern")) #expect(StyleFieldState.resolve([.valid("fern"), .valid("chalk")]) == .mixed) // A set value and an absent one disagree: half the batch is coloured, which is exactly the // case "—" exists for. #expect(StyleFieldState.resolve([.valid("fern"), .missing]) == .mixed) } @Test("An off-palette value is uniform like any other — the display, not this rule, is what differs") func offPaletteValuesAreOrdinary() { let state = StyleFieldState.resolve([.valid("#112233AA"), .valid("#112233AA")]) #expect(state == .uniform("#112233AA")) #expect(!Palette.backgrounds.contains { $0.name == "#112233AA" }, "it is the editor's verbatim chip that treats this specially, outside the grids") } @Test("A malformed value reads as the bytes on disk, and two of a kind agree") func malformedValuesReadVerbatim() { #expect(StyleFieldState.written(.malformed(raw: "[a, b]")) == "[a, b]") #expect(StyleFieldState.written(.missing) == nil) #expect(StyleFieldState.resolve([.malformed(raw: "[a, b]"), .malformed(raw: "[a, b]")]) == .uniform("[a, b]")) #expect(StyleFieldState.resolve([.malformed(raw: "[a, b]"), .missing]) == .mixed) } @Test("Only a change that would rewrite the same bytes is skipped") func noOpNarrowing() { #expect(BoardStore.effective(.set("fern"), against: .valid("fern")) == .keep) #expect(BoardStore.effective(.set("fern"), against: .valid("chalk")) == .set("fern")) #expect(BoardStore.effective(.set("fern"), against: .missing) == .set("fern")) // A malformed value is never equal to a palette name, so a well always replaces it. #expect(BoardStore.effective(.set("fern"), against: .malformed(raw: "[a, b]")) == .set("fern")) #expect(BoardStore.effective(.remove, against: .missing) == .keep) #expect(BoardStore.effective(.remove, against: .valid("fern")) == .remove) #expect(BoardStore.effective(.remove, against: .malformed(raw: "[a, b]")) == .remove) #expect(BoardStore.effective(.keep, against: .valid("fern")) == .keep) } } // MARK: - The popover's target @MainActor @Suite("Styling ▸ the Style… popover's target") struct StyleEditorSessionTests { @Test("A vanished member leaves the set; the survivors keep the popover open") func vanishedMemberLeavesTheSet() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.transient.beginStyleEditor(for: .items([card1, card2])) try fixture.move("\(Ident.lane1)/\(Ident.card2)", toTrash: Ident.card2) await reload(store) let session = try #require(store.transient.styleEditor) #expect(session.target == .items([card1])) // And the display recomputes off the survivors, which is the point of narrowing rather than // dismissing. #expect(store.styleSubjects(of: session.target).map(\.id) == [card1]) } @Test("A set emptied by a foreign reload dismisses the popover") func emptiedSetDismisses() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.transient.beginStyleEditor(for: .items([card3])) try FileManager.default.removeItem(at: fixture.url(Ident.lane2)) await reload(store) // The card's own flag never changed — its lane's did. Effective liveness is ancestor-walked, // so the card renders nowhere and the session has nothing left to style. #expect(store.transient.styleEditor == nil, "the editor is closed") } @Test("It never silently retargets to the board") func neverRetargetsToTheBoard() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.transient.beginStyleEditor(for: .items([card1])) try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1) await reload(store) #expect(store.transient.styleEditor?.target != .board) #expect(store.transient.styleEditor == nil) } @Test("A board-targeted editor has no vanish case") func boardSessionsSurvive() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.transient.beginStyleEditor(for: .board) try FileManager.default.removeItem(at: fixture.url(Ident.lane1)) try FileManager.default.removeItem(at: fixture.url(Ident.lane2)) await reload(store) #expect(store.transient.styleEditor?.target == .board) } @Test("An unknown id is gone from the start — a session of nothing but strangers closes") func unknownIdsResolveAway() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let stranger = StyleEditorSession(target: .items([card4])) #expect(stranger.resolved(against: store.snapshot) == nil) let mixed = StyleEditorSession(target: .items([card4, card1])) #expect(mixed.resolved(against: store.snapshot)?.target == .items([card1])) } @Test("The presenting anchor is the first live target in display order, board sessions none") func anchorFollowsDisplayOrder() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) #expect(StyleEditorSession(target: .board).presentationAnchor(in: store.snapshot) == nil) #expect(StyleEditorSession(target: .items([card3, card2])).presentationAnchor(in: store.snapshot) == card2) #expect(StyleEditorSession(target: .items([lane2, lane1])).presentationAnchor(in: store.snapshot) == lane1) // A lane outranks a card in its own lane — a cards-XOR-lanes selection never mixes the two, // but the walk has to be total. #expect(StyleEditorSession(target: .items([card1, lane1])).presentationAnchor(in: store.snapshot) == lane1) } @Test("The style editor is not an inline editor") func notAnInlineEditor() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.transient.beginStyleEditor(for: .board) // `isEditingInline` gates every board command; a popover that claimed the text domain would // disable the very menu items that opened it. #expect(!store.isEditingInline) #expect(store.acceptsBoardMutations) store.transient.discardStyleEditor() #expect(store.transient.styleEditor == nil) } } // MARK: - Recents @MainActor @Suite("Styling ▸ quick-style recents") struct StyleRecentsTests { /// A defaults domain of this test's own — the list is a real user preference, and a suite that /// wrote into `UserDefaults.standard` would be editing the developer's own quick-style row. private func makeRecents() -> (recents: StyleRecents, teardown: () -> Void) { let name = "StyleRecentsTests-\(UUID().uuidString)" guard let defaults = UserDefaults(suiteName: name) else { Issue.record("could not create a defaults suite") return (StyleRecents(defaults: .standard), {}) } return (StyleRecents(defaults: defaults), { defaults.removePersistentDomain(forName: name) }) } @Test("Most-recent-first, deduped by move-to-front, capped") func listRule() { #expect(StyleRecents.updated([], with: "fern") == ["fern"]) #expect(StyleRecents.updated(["fern"], with: "chalk") == ["chalk", "fern"]) // A repeat is a move, never a second entry. #expect(StyleRecents.updated(["chalk", "fern"], with: "fern") == ["fern", "chalk"]) #expect(StyleRecents.updated(["fern"], with: "fern") == ["fern"]) let full = ["a", "b", "c", "d", "e", "f"] #expect(StyleRecents.updated(full, with: "g", cap: 6) == ["g", "a", "b", "c", "d", "e"]) #expect(StyleRecents.updated(full, with: "g", cap: 6).count == 6) } @Test("An empty value is not a colour anyone applied") func emptyValuesAreIgnored() { // The None well is a *removal* — nothing to remember — and the call site never records for // it; this is the belt behind that brace. #expect(StyleRecents.updated(["fern"], with: "") == ["fern"]) } @Test("Recording persists, and a fresh instance reads the same list back") func recordingRoundTrips() throws { let name = "StyleRecentsTests-\(UUID().uuidString)" let store = try #require(UserDefaults(suiteName: name)) defer { store.removePersistentDomain(forName: name) } let recents = StyleRecents(defaults: store) recents.record("fern") recents.record("chalk") recents.record("fern") #expect(recents.backgrounds == ["fern", "chalk"]) #expect(StyleRecents(defaults: store).backgrounds == ["fern", "chalk"]) #expect(store.array(forKey: AppPreferences.quickStyleBackgroundsKey) as? [String] == ["fern", "chalk"]) } @Test("A garbage preference reads as an empty list rather than taking the row down") func toleratesGarbage() throws { let (recents, teardown) = makeRecents() defer { teardown() } #expect(recents.backgrounds.isEmpty, "a first launch has no recents and no row") let name = "StyleRecentsTests-garbage-\(UUID().uuidString)" let store = try #require(UserDefaults(suiteName: name)) defer { store.removePersistentDomain(forName: name) } store.set(42, forKey: AppPreferences.quickStyleBackgroundsKey) #expect(StyleRecents(defaults: store).backgrounds.isEmpty) } } // MARK: - The curated grid /// **2026-08-09: one flat ~65-entry list split into three level-specific sets** (`boards`, `lanes`, /// `cards`) plus `combined`, their stable-order union — this suite pins the new shape per set rather /// than the old single-list bounds. @Suite("Styling ▸ the curated symbol sets") struct CuratedSymbolsTests { @Test("Each level's set is in the ~30–40 range, no duplicates", arguments: [ CuratedSymbols.boards, CuratedSymbols.lanes, CuratedSymbols.cards, ]) func shape(_ set: [String]) { #expect(set.count >= 30) #expect(set.count <= 40) #expect(Set(set).count == set.count) } @Test("Every curated name, at every level, is one this system can actually draw", arguments: [ CuratedSymbols.boards, CuratedSymbols.lanes, CuratedSymbols.cards, ]) func everyNameResolves(_ set: [String]) { // A curated list is a convenience, never a claim about the running OS — `available(for:)` // filters it — but a name that fails here on the *deployment target* is a typo, not an // inventory difference, and the grid would show an empty well. let missing = set.filter { !ItemSymbol.exists($0) } #expect(missing.isEmpty, "unknown SF Symbol names: \(missing)") } @Test("available(for:) reads the matching level's set, filtered") func availableForLevelMatchesTheSet() { #expect(CuratedSymbols.available(for: .board) == CuratedSymbols.availableBoards) #expect(CuratedSymbols.available(for: .lane) == CuratedSymbols.availableLanes) #expect(CuratedSymbols.available(for: .card) == CuratedSymbols.availableCards) // Nothing here fails to resolve, so filtering is a no-op on the deployment target — the same // claim `everyNameResolves` makes, restated for the filtered accessors the app actually calls. #expect(CuratedSymbols.availableBoards.count == CuratedSymbols.boards.count) #expect(CuratedSymbols.availableLanes.count == CuratedSymbols.lanes.count) #expect(CuratedSymbols.availableCards.count == CuratedSymbols.cards.count) } @Test("Overlap between sets is allowed — a status marker fits at every level") func genuineOverlapIsAllowed() { for name in ["flag", "star"] { #expect(CuratedSymbols.boards.contains(name)) #expect(CuratedSymbols.lanes.contains(name)) #expect(CuratedSymbols.cards.contains(name)) } } @Test("combined is the three sets' union, stable order, deduplicated") func combinedIsTheStableUnion() { let combined = CuratedSymbols.combined #expect(Set(combined).count == combined.count, "no duplicate across the three sets") #expect(Set(combined) == Set(CuratedSymbols.boards + CuratedSymbols.lanes + CuratedSymbols.cards)) // Stable order: boards' own members appear in `combined` in the same relative order boards // itself lists them, and the first new (non-boards) name is the first lanes-only entry. let boardsOnly = combined.filter(CuratedSymbols.boards.contains) #expect(boardsOnly == CuratedSymbols.boards) #expect(CuratedSymbols.availableCombined.count == combined.filter(ItemSymbol.exists).count) } @Test("The level defaults are drawable too — they are the grid's leading well") func levelDefaultsResolve() { #expect(ItemSymbol.exists(ItemSymbol.board)) #expect(ItemSymbol.exists(ItemSymbol.lane)) #expect(ItemSymbol.exists(ItemSymbol.card)) } // MARK: - Context wiring /// **The style editor's grid content**: one level's set for a homogeneous target, the combined set /// for one that spans levels — `StyleEditorView.curatedSymbols`' whole decision, pulled out as /// `CuratedSymbols.availableForStyleEditor` so it is testable without a view /// (`BoardStore.styleTargetSpansLevels` supplies the second argument from a real target in /// `StyleWriteTests.spansLevelsDetectsAGenuineMix`). @Test("The style editor's grid: a homogeneous target reads its own level's set, a spanning target reads the combined set") func styleEditorGridChoosesByLevelOrSpan() { #expect(CuratedSymbols.availableForStyleEditor(level: .board, spansLevels: false) == CuratedSymbols.availableBoards) #expect(CuratedSymbols.availableForStyleEditor(level: .lane, spansLevels: false) == CuratedSymbols.availableLanes) #expect(CuratedSymbols.availableForStyleEditor(level: .card, spansLevels: false) == CuratedSymbols.availableCards) // `spansLevels` wins regardless of which level `styleLevel(of:)` collapsed to — a spanning // target must never read as a single level's narrower set. #expect(CuratedSymbols.availableForStyleEditor(level: .card, spansLevels: true) == CuratedSymbols.availableCombined) #expect(CuratedSymbols.availableForStyleEditor(level: .lane, spansLevels: true) == CuratedSymbols.availableCombined) } /// **`BoardInfoPopover`'s board glyph** passes a fixed board-level set rather than asking /// `styleLevel` at all — there is no target to ask, since this anchor only ever styles the board. /// The tripwire is the same one `CardSidebarTests.theCuratedSetPassedToTheSidebarPickerDoesNotShrinkIt` /// pins for the card anchor: the set stays wider than the picker's own domain-agnostic default, so /// the anchor's explicit `symbols:` argument can never quietly become a no-op. @Test("The board glyph's set stays wider than the picker's own default") func boardAnchorSetStaysWiderThanThePickerDefault() { #expect(CuratedSymbols.availableBoards.count > SymbolPickerCatalog.defaultSet.count) } }