// @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.js"; import { AllProviders } from "./test-providers.js"; // 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(, { wrapper: AllProviders }); // 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(, { wrapper: AllProviders }); 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(, { wrapper: AllProviders }); 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(); }); });