diff --git a/apps/web/src/components/__tests__/difficulty-indicator.test.tsx b/apps/web/src/components/__tests__/difficulty-indicator.test.tsx
new file mode 100644
index 0000000..7c81df5
--- /dev/null
+++ b/apps/web/src/components/__tests__/difficulty-indicator.test.tsx
@@ -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(
+ ,
+ );
+ const bars = container.querySelectorAll("[class*='rounded-sm']");
+ expect(bars).toHaveLength(3);
+ });
+
+ it("shows 'Trivial encounter difficulty' label for trivial tier", () => {
+ render();
+ expect(
+ screen.getByRole("img", {
+ name: "Trivial encounter difficulty",
+ }),
+ ).toBeDefined();
+ });
+
+ it("shows 'Low encounter difficulty' label for low tier", () => {
+ render();
+ expect(
+ screen.getByRole("img", { name: "Low encounter difficulty" }),
+ ).toBeDefined();
+ });
+
+ it("shows 'Moderate encounter difficulty' label for moderate tier", () => {
+ render();
+ expect(
+ screen.getByRole("img", {
+ name: "Moderate encounter difficulty",
+ }),
+ ).toBeDefined();
+ });
+
+ it("shows 'High encounter difficulty' label for high tier", () => {
+ render();
+ expect(
+ screen.getByRole("img", {
+ name: "High encounter difficulty",
+ }),
+ ).toBeDefined();
+ });
+});
diff --git a/apps/web/src/components/__tests__/overflow-menu.test.tsx b/apps/web/src/components/__tests__/overflow-menu.test.tsx
new file mode 100644
index 0000000..93d9a10
--- /dev/null
+++ b/apps/web/src/components/__tests__/overflow-menu.test.tsx
@@ -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: , label: "Action A", onClick: vi.fn() },
+ { icon: , label: "Action B", onClick: vi.fn() },
+];
+
+describe("OverflowMenu", () => {
+ it("renders toggle button", () => {
+ render();
+ expect(screen.getByRole("button", { name: "More actions" })).toBeDefined();
+ });
+
+ it("does not show menu items when closed", () => {
+ render();
+ expect(screen.queryByText("Action A")).toBeNull();
+ });
+
+ it("shows menu items when toggled open", async () => {
+ render();
+
+ 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(
+ , 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(
+ ,
+ 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(
+ ,
+ 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);
+ });
+});
diff --git a/apps/web/src/components/__tests__/roll-mode-menu.test.tsx b/apps/web/src/components/__tests__/roll-mode-menu.test.tsx
new file mode 100644
index 0000000..38d64e5
--- /dev/null
+++ b/apps/web/src/components/__tests__/roll-mode-menu.test.tsx
@@ -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(
+ {}}
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ await userEvent.click(screen.getByText("Disadvantage"));
+
+ expect(onSelect).toHaveBeenCalledWith("disadvantage");
+ expect(onClose).toHaveBeenCalled();
+ });
+});
diff --git a/apps/web/src/components/__tests__/toast.test.tsx b/apps/web/src/components/__tests__/toast.test.tsx
new file mode 100644
index 0000000..79b70ad
--- /dev/null
+++ b/apps/web/src/components/__tests__/toast.test.tsx
@@ -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( {}} />);
+ expect(screen.getByText("Hello")).toBeDefined();
+ });
+
+ it("renders progress bar when progress is provided", () => {
+ render( {}} />);
+ 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( {}} />);
+ const bar = document.body.querySelector("[style*='width']");
+ expect(bar).toBeNull();
+ });
+
+ it("calls onDismiss when close button is clicked", async () => {
+ const onDismiss = vi.fn();
+ render();
+
+ 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(
+ ,
+ );
+
+ expect(onDismiss).not.toHaveBeenCalled();
+ vi.advanceTimersByTime(3000);
+ expect(onDismiss).toHaveBeenCalledOnce();
+ });
+
+ it("does not auto-dismiss when autoDismissMs is omitted", () => {
+ const onDismiss = vi.fn();
+ render();
+
+ vi.advanceTimersByTime(10000);
+ expect(onDismiss).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/apps/web/src/hooks/__tests__/use-rules-edition.test.ts b/apps/web/src/hooks/__tests__/use-rules-edition.test.ts
new file mode 100644
index 0000000..5127f2c
--- /dev/null
+++ b/apps/web/src/hooks/__tests__/use-rules-edition.test.ts
@@ -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");
+ });
+});
diff --git a/apps/web/src/hooks/__tests__/use-theme.test.ts b/apps/web/src/hooks/__tests__/use-theme.test.ts
new file mode 100644
index 0000000..6f5d9e6
--- /dev/null
+++ b/apps/web/src/hooks/__tests__/use-theme.test.ts
@@ -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");
+ });
+});
diff --git a/vitest.config.ts b/vitest.config.ts
index f7fc12f..89aadb6 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -26,16 +26,16 @@ export default defineConfig({
branches: 70,
},
"apps/web/src/hooks": {
- lines: 59,
- branches: 41,
+ lines: 64,
+ branches: 48,
},
"apps/web/src/components": {
- lines: 49,
- branches: 47,
+ lines: 52,
+ branches: 49,
},
"apps/web/src/components/ui": {
- lines: 73,
- branches: 67,
+ lines: 83,
+ branches: 74,
},
},
},