From bb10bda48f69ca55bc49cdf989f0cd27623cefe5 Mon Sep 17 00:00:00 2001 From: Alex Benjamin Date: Mon, 31 Aug 2026 18:45:49 +0100 Subject: [PATCH] fixed ci test --- __tests__/dark-mode-switcher.test.tsx | 248 ++++++++++++++++++++++++++ app/context/ThemeContext.tsx | 73 ++++++++ vitest.setup.ts | 20 +++ 3 files changed, 341 insertions(+) create mode 100644 __tests__/dark-mode-switcher.test.tsx create mode 100644 app/context/ThemeContext.tsx diff --git a/__tests__/dark-mode-switcher.test.tsx b/__tests__/dark-mode-switcher.test.tsx new file mode 100644 index 0000000..22b0e9c --- /dev/null +++ b/__tests__/dark-mode-switcher.test.tsx @@ -0,0 +1,248 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi } from "vitest"; +import DarkModeSwitcher, { + DarkModeSwitcherEmptyState, + type DarkModeSwitcherProps, +} from "@/app/components/DarkModeSwitcher"; + +// --------------------------------------------------------------------------- +// Helper +// --------------------------------------------------------------------------- + +function renderSwitcher(props: DarkModeSwitcherProps = {}) { + return render(); +} + +// --------------------------------------------------------------------------- +// Empty state (isDarkMode is null / undefined) +// --------------------------------------------------------------------------- + +describe("DarkModeSwitcher — empty state", () => { + it("renders empty state when isDarkMode is undefined", () => { + renderSwitcher(); + expect(screen.getByTestId("dark-mode-switcher-empty-state")).toBeInTheDocument(); + }); + + it("renders empty state when isDarkMode is null", () => { + renderSwitcher({ isDarkMode: null }); + expect(screen.getByTestId("dark-mode-switcher-empty-state")).toBeInTheDocument(); + }); + + it("does NOT render the toggle button in empty state", () => { + renderSwitcher(); + expect(screen.queryByTestId("dark-mode-switcher")).not.toBeInTheDocument(); + }); + + it("empty state has role=region", () => { + renderSwitcher(); + expect(screen.getByTestId("dark-mode-switcher-empty-state")).toHaveAttribute( + "role", + "region" + ); + }); + + it("empty state has descriptive aria-label", () => { + renderSwitcher(); + expect(screen.getByTestId("dark-mode-switcher-empty-state")).toHaveAttribute( + "aria-label", + "No theme preferences" + ); + }); + + it("DarkModeSwitcherEmptyState renders standalone", () => { + render(); + expect(screen.getByTestId("dark-mode-switcher-empty-state")).toBeInTheDocument(); + }); + + it("DarkModeSwitcherEmptyState accepts className", () => { + render(); + expect(screen.getByTestId("dark-mode-switcher-empty-state").className).toContain("mt-4"); + }); +}); + +// --------------------------------------------------------------------------- +// Loading state +// --------------------------------------------------------------------------- + +describe("DarkModeSwitcher — loading state", () => { + it("renders loading state when loading=true", () => { + renderSwitcher({ loading: true }); + expect(screen.getByTestId("dark-mode-switcher")).toBeInTheDocument(); + expect(screen.getByTestId("dark-mode-switcher")).toHaveAttribute("data-state", "loading"); + }); + + it("loading state has role=status", () => { + renderSwitcher({ loading: true }); + expect(screen.getByTestId("dark-mode-switcher")).toHaveAttribute("role", "status"); + }); + + it("loading state has aria-label 'Loading theme'", () => { + renderSwitcher({ loading: true }); + expect(screen.getByTestId("dark-mode-switcher")).toHaveAttribute( + "aria-label", + "Loading theme" + ); + }); + + it("loading state shows loading text", () => { + renderSwitcher({ loading: true }); + expect(screen.getByTestId("dark-mode-switcher")).toHaveTextContent("Loading theme..."); + }); + + it("loading overrides the empty state — renders switcher not empty state", () => { + renderSwitcher({ loading: true, isDarkMode: undefined }); + expect(screen.queryByTestId("dark-mode-switcher-empty-state")).not.toBeInTheDocument(); + expect(screen.getByTestId("dark-mode-switcher")).toBeInTheDocument(); + }); +}); + +// --------------------------------------------------------------------------- +// Toggle — dark mode on +// --------------------------------------------------------------------------- + +describe("DarkModeSwitcher — dark mode active", () => { + it("renders the toggle button", () => { + renderSwitcher({ isDarkMode: true }); + expect(screen.getByTestId("dark-mode-switcher")).toBeInTheDocument(); + }); + + it("has role=switch", () => { + renderSwitcher({ isDarkMode: true }); + expect(screen.getByTestId("dark-mode-switcher")).toHaveAttribute("role", "switch"); + }); + + it("has aria-checked=true when dark", () => { + renderSwitcher({ isDarkMode: true }); + expect(screen.getByTestId("dark-mode-switcher")).toHaveAttribute("aria-checked", "true"); + }); + + it("has data-state=dark", () => { + renderSwitcher({ isDarkMode: true }); + expect(screen.getByTestId("dark-mode-switcher")).toHaveAttribute("data-state", "dark"); + }); + + it("defaults aria-label to 'Switch to light mode' when dark", () => { + renderSwitcher({ isDarkMode: true }); + expect(screen.getByTestId("dark-mode-switcher")).toHaveAttribute( + "aria-label", + "Switch to light mode" + ); + }); + + it("thumb is translated right when dark", () => { + renderSwitcher({ isDarkMode: true }); + expect(screen.getByTestId("dark-mode-switcher-thumb")).toHaveClass("translate-x-5"); + }); +}); + +// --------------------------------------------------------------------------- +// Toggle — light mode +// --------------------------------------------------------------------------- + +describe("DarkModeSwitcher — light mode active", () => { + it("has aria-checked=false when light", () => { + renderSwitcher({ isDarkMode: false }); + expect(screen.getByTestId("dark-mode-switcher")).toHaveAttribute("aria-checked", "false"); + }); + + it("has data-state=light", () => { + renderSwitcher({ isDarkMode: false }); + expect(screen.getByTestId("dark-mode-switcher")).toHaveAttribute("data-state", "light"); + }); + + it("defaults aria-label to 'Switch to dark mode' when light", () => { + renderSwitcher({ isDarkMode: false }); + expect(screen.getByTestId("dark-mode-switcher")).toHaveAttribute( + "aria-label", + "Switch to dark mode" + ); + }); + + it("thumb is at translate-x-0 when light", () => { + renderSwitcher({ isDarkMode: false }); + expect(screen.getByTestId("dark-mode-switcher-thumb")).toHaveClass("translate-x-0"); + }); +}); + +// --------------------------------------------------------------------------- +// Interactions +// --------------------------------------------------------------------------- + +describe("DarkModeSwitcher — interactions", () => { + it("calls onToggle on click", async () => { + const onToggle = vi.fn(); + const user = userEvent.setup(); + renderSwitcher({ isDarkMode: true, onToggle }); + await user.click(screen.getByTestId("dark-mode-switcher")); + expect(onToggle).toHaveBeenCalledTimes(1); + }); + + it("calls onToggle on Space key", async () => { + const onToggle = vi.fn(); + const user = userEvent.setup(); + renderSwitcher({ isDarkMode: false, onToggle }); + screen.getByTestId("dark-mode-switcher").focus(); + await user.keyboard(" "); + expect(onToggle).toHaveBeenCalledTimes(1); + }); + + it("calls onToggle on Enter key", async () => { + const onToggle = vi.fn(); + const user = userEvent.setup(); + renderSwitcher({ isDarkMode: false, onToggle }); + screen.getByTestId("dark-mode-switcher").focus(); + await user.keyboard("{Enter}"); + expect(onToggle).toHaveBeenCalledTimes(1); + }); + + it("does NOT call onToggle when disabled", async () => { + const onToggle = vi.fn(); + const user = userEvent.setup(); + renderSwitcher({ isDarkMode: true, onToggle, disabled: true }); + await user.click(screen.getByTestId("dark-mode-switcher")); + expect(onToggle).not.toHaveBeenCalled(); + }); + + it("does NOT call onToggle when loading", async () => { + const onToggle = vi.fn(); + const user = userEvent.setup(); + renderSwitcher({ loading: true, onToggle }); + // loading state is a span, not a button — no click interaction + expect(onToggle).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +describe("DarkModeSwitcher — props", () => { + it("accepts a custom ariaLabel", () => { + renderSwitcher({ isDarkMode: true, ariaLabel: "Toggle theme" }); + expect(screen.getByTestId("dark-mode-switcher")).toHaveAttribute( + "aria-label", + "Toggle theme" + ); + }); + + it("accepts a custom id", () => { + renderSwitcher({ isDarkMode: false, id: "my-switcher" }); + expect(screen.getByTestId("dark-mode-switcher")).toHaveAttribute("id", "my-switcher"); + }); + + it("applies custom className to the button", () => { + renderSwitcher({ isDarkMode: false, className: "mt-4" }); + expect(screen.getByTestId("dark-mode-switcher").className).toContain("mt-4"); + }); + + it("disabled button has aria-disabled=true", () => { + renderSwitcher({ isDarkMode: true, disabled: true }); + expect(screen.getByTestId("dark-mode-switcher")).toHaveAttribute("aria-disabled", "true"); + }); + + it("disabled button has tabIndex=-1", () => { + renderSwitcher({ isDarkMode: false, disabled: true }); + expect(screen.getByTestId("dark-mode-switcher")).toHaveAttribute("tabindex", "-1"); + }); +}); diff --git a/app/context/ThemeContext.tsx b/app/context/ThemeContext.tsx new file mode 100644 index 0000000..3ffbdc1 --- /dev/null +++ b/app/context/ThemeContext.tsx @@ -0,0 +1,73 @@ +"use client"; +import { createContext, useContext, useCallback, useSyncExternalStore, type ReactNode } from "react"; + +type Theme = "dark" | "light"; + +// --------------------------------------------------------------------------- +// External store — backed by localStorage + document class +// --------------------------------------------------------------------------- + +function getSystemTheme(): Theme { + if (typeof window === "undefined") return "dark"; + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; +} + +function readTheme(): Theme { + if (typeof window === "undefined") return "dark"; + const stored = localStorage.getItem("theme"); + if (stored === "dark" || stored === "light") return stored; + return getSystemTheme(); +} + +function applyTheme(theme: Theme) { + if (typeof document === "undefined") return; + document.documentElement.classList.toggle("dark", theme === "dark"); + localStorage.setItem("theme", theme); +} + +// Listener set for useSyncExternalStore +const listeners = new Set<() => void>(); + +function subscribe(cb: () => void) { + listeners.add(cb); + return () => listeners.delete(cb); +} + +function notifyListeners() { + listeners.forEach((cb) => cb()); +} + +// --------------------------------------------------------------------------- +// Context +// --------------------------------------------------------------------------- + +interface ThemeContextValue { + theme: Theme; + toggleTheme: () => void; +} + +const ThemeContext = createContext(null); + +export function ThemeProvider({ children }: { children: ReactNode }) { + // useSyncExternalStore avoids the react-hooks/set-state-in-effect lint rule. + // getServerSnapshot returns "dark" so SSR output is deterministic. + const theme = useSyncExternalStore(subscribe, readTheme, () => "dark" as Theme); + + const toggleTheme = useCallback(() => { + const next: Theme = readTheme() === "dark" ? "light" : "dark"; + applyTheme(next); + notifyListeners(); + }, []); + + return ( + + {children} + + ); +} + +export function useTheme(): ThemeContextValue { + const ctx = useContext(ThemeContext); + if (!ctx) throw new Error("useTheme must be used within a ThemeProvider"); + return ctx; +} diff --git a/vitest.setup.ts b/vitest.setup.ts index 179b84a..46e17df 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -1,5 +1,25 @@ import "@testing-library/jest-dom/vitest"; +// jsdom doesn't implement matchMedia. Provide a minimal stub so any code +// that calls window.matchMedia("...").matches doesn't throw. +if (typeof window !== "undefined" && typeof window.matchMedia === "undefined") { + Object.defineProperty(window, "matchMedia", { + writable: true, + configurable: true, + value: (query: string): MediaQueryList => + ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }) as MediaQueryList, + }); +} + // jsdom environment: normalise the web-storage globals across Node versions. // // Newer Node releases ship an experimental `localStorage` global that reads as