From 4a41646b1aa525c9798428d2b136d14b8bbd3eca Mon Sep 17 00:00:00 2001 From: Molecule AI Fullstack Engineer Date: Mon, 11 May 2026 21:21:00 +0000 Subject: [PATCH] test(canvas): add palette-context coverage (9 cases) for #568 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement MobileAccentProvider + usePalette + pure helpers and their 22-test suite. Coverage: - MOL_LIGHT / MOL_DARK singletons (never mutated) - getPalette: accent=null → base unchanged - getPalette: accent=base.accent → identity guard (no copy) - getPalette: accent="#custom" → accent+online overridden - normalizeStatus: all status → correct colour class - tierCode: tier number → display string - MobileAccentProvider: renders children - usePalette(false): returns base palette for current theme - usePalette(true): respects theme dark/light mode Files: - src/lib/palette-context.tsx (new — MobileAccentProvider + usePalette hook) - src/lib/__tests__/palette-context.test.tsx (new — 22 tests) Closes #568. Co-Authored-By: Claude Opus 4.7 --- .../lib/__tests__/palette-context.test.tsx | 205 ++++++++++++++++++ canvas/src/lib/palette-context.tsx | 167 ++++++++++++++ 2 files changed, 372 insertions(+) create mode 100644 canvas/src/lib/__tests__/palette-context.test.tsx create mode 100644 canvas/src/lib/palette-context.tsx diff --git a/canvas/src/lib/__tests__/palette-context.test.tsx b/canvas/src/lib/__tests__/palette-context.test.tsx new file mode 100644 index 00000000..def5b4c6 --- /dev/null +++ b/canvas/src/lib/__tests__/palette-context.test.tsx @@ -0,0 +1,205 @@ +// @vitest-environment jsdom +"use client"; +/** + * Tests for palette-context.tsx — MobileAccentProvider context + usePalette hook. + * + * Test coverage (9 cases): + * 1. MobileAccentProvider renders children + * 2. usePalette(false) without provider → MOL_LIGHT + * 3. usePalette(true) without provider → MOL_DARK + * 4. accent=null returns base palette unchanged + * 5. accent=base.accent returns base palette unchanged (identity guard) + * 6. accent="#custom" overrides both accent and online + * 7. MOL_LIGHT singleton never mutated + * 8. MOL_DARK singleton never mutated + * + * Plus pure-function coverage for normalizeStatus + tierCode. + */ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import React from "react"; +import { render, screen, cleanup } from "@testing-library/react"; +import { + MOL_LIGHT, + MOL_DARK, + getPalette, + normalizeStatus, + tierCode, + MobileAccentProvider, + usePalette, +} from "../palette-context"; + +// ─── usePalette test helper ─────────────────────────────────────────────────── +// usePalette reads document.documentElement.dataset.theme internally. +// We set this before rendering so the hook sees the right value. + +function setDataTheme(theme: "light" | "dark") { + if (typeof document !== "undefined") { + document.documentElement.dataset.theme = theme; + } +} + +// ─── Pure function tests ────────────────────────────────────────────────────── + +describe("normalizeStatus", () => { + it("returns emerald-400 for online status", () => { + expect(normalizeStatus("online", false)).toBe("bg-emerald-400"); + expect(normalizeStatus("online", true)).toBe("bg-emerald-400"); + }); + + it("returns emerald-400 for degraded status", () => { + expect(normalizeStatus("degraded", false)).toBe("bg-emerald-400"); + expect(normalizeStatus("degraded", true)).toBe("bg-emerald-400"); + }); + + it("returns red-400 for failed status", () => { + expect(normalizeStatus("failed", false)).toBe("bg-red-400"); + expect(normalizeStatus("failed", true)).toBe("bg-red-400"); + }); + + it("returns amber-400 for paused status", () => { + expect(normalizeStatus("paused", false)).toBe("bg-amber-400"); + expect(normalizeStatus("paused", true)).toBe("bg-amber-400"); + }); + + it("returns amber-400 for not_configured status", () => { + expect(normalizeStatus("not_configured", false)).toBe("bg-amber-400"); + }); + + it("returns zinc-400 for unknown status", () => { + expect(normalizeStatus("unknown", false)).toBe("bg-zinc-400"); + expect(normalizeStatus("", false)).toBe("bg-zinc-400"); + }); +}); + +describe("tierCode", () => { + it("returns T1 for tier 1", () => { + expect(tierCode(1)).toBe("T1"); + }); + + it("returns T2 for tier 2", () => { + expect(tierCode(2)).toBe("T2"); + }); + + it("returns T4 for tier 4", () => { + expect(tierCode(4)).toBe("T4"); + }); + + it("returns generic T{n} for non-standard tiers", () => { + expect(tierCode(99)).toBe("T99"); + }); +}); + +// ─── getPalette tests ───────────────────────────────────────────────────────── + +describe("getPalette — accent override", () => { + it("accent=null returns base palette unchanged (light)", () => { + const result = getPalette(null, false); + expect(result).toEqual({ ...MOL_LIGHT }); + expect(result).not.toBe(MOL_LIGHT); // returned object is a copy + }); + + it("accent=null returns base palette unchanged (dark)", () => { + const result = getPalette(null, true); + expect(result).toEqual({ ...MOL_DARK }); + expect(result).not.toBe(MOL_DARK); + }); + + it("accent=base.accent returns base palette unchanged (identity guard, light)", () => { + const result = getPalette(MOL_LIGHT.accent, false); + expect(result).toEqual({ ...MOL_LIGHT }); + expect(result).not.toBe(MOL_LIGHT); + }); + + it("accent=base.accent returns base palette unchanged (identity guard, dark)", () => { + const result = getPalette(MOL_DARK.accent, true); + expect(result).toEqual({ ...MOL_DARK }); + expect(result).not.toBe(MOL_DARK); + }); + + it("accent='#custom' overrides accent and online (light)", () => { + const result = getPalette("#ff0000", false); + expect(result.accent).toBe("#ff0000"); + expect(result.online).toBe("bg-emerald-400"); // normalizeStatus("online", false) + }); + + it("accent='#custom' overrides accent and online (dark)", () => { + const result = getPalette("#00ff00", true); + expect(result.accent).toBe("#00ff00"); + expect(result.online).toBe("bg-emerald-400"); // normalizeStatus("online", true) + }); + + it("MOL_LIGHT singleton is never mutated", () => { + getPalette("#mutate", false); + // All fields must still match the original freeze definition + expect(MOL_LIGHT.accent).toBe("bg-blue-500"); + expect(MOL_LIGHT.online).toBe("bg-emerald-400"); + expect(MOL_LIGHT.surface).toBe("bg-zinc-900"); + expect(MOL_LIGHT.ink).toBe("text-zinc-100"); + expect(MOL_LIGHT.line).toBe("border-zinc-700"); + expect(MOL_LIGHT.bg).toBe("bg-zinc-950"); + }); + + it("MOL_DARK singleton is never mutated", () => { + getPalette("#mutate", true); + expect(MOL_DARK.accent).toBe("bg-sky-400"); + expect(MOL_DARK.online).toBe("bg-emerald-400"); + expect(MOL_DARK.surface).toBe("bg-zinc-800"); + expect(MOL_DARK.ink).toBe("text-zinc-100"); + expect(MOL_DARK.line).toBe("border-zinc-700"); + expect(MOL_DARK.bg).toBe("bg-zinc-950"); + }); + + it("getPalette always returns a new object (no shared mutation risk)", () => { + const a = getPalette("#a", false); + const b = getPalette("#b", false); + expect(a).not.toBe(b); + expect(a.accent).not.toBe(b.accent); + }); +}); + +// ─── MobileAccentProvider tests ─────────────────────────────────────────────── + +describe("MobileAccentProvider", () => { + beforeEach(() => { + setDataTheme("light"); + }); + + afterEach(() => { + cleanup(); + if (typeof document !== "undefined") { + document.documentElement.dataset.theme = ""; + } + }); + + it("renders children", () => { + render( + + Hello + , + ); + expect(screen.getByTestId("child")).toBeTruthy(); + }); + + // usePalette hook reads data-theme from to determine light/dark. + // In the test environment, data-theme is empty, which falls through to + // the "light" default in usePalette, giving MOL_LIGHT. + it("usePalette(false) without provider → MOL_LIGHT", () => { + setDataTheme("light"); + function ShowPalette() { + const p = usePalette(false); + return {p.accent}; + } + render(); + expect(screen.getByTestId("accent-light").textContent).toBe(MOL_LIGHT.accent); + }); + + it("usePalette(true) without provider → MOL_DARK when data-theme=dark", () => { + setDataTheme("dark"); + function ShowPalette() { + const p = usePalette(true); + return {p.accent}; + } + render(); + expect(screen.getByTestId("accent-dark").textContent).toBe(MOL_DARK.accent); + }); +}); diff --git a/canvas/src/lib/palette-context.tsx b/canvas/src/lib/palette-context.tsx new file mode 100644 index 00000000..c88cf2be --- /dev/null +++ b/canvas/src/lib/palette-context.tsx @@ -0,0 +1,167 @@ +"use client"; + +/** + * palette-context.tsx + * + * Mobile canvas accent palette system. + * + * - MOL_LIGHT / MOL_DARK — immutable base singletons + * - getPalette(accent, isDark) — returns base palette or accent-overridden copy + * - normalizeStatus(status, isDark) — maps workspace status → online dot color + * - tierCode(tier) — maps tier number → display label + * - MobileAccentProvider — React context that propagates accent override + * - usePalette(allowAccentOverride) — hook; returns the effective palette + */ + +import { createContext, useContext } from "react"; + +// ─── Types ───────────────────────────────────────────────────────────────────── + +export interface Palette { + /** Accent colour (CSS colour string). */ + accent: string; + /** Online indicator colour (CSS class string, e.g. "bg-emerald-400"). */ + online: string; + /** Surface background colour class. */ + surface: string; + /** Primary text colour class. */ + ink: string; + /** Border/divider colour class. */ + line: string; + /** Background colour class. */ + bg: string; + /** Tier display code, e.g. "T1". */ + tier: string; +} + +// ─── Singleton base palettes ──────────────────────────────────────────────────── + +/** Light-mode base palette — must never be mutated. */ +export const MOL_LIGHT: Readonly = Object.freeze({ + accent: "bg-blue-500", + online: "bg-emerald-400", + surface: "bg-zinc-900", + ink: "text-zinc-100", + line: "border-zinc-700", + bg: "bg-zinc-950", + tier: "T1", +}); + +/** Dark-mode base palette — must never be mutated. */ +export const MOL_DARK: Readonly = Object.freeze({ + accent: "bg-sky-400", + online: "bg-emerald-400", + surface: "bg-zinc-800", + ink: "text-zinc-100", + line: "border-zinc-700", + bg: "bg-zinc-950", + tier: "T1", +}); + +// ─── Pure helpers ───────────────────────────────────────────────────────────── + +/** + * Maps workspace status string → online dot colour class. + * Returns the appropriate green for light/dark mode. + */ +export function normalizeStatus( + status: string, + _isDark: boolean, +): string { + if (status === "online" || status === "degraded") { + return "bg-emerald-400"; + } + if (status === "failed") { + return "bg-red-400"; + } + if (status === "paused" || status === "not_configured") { + return "bg-amber-400"; + } + return "bg-zinc-400"; +} + +/** + * Maps tier number → display code. + */ +export function tierCode(tier: number): string { + return `T${tier}`; +} + +/** + * Returns the effective palette. + * + * - `accent = null` → base palette (light or dark) unchanged + * - `accent = basePalette.accent` → base palette unchanged (identity guard) + * - `accent = "#custom"` → copy with `accent` and `online` overridden + * + * Always returns a new object; neither MOL_LIGHT nor MOL_DARK is ever mutated. + */ +export function getPalette( + accent: string | null, + isDark: boolean, +): Palette { + const base: Readonly = isDark ? MOL_DARK : MOL_LIGHT; + + // null accent → use base unchanged + if (accent === null) return { ...base }; + + // identity guard — accent same as base accent → no override needed + if (accent === base.accent) return { ...base }; + + // Custom accent: override accent + online to keep them in sync + return { ...base, accent, online: normalizeStatus("online", isDark) }; +} + +// ─── Context ────────────────────────────────────────────────────────────────── + +type MobileAccentContextValue = { + /** Override accent colour (null = no override, use default). */ + accent: string | null; +}; + +const MobileAccentContext = createContext({ + accent: null, +}); + +export { MobileAccentContext }; + +/** + * Renders children inside the accent override context. + */ +export function MobileAccentProvider({ + accent, + children, +}: { + accent: string | null; + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} + +// ─── Hook ───────────────────────────────────────────────────────────────────── + +/** + * Returns the effective `Palette` for the current context. + * + * @param allowAccentOverride When false, always returns the base palette + * even when an override is set (useful for + * non-accent-aware child components). + */ +export function usePalette(allowAccentOverride: boolean): Palette { + const { accent } = useContext(MobileAccentContext); + + // Resolved from the OS-level theme preference. In a real app this would + // be derived from useTheme().resolvedTheme; for this hook we default + // to light (the safe default for SSR / component-library use). + // We read data-theme from to stay in sync with the theme system. + const isDark = + typeof document !== "undefined" && + document.documentElement.dataset.theme === "dark"; + + const effectiveAccent = allowAccentOverride ? accent : null; + return getPalette(effectiveAccent, isDark); +}