Add missing component and hook tests, raise coverage thresholds

13 new test files for untested components (color-palette, player-management,
stat-block, settings-modal, export/import dialogs, bulk-import-prompt,
source-fetch-prompt, player-character-section) and hooks (use-long-press,
use-swipe-to-dismiss, use-bulk-import, use-initiative-rolls). Expand
combatant-row tests with inline editing, HP popover, and condition picker.

Component coverage: 59% → 80% lines, 55% → 71% branches
Hook coverage: 72% → 83% lines, 55% → 66% branches

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-29 23:55:21 +02:00
parent 08b5db81ad
commit b229a0dac7
15 changed files with 1802 additions and 4 deletions

View File

@@ -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(<BulkImportPrompt />);
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(<BulkImportPrompt />);
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(<BulkImportPrompt />);
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(<BulkImportPrompt />);
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(<BulkImportPrompt />);
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(<BulkImportPrompt />);
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(<BulkImportPrompt />);
expect(screen.getByText(SEVEN_OF_TEN_REGEX)).toBeInTheDocument();
expect(screen.getByText(THREE_FAILED_REGEX)).toBeInTheDocument();
});
});

View File

@@ -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(<ColorPalette value="" onChange={() => {}} />);
const buttons = screen.getAllByRole("button");
expect(buttons).toHaveLength(VALID_PLAYER_COLORS.size);
});
it("each button has an aria-label matching the color name", () => {
render(<ColorPalette value="" onChange={() => {}} />);
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(<ColorPalette value="" onChange={onChange} />);
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(<ColorPalette value="red" onChange={onChange} />);
await user.click(screen.getByRole("button", { name: "red" }));
expect(onChange).toHaveBeenCalledWith("");
});
it("selected color has ring styling", () => {
render(<ColorPalette value="green" onChange={() => {}} />);
const selected = screen.getByRole("button", { name: "green" });
expect(selected.className).toContain("ring-2");
});
it("non-selected colors do not have ring styling", () => {
render(<ColorPalette value="green" onChange={() => {}} />);
const other = screen.getByRole("button", { name: "blue" });
expect(other.className).not.toContain("ring-2");
});
});

View File

@@ -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({

View File

@@ -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(
<ExportMethodDialog
open={open}
onDownload={onDownload}
onCopyToClipboard={onCopyToClipboard}
onClose={onClose}
/>,
);
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();
});
});

View File

@@ -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(
<ImportMethodDialog
open={open}
onSelectFile={onSelectFile}
onSubmitClipboard={onSubmitClipboard}
onClose={onClose}
/>,
);
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();
});
});

View File

@@ -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<PlayerCharacterSectionHandle>();
const result = render(<PlayerCharacterSection ref={ref} />, {
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();
});
});
});

View File

@@ -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<Parameters<typeof PlayerManagement>[0]> = {},
) {
const props = {
open: true,
onClose: vi.fn(),
characters: [] as readonly PlayerCharacter[],
onEdit: vi.fn(),
onDelete: vi.fn(),
onCreate: vi.fn(),
...overrides,
};
return { ...render(<PlayerManagement {...props} />), 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();
});
});

View File

@@ -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(<SettingsModal open={open} onClose={onClose} />, {
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();
});
});

View File

@@ -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(
<SourceFetchPrompt
sourceCode={sourceCode}
onSourceLoaded={onSourceLoaded}
/>,
);
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();
});
});
});

View File

@@ -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(<StatBlock creature={creature} />);
}
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();
});
});
});

View File

@@ -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<void> {
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();
});
});

View File

@@ -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);
});
});

View File

@@ -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>): 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();
});
});

View File

@@ -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);
});
});

View File

@@ -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,