// @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"; const TEMP_HP_REGEX = /^\+\d/; const CURRENT_HP_7_REGEX = /Current HP: 7/; const CURRENT_HP_REGEX = /Current HP/; // 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 'Max' placeholder when no maxHp is set", () => { renderRow({ combatant: { id: combatantId("1"), name: "Goblin", }, }); expect(screen.getByText("Max")).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(); }); describe("concentration pulse", () => { it("pulses when currentHp drops on a concentrating combatant", () => { const combatant = { id: combatantId("1"), name: "Goblin", maxHp: 20, currentHp: 15, isConcentrating: true, }; const { rerender, container } = renderRow({ combatant }); rerender( , ); const row = container.firstElementChild; expect(row?.className).toContain("animate-concentration-pulse"); }); it("does not pulse when not concentrating", () => { const combatant = { id: combatantId("1"), name: "Goblin", maxHp: 20, currentHp: 15, isConcentrating: false, }; const { rerender, container } = renderRow({ combatant }); rerender( , ); const row = container.firstElementChild; expect(row?.className).not.toContain("animate-concentration-pulse"); }); it("pulses when temp HP absorbs all damage on a concentrating combatant", () => { const combatant = { id: combatantId("1"), name: "Goblin", maxHp: 20, currentHp: 15, tempHp: 8, isConcentrating: true, }; const { rerender, container } = renderRow({ combatant }); // Temp HP absorbs all damage, currentHp unchanged rerender( , ); const row = container.firstElementChild; expect(row?.className).toContain("animate-concentration-pulse"); }); }); 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({ combatant: { id: combatantId("1"), name: "Goblin", maxHp: 20, currentHp: 15, tempHp: 5, }, }); expect(screen.getByText("+5")).toBeInTheDocument(); }); it("does not show +N when combatant has no temp HP", () => { renderRow({ combatant: { id: combatantId("1"), name: "Goblin", maxHp: 20, currentHp: 15, }, }); expect(screen.queryByText(TEMP_HP_REGEX)).not.toBeInTheDocument(); }); it("temp HP display uses cyan color", () => { renderRow({ combatant: { id: combatantId("1"), name: "Goblin", maxHp: 20, currentHp: 15, tempHp: 8, }, }); const tempHpEl = screen.getByText("+8"); expect(tempHpEl.className).toContain("text-cyan-400"); }); }); });