// @vitest-environment jsdom import "@testing-library/jest-dom/vitest"; import { type CreatureId, combatantId } 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 { AllProviders } from "../../__tests__/test-providers.js"; import { CombatantRow } from "../combatant-row.js"; import { PLAYER_COLOR_HEX } from "../player-icon-map.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 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(), })), }); }); afterEach(cleanup); function renderRow( overrides: Partial<{ combatant: Parameters[0]["combatant"]; isActive: boolean; }> = {}, ) { const combatant = overrides.combatant ?? { id: combatantId("1"), name: "Goblin", initiative: 15, maxHp: 10, currentHp: 10, ac: 13, }; return render( , { wrapper: AllProviders }, ); } describe("CombatantRow", () => { it("renders combatant name", () => { renderRow(); expect(screen.getByText("Goblin")).toBeInTheDocument(); }); it("renders initiative value", () => { renderRow(); expect(screen.getByText("15")).toBeInTheDocument(); }); it("renders current HP", () => { renderRow({ combatant: { id: combatantId("1"), name: "Goblin", maxHp: 10, currentHp: 7, }, }); expect(screen.getByText("7")).toBeInTheDocument(); }); it("active combatant gets active border styling", () => { const { container } = renderRow({ isActive: true }); const row = container.firstElementChild; expect(row?.className).toContain("border-active-row-border"); }); it("unconscious combatant (currentHp === 0) gets dimmed styling", () => { renderRow({ combatant: { id: combatantId("1"), name: "Goblin", maxHp: 10, currentHp: 0, }, }); // The name area should have opacity-50 const nameEl = screen.getByText("Goblin"); const nameContainer = nameEl.closest(".opacity-50"); expect(nameContainer).not.toBeNull(); }); it("shows '--' for current HP when no maxHp is set", () => { renderRow({ combatant: { id: combatantId("1"), name: "Goblin", }, }); expect(screen.getByLabelText("No HP set")).toBeInTheDocument(); }); it("shows concentration icon when isConcentrating is true", () => { renderRow({ combatant: { id: combatantId("1"), name: "Goblin", isConcentrating: true, }, }); const concButton = screen.getByRole("button", { name: "Toggle concentration", }); expect(concButton.className).toContain("text-purple-400"); }); it("shows player character icon and color when set", () => { const { container } = renderRow({ combatant: { id: combatantId("1"), name: "Aragorn", color: "red", icon: "sword", }, }); // The icon should be rendered with the player color const svgIcon = container.querySelector("svg[style]"); expect(svgIcon).not.toBeNull(); expect(svgIcon).toHaveStyle({ color: PLAYER_COLOR_HEX.red }); }); it("remove button removes after confirmation", async () => { const user = userEvent.setup(); renderRow(); const removeBtn = screen.getByRole("button", { name: "Remove combatant", }); // First click enters confirm state await user.click(removeBtn); // Second click confirms const confirmBtn = screen.getByRole("button", { name: "Confirm remove combatant", }); await user.click(confirmBtn); // After confirming, the button returns to its initial state expect( screen.queryByRole("button", { name: "Confirm remove combatant" }), ).not.toBeInTheDocument(); }); it("shows d20 roll button when initiative is undefined and combatant has creatureId", () => { renderRow({ combatant: { id: combatantId("1"), name: "Goblin", creatureId: "srd:goblin" as CreatureId, }, }); expect( screen.getByRole("button", { name: "Roll initiative" }), ).toBeInTheDocument(); }); });