Add quick-win tests for components and hooks

Adds tests for DifficultyIndicator, Toast, RollModeMenu, OverflowMenu,
useTheme, and useRulesEdition. Covers rendering, user interactions,
auto-dismiss timers, external store sync, and localStorage persistence.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-28 19:28:58 +01:00
parent d8c8a0c44d
commit a77db0eeee
7 changed files with 385 additions and 6 deletions

View File

@@ -0,0 +1,59 @@
// @vitest-environment jsdom
import type { DifficultyResult } from "@initiative/domain";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { DifficultyIndicator } from "../difficulty-indicator.js";
afterEach(cleanup);
function makeResult(tier: DifficultyResult["tier"]): DifficultyResult {
return {
tier,
totalMonsterXp: 100,
partyBudget: { low: 50, moderate: 100, high: 200 },
};
}
describe("DifficultyIndicator", () => {
it("renders 3 bars", () => {
const { container } = render(
<DifficultyIndicator result={makeResult("moderate")} />,
);
const bars = container.querySelectorAll("[class*='rounded-sm']");
expect(bars).toHaveLength(3);
});
it("shows 'Trivial encounter difficulty' label for trivial tier", () => {
render(<DifficultyIndicator result={makeResult("trivial")} />);
expect(
screen.getByRole("img", {
name: "Trivial encounter difficulty",
}),
).toBeDefined();
});
it("shows 'Low encounter difficulty' label for low tier", () => {
render(<DifficultyIndicator result={makeResult("low")} />);
expect(
screen.getByRole("img", { name: "Low encounter difficulty" }),
).toBeDefined();
});
it("shows 'Moderate encounter difficulty' label for moderate tier", () => {
render(<DifficultyIndicator result={makeResult("moderate")} />);
expect(
screen.getByRole("img", {
name: "Moderate encounter difficulty",
}),
).toBeDefined();
});
it("shows 'High encounter difficulty' label for high tier", () => {
render(<DifficultyIndicator result={makeResult("high")} />);
expect(
screen.getByRole("img", {
name: "High encounter difficulty",
}),
).toBeDefined();
});
});

View File

@@ -0,0 +1,89 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { Circle } from "lucide-react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { OverflowMenu } from "../ui/overflow-menu.js";
afterEach(cleanup);
const items = [
{ icon: <Circle />, label: "Action A", onClick: vi.fn() },
{ icon: <Circle />, label: "Action B", onClick: vi.fn() },
];
describe("OverflowMenu", () => {
it("renders toggle button", () => {
render(<OverflowMenu items={items} />);
expect(screen.getByRole("button", { name: "More actions" })).toBeDefined();
});
it("does not show menu items when closed", () => {
render(<OverflowMenu items={items} />);
expect(screen.queryByText("Action A")).toBeNull();
});
it("shows menu items when toggled open", async () => {
render(<OverflowMenu items={items} />);
await userEvent.click(screen.getByRole("button", { name: "More actions" }));
expect(screen.getByText("Action A")).toBeDefined();
expect(screen.getByText("Action B")).toBeDefined();
});
it("closes menu after clicking an item", async () => {
const onClick = vi.fn();
render(
<OverflowMenu items={[{ icon: <Circle />, label: "Do it", onClick }]} />,
);
await userEvent.click(screen.getByRole("button", { name: "More actions" }));
await userEvent.click(screen.getByText("Do it"));
expect(onClick).toHaveBeenCalledOnce();
expect(screen.queryByText("Do it")).toBeNull();
});
it("keeps menu open when keepOpen is true", async () => {
const onClick = vi.fn();
render(
<OverflowMenu
items={[
{
icon: <Circle />,
label: "Stay",
onClick,
keepOpen: true,
},
]}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "More actions" }));
await userEvent.click(screen.getByText("Stay"));
expect(onClick).toHaveBeenCalledOnce();
expect(screen.getByText("Stay")).toBeDefined();
});
it("disables items when disabled is true", async () => {
const onClick = vi.fn();
render(
<OverflowMenu
items={[
{
icon: <Circle />,
label: "Nope",
onClick,
disabled: true,
},
]}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "More actions" }));
const item = screen.getByText("Nope");
expect(item.closest("button")?.hasAttribute("disabled")).toBe(true);
});
});

View File

@@ -0,0 +1,55 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { RollModeMenu } from "../roll-mode-menu.js";
afterEach(cleanup);
describe("RollModeMenu", () => {
it("renders advantage and disadvantage buttons", () => {
render(
<RollModeMenu
position={{ x: 100, y: 100 }}
onSelect={() => {}}
onClose={() => {}}
/>,
);
expect(screen.getByText("Advantage")).toBeDefined();
expect(screen.getByText("Disadvantage")).toBeDefined();
});
it("calls onSelect with 'advantage' and onClose when clicked", async () => {
const onSelect = vi.fn();
const onClose = vi.fn();
render(
<RollModeMenu
position={{ x: 100, y: 100 }}
onSelect={onSelect}
onClose={onClose}
/>,
);
await userEvent.click(screen.getByText("Advantage"));
expect(onSelect).toHaveBeenCalledWith("advantage");
expect(onClose).toHaveBeenCalled();
});
it("calls onSelect with 'disadvantage' and onClose when clicked", async () => {
const onSelect = vi.fn();
const onClose = vi.fn();
render(
<RollModeMenu
position={{ x: 100, y: 100 }}
onSelect={onSelect}
onClose={onClose}
/>,
);
await userEvent.click(screen.getByText("Disadvantage"));
expect(onSelect).toHaveBeenCalledWith("disadvantage");
expect(onClose).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,68 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Toast } from "../toast.js";
afterEach(cleanup);
describe("Toast", () => {
it("renders message text", () => {
render(<Toast message="Hello" onDismiss={() => {}} />);
expect(screen.getByText("Hello")).toBeDefined();
});
it("renders progress bar when progress is provided", () => {
render(<Toast message="Loading" progress={0.5} onDismiss={() => {}} />);
const bar = document.body.querySelector("[style*='width']") as HTMLElement;
expect(bar).not.toBeNull();
expect(bar.style.width).toBe("50%");
});
it("does not render progress bar when progress is omitted", () => {
render(<Toast message="Done" onDismiss={() => {}} />);
const bar = document.body.querySelector("[style*='width']");
expect(bar).toBeNull();
});
it("calls onDismiss when close button is clicked", async () => {
const onDismiss = vi.fn();
render(<Toast message="Hi" onDismiss={onDismiss} />);
const toast = screen.getByText("Hi").closest("div");
const button = toast?.querySelector("button");
expect(button).not.toBeNull();
await userEvent.click(button as HTMLElement);
expect(onDismiss).toHaveBeenCalledOnce();
});
describe("auto-dismiss", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("auto-dismisses after specified timeout", () => {
const onDismiss = vi.fn();
render(
<Toast message="Auto" onDismiss={onDismiss} autoDismissMs={3000} />,
);
expect(onDismiss).not.toHaveBeenCalled();
vi.advanceTimersByTime(3000);
expect(onDismiss).toHaveBeenCalledOnce();
});
it("does not auto-dismiss when autoDismissMs is omitted", () => {
const onDismiss = vi.fn();
render(<Toast message="Stay" onDismiss={onDismiss} />);
vi.advanceTimersByTime(10000);
expect(onDismiss).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,45 @@
// @vitest-environment jsdom
import { act, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { useRulesEdition } from "../use-rules-edition.js";
const STORAGE_KEY = "initiative:rules-edition";
describe("useRulesEdition", () => {
afterEach(() => {
// Reset to default
const { result } = renderHook(() => useRulesEdition());
act(() => result.current.setEdition("5.5e"));
localStorage.removeItem(STORAGE_KEY);
});
it("defaults to 5.5e", () => {
const { result } = renderHook(() => useRulesEdition());
expect(result.current.edition).toBe("5.5e");
});
it("setEdition updates value", () => {
const { result } = renderHook(() => useRulesEdition());
act(() => result.current.setEdition("5e"));
expect(result.current.edition).toBe("5e");
});
it("setEdition persists to localStorage", () => {
const { result } = renderHook(() => useRulesEdition());
act(() => result.current.setEdition("5e"));
expect(localStorage.getItem(STORAGE_KEY)).toBe("5e");
});
it("multiple hooks stay in sync", () => {
const { result: r1 } = renderHook(() => useRulesEdition());
const { result: r2 } = renderHook(() => useRulesEdition());
act(() => r1.current.setEdition("5e"));
expect(r2.current.edition).toBe("5e");
});
});

View File

@@ -0,0 +1,63 @@
// @vitest-environment jsdom
import { act, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { useTheme } from "../use-theme.js";
const STORAGE_KEY = "initiative:theme";
describe("useTheme", () => {
afterEach(() => {
// Reset to default
const { result } = renderHook(() => useTheme());
act(() => result.current.setPreference("system"));
localStorage.removeItem(STORAGE_KEY);
});
it("defaults to system preference", () => {
const { result } = renderHook(() => useTheme());
expect(result.current.preference).toBe("system");
});
it("setPreference updates to light", () => {
const { result } = renderHook(() => useTheme());
act(() => result.current.setPreference("light"));
expect(result.current.preference).toBe("light");
expect(result.current.resolved).toBe("light");
});
it("setPreference updates to dark", () => {
const { result } = renderHook(() => useTheme());
act(() => result.current.setPreference("dark"));
expect(result.current.preference).toBe("dark");
expect(result.current.resolved).toBe("dark");
});
it("persists preference to localStorage", () => {
const { result } = renderHook(() => useTheme());
act(() => result.current.setPreference("light"));
expect(localStorage.getItem(STORAGE_KEY)).toBe("light");
});
it("applies theme to document element", () => {
const { result } = renderHook(() => useTheme());
act(() => result.current.setPreference("light"));
expect(document.documentElement.dataset.theme).toBe("light");
});
it("multiple hooks stay in sync", () => {
const { result: r1 } = renderHook(() => useTheme());
const { result: r2 } = renderHook(() => useTheme());
act(() => r1.current.setPreference("dark"));
expect(r2.current.preference).toBe("dark");
});
});

View File

@@ -26,16 +26,16 @@ export default defineConfig({
branches: 70, branches: 70,
}, },
"apps/web/src/hooks": { "apps/web/src/hooks": {
lines: 59, lines: 64,
branches: 41, branches: 48,
}, },
"apps/web/src/components": { "apps/web/src/components": {
lines: 49, lines: 52,
branches: 47, branches: 49,
}, },
"apps/web/src/components/ui": { "apps/web/src/components/ui": {
lines: 73, lines: 83,
branches: 67, branches: 74,
}, },
}, },
}, },