Group current HP, temp HP, and max HP into a single bordered pill container with a subtle slash separator. Removes the scattered layout with separate elements and gaps. Temp HP +N only renders when present (no invisible spacer). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
301 lines
7.6 KiB
TypeScript
301 lines
7.6 KiB
TypeScript
// @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/;
|
|
|
|
// 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<typeof CombatantRow>[0]["combatant"];
|
|
isActive: boolean;
|
|
}> = {},
|
|
) {
|
|
const combatant = overrides.combatant ?? {
|
|
id: combatantId("1"),
|
|
name: "Goblin",
|
|
initiative: 15,
|
|
maxHp: 10,
|
|
currentHp: 10,
|
|
ac: 13,
|
|
};
|
|
return render(
|
|
<CombatantRow
|
|
combatant={combatant}
|
|
isActive={overrides.isActive ?? false}
|
|
/>,
|
|
{ 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(
|
|
<CombatantRow
|
|
combatant={{ ...combatant, currentHp: 10 }}
|
|
isActive={false}
|
|
/>,
|
|
);
|
|
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(
|
|
<CombatantRow
|
|
combatant={{ ...combatant, currentHp: 10 }}
|
|
isActive={false}
|
|
/>,
|
|
);
|
|
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(
|
|
<CombatantRow
|
|
combatant={{ ...combatant, tempHp: 3 }}
|
|
isActive={false}
|
|
/>,
|
|
);
|
|
const row = container.firstElementChild;
|
|
expect(row?.className).toContain("animate-concentration-pulse");
|
|
});
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|
|
});
|