test(canvas): add coverage for useKeyboardShortcut, useSocketEvent, cssVar, ThemeProvider, useWorkspaceName, AuditTrailPanel, MemoryInspectorPanel #1508
@@ -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$/);
|
||||
});
|
||||
});
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 <div data-testid="consumer" />;
|
||||
}
|
||||
|
||||
describe("useSocketEvent", () => {
|
||||
it("calls subscribeSocketEvents on mount", () => {
|
||||
render(<SocketConsumer cb={vi.fn()} />);
|
||||
expect(subscribeSocketEvents).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls the unsubscribe function on unmount", () => {
|
||||
const unsubscribe = vi.fn();
|
||||
vi.mocked(subscribeSocketEvents).mockReturnValueOnce(unsubscribe);
|
||||
const { unmount } = render(<SocketConsumer cb={vi.fn()} />);
|
||||
unmount();
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("subscribeSocketEvents is called only once on re-renders", () => {
|
||||
const { rerender } = render(<SocketConsumer cb={vi.fn()} />);
|
||||
const initial = vi.mocked(subscribeSocketEvents).mock.calls.length;
|
||||
|
||||
rerender(<SocketConsumer cb={vi.fn()} />);
|
||||
rerender(<SocketConsumer cb={vi.fn()} />);
|
||||
rerender(<SocketConsumer cb={vi.fn()} />);
|
||||
|
||||
expect(vi.mocked(subscribeSocketEvents).mock.calls.length).toBe(initial);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }) => (
|
||||
<ThemeProvider initialTheme="dark">{children}</ThemeProvider>
|
||||
),
|
||||
});
|
||||
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 }) => (
|
||||
<ThemeProvider initialTheme="system">{children}</ThemeProvider>
|
||||
),
|
||||
});
|
||||
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 }) => (
|
||||
<ThemeProvider initialTheme="light">{children}</ThemeProvider>
|
||||
),
|
||||
});
|
||||
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 }) => (
|
||||
<ThemeProvider initialTheme="light">{children}</ThemeProvider>
|
||||
),
|
||||
});
|
||||
|
||||
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(
|
||||
<ThemeProvider initialTheme="dark">
|
||||
<div />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
// renderHook already flushed effects; plain render also needs act
|
||||
act(() => {});
|
||||
expect(document.documentElement.dataset.theme).toBe("dark");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user