diff --git a/apps/web/src/components/theme-toggle.test.tsx b/apps/web/src/components/theme-toggle.test.tsx new file mode 100644 index 0000000..034eb07 --- /dev/null +++ b/apps/web/src/components/theme-toggle.test.tsx @@ -0,0 +1,149 @@ +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"); + }); +});