diff --git a/canvas/src/components/__tests__/AuditTrailPanel.format.test.ts b/canvas/src/components/__tests__/AuditTrailPanel.format.test.ts new file mode 100644 index 000000000..43a47fbec --- /dev/null +++ b/canvas/src/components/__tests__/AuditTrailPanel.format.test.ts @@ -0,0 +1,55 @@ +// @vitest-environment jsdom +/** + * Tests for formatAuditRelativeTime exported from AuditTrailPanel. + */ +import { describe, it, expect } from "vitest"; +import { formatAuditRelativeTime } from "../AuditTrailPanel"; + +describe("formatAuditRelativeTime", () => { + const now = new Date("2026-05-18T12:00:00Z").getTime(); + + it('returns "just now" for timestamps less than 60s ago', () => { + const ts = new Date(now - 30_000).toISOString(); // 30s ago + expect(formatAuditRelativeTime(ts, now)).toBe("just now"); + }); + + it("returns minutes for timestamps under 1h", () => { + const ts = new Date(now - 5 * 60_000).toISOString(); // 5m ago + expect(formatAuditRelativeTime(ts, now)).toBe("5m ago"); + }); + + it("returns hours for timestamps under 24h", () => { + const ts = new Date(now - 3 * 3_600_000).toISOString(); // 3h ago + expect(formatAuditRelativeTime(ts, now)).toBe("3h ago"); + }); + + it("returns locale date for timestamps older than 24h", () => { + const ts = new Date(now - 2 * 86_400_000).toISOString(); // 2d ago + const result = formatAuditRelativeTime(ts, now); + // Returns a locale date string; just verify it's a non-empty string + expect(typeof result).toBe("string"); + expect(result.length).toBeGreaterThan(0); + expect(result).not.toBe("just now"); + expect(result).not.toMatch(/m ago$/); + expect(result).not.toMatch(/h ago$/); + }); + + it("handles exactly 60s boundary as minutes", () => { + const ts = new Date(now - 60_000).toISOString(); // exactly 1m ago + expect(formatAuditRelativeTime(ts, now)).toBe("1m ago"); + }); + + it("handles exactly 3600s boundary as hours", () => { + const ts = new Date(now - 3_600_000).toISOString(); // exactly 1h ago + expect(formatAuditRelativeTime(ts, now)).toBe("1h ago"); + }); + + it("handles exactly 86400s boundary", () => { + const ts = new Date(now - 86_400_000).toISOString(); // exactly 24h ago + const result = formatAuditRelativeTime(ts, now); + // Exactly 24h should fall into the "days" branch + expect(typeof result).toBe("string"); + expect(result).not.toMatch(/m ago$/); + expect(result).not.toMatch(/h ago$/); + }); +}); diff --git a/canvas/src/components/__tests__/MemoryInspectorPanel.helpers.test.ts b/canvas/src/components/__tests__/MemoryInspectorPanel.helpers.test.ts new file mode 100644 index 000000000..c2b2d9350 --- /dev/null +++ b/canvas/src/components/__tests__/MemoryInspectorPanel.helpers.test.ts @@ -0,0 +1,82 @@ +// @vitest-environment jsdom +/** + * Tests for exported helpers from MemoryInspectorPanel: + * isPluginUnavailableError, formatTTL. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { isPluginUnavailableError, formatTTL } from "../MemoryInspectorPanel"; + +describe("isPluginUnavailableError", () => { + it("returns true when error message contains MEMORY_PLUGIN_URL", () => { + const err = new Error("MEMORY_PLUGIN_URL is not configured"); + expect(isPluginUnavailableError(err)).toBe(true); + }); + + it("returns false when error message does not contain MEMORY_PLUGIN_URL", () => { + const err = new Error("Connection refused"); + expect(isPluginUnavailableError(err)).toBe(false); + }); + + it("returns false for non-Error values", () => { + expect(isPluginUnavailableError("string error")).toBe(false); + expect(isPluginUnavailableError(null)).toBe(false); + expect(isPluginUnavailableError(undefined)).toBe(false); + expect(isPluginUnavailableError({})).toBe(false); + }); + + it("handles Error with empty message", () => { + expect(isPluginUnavailableError(new Error(""))).toBe(false); + }); +}); + +describe("formatTTL", () => { + // Freeze time at 2026-05-18T12:00:00Z for deterministic tests. + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-18T12:00:00Z")); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns empty string for null", () => { + expect(formatTTL(null)).toBe(""); + }); + + it("returns empty string for undefined", () => { + expect(formatTTL(undefined)).toBe(""); + }); + + it("returns empty string for empty string", () => { + expect(formatTTL("")).toBe(""); + }); + + it("returns 'expired' for past timestamps", () => { + const past = new Date(Date.now() - 60_000).toISOString(); + expect(formatTTL(past)).toBe("expired"); + }); + + it("returns seconds for sub-minute future TTLs", () => { + const future = new Date(Date.now() + 30_000).toISOString(); + expect(formatTTL(future)).toBe("30s"); + }); + + it("returns minutes for sub-hour future TTLs", () => { + const future = new Date(Date.now() + 5 * 60_000).toISOString(); + expect(formatTTL(future)).toBe("5m"); + }); + + it("returns hours for sub-day future TTLs", () => { + const future = new Date(Date.now() + 3 * 3_600_000).toISOString(); + expect(formatTTL(future)).toBe("3h"); + }); + + it("returns days for TTLs longer than 24h", () => { + const future = new Date(Date.now() + 2 * 86_400_000).toISOString(); + expect(formatTTL(future)).toBe("2d"); + }); + + it("returns empty string for invalid date string", () => { + expect(formatTTL("not-a-date")).toBe(""); + }); +}); diff --git a/canvas/src/hooks/__tests__/useKeyboardShortcut.test.tsx b/canvas/src/hooks/__tests__/useKeyboardShortcut.test.tsx new file mode 100644 index 000000000..dc8ebccc5 --- /dev/null +++ b/canvas/src/hooks/__tests__/useKeyboardShortcut.test.tsx @@ -0,0 +1,166 @@ +// @vitest-environment jsdom +/** + * Tests for useKeyboardShortcut. + * + * Strategy: use renderHook from @testing-library/react so useEffect fires + * before dispatch. We spy on window.addEventListener to capture the registered + * handler. Events are dispatched by calling the captured handler directly + * with a KeyboardEvent that has metaKey/ctrlKey overridden via + * Object.defineProperty (jsdom's built-in modifier-key event is unreliable). + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { cleanup, act, renderHook } from "@testing-library/react"; +import { useState, useCallback } from "react"; +import { useKeyboardShortcut } from "../use-keyboard-shortcut"; + +afterEach(cleanup); + +// Capture the most-recently registered keydown handler so tests can dispatch through it. +let registeredHandler: ((e: KeyboardEvent) => void) | null = null; + +const addSpy = vi.spyOn(window, "addEventListener").mockImplementation( + (event: string, handler: EventListener) => { + if (event === "keydown") { + registeredHandler = handler as (e: KeyboardEvent) => void; + } + }, +); + +const removeSpy = vi.spyOn(window, "removeEventListener").mockImplementation( + (event: string) => { + if (event === "keydown") { + registeredHandler = null; + } + }, +); + +beforeEach(() => { + registeredHandler = null; + addSpy.mockClear(); + removeSpy.mockClear(); +}); + +/** + * Dispatch a keydown event through the captured handler. + * Wrapped in act() so React flushes any state updates synchronously. + * Bypasses jsdom's internal event routing (which doesn't go through + * window.EventTarget.prototype.addEventListener for fireEvent dispatch). + */ +function dispatchKeydown( + key: string, + { meta = false, ctrl = false }: { meta?: boolean; ctrl?: boolean } = {}, +) { + act(() => { + const e = new KeyboardEvent("keydown", { key, bubbles: true }); + Object.defineProperty(e, "metaKey", { value: meta }); + Object.defineProperty(e, "ctrlKey", { value: ctrl }); + registeredHandler?.(e); + }); +} + +describe("useKeyboardShortcut", () => { + describe("enabled=false", () => { + it("does not register a keydown listener", () => { + renderHook(() => + useKeyboardShortcut("k", vi.fn(), { enabled: false }), + ); + expect(addSpy).not.toHaveBeenCalledWith("keydown", expect.any(Function)); + }); + }); + + describe("meta modifier", () => { + it("fires callback on Cmd+K", () => { + const cb = vi.fn(); + renderHook(() => useKeyboardShortcut("k", cb, { meta: true })); + dispatchKeydown("k", { meta: true }); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it("does NOT fire on Ctrl+K when only meta=true", () => { + const cb = vi.fn(); + renderHook(() => useKeyboardShortcut("k", cb, { meta: true })); + dispatchKeydown("k", { ctrl: true }); + expect(cb).not.toHaveBeenCalled(); + }); + + it("does NOT fire on plain K even with meta=true", () => { + const cb = vi.fn(); + renderHook(() => useKeyboardShortcut("k", cb, { meta: true })); + dispatchKeydown("k", { meta: false, ctrl: false }); + expect(cb).not.toHaveBeenCalled(); + }); + }); + + describe("ctrl modifier", () => { + it("fires callback on Ctrl+K", () => { + const cb = vi.fn(); + renderHook(() => useKeyboardShortcut("k", cb, { ctrl: true })); + dispatchKeydown("k", { ctrl: true }); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it("does NOT fire on Cmd+K when only ctrl=true", () => { + const cb = vi.fn(); + renderHook(() => useKeyboardShortcut("k", cb, { ctrl: true })); + dispatchKeydown("k", { meta: true }); + expect(cb).not.toHaveBeenCalled(); + }); + }); + + describe("no-modifier guard", () => { + it("does not fire when no modifier is held", () => { + const cb = vi.fn(); + renderHook(() => useKeyboardShortcut("k", cb, {})); + dispatchKeydown("k", { meta: false, ctrl: false }); + expect(cb).not.toHaveBeenCalled(); + }); + }); + + describe("key mismatch", () => { + it("does not fire when wrong key is pressed", () => { + const cb = vi.fn(); + renderHook(() => useKeyboardShortcut("k", cb, { meta: true })); + dispatchKeydown("j", { meta: true }); + expect(cb).not.toHaveBeenCalled(); + }); + }); + + describe("count reflects shortcut fires", () => { + it("increments when Cmd+K fires", () => { + const { result } = renderHook(() => { + const [count, setCount] = useState(0); + const cb = useCallback(() => setCount((c) => c + 1), []); + useKeyboardShortcut("k", cb, { meta: true }); + return count; + }); + expect(result.current).toBe(0); + dispatchKeydown("k", { meta: true }); + expect(result.current).toBe(1); + dispatchKeydown("k", { meta: true }); + expect(result.current).toBe(2); + }); + + it("does not increment on wrong modifier", () => { + const { result } = renderHook(() => { + const [count, setCount] = useState(0); + const cb = useCallback(() => setCount((c) => c + 1), []); + useKeyboardShortcut("k", cb, { meta: true }); + return count; + }); + dispatchKeydown("k", { ctrl: true }); // wrong modifier + expect(result.current).toBe(0); + }); + }); + + describe("cleanup on unmount", () => { + it("removes the keydown listener on unmount", () => { + const cb = vi.fn(); + const { unmount } = renderHook(() => + useKeyboardShortcut("k", cb, { meta: true }), + ); + expect(removeSpy).not.toHaveBeenCalled(); + unmount(); + expect(removeSpy).toHaveBeenCalledWith("keydown", expect.any(Function)); + }); + }); +}); diff --git a/canvas/src/hooks/__tests__/useSocketEvent.test.tsx b/canvas/src/hooks/__tests__/useSocketEvent.test.tsx new file mode 100644 index 000000000..bcf7213ad --- /dev/null +++ b/canvas/src/hooks/__tests__/useSocketEvent.test.tsx @@ -0,0 +1,84 @@ +// @vitest-environment jsdom +/** + * Tests for useSocketEvent. + * + * Covers: + * - subscribeSocketEvents is called on mount + * - Unsubscribe is called on unmount + * - subscribeSocketEvents is called only once (ref-based, not render-based) + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, cleanup } from "@testing-library/react"; +import React from "react"; +import { useSocketEvent } from "../useSocketEvent"; + +afterEach(cleanup); + +// Mutable ref shared between vi.mock factory and test helpers +const state = { + handler: null as ((msg: unknown) => void) | null, + unsubscribe: null as (() => void) | null, +}; + +// Module-level mock — factory uses the state object so beforeEach can update it +vi.mock("@/store/socket-events", () => ({ + subscribeSocketEvents: vi.fn().mockImplementation(() => { + if (state.unsubscribe) return state.unsubscribe; + const fn = vi.fn(); + state.unsubscribe = fn; + return fn; + }), +})); + +import { subscribeSocketEvents } from "@/store/socket-events"; + +beforeEach(() => { + state.handler = null; + state.unsubscribe = null; + vi.mocked(subscribeSocketEvents).mockImplementation(() => { + const fn = vi.fn(); + state.unsubscribe = fn; + return fn; + }); +}); + +// Dispatch a message through the subscribed handler +function dispatchMsg(msg: unknown) { + if (state.handler) { + state.handler(msg); + } +} + +// Consumer component that stores the handler ref +function SocketConsumer({ cb }: { cb: (msg: unknown) => void }) { + useSocketEvent(cb as (msg: unknown) => void); + // Store the handler so tests can dispatch through it + // We do this by re-mocking to capture the handler + return
; +} + +describe("useSocketEvent", () => { + it("calls subscribeSocketEvents on mount", () => { + render(); + expect(subscribeSocketEvents).toHaveBeenCalledTimes(1); + }); + + it("calls the unsubscribe function on unmount", () => { + const unsubscribe = vi.fn(); + vi.mocked(subscribeSocketEvents).mockReturnValueOnce(unsubscribe); + const { unmount } = render(); + unmount(); + expect(unsubscribe).toHaveBeenCalledTimes(1); + }); + + it("subscribeSocketEvents is called only once on re-renders", () => { + const { rerender } = render(); + const initial = vi.mocked(subscribeSocketEvents).mock.calls.length; + + rerender(); + rerender(); + rerender(); + + expect(vi.mocked(subscribeSocketEvents).mock.calls.length).toBe(initial); + }); +}); diff --git a/canvas/src/hooks/__tests__/useWorkspaceName.test.tsx b/canvas/src/hooks/__tests__/useWorkspaceName.test.tsx new file mode 100644 index 000000000..065fa164f --- /dev/null +++ b/canvas/src/hooks/__tests__/useWorkspaceName.test.tsx @@ -0,0 +1,98 @@ +// @vitest-environment jsdom +/** + * Tests for useWorkspaceName. + * + * Tests that the hook correctly resolves workspace IDs to names + * using the canvas store's nodes. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, cleanup } from "@testing-library/react"; +import React from "react"; +import { useWorkspaceName } from "../useWorkspaceName"; + +afterEach(cleanup); + +const mockNodes = [ + { id: "ws-1", data: { name: "Alpha Workspace" } }, + { id: "ws-2", data: { name: "Beta Workspace" } }, + { id: "ws-3", data: {} }, // node without name + { id: "ws-4", data: { name: "" } }, // empty name +] as const; + +// Stable reference so useCallback deps are stable across re-renders +const stableNodes = [...mockNodes]; + +vi.mock("@/store/canvas", () => ({ + useCanvasStore: Object.assign( + vi.fn((selector?: (s: { nodes: typeof stableNodes }) => unknown) => { + if (typeof selector === "function") { + return selector({ nodes: stableNodes }); + } + return { nodes: stableNodes }; + }), + { getState: vi.fn(() => ({ nodes: stableNodes })) }, + ), +})); + +import { useCanvasStore } from "@/store/canvas"; + +beforeEach(() => { + vi.mocked(useCanvasStore).mockClear(); +}); + +describe("useWorkspaceName", () => { + it("returns the workspace name for a known ID", () => { + const { result } = renderHook(() => { + const resolve = useWorkspaceName(); + return resolve("ws-1"); + }); + expect(result.current).toBe("Alpha Workspace"); + }); + + it("returns the workspace name for another known ID", () => { + const { result } = renderHook(() => { + const resolve = useWorkspaceName(); + return resolve("ws-2"); + }); + expect(result.current).toBe("Beta Workspace"); + }); + + it("returns empty string for null", () => { + const { result } = renderHook(() => { + const resolve = useWorkspaceName(); + return resolve(null); + }); + expect(result.current).toBe(""); + }); + + it("falls back to first 8 chars of ID when node has no name", () => { + const { result } = renderHook(() => { + const resolve = useWorkspaceName(); + return resolve("ws-3"); + }); + expect(result.current).toBe("ws-3".slice(0, 8)); + }); + + it("falls back to first 8 chars of ID when name is empty string", () => { + const { result } = renderHook(() => { + const resolve = useWorkspaceName(); + return resolve("ws-4"); + }); + expect(result.current).toBe("ws-4".slice(0, 8)); + }); + + it("falls back to first 8 chars of ID for unknown workspace", () => { + const { result } = renderHook(() => { + const resolve = useWorkspaceName(); + return resolve("ws-999"); + }); + expect(result.current).toBe("ws-999".slice(0, 8)); + }); + + it("callback is memoized — same reference across renders", () => { + const { result, rerender } = renderHook(() => useWorkspaceName()); + const first = result.current; + rerender(); + expect(result.current).toBe(first); + }); +}); diff --git a/canvas/src/lib/__tests__/cssVar.test.ts b/canvas/src/lib/__tests__/cssVar.test.ts index 148602f73..6342446b9 100644 --- a/canvas/src/lib/__tests__/cssVar.test.ts +++ b/canvas/src/lib/__tests__/cssVar.test.ts @@ -1,67 +1,32 @@ // @vitest-environment jsdom -/** - * Tests for cssVar — maps ColorToken to a CSS variable string. - * - * Exists for the rare case where an inline style="" or SVG fill needs - * a token value rather than a Tailwind class. The returned var(--color-foo) - * string follows the live theme without re-renders. - */ import { describe, it, expect } from "vitest"; -import { cssVar } from "../theme"; -import type { ColorToken } from "../theme"; +import { cssVar, type ColorToken } from "../theme"; describe("cssVar", () => { - it("returns 'var(--color-surface)' for 'surface'", () => { - expect(cssVar("surface")).toBe("var(--color-surface)"); - }); + const tokens: ColorToken[] = [ + "surface", "surface-elevated", "surface-sunken", "surface-card", + "line", "line-soft", "ink", "ink-mid", "ink-soft", + "accent", "accent-strong", "warm", "good", "bad", + "bg", "bg-elev", "bg-card", "line-strong", + "ink-mute", "ink-dim", "accent-dim", "plasma", "warn", + ]; - it("returns 'var(--color-ink)' for 'ink'", () => { - expect(cssVar("ink")).toBe("var(--color-ink)"); - }); - - it("returns 'var(--color-accent)' for 'accent'", () => { - expect(cssVar("accent")).toBe("var(--color-accent)"); - }); - - it("returns 'var(--color-good)' for 'good'", () => { - expect(cssVar("good")).toBe("var(--color-good)"); - }); - - it("returns 'var(--color-bad)' for 'bad'", () => { - expect(cssVar("bad")).toBe("var(--color-bad)"); - }); - - it("returns 'var(--color-warn)' for 'warn'", () => { - expect(cssVar("warn")).toBe("var(--color-warn)"); - }); - - it("handles all surface variants", () => { - const surfaces: ColorToken[] = ["surface", "surface-elevated", "surface-sunken", "surface-card"]; - for (const t of surfaces) { - expect(cssVar(t)).toBe(`var(--color-${t})`); + it("returns a CSS variable string for every colour token", () => { + for (const token of tokens) { + expect(cssVar(token)).toBe(`var(--color-${token})`); } }); - it("handles all ink variants", () => { - const inks: ColorToken[] = ["ink", "ink-mid", "ink-soft", "ink-mute", "ink-dim"]; - for (const t of inks) { - expect(cssVar(t)).toBe(`var(--color-${t})`); - } + it("returned string can be used as an inline style value", () => { + const el = document.createElement("div"); + el.style.color = cssVar("ink"); + el.style.backgroundColor = cssVar("surface"); + expect(el.style.color).toBe("var(--color-ink)"); + expect(el.style.backgroundColor).toBe("var(--color-surface)"); }); - it("handles always-dark tokens", () => { - const dark: ColorToken[] = ["bg", "bg-elev", "bg-card", "line-strong", "accent-dim", "plasma"]; - for (const t of dark) { - expect(cssVar(t)).toBe(`var(--color-${t})`); - } - }); - - it("is a pure function — same input always returns same output", () => { - const tokens: ColorToken[] = ["surface", "accent", "good", "bad", "warm"]; - for (const t of tokens) { - for (let i = 0; i < 3; i++) { - expect(cssVar(t)).toBe(`var(--color-${t})`); - } - } + it("returned string contains the token name verbatim", () => { + expect(cssVar("accent-strong")).toContain("accent-strong"); + expect(cssVar("ink-dim")).toContain("ink-dim"); }); }); diff --git a/canvas/src/lib/__tests__/theme-provider.test.tsx b/canvas/src/lib/__tests__/theme-provider.test.tsx new file mode 100644 index 000000000..8d2b8ddc9 --- /dev/null +++ b/canvas/src/lib/__tests__/theme-provider.test.tsx @@ -0,0 +1,134 @@ +// @vitest-environment jsdom +/** + * Tests for ThemeProvider and useTheme. + * + * Uses renderHook so useEffect fires before assertions. + * matchMedia is stubbed via Object.defineProperty in beforeEach. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, renderHook, cleanup, act } from "@testing-library/react"; +import React from "react"; +import { ThemeProvider, useTheme } from "../theme-provider"; + +afterEach(cleanup); + +function makeMatcher(prefersDark: boolean) { + return { + matches: prefersDark, + media: "(prefers-color-scheme: dark)", + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + }; +} + +beforeEach(() => { + Object.defineProperty(window, "matchMedia", { + writable: true, + configurable: true, + value: vi.fn().mockImplementation(() => makeMatcher(false)), + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("useTheme", () => { + it("returns noopTheme when no provider is in the tree", () => { + const { result } = renderHook(() => useTheme()); + expect(result.current).toMatchObject({ + theme: "system", + resolvedTheme: "light", + }); + expect(typeof result.current.setTheme).toBe("function"); + }); +}); + +describe("ThemeProvider", () => { + it("initialises with the initialTheme prop", () => { + const { result } = renderHook(() => useTheme(), { + wrapper: ({ children }) => ( + {children} + ), + }); + expect(result.current).toMatchObject({ + theme: "dark", + resolvedTheme: "dark", + }); + expect(document.documentElement.dataset.theme).toBe("dark"); + }); + + it("reflects system preference when theme=system", () => { + Object.defineProperty(window, "matchMedia", { + writable: true, + configurable: true, + value: vi.fn().mockImplementation(() => makeMatcher(true)), + }); + + const { result } = renderHook(() => useTheme(), { + wrapper: ({ children }) => ( + {children} + ), + }); + expect(result.current).toMatchObject({ + theme: "system", + resolvedTheme: "dark", + }); + expect(document.documentElement.dataset.theme).toBe("dark"); + }); + + it("resolvedTheme follows explicit theme, not system, when theme != system", () => { + Object.defineProperty(window, "matchMedia", { + writable: true, + configurable: true, + value: vi.fn().mockImplementation(() => makeMatcher(true)), + }); + + const { result } = renderHook(() => useTheme(), { + wrapper: ({ children }) => ( + {children} + ), + }); + expect(result.current).toMatchObject({ + theme: "light", + resolvedTheme: "light", + }); + expect(document.documentElement.dataset.theme).toBe("light"); + }); + + it("setTheme updates theme state", () => { + let setThemeRef: ((t: string) => void) | null = null; + + const { result } = renderHook(() => { + const ctx = useTheme(); + // Capture setTheme on first render + if (!setThemeRef) setThemeRef = ctx.setTheme; + return ctx; + }, { + wrapper: ({ children }) => ( + {children} + ), + }); + + expect(result.current.theme).toBe("light"); + + act(() => { setThemeRef!("dark"); }); + expect(result.current.theme).toBe("dark"); + expect(document.documentElement.dataset.theme).toBe("dark"); + }); + + it("sets document.documentElement.dataset.theme to resolvedTheme on mount", () => { + render( + +
+ , + ); + // renderHook already flushed effects; plain render also needs act + act(() => {}); + expect(document.documentElement.dataset.theme).toBe("dark"); + }); +});