import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; import type { ReactNode } from "react"; // next-themes accesses window/localStorage on import; stub the hook so the // component is testable in a Node environment. const setThemeMock = vi.fn(); const useThemeReturn: { theme: string | undefined; setTheme: typeof setThemeMock } = { theme: "system", setTheme: setThemeMock, }; vi.mock("next-themes", () => ({ useTheme: () => useThemeReturn, })); // Radix DropdownMenu uses portals + client refs. Render trigger and items // inline so we can assert on the markup deterministically. vi.mock("@/components/ui/dropdown-menu", () => ({ DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}, DropdownMenuTrigger: ({ children }: { children: ReactNode; asChild?: boolean }) => <>{children}, DropdownMenuContent: ({ children }: { children: ReactNode }) => (
{children}
), DropdownMenuItem: ({ children, onClick, }: { children: ReactNode; onClick?: () => void; }) => ( ), })); import { ThemeToggle } from "./theme-toggle"; function setTheme(t: string | undefined) { useThemeReturn.theme = t; } describe("ThemeToggle — rendered markup", () => { beforeEach(() => { setThemeMock.mockReset(); setTheme("system"); }); it("shows the system label + monitor icon when theme is 'system'", () => { setTheme("system"); const html = renderToStaticMarkup(); expect(html).toMatch(/lucide-monitor/); expect(html).toContain(">system<"); }); it("shows the light label + sun icon when theme is 'light'", () => { setTheme("light"); const html = renderToStaticMarkup(); expect(html).toMatch(/lucide-sun/); expect(html).toContain(">light<"); }); it("shows the dark label + moon icon when theme is 'dark'", () => { setTheme("dark"); const html = renderToStaticMarkup(); expect(html).toMatch(/lucide-moon/); expect(html).toContain(">dark<"); }); it("falls back to 'system' label when next-themes returns undefined (pre-mount)", () => { setTheme(undefined); const html = renderToStaticMarkup(); expect(html).toContain(">system<"); }); it("renders three menu items (Light / Dark / System)", () => { setTheme("system"); const html = renderToStaticMarkup(); expect(html).toContain("Light"); expect(html).toContain("Dark"); expect(html).toContain("System"); // One trigger + three menu items. const items = html.match(/data-testid="dropdown-item"/g) ?? []; expect(items).toHaveLength(3); }); }); describe("ThemeToggle — onClick wires through to setTheme", () => { beforeEach(() => { setThemeMock.mockReset(); setTheme("system"); }); // We can't realistically click a server-rendered button. Instead reach // into the rendered React tree and grab the onClick handlers — that's // exactly what the click handler will fire in the browser, so it's a // faithful contract check. function getMenuItemHandlers(): Array<() => void> { // Render via React.createElement to keep the element tree. // Then walk it for the dropdown-item buttons. // (Easier than parsing the SSR markup.) const el = () as unknown as React.ReactElement; const handlers: Array<() => void> = []; function visit(node: unknown): void { if (!node) return; if (Array.isArray(node)) { for (const c of node) visit(c); return; } if (typeof node !== "object") return; const n = node as { props?: Record; type?: unknown }; const props = n.props ?? {}; if (typeof props.onClick === "function" && props["data-testid"] === "dropdown-item") { handlers.push(props.onClick as () => void); } // type can be a function component — render it once with its props // so we can recurse into its output. Component functions used here // (DropdownMenuItem mocks etc.) are pure render-children. if (typeof n.type === "function") { const rendered = (n.type as (p: unknown) => unknown)(props); visit(rendered); } if ("children" in props) { visit(props.children); } } visit(el); return handlers; } it("first menu item sets theme to 'light'", () => { const [light] = getMenuItemHandlers(); light?.(); expect(setThemeMock).toHaveBeenCalledTimes(1); expect(setThemeMock).toHaveBeenCalledWith("light"); }); it("second menu item sets theme to 'dark'", () => { const [, dark] = getMenuItemHandlers(); dark?.(); expect(setThemeMock).toHaveBeenCalledWith("dark"); }); it("third menu item sets theme to 'system'", () => { const [, , system] = getMenuItemHandlers(); system?.(); expect(setThemeMock).toHaveBeenCalledWith("system"); }); });