From b229a0dac79c3d4e9d48b50d465d49f63146433b Mon Sep 17 00:00:00 2001 From: Lukas Date: Sun, 29 Mar 2026 23:55:21 +0200 Subject: [PATCH] Add missing component and hook tests, raise coverage thresholds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../__tests__/bulk-import-prompt.test.tsx | 146 ++++++++++ .../__tests__/color-palette.test.tsx | 56 ++++ .../__tests__/combatant-row.test.tsx | 168 +++++++++++ .../__tests__/export-method-dialog.test.tsx | 86 ++++++ .../__tests__/import-method-dialog.test.tsx | 98 +++++++ .../player-character-section.test.tsx | 134 +++++++++ .../__tests__/player-management.test.tsx | 120 ++++++++ .../__tests__/settings-modal.test.tsx | 110 +++++++ .../__tests__/source-fetch-prompt.test.tsx | 124 ++++++++ .../components/__tests__/stat-block.test.tsx | 273 ++++++++++++++++++ .../hooks/__tests__/use-bulk-import.test.ts | 145 ++++++++++ .../__tests__/use-initiative-rolls.test.ts | 118 ++++++++ .../hooks/__tests__/use-long-press.test.ts | 104 +++++++ .../__tests__/use-swipe-to-dismiss.test.ts | 116 ++++++++ vitest.config.ts | 8 +- 15 files changed, 1802 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/components/__tests__/bulk-import-prompt.test.tsx create mode 100644 apps/web/src/components/__tests__/color-palette.test.tsx create mode 100644 apps/web/src/components/__tests__/export-method-dialog.test.tsx create mode 100644 apps/web/src/components/__tests__/import-method-dialog.test.tsx create mode 100644 apps/web/src/components/__tests__/player-character-section.test.tsx create mode 100644 apps/web/src/components/__tests__/player-management.test.tsx create mode 100644 apps/web/src/components/__tests__/settings-modal.test.tsx create mode 100644 apps/web/src/components/__tests__/source-fetch-prompt.test.tsx create mode 100644 apps/web/src/components/__tests__/stat-block.test.tsx create mode 100644 apps/web/src/hooks/__tests__/use-bulk-import.test.ts create mode 100644 apps/web/src/hooks/__tests__/use-initiative-rolls.test.ts create mode 100644 apps/web/src/hooks/__tests__/use-long-press.test.ts create mode 100644 apps/web/src/hooks/__tests__/use-swipe-to-dismiss.test.ts 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,