Files
fusion/packages/dashboard/app/hooks/__tests__/useTheme.test.ts
gsxdsm 9219323bc0 feat(FN-1277): hydrate dashboard theme settings from backend
- Hydrate useTheme from global settings while seeding initial state from localStorage cache to prevent flash
- Add write-through theme persistence so setter updates local cache immediately and asynchronously saves via updateGlobalSettings
- Clarify Settings modal save behavior for intentional idempotent global theme writes
- Expand useTheme and App tests to cover backend hydration, fallback handling, and new global settings fetch expectations
2026-04-08 11:58:18 -07:00

471 lines
15 KiB
TypeScript

import { readFileSync } from "node:fs";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { COLOR_THEMES, type Settings } from "@fusion/core";
import { useTheme, getThemeInitScript } from "../useTheme";
import { fetchGlobalSettings, updateGlobalSettings } from "../../api";
vi.mock("../../api", () => ({
fetchGlobalSettings: vi.fn(),
updateGlobalSettings: vi.fn(),
}));
const THEME_MODE_STORAGE_KEY = "kb-dashboard-theme-mode";
const COLOR_THEME_STORAGE_KEY = "kb-dashboard-color-theme";
const mockFetchGlobalSettings = vi.mocked(fetchGlobalSettings);
const mockUpdateGlobalSettings = vi.mocked(updateGlobalSettings);
describe("useTheme", () => {
// Mock localStorage
let localStorageMock: Record<string, string> = {};
// Mock matchMedia
let matchMediaListeners: Array<(e: { matches: boolean }) => void> = [];
let currentSystemDark = true;
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
// Reset mocks
localStorageMock = {};
matchMediaListeners = [];
currentSystemDark = true;
mockFetchGlobalSettings.mockReset();
mockUpdateGlobalSettings.mockReset();
// Default: keep hydration pending unless a test opts into explicit backend behavior.
mockFetchGlobalSettings.mockImplementation(() => new Promise(() => {}));
mockUpdateGlobalSettings.mockResolvedValue({} as Settings);
consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
// Mock localStorage
vi.stubGlobal("localStorage", {
getItem: (key: string) => localStorageMock[key] || null,
setItem: (key: string, value: string) => {
localStorageMock[key] = value;
},
removeItem: (key: string) => {
delete localStorageMock[key];
},
});
// Mock matchMedia
vi.stubGlobal("matchMedia", (query: string) => ({
matches: query === "(prefers-color-scheme: dark)" ? currentSystemDark : false,
media: query,
onchange: null,
addEventListener: (event: string, listener: (e: { matches: boolean }) => void) => {
if (event === "change") {
matchMediaListeners.push(listener);
}
},
removeEventListener: (event: string, listener: (e: { matches: boolean }) => void) => {
if (event === "change") {
matchMediaListeners = matchMediaListeners.filter((l) => l !== listener);
}
},
dispatchEvent: () => true,
}));
// Clear document attributes
document.documentElement.removeAttribute("data-theme");
document.documentElement.removeAttribute("data-color-theme");
});
afterEach(() => {
consoleWarnSpy.mockRestore();
vi.unstubAllGlobals();
});
it("initializes with default values when localStorage is empty", () => {
const { result } = renderHook(() => useTheme());
expect(result.current.themeMode).toBe("dark");
expect(result.current.colorTheme).toBe("default");
});
it("initializes from localStorage", () => {
localStorageMock[THEME_MODE_STORAGE_KEY] = "light";
localStorageMock[COLOR_THEME_STORAGE_KEY] = "ocean";
const { result } = renderHook(() => useTheme());
expect(result.current.themeMode).toBe("light");
expect(result.current.colorTheme).toBe("ocean");
});
it("hydrates themeMode from backend on mount", async () => {
mockFetchGlobalSettings.mockResolvedValue({ themeMode: "light" });
const { result } = renderHook(() => useTheme());
expect(result.current.themeMode).toBe("dark");
await waitFor(() => {
expect(result.current.themeMode).toBe("light");
});
expect(localStorageMock[THEME_MODE_STORAGE_KEY]).toBe("light");
});
it("hydrates colorTheme from backend on mount", async () => {
mockFetchGlobalSettings.mockResolvedValue({ colorTheme: "ocean" });
const { result } = renderHook(() => useTheme());
expect(result.current.colorTheme).toBe("default");
await waitFor(() => {
expect(result.current.colorTheme).toBe("ocean");
});
expect(localStorageMock[COLOR_THEME_STORAGE_KEY]).toBe("ocean");
});
it("prefers backend over localStorage on hydration", async () => {
localStorageMock[THEME_MODE_STORAGE_KEY] = "light";
mockFetchGlobalSettings.mockResolvedValue({ themeMode: "dark" });
const { result } = renderHook(() => useTheme());
expect(result.current.themeMode).toBe("light");
await waitFor(() => {
expect(result.current.themeMode).toBe("dark");
});
expect(localStorageMock[THEME_MODE_STORAGE_KEY]).toBe("dark");
});
it("keeps localStorage value when backend matches", async () => {
localStorageMock[THEME_MODE_STORAGE_KEY] = "dark";
mockFetchGlobalSettings.mockResolvedValue({ themeMode: "dark" });
const { result } = renderHook(() => useTheme());
expect(result.current.themeMode).toBe("dark");
await waitFor(() => {
expect(mockFetchGlobalSettings).toHaveBeenCalledTimes(1);
});
expect(result.current.themeMode).toBe("dark");
expect(localStorageMock[THEME_MODE_STORAGE_KEY]).toBe("dark");
});
it("write-through calls updateGlobalSettings on setThemeMode", () => {
const { result } = renderHook(() => useTheme());
act(() => {
result.current.setThemeMode("light");
});
expect(result.current.themeMode).toBe("light");
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith({ themeMode: "light" });
});
it("write-through calls updateGlobalSettings on setColorTheme", () => {
const { result } = renderHook(() => useTheme());
act(() => {
result.current.setColorTheme("forest");
});
expect(result.current.colorTheme).toBe("forest");
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith({ colorTheme: "forest" });
});
it("write-through updates localStorage immediately", () => {
let resolveUpdate: (value: Settings) => void;
const pendingUpdate = new Promise<Settings>((resolve) => {
resolveUpdate = resolve;
});
mockUpdateGlobalSettings.mockReturnValue(pendingUpdate);
const { result } = renderHook(() => useTheme());
act(() => {
result.current.setThemeMode("system");
});
expect(localStorageMock[THEME_MODE_STORAGE_KEY]).toBe("system");
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith({ themeMode: "system" });
resolveUpdate!({} as Settings);
});
it("backend hydration failure falls back to localStorage", async () => {
localStorageMock[THEME_MODE_STORAGE_KEY] = "light";
mockFetchGlobalSettings.mockRejectedValue(new Error("network unavailable"));
const { result } = renderHook(() => useTheme());
await waitFor(() => {
expect(mockFetchGlobalSettings).toHaveBeenCalledTimes(1);
});
expect(result.current.themeMode).toBe("light");
expect(consoleWarnSpy).toHaveBeenCalledWith(
"[useTheme] Failed to hydrate theme from global settings",
expect.any(Error),
);
});
it("updates theme mode", () => {
const { result } = renderHook(() => useTheme());
act(() => {
result.current.setThemeMode("light");
});
expect(result.current.themeMode).toBe("light");
expect(localStorageMock[THEME_MODE_STORAGE_KEY]).toBe("light");
});
it("updates color theme", () => {
const { result } = renderHook(() => useTheme());
act(() => {
result.current.setColorTheme("forest");
});
expect(result.current.colorTheme).toBe("forest");
expect(localStorageMock[COLOR_THEME_STORAGE_KEY]).toBe("forest");
});
it("sets data-theme attribute on document", () => {
renderHook(() => useTheme());
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
});
it("sets data-color-theme attribute on document", () => {
localStorageMock[COLOR_THEME_STORAGE_KEY] = "sunset";
renderHook(() => useTheme());
expect(document.documentElement.getAttribute("data-color-theme")).toBe("sunset");
});
it("handles system theme mode by setting effective theme", () => {
currentSystemDark = false;
localStorageMock[THEME_MODE_STORAGE_KEY] = "system";
renderHook(() => useTheme());
// When system is light, data-theme should be "light"
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
});
it("detects system dark preference", () => {
currentSystemDark = true;
const { result } = renderHook(() => useTheme());
expect(result.current.isSystemDark).toBe(true);
});
it("detects system light preference", () => {
currentSystemDark = false;
const { result } = renderHook(() => useTheme());
expect(result.current.isSystemDark).toBe(false);
});
it("reacts to system theme changes", () => {
const { result } = renderHook(() => useTheme());
// Initially dark
expect(result.current.isSystemDark).toBe(true);
// Simulate system theme change to light
act(() => {
currentSystemDark = false;
matchMediaListeners.forEach((listener) => listener({ matches: false }));
});
expect(result.current.isSystemDark).toBe(false);
});
it("updates effective theme when system changes in system mode", () => {
localStorageMock[THEME_MODE_STORAGE_KEY] = "system";
renderHook(() => useTheme());
// Initially dark
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
// Simulate system theme change to light
act(() => {
currentSystemDark = false;
matchMediaListeners.forEach((listener) => listener({ matches: false }));
});
// Should update to light
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
});
it("applies factory theme attributes", () => {
localStorageMock[COLOR_THEME_STORAGE_KEY] = "factory";
renderHook(() => useTheme());
expect(document.documentElement.getAttribute("data-color-theme")).toBe("factory");
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
});
it("applies nord theme attributes", () => {
localStorageMock[COLOR_THEME_STORAGE_KEY] = "nord";
renderHook(() => useTheme());
expect(document.documentElement.getAttribute("data-color-theme")).toBe("nord");
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
});
it("applies dracula theme attributes", () => {
localStorageMock[COLOR_THEME_STORAGE_KEY] = "dracula";
renderHook(() => useTheme());
expect(document.documentElement.getAttribute("data-color-theme")).toBe("dracula");
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
});
it("applies gruvbox theme attributes", () => {
localStorageMock[COLOR_THEME_STORAGE_KEY] = "gruvbox";
renderHook(() => useTheme());
expect(document.documentElement.getAttribute("data-color-theme")).toBe("gruvbox");
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
});
it("applies tokyo-night theme attributes", () => {
localStorageMock[COLOR_THEME_STORAGE_KEY] = "tokyo-night";
renderHook(() => useTheme());
expect(document.documentElement.getAttribute("data-color-theme")).toBe("tokyo-night");
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
});
it("applies factory-specific design tokens from the stylesheet", () => {
const style = document.createElement("style");
style.textContent = readFileSync("app/styles.css", "utf8");
document.head.appendChild(style);
localStorageMock[COLOR_THEME_STORAGE_KEY] = "factory";
renderHook(() => useTheme());
const styles = getComputedStyle(document.documentElement);
expect(styles.getPropertyValue("--radius-md").trim()).toBe("4px");
expect(styles.getPropertyValue("--btn-padding").trim()).toBe("6px 12px");
expect(styles.getPropertyValue("--font-primary")).toContain("JetBrains Mono");
document.head.removeChild(style);
});
it("supports all valid theme modes", () => {
const { result } = renderHook(() => useTheme());
act(() => result.current.setThemeMode("dark"));
expect(result.current.themeMode).toBe("dark");
act(() => result.current.setThemeMode("light"));
expect(result.current.themeMode).toBe("light");
act(() => result.current.setThemeMode("system"));
expect(result.current.themeMode).toBe("system");
});
it("supports all valid color themes", () => {
const { result } = renderHook(() => useTheme());
COLOR_THEMES.forEach((theme) => {
act(() => result.current.setColorTheme(theme));
expect(result.current.colorTheme).toBe(theme);
});
});
it("ignores invalid theme mode in localStorage", () => {
localStorageMock[THEME_MODE_STORAGE_KEY] = "invalid";
const { result } = renderHook(() => useTheme());
expect(result.current.themeMode).toBe("dark");
});
it("ignores invalid color theme in localStorage", () => {
localStorageMock[COLOR_THEME_STORAGE_KEY] = "invalid-theme";
const { result } = renderHook(() => useTheme());
expect(result.current.colorTheme).toBe("default");
});
it("falls back to defaults when localStorage throws", () => {
vi.stubGlobal("localStorage", {
getItem: () => {
throw new Error("localStorage disabled");
},
setItem: () => {
throw new Error("localStorage disabled");
},
removeItem: () => {
throw new Error("localStorage disabled");
},
});
const { result } = renderHook(() => useTheme());
expect(result.current.themeMode).toBe("dark");
expect(result.current.colorTheme).toBe("default");
});
});
describe("getThemeInitScript", () => {
it("returns a script string", () => {
const script = getThemeInitScript();
expect(typeof script).toBe("string");
expect(script).toContain("localStorage");
expect(script).toContain("data-theme");
expect(script).toContain("data-color-theme");
});
it("includes the correct localStorage keys", () => {
const script = getThemeInitScript();
expect(script).toContain(THEME_MODE_STORAGE_KEY);
expect(script).toContain(COLOR_THEME_STORAGE_KEY);
});
it("includes every supported theme in the validated theme list", () => {
const script = getThemeInitScript();
COLOR_THEMES.forEach((theme) => {
expect(script).toContain(theme);
});
expect(script).toContain("validThemes");
expect(script).toContain("colorTheme = 'default'");
});
it("keeps index.html inline theme validation in sync with supported themes", () => {
const indexHtml = readFileSync("app/index.html", "utf8");
COLOR_THEMES.forEach((theme) => {
expect(indexHtml).toContain(`'${theme}'`);
});
expect(indexHtml).toContain("validThemes");
});
it("handles system theme in script", () => {
const script = getThemeInitScript();
expect(script).toContain("prefers-color-scheme");
expect(script).toContain("systemDark");
expect(script).toContain("effectiveMode");
});
});