diff --git a/canvas/src/lib/__tests__/theme-cookie.test.ts b/canvas/src/lib/__tests__/theme-cookie.test.ts index 018e382ec..a9a01ad08 100644 --- a/canvas/src/lib/__tests__/theme-cookie.test.ts +++ b/canvas/src/lib/__tests__/theme-cookie.test.ts @@ -1,9 +1,12 @@ // @vitest-environment jsdom /** - * Tests for readThemeCookie — parses a cookie value into a ThemePreference. + * Tests for theme-cookie.ts: + * - THEME_COOKIE constant + * - readThemeCookie + * - themeBootScript */ import { describe, it, expect } from "vitest"; -import { readThemeCookie } from "../theme-cookie"; +import { readThemeCookie, THEME_COOKIE, themeBootScript } from "../theme-cookie"; describe("readThemeCookie", () => { it('returns "light" when cookie value is "light"', () => { @@ -45,3 +48,63 @@ describe("readThemeCookie", () => { } }); }); + +// ── THEME_COOKIE ──────────────────────────────────────────────────────────────── + +describe("THEME_COOKIE", () => { + it("is a non-empty string", () => { + expect(typeof THEME_COOKIE).toBe("string"); + expect(THEME_COOKIE.length).toBeGreaterThan(0); + }); + + it("equals 'mol_theme'", () => { + expect(THEME_COOKIE).toBe("mol_theme"); + }); + + it("is stable — constant is not reassigned", () => { + const first = THEME_COOKIE; + const second = THEME_COOKIE; + expect(first).toBe(second); + }); +}); + +// ── themeBootScript ───────────────────────────────────────────────────────────── + +describe("themeBootScript", () => { + it("is a non-empty string", () => { + expect(typeof themeBootScript).toBe("string"); + expect(themeBootScript.length).toBeGreaterThan(0); + }); + + it("contains THEME_COOKIE value in the cookie-regex pattern", () => { + // The script reads document.cookie looking for mol_theme=... + expect(themeBootScript).toContain(THEME_COOKIE); + }); + + it("contains 'system', 'light', 'dark' in the match pattern", () => { + expect(themeBootScript).toContain("system"); + expect(themeBootScript).toContain("light"); + expect(themeBootScript).toContain("dark"); + }); + + it("contains data-theme assignment on documentElement", () => { + // The script sets document.documentElement.dataset.theme = resolved + expect(themeBootScript).toContain("dataset.theme"); + expect(themeBootScript).toContain("document.documentElement"); + }); + + it("contains matchMedia call for OS preference fallback", () => { + expect(themeBootScript).toContain("matchMedia"); + expect(themeBootScript).toContain("prefers-color-scheme"); + }); + + it("wraps the entire body in an IIFE so it runs immediately", () => { + expect(themeBootScript).toMatch(/^\(\(\)=>/); + }); + + it("is pure — constant evaluated once, same value every time", () => { + const a = themeBootScript; + const b = themeBootScript; + expect(a).toBe(b); + }); +}); diff --git a/canvas/src/lib/__tests__/theme-provider.test.tsx b/canvas/src/lib/__tests__/theme-provider.test.tsx new file mode 100644 index 000000000..88c01a74f --- /dev/null +++ b/canvas/src/lib/__tests__/theme-provider.test.tsx @@ -0,0 +1,277 @@ +// @vitest-environment jsdom +"use client"; +/** + * Tests for theme-provider.tsx: + * - applyResolvedTheme — pure DOM side-effect function + * - ThemeProvider — context, setTheme, resolvedTheme derivation + * - useTheme — hook + noop fallback + * + * Coverage gaps filled vs theme-cookie.test.ts (which tests only readThemeCookie): + * applyResolvedTheme, ThemeProvider initialTheme, resolvedTheme derivation + * from system preference, writeThemeCookie integration, useTheme noop fallback. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import React from "react"; +import { render, screen, cleanup, act, waitFor } from "@testing-library/react"; +import { applyResolvedTheme, ThemeProvider, useTheme } from "../theme-provider"; + +// ─── applyResolvedTheme ──────────────────────────────────────────────────────── + +describe("applyResolvedTheme", () => { + beforeEach(() => { + if (typeof document !== "undefined") { + delete (document.documentElement as Record).dataset; + } + }); + + afterEach(() => { + cleanup(); + if (typeof document !== "undefined") { + delete (document.documentElement as Record).dataset; + } + }); + + it('sets data-theme="light" on document.documentElement', () => { + applyResolvedTheme("light"); + expect(document.documentElement.dataset.theme).toBe("light"); + }); + + it('sets data-theme="dark" on document.documentElement', () => { + applyResolvedTheme("dark"); + expect(document.documentElement.dataset.theme).toBe("dark"); + }); + + it("is idempotent — calling twice with same value keeps the same attribute", () => { + applyResolvedTheme("dark"); + applyResolvedTheme("dark"); + expect(document.documentElement.dataset.theme).toBe("dark"); + }); + + it("is a pure function for its DOM side-effect — no return value", () => { + expect(applyResolvedTheme("light")).toBeUndefined(); + }); + + it("guards against undefined document (SSR safety)", () => { + // In Node.js / SSR context document is undefined; the function returns + // early without throwing. We simulate this by temporarily deleting document. + const saved = globalThis.document; + // @ts-expect-error — intentionally undefined for SSR test + globalThis.document = undefined; + expect(() => applyResolvedTheme("dark")).not.toThrow(); + globalThis.document = saved; + }); +}); + +// ─── ThemeProvider ───────────────────────────────────────────────────────────── + +describe("ThemeProvider", () => { + beforeEach(() => { + // Stub matchMedia so ThemeProvider's system-preference useEffect works in jsdom. + // Default to light mode (matches=false) so resolvedTheme="light" when theme="system". + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, // light preference by default + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); + }); + + afterEach(() => { + cleanup(); + if (typeof document !== "undefined") { + delete (document.documentElement as Record).dataset; + } + // Clear cookies set by writeThemeCookie. + if (typeof document !== "undefined") { + document.cookie = "mol_theme=; Max-Age=0"; + } + }); + + function ThemeChild() { + const { theme, resolvedTheme, setTheme } = useTheme(); + return ( +
+ {theme} + {resolvedTheme} + + +
+ ); + } + + it("renders children", () => { + render( + + Hello + , + ); + expect(screen.getByTestId("child")).toBeTruthy(); + }); + + it('initialTheme="light" sets theme=light', () => { + render( + + + , + ); + expect(screen.getByTestId("theme").textContent).toBe("light"); + }); + + it('initialTheme="dark" sets theme=dark', () => { + render( + + + , + ); + expect(screen.getByTestId("theme").textContent).toBe("dark"); + }); + + it('initialTheme="system" falls back to light (matchMedia stub)', () => { + // matchMedia is not stubbed in jsdom by default; the provider calls it + // and reads the OS preference. Without a stub, jsdom returns + // { matches: false } → "light". + render( + + + , + ); + // Resolved is "light" because jsdom matchMedia stub returns false for dark. + expect(screen.getByTestId("resolved").textContent).toBe("light"); + }); + + it("setTheme('dark') updates both theme and resolvedTheme", async () => { + render( + + + , + ); + expect(screen.getByTestId("theme").textContent).toBe("light"); + + await act(async () => { + screen.getByTestId("set-dark").click(); + }); + + expect(screen.getByTestId("theme").textContent).toBe("dark"); + // resolvedTheme tracks theme when not in system mode. + expect(screen.getByTestId("resolved").textContent).toBe("dark"); + }); + + it("setTheme('light') updates both theme and resolvedTheme", async () => { + render( + + + , + ); + await act(async () => { + screen.getByTestId("set-light").click(); + }); + expect(screen.getByTestId("theme").textContent).toBe("light"); + expect(screen.getByTestId("resolved").textContent).toBe("light"); + }); + + it("writes mol_theme cookie when setTheme is called", async () => { + render( + + + , + ); + await act(async () => { + screen.getByTestId("set-dark").click(); + }); + expect(document.cookie).toContain("mol_theme=dark"); + }); + + it("calls applyResolvedTheme on mount (data-theme set on )", () => { + render( + + hi + , + ); + expect(document.documentElement.dataset.theme).toBe("dark"); + }); + + it("calls applyResolvedTheme when resolvedTheme changes", async () => { + render( + + + , + ); + // Start at light. + expect(document.documentElement.dataset.theme).toBe("light"); + + await act(async () => { + screen.getByTestId("set-dark").click(); + }); + + expect(document.documentElement.dataset.theme).toBe("dark"); + }); +}); + +// ─── useTheme noop fallback ──────────────────────────────────────────────────── + +describe("useTheme without ThemeProvider", () => { + afterEach(() => { + cleanup(); + }); + + it("useTheme returns noopTheme when no provider is in the tree", () => { + function ShowTheme() { + const { theme, resolvedTheme, setTheme } = useTheme(); + return ( +
+ {theme} + {resolvedTheme} + {typeof setTheme} +
+ ); + } + render(); + // noopTheme defaults: theme="system", resolvedTheme="light", setTheme no-op. + expect(screen.getByTestId("theme").textContent).toBe("system"); + expect(screen.getByTestId("resolved").textContent).toBe("light"); + expect(screen.getByTestId("setTheme-type").textContent).toBe("function"); + }); + + it("setTheme is a no-op when no provider is present (no throw)", async () => { + let threw = false; + function ClickSetTheme() { + const { setTheme } = useTheme(); + return ( + + ); + } + render(); + await act(async () => { + screen.getByTestId("call-setTheme").click(); + }); + expect(threw).toBe(false); + }); +}); diff --git a/canvas/src/lib/theme-provider.tsx b/canvas/src/lib/theme-provider.tsx index 4ca783205..5baebe117 100644 --- a/canvas/src/lib/theme-provider.tsx +++ b/canvas/src/lib/theme-provider.tsx @@ -75,7 +75,7 @@ function writeThemeCookie(value: ThemePreference): void { document.cookie = parts.join("; "); } -function applyResolvedTheme(resolved: ResolvedTheme): void { +export function applyResolvedTheme(resolved: ResolvedTheme): void { if (typeof document === "undefined") return; document.documentElement.dataset.theme = resolved; }