diff --git a/apps/web/src/__tests__/app-integration.test.tsx b/apps/web/src/__tests__/app-integration.test.tsx new file mode 100644 index 0000000..7832a35 --- /dev/null +++ b/apps/web/src/__tests__/app-integration.test.tsx @@ -0,0 +1,163 @@ +// @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 { App } from "../App"; + +// Mock persistence — no localStorage interaction +vi.mock("../persistence/encounter-storage.js", () => ({ + loadEncounter: () => null, + saveEncounter: () => {}, +})); + +vi.mock("../persistence/player-character-storage.js", () => ({ + loadPlayerCharacters: () => [], + savePlayerCharacters: () => {}, +})); + +// Mock bestiary — no IndexedDB or JSON index +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, +})); + +// DOM API stubs — jsdom doesn't implement these +beforeAll(() => { + 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(), + })), + }); + Element.prototype.scrollIntoView = vi.fn(); +}); + +afterEach(cleanup); + +async function addCombatant( + user: ReturnType, + name: string, + opts?: { maxHp?: string }, +) { + const inputs = screen.getAllByPlaceholderText("+ Add combatants"); + // biome-ignore lint/style/noNonNullAssertion: getAllBy always returns at least one + const input = inputs.at(-1)!; + await user.type(input, name); + + if (opts?.maxHp) { + const maxHpInput = screen.getByPlaceholderText("MaxHP"); + await user.type(maxHpInput, opts.maxHp); + } + + const addButton = screen.getByRole("button", { name: "Add" }); + await user.click(addButton); +} + +describe("App integration", () => { + it("adds a combatant and removes it, returning to empty state", async () => { + const user = userEvent.setup(); + render(); + + // Empty state: centered input visible, no TurnNavigation + expect(screen.getByPlaceholderText("+ Add combatants")).toBeInTheDocument(); + expect(screen.queryByText("R1")).not.toBeInTheDocument(); + + // Add a combatant + await addCombatant(user, "Goblin"); + + // Verify combatant appears and TurnNavigation shows + expect(screen.getByRole("button", { name: "Goblin" })).toBeInTheDocument(); + expect(screen.getByText("R1")).toBeInTheDocument(); + expect(screen.getAllByText("Goblin").length).toBeGreaterThanOrEqual(2); + + // Remove combatant via ConfirmButton (two clicks) + const removeBtn = screen.getByRole("button", { + name: "Remove combatant", + }); + await user.click(removeBtn); + const confirmBtn = screen.getByRole("button", { + name: "Confirm remove combatant", + }); + await user.click(confirmBtn); + + // Back to empty state (R1 badge may linger due to exit animation in jsdom) + expect( + screen.queryByRole("button", { name: "Goblin" }), + ).not.toBeInTheDocument(); + expect(screen.getByPlaceholderText("+ Add combatants")).toBeInTheDocument(); + }); + + it("advances and retreats turns across two combatants", async () => { + const user = userEvent.setup(); + render(); + + await addCombatant(user, "Fighter"); + await addCombatant(user, "Wizard"); + + // Initial state — R1, Fighter active (Previous turn disabled) + expect(screen.getByText("R1")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Previous turn" }), + ).toBeDisabled(); + + // Advance turn — Wizard becomes active + await user.click(screen.getByRole("button", { name: "Next turn" })); + expect(screen.getByText("R1")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Previous turn" })).toBeEnabled(); + + // Advance again — wraps to R2, Fighter active + await user.click(screen.getByRole("button", { name: "Next turn" })); + expect(screen.getByText("R2")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Previous turn" })).toBeEnabled(); + + // Retreat — back to R1, Wizard active + await user.click(screen.getByRole("button", { name: "Previous turn" })); + expect(screen.getByText("R1")).toBeInTheDocument(); + }); + + it("adds a combatant with HP, applies damage, and shows unconscious state", async () => { + const user = userEvent.setup(); + render(); + + await addCombatant(user, "Ogre", { maxHp: "59" }); + + // Verify HP displays — currentHp and maxHp both show "59" + expect(screen.getByText("/")).toBeInTheDocument(); + const hpButton = screen.getByRole("button", { + name: "Current HP: 59 (healthy)", + }); + expect(hpButton).toBeInTheDocument(); + + // Click currentHp to open HpAdjustPopover, apply full damage + await user.click(hpButton); + const hpInput = screen.getByPlaceholderText("HP"); + expect(hpInput).toBeInTheDocument(); + await user.type(hpInput, "59"); + await user.click(screen.getByRole("button", { name: "Apply damage" })); + + // Verify HP decreased to 0 and unconscious state + expect( + screen.getByRole("button", { name: "Current HP: 0 (unconscious)" }), + ).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/combatant-row.tsx b/apps/web/src/components/combatant-row.tsx index a27ffda..7898f9a 100644 --- a/apps/web/src/components/combatant-row.tsx +++ b/apps/web/src/components/combatant-row.tsx @@ -244,6 +244,7 @@ function ClickableHp({