Files
stack/apps/web/src/providers/ThemeProvider.test.tsx
Jason Woltje 79286e98c6
All checks were successful
ci/woodpecker/push/web Pipeline was successful
feat(web): upgrade ThemeProvider for multi-theme registry (#494)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 14:09:10 +00:00

342 lines
8.6 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, act } from "@testing-library/react";
import { ThemeProvider, useTheme } from "./ThemeProvider";
function ThemeConsumer(): React.JSX.Element {
const { theme, themeId, themeDefinition, resolvedTheme, setTheme, toggleTheme } = useTheme();
return (
<div>
<span data-testid="theme">{theme}</span>
<span data-testid="themeId">{themeId}</span>
<span data-testid="themeName">{themeDefinition.name}</span>
<span data-testid="resolved">{resolvedTheme}</span>
<button
onClick={() => {
setTheme("light");
}}
>
Set Light
</button>
<button
onClick={() => {
setTheme("dark");
}}
>
Set Dark
</button>
<button
onClick={() => {
setTheme("nord");
}}
>
Set Nord
</button>
<button
onClick={() => {
setTheme("dracula");
}}
>
Set Dracula
</button>
<button
onClick={() => {
setTheme("system");
}}
>
Set System
</button>
<button
onClick={() => {
toggleTheme();
}}
>
Toggle
</button>
</div>
);
}
describe("ThemeProvider", (): void => {
let mockMatchMedia: ReturnType<typeof vi.fn>;
beforeEach((): void => {
localStorage.clear();
document.documentElement.removeAttribute("data-theme");
// Clear any inline style properties set by theme application
document.documentElement.removeAttribute("style");
mockMatchMedia = vi.fn().mockReturnValue({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
Object.defineProperty(window, "matchMedia", {
writable: true,
value: mockMatchMedia,
});
});
afterEach((): void => {
vi.restoreAllMocks();
});
it("should use 'mosaic-theme' as storage key", (): void => {
localStorage.setItem("mosaic-theme", "light");
render(
<ThemeProvider>
<ThemeConsumer />
</ThemeProvider>
);
expect(screen.getByTestId("theme")).toHaveTextContent("light");
expect(screen.getByTestId("themeId")).toHaveTextContent("light");
});
it("should NOT read from old 'jarvis-theme' storage key", (): void => {
localStorage.setItem("jarvis-theme", "light");
render(
<ThemeProvider>
<ThemeConsumer />
</ThemeProvider>
);
expect(screen.getByTestId("theme")).toHaveTextContent("system");
});
it("should store theme under 'mosaic-theme' key", (): void => {
render(
<ThemeProvider>
<ThemeConsumer />
</ThemeProvider>
);
act(() => {
screen.getByText("Set Light").click();
});
expect(localStorage.getItem("mosaic-theme")).toBe("light");
expect(localStorage.getItem("jarvis-theme")).toBeNull();
});
it("should render children", (): void => {
render(
<ThemeProvider>
<div data-testid="child">Hello</div>
</ThemeProvider>
);
expect(screen.getByTestId("child")).toBeInTheDocument();
});
it("should throw when useTheme is used outside provider", (): void => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {
// Intentionally empty
});
expect(() => {
render(<ThemeConsumer />);
}).toThrow("useTheme must be used within a ThemeProvider");
consoleSpy.mockRestore();
});
it("should resolve 'system' to dark when OS prefers dark", (): void => {
mockMatchMedia.mockReturnValue({
matches: true,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
render(
<ThemeProvider>
<ThemeConsumer />
</ThemeProvider>
);
expect(screen.getByTestId("themeId")).toHaveTextContent("dark");
expect(screen.getByTestId("resolved")).toHaveTextContent("dark");
});
it("should resolve 'system' to light when OS prefers light", (): void => {
mockMatchMedia.mockReturnValue({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
render(
<ThemeProvider>
<ThemeConsumer />
</ThemeProvider>
);
expect(screen.getByTestId("themeId")).toHaveTextContent("light");
expect(screen.getByTestId("resolved")).toHaveTextContent("light");
});
it("should support Nord theme", (): void => {
localStorage.setItem("mosaic-theme", "nord");
render(
<ThemeProvider>
<ThemeConsumer />
</ThemeProvider>
);
expect(screen.getByTestId("theme")).toHaveTextContent("nord");
expect(screen.getByTestId("themeId")).toHaveTextContent("nord");
expect(screen.getByTestId("themeName")).toHaveTextContent("Nord");
expect(screen.getByTestId("resolved")).toHaveTextContent("dark");
});
it("should support Dracula theme", (): void => {
localStorage.setItem("mosaic-theme", "dracula");
render(
<ThemeProvider>
<ThemeConsumer />
</ThemeProvider>
);
expect(screen.getByTestId("themeId")).toHaveTextContent("dracula");
expect(screen.getByTestId("resolved")).toHaveTextContent("dark");
});
it("should support Solarized Dark theme", (): void => {
localStorage.setItem("mosaic-theme", "solarized-dark");
render(
<ThemeProvider>
<ThemeConsumer />
</ThemeProvider>
);
expect(screen.getByTestId("themeId")).toHaveTextContent("solarized-dark");
expect(screen.getByTestId("resolved")).toHaveTextContent("dark");
});
it("should fall back to system for unknown theme IDs", (): void => {
localStorage.setItem("mosaic-theme", "nonexistent-theme");
render(
<ThemeProvider>
<ThemeConsumer />
</ThemeProvider>
);
// Falls back to "system" because "nonexistent-theme" is not a valid theme ID
expect(screen.getByTestId("theme")).toHaveTextContent("system");
});
it("should switch between themes via setTheme", (): void => {
render(
<ThemeProvider>
<ThemeConsumer />
</ThemeProvider>
);
act(() => {
screen.getByText("Set Nord").click();
});
expect(screen.getByTestId("themeId")).toHaveTextContent("nord");
expect(screen.getByTestId("themeName")).toHaveTextContent("Nord");
expect(localStorage.getItem("mosaic-theme")).toBe("nord");
act(() => {
screen.getByText("Set Dracula").click();
});
expect(screen.getByTestId("themeId")).toHaveTextContent("dracula");
expect(localStorage.getItem("mosaic-theme")).toBe("dracula");
});
it("should toggle between dark and light", (): void => {
localStorage.setItem("mosaic-theme", "dark");
render(
<ThemeProvider>
<ThemeConsumer />
</ThemeProvider>
);
expect(screen.getByTestId("resolved")).toHaveTextContent("dark");
act(() => {
screen.getByText("Toggle").click();
});
expect(screen.getByTestId("themeId")).toHaveTextContent("light");
expect(screen.getByTestId("resolved")).toHaveTextContent("light");
});
it("should toggle from a dark theme (nord) to light", (): void => {
localStorage.setItem("mosaic-theme", "nord");
render(
<ThemeProvider>
<ThemeConsumer />
</ThemeProvider>
);
expect(screen.getByTestId("resolved")).toHaveTextContent("dark");
act(() => {
screen.getByText("Toggle").click();
});
expect(screen.getByTestId("themeId")).toHaveTextContent("light");
expect(screen.getByTestId("resolved")).toHaveTextContent("light");
});
it("should apply CSS variables on theme change", (): void => {
render(
<ThemeProvider>
<ThemeConsumer />
</ThemeProvider>
);
act(() => {
screen.getByText("Set Nord").click();
});
// Nord's bg-900 is #2e3440
const bgValue = document.documentElement.style.getPropertyValue("--ms-bg-900");
expect(bgValue).toBe("#2e3440");
});
it("should set data-theme attribute based on isDark", (): void => {
render(
<ThemeProvider>
<ThemeConsumer />
</ThemeProvider>
);
act(() => {
screen.getByText("Set Nord").click();
});
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
act(() => {
screen.getByText("Set Light").click();
});
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
});
it("should expose themeDefinition with full theme data", (): void => {
localStorage.setItem("mosaic-theme", "dark");
render(
<ThemeProvider>
<ThemeConsumer />
</ThemeProvider>
);
expect(screen.getByTestId("themeName")).toHaveTextContent("Dark");
});
});