diff --git a/apps/web/src/components/__tests__/bulk-import-prompt.test.tsx b/apps/web/src/components/__tests__/bulk-import-prompt.test.tsx
new file mode 100644
index 0000000..554ff51
--- /dev/null
+++ b/apps/web/src/components/__tests__/bulk-import-prompt.test.tsx
@@ -0,0 +1,146 @@
+// @vitest-environment jsdom
+import "@testing-library/jest-dom/vitest";
+
+import { cleanup, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { BulkImportPrompt } from "../bulk-import-prompt.js";
+
+const THREE_SOURCES_REGEX = /3 sources/;
+const GITHUB_URL_REGEX = /raw\.githubusercontent/;
+const LOADING_PROGRESS_REGEX = /Loading sources\.\.\. 4\/10/;
+const SEVEN_OF_TEN_REGEX = /7\/10 sources/;
+const THREE_FAILED_REGEX = /3 failed/;
+
+afterEach(cleanup);
+
+const mockFetchAndCacheSource = vi.fn();
+const mockIsSourceCached = vi.fn().mockResolvedValue(false);
+const mockRefreshCache = vi.fn();
+const mockStartImport = vi.fn();
+const mockReset = vi.fn();
+const mockDismissPanel = vi.fn();
+
+let mockImportState = {
+ status: "idle" as string,
+ total: 0,
+ completed: 0,
+ failed: 0,
+};
+
+vi.mock("../../contexts/bestiary-context.js", () => ({
+ useBestiaryContext: () => ({
+ fetchAndCacheSource: mockFetchAndCacheSource,
+ isSourceCached: mockIsSourceCached,
+ refreshCache: mockRefreshCache,
+ }),
+}));
+
+vi.mock("../../contexts/bulk-import-context.js", () => ({
+ useBulkImportContext: () => ({
+ state: mockImportState,
+ startImport: mockStartImport,
+ reset: mockReset,
+ }),
+}));
+
+vi.mock("../../contexts/side-panel-context.js", () => ({
+ useSidePanelContext: () => ({
+ dismissPanel: mockDismissPanel,
+ }),
+}));
+
+vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
+ getAllSourceCodes: () => ["MM", "VGM", "XGE"],
+ getDefaultFetchUrl: () => "",
+ getSourceDisplayName: (code: string) => code,
+ loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
+}));
+
+describe("BulkImportPrompt", () => {
+ afterEach(() => {
+ vi.clearAllMocks();
+ mockImportState = { status: "idle", total: 0, completed: 0, failed: 0 };
+ });
+
+ it("idle: shows base URL input, source count, Load All button", () => {
+ render();
+ expect(screen.getByText(THREE_SOURCES_REGEX)).toBeInTheDocument();
+ expect(screen.getByDisplayValue(GITHUB_URL_REGEX)).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Load All" }),
+ ).toBeInTheDocument();
+ });
+
+ it("idle: clearing URL disables the button", async () => {
+ const user = userEvent.setup();
+ render();
+
+ const input = screen.getByDisplayValue(GITHUB_URL_REGEX);
+ await user.clear(input);
+ expect(screen.getByRole("button", { name: "Load All" })).toBeDisabled();
+ });
+
+ it("idle: clicking Load All calls startImport with URL", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByRole("button", { name: "Load All" }));
+ expect(mockStartImport).toHaveBeenCalledWith(
+ expect.stringContaining("raw.githubusercontent"),
+ mockFetchAndCacheSource,
+ mockIsSourceCached,
+ mockRefreshCache,
+ );
+ });
+
+ it("loading: shows progress text and progress bar", () => {
+ mockImportState = {
+ status: "loading",
+ total: 10,
+ completed: 3,
+ failed: 1,
+ };
+ render();
+ expect(screen.getByText(LOADING_PROGRESS_REGEX)).toBeInTheDocument();
+ });
+
+ it("complete: shows success message and Done button", () => {
+ mockImportState = {
+ status: "complete",
+ total: 10,
+ completed: 10,
+ failed: 0,
+ };
+ render();
+ expect(screen.getByText("All sources loaded")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Done" })).toBeInTheDocument();
+ });
+
+ it("complete: Done calls dismissPanel and reset", async () => {
+ mockImportState = {
+ status: "complete",
+ total: 10,
+ completed: 10,
+ failed: 0,
+ };
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByRole("button", { name: "Done" }));
+ expect(mockDismissPanel).toHaveBeenCalled();
+ expect(mockReset).toHaveBeenCalled();
+ });
+
+ it("partial-failure: shows loaded/failed counts", () => {
+ mockImportState = {
+ status: "partial-failure",
+ total: 10,
+ completed: 7,
+ failed: 3,
+ };
+ render();
+ expect(screen.getByText(SEVEN_OF_TEN_REGEX)).toBeInTheDocument();
+ expect(screen.getByText(THREE_FAILED_REGEX)).toBeInTheDocument();
+ });
+});
diff --git a/apps/web/src/components/__tests__/color-palette.test.tsx b/apps/web/src/components/__tests__/color-palette.test.tsx
new file mode 100644
index 0000000..c3c1fb9
--- /dev/null
+++ b/apps/web/src/components/__tests__/color-palette.test.tsx
@@ -0,0 +1,56 @@
+// @vitest-environment jsdom
+import "@testing-library/jest-dom/vitest";
+
+import { VALID_PLAYER_COLORS } from "@initiative/domain";
+import { cleanup, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+afterEach(cleanup);
+
+import { ColorPalette } from "../color-palette.js";
+
+describe("ColorPalette", () => {
+ it("renders a button for each valid color", () => {
+ render( {}} />);
+ const buttons = screen.getAllByRole("button");
+ expect(buttons).toHaveLength(VALID_PLAYER_COLORS.size);
+ });
+
+ it("each button has an aria-label matching the color name", () => {
+ render( {}} />);
+ for (const color of VALID_PLAYER_COLORS) {
+ expect(screen.getByRole("button", { name: color })).toBeInTheDocument();
+ }
+ });
+
+ it("clicking a color calls onChange with that color", async () => {
+ const user = userEvent.setup();
+ const onChange = vi.fn();
+ render();
+
+ await user.click(screen.getByRole("button", { name: "blue" }));
+ expect(onChange).toHaveBeenCalledWith("blue");
+ });
+
+ it("clicking the selected color deselects it", async () => {
+ const user = userEvent.setup();
+ const onChange = vi.fn();
+ render();
+
+ await user.click(screen.getByRole("button", { name: "red" }));
+ expect(onChange).toHaveBeenCalledWith("");
+ });
+
+ it("selected color has ring styling", () => {
+ render( {}} />);
+ const selected = screen.getByRole("button", { name: "green" });
+ expect(selected.className).toContain("ring-2");
+ });
+
+ it("non-selected colors do not have ring styling", () => {
+ render( {}} />);
+ const other = screen.getByRole("button", { name: "blue" });
+ expect(other.className).not.toContain("ring-2");
+ });
+});
diff --git a/apps/web/src/components/__tests__/combatant-row.test.tsx b/apps/web/src/components/__tests__/combatant-row.test.tsx
index fccb223..d34a51e 100644
--- a/apps/web/src/components/__tests__/combatant-row.test.tsx
+++ b/apps/web/src/components/__tests__/combatant-row.test.tsx
@@ -10,6 +10,8 @@ import { CombatantRow } from "../combatant-row.js";
import { PLAYER_COLOR_HEX } from "../player-icon-map.js";
const TEMP_HP_REGEX = /^\+\d/;
+const CURRENT_HP_7_REGEX = /Current HP: 7/;
+const CURRENT_HP_REGEX = /Current HP/;
// Mock persistence — no localStorage interaction
vi.mock("../../persistence/encounter-storage.js", () => ({
@@ -257,6 +259,172 @@ describe("CombatantRow", () => {
});
});
+ describe("inline name editing", () => {
+ it("click rename → type new name → blur commits rename", async () => {
+ const user = userEvent.setup();
+ renderRow();
+
+ await user.click(screen.getByRole("button", { name: "Rename" }));
+ const input = screen.getByDisplayValue("Goblin");
+ await user.clear(input);
+ await user.type(input, "Hobgoblin");
+ await user.tab(); // blur
+ // The input should be gone, name committed
+ expect(screen.queryByDisplayValue("Hobgoblin")).not.toBeInTheDocument();
+ });
+
+ it("Escape cancels without renaming", async () => {
+ const user = userEvent.setup();
+ renderRow();
+
+ await user.click(screen.getByRole("button", { name: "Rename" }));
+ const input = screen.getByDisplayValue("Goblin");
+ await user.clear(input);
+ await user.type(input, "Changed");
+ await user.keyboard("{Escape}");
+ // Should revert to showing the original name
+ expect(screen.getByText("Goblin")).toBeInTheDocument();
+ });
+ });
+
+ describe("inline AC editing", () => {
+ it("click AC → type value → Enter commits", async () => {
+ const user = userEvent.setup();
+ renderRow({
+ combatant: {
+ id: combatantId("1"),
+ name: "Goblin",
+ ac: 13,
+ },
+ });
+
+ // Click the AC shield button
+ const acButton = screen.getByText("13").closest("button");
+ expect(acButton).not.toBeNull();
+ await user.click(acButton as HTMLElement);
+ const input = screen.getByDisplayValue("13");
+ await user.clear(input);
+ await user.type(input, "16");
+ await user.keyboard("{Enter}");
+ expect(screen.queryByDisplayValue("16")).not.toBeInTheDocument();
+ });
+ });
+
+ describe("inline max HP editing", () => {
+ it("click max HP → type value → blur commits", async () => {
+ const user = userEvent.setup();
+ renderRow({
+ combatant: {
+ id: combatantId("1"),
+ name: "Goblin",
+ maxHp: 10,
+ currentHp: 10,
+ },
+ });
+
+ // The max HP button shows "10" as muted text
+ const maxHpButton = screen
+ .getAllByText("10")
+ .find(
+ (el) => el.closest("button") && el.className.includes("text-muted"),
+ );
+ expect(maxHpButton).toBeDefined();
+ const maxHpBtn = (maxHpButton as HTMLElement).closest("button");
+ expect(maxHpBtn).not.toBeNull();
+ await user.click(maxHpBtn as HTMLElement);
+ const input = screen.getByDisplayValue("10");
+ await user.clear(input);
+ await user.type(input, "25");
+ await user.tab();
+ expect(screen.queryByDisplayValue("25")).not.toBeInTheDocument();
+ });
+ });
+
+ describe("inline initiative editing", () => {
+ it("click initiative → type value → Enter commits", async () => {
+ const user = userEvent.setup();
+ renderRow({
+ combatant: {
+ id: combatantId("1"),
+ name: "Goblin",
+ initiative: 15,
+ },
+ });
+
+ await user.click(screen.getByText("15"));
+ const input = screen.getByDisplayValue("15");
+ await user.clear(input);
+ await user.type(input, "20");
+ await user.keyboard("{Enter}");
+ expect(screen.queryByDisplayValue("20")).not.toBeInTheDocument();
+ });
+
+ it("clearing initiative and pressing Enter commits the edit", async () => {
+ const user = userEvent.setup();
+ renderRow({
+ combatant: {
+ id: combatantId("1"),
+ name: "Goblin",
+ initiative: 15,
+ },
+ });
+
+ await user.click(screen.getByText("15"));
+ const input = screen.getByDisplayValue("15");
+ await user.clear(input);
+ await user.keyboard("{Enter}");
+ // Input should be dismissed (editing mode exited)
+ expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
+ });
+ });
+
+ describe("HP popover", () => {
+ it("clicking current HP opens the HP adjust popover", async () => {
+ const user = userEvent.setup();
+ renderRow({
+ combatant: {
+ id: combatantId("1"),
+ name: "Goblin",
+ maxHp: 10,
+ currentHp: 7,
+ },
+ });
+
+ const hpButton = screen.getByLabelText(CURRENT_HP_7_REGEX);
+ await user.click(hpButton);
+ // The popover should appear with damage/heal controls
+ expect(
+ screen.getByRole("button", { name: "Apply damage" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Apply healing" }),
+ ).toBeInTheDocument();
+ });
+
+ it("HP section is absent when maxHp is undefined", () => {
+ renderRow({
+ combatant: {
+ id: combatantId("1"),
+ name: "Goblin",
+ },
+ });
+ expect(screen.queryByLabelText(CURRENT_HP_REGEX)).not.toBeInTheDocument();
+ });
+ });
+
+ describe("condition picker", () => {
+ it("clicking Add condition button opens the picker", async () => {
+ const user = userEvent.setup();
+ renderRow();
+ const addButton = screen.getByRole("button", {
+ name: "Add condition",
+ });
+ await user.click(addButton);
+ // Condition picker should render with condition options
+ expect(screen.getByText("Blinded")).toBeInTheDocument();
+ });
+ });
+
describe("temp HP display", () => {
it("shows +N when combatant has temp HP", () => {
renderRow({
diff --git a/apps/web/src/components/__tests__/export-method-dialog.test.tsx b/apps/web/src/components/__tests__/export-method-dialog.test.tsx
new file mode 100644
index 0000000..3686ce7
--- /dev/null
+++ b/apps/web/src/components/__tests__/export-method-dialog.test.tsx
@@ -0,0 +1,86 @@
+// @vitest-environment jsdom
+import "@testing-library/jest-dom/vitest";
+
+import { cleanup, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
+import { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
+import { ExportMethodDialog } from "../export-method-dialog.js";
+
+afterEach(cleanup);
+
+beforeAll(() => {
+ polyfillDialog();
+});
+
+function renderDialog(open = true) {
+ const onDownload = vi.fn();
+ const onCopyToClipboard = vi.fn();
+ const onClose = vi.fn();
+ const result = render(
+ ,
+ );
+ return { ...result, onDownload, onCopyToClipboard, onClose };
+}
+
+describe("ExportMethodDialog", () => {
+ it("renders filename input and unchecked history checkbox", () => {
+ renderDialog();
+ expect(
+ screen.getByPlaceholderText("Filename (optional)"),
+ ).toBeInTheDocument();
+ const checkbox = screen.getByRole("checkbox");
+ expect(checkbox).not.toBeChecked();
+ });
+
+ it("download button calls onDownload with defaults", async () => {
+ const user = userEvent.setup();
+ const { onDownload } = renderDialog();
+
+ await user.click(screen.getByText("Download file"));
+ expect(onDownload).toHaveBeenCalledWith(false, "");
+ });
+
+ it("download with filename and history checked", async () => {
+ const user = userEvent.setup();
+ const { onDownload } = renderDialog();
+
+ await user.type(
+ screen.getByPlaceholderText("Filename (optional)"),
+ "my-encounter",
+ );
+ await user.click(screen.getByRole("checkbox"));
+ await user.click(screen.getByText("Download file"));
+ expect(onDownload).toHaveBeenCalledWith(true, "my-encounter");
+ });
+
+ it("copy to clipboard calls onCopyToClipboard and shows Copied", async () => {
+ const user = userEvent.setup();
+ const { onCopyToClipboard } = renderDialog();
+
+ await user.click(screen.getByText("Copy to clipboard"));
+ expect(onCopyToClipboard).toHaveBeenCalledWith(false);
+ expect(screen.getByText("Copied!")).toBeInTheDocument();
+ });
+
+ it("Copied! reverts after 2 seconds", async () => {
+ const user = userEvent.setup();
+ renderDialog();
+
+ await user.click(screen.getByText("Copy to clipboard"));
+ expect(screen.getByText("Copied!")).toBeInTheDocument();
+
+ await waitFor(
+ () => {
+ expect(screen.queryByText("Copied!")).not.toBeInTheDocument();
+ },
+ { timeout: 3000 },
+ );
+ expect(screen.getByText("Copy to clipboard")).toBeInTheDocument();
+ });
+});
diff --git a/apps/web/src/components/__tests__/import-method-dialog.test.tsx b/apps/web/src/components/__tests__/import-method-dialog.test.tsx
new file mode 100644
index 0000000..b1a83b7
--- /dev/null
+++ b/apps/web/src/components/__tests__/import-method-dialog.test.tsx
@@ -0,0 +1,98 @@
+// @vitest-environment jsdom
+import "@testing-library/jest-dom/vitest";
+
+import { cleanup, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
+import { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
+import { ImportMethodDialog } from "../import-method-dialog.js";
+
+beforeAll(() => {
+ polyfillDialog();
+});
+
+afterEach(cleanup);
+
+function renderDialog(open = true) {
+ const onSelectFile = vi.fn();
+ const onSubmitClipboard = vi.fn();
+ const onClose = vi.fn();
+ const result = render(
+ ,
+ );
+ return { ...result, onSelectFile, onSubmitClipboard, onClose };
+}
+
+describe("ImportMethodDialog", () => {
+ it("opens in pick mode with two method buttons", () => {
+ renderDialog();
+ expect(screen.getByText("From file")).toBeInTheDocument();
+ expect(screen.getByText("Paste content")).toBeInTheDocument();
+ });
+
+ it("From file button calls onSelectFile and closes", async () => {
+ const user = userEvent.setup();
+ const { onSelectFile, onClose } = renderDialog();
+
+ await user.click(screen.getByText("From file"));
+ expect(onSelectFile).toHaveBeenCalled();
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it("Paste content button switches to paste mode", async () => {
+ const user = userEvent.setup();
+ renderDialog();
+
+ await user.click(screen.getByText("Paste content"));
+ expect(
+ screen.getByPlaceholderText("Paste exported JSON here..."),
+ ).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Import" })).toBeDisabled();
+ });
+
+ it("typing text enables Import button", async () => {
+ const user = userEvent.setup();
+ renderDialog();
+
+ await user.click(screen.getByText("Paste content"));
+ const textarea = screen.getByPlaceholderText("Paste exported JSON here...");
+ await user.type(textarea, "test-data");
+ expect(screen.getByRole("button", { name: "Import" })).not.toBeDisabled();
+ });
+
+ it("Import calls onSubmitClipboard with text and closes", async () => {
+ const user = userEvent.setup();
+ const { onSubmitClipboard, onClose } = renderDialog();
+
+ await user.click(screen.getByText("Paste content"));
+ await user.type(
+ screen.getByPlaceholderText("Paste exported JSON here..."),
+ "some-json-content",
+ );
+ await user.click(screen.getByRole("button", { name: "Import" }));
+ expect(onSubmitClipboard).toHaveBeenCalledWith("some-json-content");
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it("Back button returns to pick mode and clears text", async () => {
+ const user = userEvent.setup();
+ renderDialog();
+
+ await user.click(screen.getByText("Paste content"));
+ await user.type(
+ screen.getByPlaceholderText("Paste exported JSON here..."),
+ "some text",
+ );
+ await user.click(screen.getByRole("button", { name: "Back" }));
+
+ expect(screen.getByText("From file")).toBeInTheDocument();
+ expect(
+ screen.queryByPlaceholderText("Paste exported JSON here..."),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/apps/web/src/components/__tests__/player-character-section.test.tsx b/apps/web/src/components/__tests__/player-character-section.test.tsx
new file mode 100644
index 0000000..288cb3f
--- /dev/null
+++ b/apps/web/src/components/__tests__/player-character-section.test.tsx
@@ -0,0 +1,134 @@
+// @vitest-environment jsdom
+import "@testing-library/jest-dom/vitest";
+
+import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { createRef } from "react";
+import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
+import { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
+import { AllProviders } from "../../__tests__/test-providers.js";
+import {
+ PlayerCharacterSection,
+ type PlayerCharacterSectionHandle,
+} from "../player-character-section.js";
+
+const CREATE_FIRST_PC_REGEX = /create your first player character/i;
+
+beforeAll(() => {
+ polyfillDialog();
+ Object.defineProperty(globalThis, "matchMedia", {
+ writable: true,
+ value: vi.fn().mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ });
+});
+
+afterEach(cleanup);
+
+vi.mock("../../persistence/encounter-storage.js", () => ({
+ loadEncounter: () => null,
+ saveEncounter: () => {},
+}));
+
+vi.mock("../../persistence/player-character-storage.js", () => ({
+ loadPlayerCharacters: () => [],
+ savePlayerCharacters: () => {},
+}));
+
+vi.mock("../../adapters/bestiary-cache.js", () => ({
+ loadAllCachedCreatures: () => Promise.resolve(new Map()),
+ isSourceCached: () => Promise.resolve(false),
+ cacheSource: () => Promise.resolve(),
+ getCachedSources: () => Promise.resolve([]),
+ clearSource: () => Promise.resolve(),
+ clearAll: () => Promise.resolve(),
+}));
+
+vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
+ loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
+ getAllSourceCodes: () => [],
+ getDefaultFetchUrl: () => "",
+ getSourceDisplayName: (code: string) => code,
+}));
+
+function renderSection() {
+ const ref = createRef();
+ const result = render(, {
+ wrapper: AllProviders,
+ });
+ return { ...result, ref };
+}
+
+describe("PlayerCharacterSection", () => {
+ it("openManagement ref handle opens the management dialog", async () => {
+ const { ref } = renderSection();
+
+ const handle = ref.current;
+ if (!handle) throw new Error("ref not set");
+ act(() => handle.openManagement());
+
+ // Management dialog should now be open with its title visible
+ await waitFor(() => {
+ const dialogs = document.querySelectorAll("dialog");
+ const managementDialog = Array.from(dialogs).find((d) =>
+ d.textContent?.includes("Player Characters"),
+ );
+ expect(managementDialog).toHaveAttribute("open");
+ });
+ });
+
+ it("creating a character from management opens create modal", async () => {
+ const user = userEvent.setup();
+ const { ref } = renderSection();
+
+ const handle = ref.current;
+ if (!handle) throw new Error("ref not set");
+ act(() => handle.openManagement());
+
+ await user.click(
+ screen.getByRole("button", {
+ name: CREATE_FIRST_PC_REGEX,
+ }),
+ );
+
+ // Create modal should now be visible
+ await waitFor(() => {
+ expect(screen.getByPlaceholderText("Character name")).toBeInTheDocument();
+ });
+ });
+
+ it("saving a new character and returning to management", async () => {
+ const user = userEvent.setup();
+ const { ref } = renderSection();
+
+ const handle = ref.current;
+ if (!handle) throw new Error("ref not set");
+ act(() => handle.openManagement());
+
+ await user.click(
+ screen.getByRole("button", {
+ name: CREATE_FIRST_PC_REGEX,
+ }),
+ );
+
+ // Fill in the create form
+ await user.type(screen.getByPlaceholderText("Character name"), "Aria");
+ await user.type(screen.getByPlaceholderText("AC"), "16");
+ await user.type(screen.getByPlaceholderText("Max HP"), "30");
+
+ await user.click(screen.getByRole("button", { name: "Create" }));
+
+ // Should return to management dialog showing the new character
+ await waitFor(() => {
+ expect(screen.getByText("Aria")).toBeInTheDocument();
+ });
+ });
+});
diff --git a/apps/web/src/components/__tests__/player-management.test.tsx b/apps/web/src/components/__tests__/player-management.test.tsx
new file mode 100644
index 0000000..69c988b
--- /dev/null
+++ b/apps/web/src/components/__tests__/player-management.test.tsx
@@ -0,0 +1,120 @@
+// @vitest-environment jsdom
+import "@testing-library/jest-dom/vitest";
+
+import { type PlayerCharacter, playerCharacterId } from "@initiative/domain";
+import { cleanup, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
+import { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
+
+afterEach(cleanup);
+
+const CREATE_FIRST_PC_REGEX = /create your first player character/i;
+const LEVEL_REGEX = /^Lv /;
+
+import { PlayerManagement } from "../player-management.js";
+
+beforeAll(() => {
+ polyfillDialog();
+});
+
+const PC_WARRIOR: PlayerCharacter = {
+ id: playerCharacterId("pc-1"),
+ name: "Thorin",
+ ac: 18,
+ maxHp: 45,
+ color: "red",
+ icon: "sword",
+};
+
+const PC_WIZARD: PlayerCharacter = {
+ id: playerCharacterId("pc-2"),
+ name: "Gandalf",
+ ac: 12,
+ maxHp: 30,
+ color: "blue",
+ icon: "wand",
+ level: 10,
+};
+
+function renderManagement(
+ overrides: Partial[0]> = {},
+) {
+ const props = {
+ open: true,
+ onClose: vi.fn(),
+ characters: [] as readonly PlayerCharacter[],
+ onEdit: vi.fn(),
+ onDelete: vi.fn(),
+ onCreate: vi.fn(),
+ ...overrides,
+ };
+ return { ...render(), props };
+}
+
+describe("PlayerManagement", () => {
+ it("shows empty state when no characters", () => {
+ renderManagement();
+ expect(screen.getByText("No player characters yet")).toBeInTheDocument();
+ });
+
+ it("shows create button in empty state that calls onCreate", async () => {
+ const user = userEvent.setup();
+ const { props } = renderManagement();
+
+ await user.click(
+ screen.getByRole("button", {
+ name: CREATE_FIRST_PC_REGEX,
+ }),
+ );
+ expect(props.onCreate).toHaveBeenCalled();
+ });
+
+ it("renders each character with name, AC, HP", () => {
+ renderManagement({ characters: [PC_WARRIOR, PC_WIZARD] });
+ expect(screen.getByText("Thorin")).toBeInTheDocument();
+ expect(screen.getByText("Gandalf")).toBeInTheDocument();
+ expect(screen.getByText("AC 18")).toBeInTheDocument();
+ expect(screen.getByText("HP 45")).toBeInTheDocument();
+ expect(screen.getByText("AC 12")).toBeInTheDocument();
+ expect(screen.getByText("HP 30")).toBeInTheDocument();
+ });
+
+ it("shows level when present, omits when undefined", () => {
+ renderManagement({ characters: [PC_WARRIOR, PC_WIZARD] });
+ expect(screen.getByText("Lv 10")).toBeInTheDocument();
+ // Thorin has no level — there should be only one "Lv" text
+ expect(screen.queryAllByText(LEVEL_REGEX)).toHaveLength(1);
+ });
+
+ it("edit button calls onEdit with the character", async () => {
+ const user = userEvent.setup();
+ const { props } = renderManagement({ characters: [PC_WARRIOR] });
+
+ await user.click(screen.getByRole("button", { name: "Edit" }));
+ expect(props.onEdit).toHaveBeenCalledWith(PC_WARRIOR);
+ });
+
+ it("delete button calls onDelete after confirmation", async () => {
+ const user = userEvent.setup();
+ const { props } = renderManagement({ characters: [PC_WARRIOR] });
+
+ const deleteBtn = screen.getByRole("button", {
+ name: "Delete player character",
+ });
+ await user.click(deleteBtn);
+ const confirmBtn = screen.getByRole("button", {
+ name: "Confirm delete player character",
+ });
+ await user.click(confirmBtn);
+ expect(props.onDelete).toHaveBeenCalledWith(PC_WARRIOR.id);
+ });
+
+ it("add button calls onCreate", async () => {
+ const user = userEvent.setup();
+ const { props } = renderManagement({ characters: [PC_WARRIOR] });
+
+ await user.click(screen.getByRole("button", { name: "Add" }));
+ expect(props.onCreate).toHaveBeenCalled();
+ });
+});
diff --git a/apps/web/src/components/__tests__/settings-modal.test.tsx b/apps/web/src/components/__tests__/settings-modal.test.tsx
new file mode 100644
index 0000000..60dea30
--- /dev/null
+++ b/apps/web/src/components/__tests__/settings-modal.test.tsx
@@ -0,0 +1,110 @@
+// @vitest-environment jsdom
+import "@testing-library/jest-dom/vitest";
+
+import { cleanup, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
+
+afterEach(cleanup);
+
+import { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
+import { AllProviders } from "../../__tests__/test-providers.js";
+import { SettingsModal } from "../settings-modal.js";
+
+beforeAll(() => {
+ polyfillDialog();
+ Object.defineProperty(globalThis, "matchMedia", {
+ writable: true,
+ value: vi.fn().mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ });
+});
+
+vi.mock("../../persistence/encounter-storage.js", () => ({
+ loadEncounter: () => null,
+ saveEncounter: () => {},
+}));
+
+vi.mock("../../persistence/player-character-storage.js", () => ({
+ loadPlayerCharacters: () => [],
+ savePlayerCharacters: () => {},
+}));
+
+vi.mock("../../adapters/bestiary-cache.js", () => ({
+ loadAllCachedCreatures: () => Promise.resolve(new Map()),
+ isSourceCached: () => Promise.resolve(false),
+ cacheSource: () => Promise.resolve(),
+ getCachedSources: () => Promise.resolve([]),
+ clearSource: () => Promise.resolve(),
+ clearAll: () => Promise.resolve(),
+}));
+
+vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
+ loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
+ getAllSourceCodes: () => [],
+ getDefaultFetchUrl: () => "",
+ getSourceDisplayName: (code: string) => code,
+}));
+
+function renderModal(open = true) {
+ const onClose = vi.fn();
+ const result = render(, {
+ wrapper: AllProviders,
+ });
+ return { ...result, onClose };
+}
+
+describe("SettingsModal", () => {
+ it("renders edition toggle buttons", () => {
+ renderModal();
+ expect(
+ screen.getByRole("button", { name: "5e (2014)" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "5.5e (2024)" }),
+ ).toBeInTheDocument();
+ });
+
+ it("renders theme toggle buttons", () => {
+ renderModal();
+ expect(screen.getByRole("button", { name: "System" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Light" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Dark" })).toBeInTheDocument();
+ });
+
+ it("clicking an edition button switches the active edition", async () => {
+ const user = userEvent.setup();
+ renderModal();
+ const btn5e = screen.getByRole("button", { name: "5e (2014)" });
+ await user.click(btn5e);
+ // After clicking 5e, it should have the active style
+ expect(btn5e.className).toContain("bg-accent");
+ });
+
+ it("clicking a theme button switches the active theme", async () => {
+ const user = userEvent.setup();
+ renderModal();
+ const darkBtn = screen.getByRole("button", { name: "Dark" });
+ await user.click(darkBtn);
+ expect(darkBtn.className).toContain("bg-accent");
+ });
+
+ it("close button calls onClose", async () => {
+ const user = userEvent.setup();
+ const { onClose } = renderModal();
+ // DialogHeader renders an X button
+ const buttons = screen.getAllByRole("button");
+ const closeBtn = buttons.find((b) => b.querySelector(".h-4.w-4") !== null);
+ expect(closeBtn).toBeDefined();
+ await user.click(closeBtn as HTMLElement);
+ expect(onClose).toHaveBeenCalled();
+ });
+});
diff --git a/apps/web/src/components/__tests__/source-fetch-prompt.test.tsx b/apps/web/src/components/__tests__/source-fetch-prompt.test.tsx
new file mode 100644
index 0000000..c6e0acb
--- /dev/null
+++ b/apps/web/src/components/__tests__/source-fetch-prompt.test.tsx
@@ -0,0 +1,124 @@
+// @vitest-environment jsdom
+import "@testing-library/jest-dom/vitest";
+
+import { cleanup, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { SourceFetchPrompt } from "../source-fetch-prompt.js";
+
+const MONSTER_MANUAL_REGEX = /Monster Manual/;
+
+afterEach(cleanup);
+
+const mockFetchAndCacheSource = vi.fn();
+const mockUploadAndCacheSource = vi.fn();
+
+vi.mock("../../contexts/bestiary-context.js", () => ({
+ useBestiaryContext: () => ({
+ fetchAndCacheSource: mockFetchAndCacheSource,
+ uploadAndCacheSource: mockUploadAndCacheSource,
+ }),
+}));
+
+vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
+ getDefaultFetchUrl: (code: string) =>
+ `https://example.com/bestiary/${code}.json`,
+ getSourceDisplayName: (code: string) =>
+ code === "MM" ? "Monster Manual" : code,
+ loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
+ getAllSourceCodes: () => [],
+}));
+
+function renderPrompt(sourceCode = "MM") {
+ const onSourceLoaded = vi.fn();
+ const result = render(
+ ,
+ );
+ return { ...result, onSourceLoaded };
+}
+
+describe("SourceFetchPrompt", () => {
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders source name, URL input, Load and Upload buttons", () => {
+ renderPrompt();
+ expect(screen.getByText(MONSTER_MANUAL_REGEX)).toBeInTheDocument();
+ expect(
+ screen.getByDisplayValue("https://example.com/bestiary/MM.json"),
+ ).toBeInTheDocument();
+ expect(screen.getByText("Load")).toBeInTheDocument();
+ expect(screen.getByText("Upload file")).toBeInTheDocument();
+ });
+
+ it("Load calls fetchAndCacheSource and onSourceLoaded on success", async () => {
+ mockFetchAndCacheSource.mockResolvedValueOnce(undefined);
+ const user = userEvent.setup();
+ const { onSourceLoaded } = renderPrompt();
+
+ await user.click(screen.getByText("Load"));
+
+ await waitFor(() => {
+ expect(mockFetchAndCacheSource).toHaveBeenCalledWith(
+ "MM",
+ "https://example.com/bestiary/MM.json",
+ );
+ expect(onSourceLoaded).toHaveBeenCalled();
+ });
+ });
+
+ it("fetch error shows error message", async () => {
+ mockFetchAndCacheSource.mockRejectedValueOnce(new Error("Network error"));
+ const user = userEvent.setup();
+ renderPrompt();
+
+ await user.click(screen.getByText("Load"));
+
+ await waitFor(() => {
+ expect(screen.getByText("Network error")).toBeInTheDocument();
+ });
+ });
+
+ it("upload file calls uploadAndCacheSource and onSourceLoaded", async () => {
+ mockUploadAndCacheSource.mockResolvedValueOnce(undefined);
+ const user = userEvent.setup();
+ const { onSourceLoaded } = renderPrompt();
+
+ const file = new File(['{"monster":[]}'], "bestiary-mm.json", {
+ type: "application/json",
+ });
+ const fileInput = document.querySelector(
+ 'input[type="file"]',
+ ) as HTMLInputElement;
+ await user.upload(fileInput, file);
+
+ await waitFor(() => {
+ expect(mockUploadAndCacheSource).toHaveBeenCalledWith("MM", {
+ monster: [],
+ });
+ expect(onSourceLoaded).toHaveBeenCalled();
+ });
+ });
+
+ it("upload error shows error message", async () => {
+ mockUploadAndCacheSource.mockRejectedValueOnce(new Error("Invalid format"));
+ const user = userEvent.setup();
+ renderPrompt();
+
+ const file = new File(['{"bad": true}'], "bad.json", {
+ type: "application/json",
+ });
+ const fileInput = document.querySelector(
+ 'input[type="file"]',
+ ) as HTMLInputElement;
+ await user.upload(fileInput, file);
+
+ await waitFor(() => {
+ expect(screen.getByText("Invalid format")).toBeInTheDocument();
+ });
+ });
+});
diff --git a/apps/web/src/components/__tests__/stat-block.test.tsx b/apps/web/src/components/__tests__/stat-block.test.tsx
new file mode 100644
index 0000000..d9d369d
--- /dev/null
+++ b/apps/web/src/components/__tests__/stat-block.test.tsx
@@ -0,0 +1,273 @@
+// @vitest-environment jsdom
+import "@testing-library/jest-dom/vitest";
+
+import type { Creature } from "@initiative/domain";
+import { creatureId } from "@initiative/domain";
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it } from "vitest";
+import { StatBlock } from "../stat-block.js";
+
+afterEach(cleanup);
+
+const ARMOR_CLASS_REGEX = /Armor Class/;
+const DEX_PLUS_4_REGEX = /Dex \+4/;
+const CR_QUARTER_REGEX = /1\/4/;
+const PROF_BONUS_2_REGEX = /Proficiency Bonus \+2/;
+const NIMBLE_ESCAPE_REGEX = /Nimble Escape\./;
+const SCIMITAR_REGEX = /Scimitar\./;
+const DETECT_REGEX = /Detect\./;
+const TAIL_ATTACK_REGEX = /Tail Attack\./;
+const INNATE_SPELLCASTING_REGEX = /Innate Spellcasting\./;
+const AT_WILL_REGEX = /At Will:/;
+const DETECT_MAGIC_REGEX = /detect magic, suggestion/;
+const DAILY_REGEX = /3\/day each:/;
+const FIREBALL_REGEX = /fireball, wall of fire/;
+const LONG_REST_REGEX = /1\/long rest:/;
+const WISH_REGEX = /wish/;
+
+const GOBLIN: Creature = {
+ id: creatureId("srd:goblin"),
+ name: "Goblin",
+ source: "MM",
+ sourceDisplayName: "Monster Manual",
+ size: "Small",
+ type: "humanoid",
+ alignment: "neutral evil",
+ ac: 15,
+ acSource: "leather armor, shield",
+ hp: { average: 7, formula: "2d6" },
+ speed: "30 ft.",
+ abilities: { str: 8, dex: 14, con: 10, int: 10, wis: 8, cha: 8 },
+ cr: "1/4",
+ initiativeProficiency: 0,
+ proficiencyBonus: 2,
+ passive: 9,
+ savingThrows: "Dex +4",
+ skills: "Stealth +6",
+ senses: "darkvision 60 ft., passive Perception 9",
+ languages: "Common, Goblin",
+ traits: [{ name: "Nimble Escape", text: "Disengage or Hide as bonus." }],
+ actions: [{ name: "Scimitar", text: "Melee: +4 to hit, 5 slashing." }],
+ bonusActions: [{ name: "Nimble", text: "Disengage or Hide." }],
+ reactions: [{ name: "Redirect", text: "Redirect attack to ally." }],
+};
+
+const DRAGON: Creature = {
+ id: creatureId("srd:dragon"),
+ name: "Ancient Red Dragon",
+ source: "MM",
+ sourceDisplayName: "Monster Manual",
+ size: "Gargantuan",
+ type: "dragon",
+ alignment: "chaotic evil",
+ ac: 22,
+ hp: { average: 546, formula: "28d20 + 252" },
+ speed: "40 ft., climb 40 ft., fly 80 ft.",
+ abilities: { str: 30, dex: 10, con: 29, int: 18, wis: 15, cha: 23 },
+ cr: "24",
+ initiativeProficiency: 0,
+ proficiencyBonus: 7,
+ passive: 26,
+ resist: "fire",
+ immune: "fire",
+ vulnerable: "cold",
+ conditionImmune: "frightened",
+ legendaryActions: {
+ preamble: "The dragon can take 3 legendary actions.",
+ entries: [
+ { name: "Detect", text: "Wisdom (Perception) check." },
+ { name: "Tail Attack", text: "Tail attack." },
+ ],
+ },
+ spellcasting: [
+ {
+ name: "Innate Spellcasting",
+ headerText: "The dragon's spellcasting ability is Charisma.",
+ atWill: ["detect magic", "suggestion"],
+ daily: [{ uses: 3, each: true, spells: ["fireball", "wall of fire"] }],
+ restLong: [{ uses: 1, each: false, spells: ["wish"] }],
+ },
+ ],
+};
+
+function renderStatBlock(creature: Creature) {
+ return render();
+}
+
+describe("StatBlock", () => {
+ describe("header", () => {
+ it("renders creature name", () => {
+ renderStatBlock(GOBLIN);
+ expect(
+ screen.getByRole("heading", { name: "Goblin" }),
+ ).toBeInTheDocument();
+ });
+
+ it("renders size, type, alignment", () => {
+ renderStatBlock(GOBLIN);
+ expect(
+ screen.getByText("Small humanoid, neutral evil"),
+ ).toBeInTheDocument();
+ });
+
+ it("renders source display name", () => {
+ renderStatBlock(GOBLIN);
+ expect(screen.getByText("Monster Manual")).toBeInTheDocument();
+ });
+ });
+
+ describe("stats bar", () => {
+ it("renders AC with source", () => {
+ renderStatBlock(GOBLIN);
+ expect(screen.getByText(ARMOR_CLASS_REGEX)).toBeInTheDocument();
+ expect(screen.getByText("15")).toBeInTheDocument();
+ expect(screen.getByText("(leather armor, shield)")).toBeInTheDocument();
+ });
+
+ it("renders AC without source when acSource is undefined", () => {
+ renderStatBlock(DRAGON);
+ expect(screen.getByText("22")).toBeInTheDocument();
+ });
+
+ it("renders HP average and formula", () => {
+ renderStatBlock(GOBLIN);
+ expect(screen.getByText("7")).toBeInTheDocument();
+ expect(screen.getByText("(2d6)")).toBeInTheDocument();
+ });
+
+ it("renders speed", () => {
+ renderStatBlock(GOBLIN);
+ expect(screen.getByText("30 ft.")).toBeInTheDocument();
+ });
+ });
+
+ describe("ability scores", () => {
+ it("renders all 6 ability labels", () => {
+ renderStatBlock(GOBLIN);
+ for (const label of ["STR", "DEX", "CON", "INT", "WIS", "CHA"]) {
+ expect(screen.getByText(label)).toBeInTheDocument();
+ }
+ });
+
+ it("renders ability scores with modifier notation", () => {
+ renderStatBlock(GOBLIN);
+ expect(screen.getByText("(+2)")).toBeInTheDocument();
+ });
+ });
+
+ describe("properties", () => {
+ it("renders saving throws when present", () => {
+ renderStatBlock(GOBLIN);
+ expect(screen.getByText("Saving Throws")).toBeInTheDocument();
+ expect(screen.getByText(DEX_PLUS_4_REGEX)).toBeInTheDocument();
+ });
+
+ it("renders skills when present", () => {
+ renderStatBlock(GOBLIN);
+ expect(screen.getByText("Skills")).toBeInTheDocument();
+ });
+
+ it("renders damage resistances, immunities, vulnerabilities", () => {
+ renderStatBlock(DRAGON);
+ expect(screen.getByText("Damage Resistances")).toBeInTheDocument();
+ expect(screen.getByText("Damage Immunities")).toBeInTheDocument();
+ expect(screen.getByText("Damage Vulnerabilities")).toBeInTheDocument();
+ expect(screen.getByText("Condition Immunities")).toBeInTheDocument();
+ });
+
+ it("omits properties when undefined", () => {
+ renderStatBlock(GOBLIN);
+ expect(screen.queryByText("Damage Resistances")).not.toBeInTheDocument();
+ expect(screen.queryByText("Damage Immunities")).not.toBeInTheDocument();
+ });
+
+ it("renders CR and proficiency bonus", () => {
+ renderStatBlock(GOBLIN);
+ expect(screen.getByText("Challenge")).toBeInTheDocument();
+ expect(screen.getByText(CR_QUARTER_REGEX)).toBeInTheDocument();
+ expect(screen.getByText(PROF_BONUS_2_REGEX)).toBeInTheDocument();
+ });
+ });
+
+ describe("traits", () => {
+ it("renders trait entries", () => {
+ renderStatBlock(GOBLIN);
+ expect(screen.getByText(NIMBLE_ESCAPE_REGEX)).toBeInTheDocument();
+ });
+ });
+
+ describe("actions / bonus actions / reactions", () => {
+ it("renders actions heading and entries", () => {
+ renderStatBlock(GOBLIN);
+ expect(
+ screen.getByRole("heading", { name: "Actions" }),
+ ).toBeInTheDocument();
+ expect(screen.getByText(SCIMITAR_REGEX)).toBeInTheDocument();
+ });
+
+ it("renders bonus actions heading and entries", () => {
+ renderStatBlock(GOBLIN);
+ expect(
+ screen.getByRole("heading", { name: "Bonus Actions" }),
+ ).toBeInTheDocument();
+ });
+
+ it("renders reactions heading and entries", () => {
+ renderStatBlock(GOBLIN);
+ expect(
+ screen.getByRole("heading", { name: "Reactions" }),
+ ).toBeInTheDocument();
+ });
+ });
+
+ describe("legendary actions", () => {
+ it("renders legendary actions with preamble", () => {
+ renderStatBlock(DRAGON);
+ expect(
+ screen.getByRole("heading", { name: "Legendary Actions" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText("The dragon can take 3 legendary actions."),
+ ).toBeInTheDocument();
+ expect(screen.getByText(DETECT_REGEX)).toBeInTheDocument();
+ expect(screen.getByText(TAIL_ATTACK_REGEX)).toBeInTheDocument();
+ });
+
+ it("omits legendary actions when undefined", () => {
+ renderStatBlock(GOBLIN);
+ expect(
+ screen.queryByRole("heading", { name: "Legendary Actions" }),
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ describe("spellcasting", () => {
+ it("renders spellcasting block with header", () => {
+ renderStatBlock(DRAGON);
+ expect(screen.getByText(INNATE_SPELLCASTING_REGEX)).toBeInTheDocument();
+ });
+
+ it("renders at-will spells", () => {
+ renderStatBlock(DRAGON);
+ expect(screen.getByText(AT_WILL_REGEX)).toBeInTheDocument();
+ expect(screen.getByText(DETECT_MAGIC_REGEX)).toBeInTheDocument();
+ });
+
+ it("renders daily spells", () => {
+ renderStatBlock(DRAGON);
+ expect(screen.getByText(DAILY_REGEX)).toBeInTheDocument();
+ expect(screen.getByText(FIREBALL_REGEX)).toBeInTheDocument();
+ });
+
+ it("renders long rest spells", () => {
+ renderStatBlock(DRAGON);
+ expect(screen.getByText(LONG_REST_REGEX)).toBeInTheDocument();
+ expect(screen.getByText(WISH_REGEX)).toBeInTheDocument();
+ });
+
+ it("omits spellcasting when undefined", () => {
+ renderStatBlock(GOBLIN);
+ expect(screen.queryByText(AT_WILL_REGEX)).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/apps/web/src/hooks/__tests__/use-bulk-import.test.ts b/apps/web/src/hooks/__tests__/use-bulk-import.test.ts
new file mode 100644
index 0000000..12b982d
--- /dev/null
+++ b/apps/web/src/hooks/__tests__/use-bulk-import.test.ts
@@ -0,0 +1,145 @@
+// @vitest-environment jsdom
+import { act, renderHook } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { useBulkImport } from "../use-bulk-import.js";
+
+vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
+ getAllSourceCodes: () => ["MM", "VGM", "XGE"],
+ getDefaultFetchUrl: (code: string, baseUrl: string) =>
+ `${baseUrl}${code}.json`,
+ loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
+ getSourceDisplayName: (code: string) => code,
+}));
+
+/** Flush microtasks so the internal async IIFE inside startImport settles. */
+function flushMicrotasks(): Promise {
+ return new Promise((resolve) => {
+ setTimeout(resolve, 0);
+ });
+}
+
+describe("useBulkImport", () => {
+ it("starts in idle state with all counters at 0", () => {
+ const { result } = renderHook(() => useBulkImport());
+ expect(result.current.state).toEqual({
+ status: "idle",
+ total: 0,
+ completed: 0,
+ failed: 0,
+ });
+ });
+
+ it("reset returns to idle state", async () => {
+ const { result } = renderHook(() => useBulkImport());
+
+ const isSourceCached = vi.fn().mockResolvedValue(true);
+ const fetchAndCacheSource = vi.fn();
+ const refreshCache = vi.fn();
+
+ await act(async () => {
+ result.current.startImport(
+ "https://example.com/",
+ fetchAndCacheSource,
+ isSourceCached,
+ refreshCache,
+ );
+ await flushMicrotasks();
+ });
+
+ act(() => result.current.reset());
+ expect(result.current.state.status).toBe("idle");
+ });
+
+ it("goes straight to complete when all sources are cached", async () => {
+ const { result } = renderHook(() => useBulkImport());
+
+ const isSourceCached = vi.fn().mockResolvedValue(true);
+ const fetchAndCacheSource = vi.fn();
+ const refreshCache = vi.fn();
+
+ await act(async () => {
+ result.current.startImport(
+ "https://example.com/",
+ fetchAndCacheSource,
+ isSourceCached,
+ refreshCache,
+ );
+ await flushMicrotasks();
+ });
+
+ expect(result.current.state.status).toBe("complete");
+ expect(result.current.state.completed).toBe(3);
+ expect(fetchAndCacheSource).not.toHaveBeenCalled();
+ });
+
+ it("fetches uncached sources and completes", async () => {
+ const { result } = renderHook(() => useBulkImport());
+
+ const isSourceCached = vi.fn().mockResolvedValue(false);
+ const fetchAndCacheSource = vi.fn().mockResolvedValue(undefined);
+ const refreshCache = vi.fn().mockResolvedValue(undefined);
+
+ await act(async () => {
+ result.current.startImport(
+ "https://example.com/",
+ fetchAndCacheSource,
+ isSourceCached,
+ refreshCache,
+ );
+ await flushMicrotasks();
+ });
+
+ expect(result.current.state.status).toBe("complete");
+ expect(result.current.state.completed).toBe(3);
+ expect(result.current.state.failed).toBe(0);
+ expect(fetchAndCacheSource).toHaveBeenCalledTimes(3);
+ expect(refreshCache).toHaveBeenCalled();
+ });
+
+ it("reports partial-failure when some sources fail", async () => {
+ const { result } = renderHook(() => useBulkImport());
+
+ const isSourceCached = vi.fn().mockResolvedValue(false);
+ const fetchAndCacheSource = vi
+ .fn()
+ .mockResolvedValueOnce(undefined)
+ .mockRejectedValueOnce(new Error("fail"))
+ .mockResolvedValueOnce(undefined);
+ const refreshCache = vi.fn().mockResolvedValue(undefined);
+
+ await act(async () => {
+ result.current.startImport(
+ "https://example.com/",
+ fetchAndCacheSource,
+ isSourceCached,
+ refreshCache,
+ );
+ await flushMicrotasks();
+ });
+
+ expect(result.current.state.status).toBe("partial-failure");
+ expect(result.current.state.completed).toBe(2);
+ expect(result.current.state.failed).toBe(1);
+ expect(refreshCache).toHaveBeenCalled();
+ });
+
+ it("calls refreshCache after all batches complete", async () => {
+ const { result } = renderHook(() => useBulkImport());
+
+ const isSourceCached = vi.fn().mockResolvedValue(false);
+ const fetchAndCacheSource = vi.fn().mockResolvedValue(undefined);
+ const refreshCache = vi.fn().mockResolvedValue(undefined);
+
+ await act(async () => {
+ result.current.startImport(
+ "https://example.com/",
+ fetchAndCacheSource,
+ isSourceCached,
+ refreshCache,
+ );
+ await flushMicrotasks();
+ });
+
+ expect(refreshCache).toHaveBeenCalledOnce();
+ });
+});
diff --git a/apps/web/src/hooks/__tests__/use-initiative-rolls.test.ts b/apps/web/src/hooks/__tests__/use-initiative-rolls.test.ts
new file mode 100644
index 0000000..0595a9d
--- /dev/null
+++ b/apps/web/src/hooks/__tests__/use-initiative-rolls.test.ts
@@ -0,0 +1,118 @@
+// @vitest-environment jsdom
+import "@testing-library/jest-dom/vitest";
+
+import { type CreatureId, combatantId } from "@initiative/domain";
+import { act, renderHook } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { useInitiativeRolls } from "../use-initiative-rolls.js";
+
+const mockMakeStore = vi.fn(() => ({}));
+const mockWithUndo = vi.fn((fn: () => unknown) => fn());
+const mockGetCreature = vi.fn();
+const mockShowCreature = vi.fn();
+
+vi.mock("../../contexts/encounter-context.js", () => ({
+ useEncounterContext: () => ({
+ encounter: {
+ combatants: [
+ {
+ id: combatantId("c1"),
+ name: "Goblin",
+ creatureId: "srd:goblin" as CreatureId,
+ },
+ ],
+ activeIndex: 0,
+ roundNumber: 1,
+ },
+ makeStore: mockMakeStore,
+ withUndo: mockWithUndo,
+ }),
+}));
+
+vi.mock("../../contexts/bestiary-context.js", () => ({
+ useBestiaryContext: () => ({
+ getCreature: mockGetCreature,
+ }),
+}));
+
+vi.mock("../../contexts/side-panel-context.js", () => ({
+ useSidePanelContext: () => ({
+ showCreature: mockShowCreature,
+ }),
+}));
+
+const mockRollInitiativeUseCase = vi.fn();
+const mockRollAllInitiativeUseCase = vi.fn();
+
+vi.mock("@initiative/application", () => ({
+ rollInitiativeUseCase: (...args: unknown[]) =>
+ mockRollInitiativeUseCase(...args),
+ rollAllInitiativeUseCase: (...args: unknown[]) =>
+ mockRollAllInitiativeUseCase(...args),
+}));
+
+function wrapper({ children }: { children: ReactNode }) {
+ return children;
+}
+
+describe("useInitiativeRolls", () => {
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("handleRollInitiative calls rollInitiativeUseCase via withUndo", () => {
+ mockRollInitiativeUseCase.mockReturnValue({ initiative: 15 });
+ const { result } = renderHook(() => useInitiativeRolls(), { wrapper });
+
+ act(() => result.current.handleRollInitiative(combatantId("c1")));
+
+ expect(mockWithUndo).toHaveBeenCalled();
+ expect(mockRollInitiativeUseCase).toHaveBeenCalled();
+ });
+
+ it("sets rollSingleSkipped on domain error", () => {
+ mockRollInitiativeUseCase.mockReturnValue({
+ kind: "domain-error",
+ code: "missing-source",
+ message: "no source",
+ });
+ const { result } = renderHook(() => useInitiativeRolls(), { wrapper });
+
+ act(() => result.current.handleRollInitiative(combatantId("c1")));
+ expect(result.current.rollSingleSkipped).toBe(true);
+ expect(mockShowCreature).toHaveBeenCalledWith("srd:goblin");
+ });
+
+ it("dismissRollSingleSkipped resets the flag", () => {
+ mockRollInitiativeUseCase.mockReturnValue({
+ kind: "domain-error",
+ code: "missing-source",
+ message: "no source",
+ });
+ const { result } = renderHook(() => useInitiativeRolls(), { wrapper });
+
+ act(() => result.current.handleRollInitiative(combatantId("c1")));
+ expect(result.current.rollSingleSkipped).toBe(true);
+
+ act(() => result.current.dismissRollSingleSkipped());
+ expect(result.current.rollSingleSkipped).toBe(false);
+ });
+
+ it("handleRollAllInitiative sets rollSkippedCount when sources missing", () => {
+ mockRollAllInitiativeUseCase.mockReturnValue({ skippedNoSource: 3 });
+ const { result } = renderHook(() => useInitiativeRolls(), { wrapper });
+
+ act(() => result.current.handleRollAllInitiative());
+ expect(result.current.rollSkippedCount).toBe(3);
+ });
+
+ it("dismissRollSkipped resets the count", () => {
+ mockRollAllInitiativeUseCase.mockReturnValue({ skippedNoSource: 2 });
+ const { result } = renderHook(() => useInitiativeRolls(), { wrapper });
+
+ act(() => result.current.handleRollAllInitiative());
+ act(() => result.current.dismissRollSkipped());
+ expect(result.current.rollSkippedCount).toBe(0);
+ });
+});
diff --git a/apps/web/src/hooks/__tests__/use-long-press.test.ts b/apps/web/src/hooks/__tests__/use-long-press.test.ts
new file mode 100644
index 0000000..cf31d0b
--- /dev/null
+++ b/apps/web/src/hooks/__tests__/use-long-press.test.ts
@@ -0,0 +1,104 @@
+// @vitest-environment jsdom
+import { act, renderHook } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { useLongPress } from "../use-long-press.js";
+
+function touchEvent(overrides?: Partial): React.TouchEvent {
+ return {
+ preventDefault: vi.fn(),
+ ...overrides,
+ } as unknown as React.TouchEvent;
+}
+
+describe("useLongPress", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("returns onTouchStart, onTouchEnd, onTouchMove handlers", () => {
+ const { result } = renderHook(() => useLongPress(vi.fn()));
+ expect(result.current.onTouchStart).toBeInstanceOf(Function);
+ expect(result.current.onTouchEnd).toBeInstanceOf(Function);
+ expect(result.current.onTouchMove).toBeInstanceOf(Function);
+ });
+
+ it("fires onLongPress after 500ms hold", () => {
+ const onLongPress = vi.fn();
+ const { result } = renderHook(() => useLongPress(onLongPress));
+
+ const e = touchEvent();
+ act(() => result.current.onTouchStart(e));
+ expect(onLongPress).not.toHaveBeenCalled();
+
+ act(() => {
+ vi.advanceTimersByTime(500);
+ });
+ expect(onLongPress).toHaveBeenCalledOnce();
+ });
+
+ it("does not fire if released before 500ms", () => {
+ const onLongPress = vi.fn();
+ const { result } = renderHook(() => useLongPress(onLongPress));
+
+ act(() => result.current.onTouchStart(touchEvent()));
+ act(() => {
+ vi.advanceTimersByTime(300);
+ });
+ act(() => result.current.onTouchEnd(touchEvent()));
+ act(() => {
+ vi.advanceTimersByTime(500);
+ });
+
+ expect(onLongPress).not.toHaveBeenCalled();
+ });
+
+ it("cancels on touch move", () => {
+ const onLongPress = vi.fn();
+ const { result } = renderHook(() => useLongPress(onLongPress));
+
+ act(() => result.current.onTouchStart(touchEvent()));
+ act(() => {
+ vi.advanceTimersByTime(200);
+ });
+ act(() => result.current.onTouchMove());
+ act(() => {
+ vi.advanceTimersByTime(500);
+ });
+
+ expect(onLongPress).not.toHaveBeenCalled();
+ });
+
+ it("onTouchEnd calls preventDefault after long press fires", () => {
+ const onLongPress = vi.fn();
+ const { result } = renderHook(() => useLongPress(onLongPress));
+
+ act(() => result.current.onTouchStart(touchEvent()));
+ act(() => {
+ vi.advanceTimersByTime(500);
+ });
+
+ const preventDefaultSpy = vi.fn();
+ const endEvent = touchEvent({ preventDefault: preventDefaultSpy });
+ act(() => result.current.onTouchEnd(endEvent));
+ expect(preventDefaultSpy).toHaveBeenCalled();
+ });
+
+ it("onTouchEnd does not preventDefault when long press did not fire", () => {
+ const onLongPress = vi.fn();
+ const { result } = renderHook(() => useLongPress(onLongPress));
+
+ act(() => result.current.onTouchStart(touchEvent()));
+ act(() => {
+ vi.advanceTimersByTime(100);
+ });
+
+ const preventDefaultSpy = vi.fn();
+ const endEvent = touchEvent({ preventDefault: preventDefaultSpy });
+ act(() => result.current.onTouchEnd(endEvent));
+ expect(preventDefaultSpy).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/web/src/hooks/__tests__/use-swipe-to-dismiss.test.ts b/apps/web/src/hooks/__tests__/use-swipe-to-dismiss.test.ts
new file mode 100644
index 0000000..97081a8
--- /dev/null
+++ b/apps/web/src/hooks/__tests__/use-swipe-to-dismiss.test.ts
@@ -0,0 +1,116 @@
+// @vitest-environment jsdom
+import { act, renderHook } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { useSwipeToDismiss } from "../use-swipe-to-dismiss.js";
+
+const PANEL_WIDTH = 300;
+
+function makeTouchEvent(clientX: number, clientY = 0): React.TouchEvent {
+ return {
+ touches: [{ clientX, clientY }],
+ currentTarget: {
+ getBoundingClientRect: () => ({ width: PANEL_WIDTH }),
+ },
+ } as unknown as React.TouchEvent;
+}
+
+describe("useSwipeToDismiss", () => {
+ beforeEach(() => {
+ vi.spyOn(Date, "now").mockReturnValue(0);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("starts with offsetX 0 and isSwiping false", () => {
+ const { result } = renderHook(() => useSwipeToDismiss(vi.fn()));
+ expect(result.current.offsetX).toBe(0);
+ expect(result.current.isSwiping).toBe(false);
+ });
+
+ it("horizontal drag updates offsetX and sets isSwiping", () => {
+ const { result } = renderHook(() => useSwipeToDismiss(vi.fn()));
+
+ act(() => result.current.handlers.onTouchStart(makeTouchEvent(0)));
+ act(() => result.current.handlers.onTouchMove(makeTouchEvent(50)));
+
+ expect(result.current.offsetX).toBe(50);
+ expect(result.current.isSwiping).toBe(true);
+ });
+
+ it("vertical drag is ignored after direction lock", () => {
+ const { result } = renderHook(() => useSwipeToDismiss(vi.fn()));
+
+ act(() => result.current.handlers.onTouchStart(makeTouchEvent(0, 0)));
+ // Move vertically > 10px to lock vertical
+ act(() => result.current.handlers.onTouchMove(makeTouchEvent(0, 20)));
+
+ expect(result.current.offsetX).toBe(0);
+ });
+
+ it("small movement does not lock direction", () => {
+ const { result } = renderHook(() => useSwipeToDismiss(vi.fn()));
+
+ act(() => result.current.handlers.onTouchStart(makeTouchEvent(0)));
+ act(() => result.current.handlers.onTouchMove(makeTouchEvent(5)));
+
+ // No direction locked yet, no update
+ expect(result.current.offsetX).toBe(0);
+ expect(result.current.isSwiping).toBe(false);
+ });
+
+ it("leftward drag is clamped to 0", () => {
+ const { result } = renderHook(() => useSwipeToDismiss(vi.fn()));
+
+ act(() => result.current.handlers.onTouchStart(makeTouchEvent(100)));
+ act(() => result.current.handlers.onTouchMove(makeTouchEvent(50)));
+
+ expect(result.current.offsetX).toBe(0);
+ });
+
+ it("calls onDismiss when ratio exceeds threshold", () => {
+ const onDismiss = vi.fn();
+ const { result } = renderHook(() => useSwipeToDismiss(onDismiss));
+
+ act(() => result.current.handlers.onTouchStart(makeTouchEvent(0)));
+ // Move > 35% of panel width (300 * 0.35 = 105)
+ act(() => result.current.handlers.onTouchMove(makeTouchEvent(120)));
+
+ vi.spyOn(Date, "now").mockReturnValue(5000); // slow swipe
+ act(() => result.current.handlers.onTouchEnd());
+
+ expect(onDismiss).toHaveBeenCalled();
+ });
+
+ it("calls onDismiss with fast velocity", () => {
+ const onDismiss = vi.fn();
+ const { result } = renderHook(() => useSwipeToDismiss(onDismiss));
+
+ act(() => result.current.handlers.onTouchStart(makeTouchEvent(0)));
+ // Small distance but fast
+ act(() => result.current.handlers.onTouchMove(makeTouchEvent(30)));
+
+ // Very fast: 30px in 0.1s = 300px/s, velocity = 300/300 = 1.0 > 0.5
+ vi.spyOn(Date, "now").mockReturnValue(100);
+ act(() => result.current.handlers.onTouchEnd());
+
+ expect(onDismiss).toHaveBeenCalled();
+ });
+
+ it("does not dismiss when below thresholds", () => {
+ const onDismiss = vi.fn();
+ const { result } = renderHook(() => useSwipeToDismiss(onDismiss));
+
+ act(() => result.current.handlers.onTouchStart(makeTouchEvent(0)));
+ // Small distance, slow speed
+ act(() => result.current.handlers.onTouchMove(makeTouchEvent(20)));
+
+ vi.spyOn(Date, "now").mockReturnValue(5000);
+ act(() => result.current.handlers.onTouchEnd());
+
+ expect(onDismiss).not.toHaveBeenCalled();
+ expect(result.current.offsetX).toBe(0);
+ expect(result.current.isSwiping).toBe(false);
+ });
+});
diff --git a/vitest.config.ts b/vitest.config.ts
index ed448de..694cd07 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -26,12 +26,12 @@ export default defineConfig({
branches: 70,
},
"apps/web/src/hooks": {
- lines: 72,
- branches: 55,
+ lines: 83,
+ branches: 66,
},
"apps/web/src/components": {
- lines: 59,
- branches: 55,
+ lines: 80,
+ branches: 71,
},
"apps/web/src/components/ui": {
lines: 93,