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.item("\(Ident.lane1)/\(Ident.card2)", tombstoned(order: "2048", title: "Second")) 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 fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Doing")) 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.item("\(Ident.lane1)/\(Ident.card1)", tombstoned(order: "1024", title: "First")) 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 fixture.item(Ident.lane1, tombstoned(order: "1024", title: "Todo")) try fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Doing")) 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 @Suite("Styling ▸ the curated symbol grid") struct CuratedSymbolsTests { @Test("Roughly five dozen symbols, no duplicates") func shape() { #expect(CuratedSymbols.all.count >= 55) #expect(CuratedSymbols.all.count <= 72) #expect(Set(CuratedSymbols.all).count == CuratedSymbols.all.count) } @Test("Every curated name is one this system can actually draw") func everyNameResolves() { // A curated list is a convenience, never a claim about the running OS — `available` 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 = CuratedSymbols.all.filter { !ItemSymbol.exists($0) } #expect(missing.isEmpty, "unknown SF Symbol names: \(missing)") #expect(CuratedSymbols.available.count == CuratedSymbols.all.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)) } }