Replace direct adapter/persistence imports with context-based injection (AdapterContext + useAdapters) so tests use in-memory implementations instead of vi.mock. Migrate component tests from context mocking to AllProviders with real hooks. Extract export/import logic from ActionBar into useEncounterExportImport hook. Add bestiary-cache and bestiary-index-adapter test suites. Raise adapter coverage thresholds (68→80 lines, 56→62 branches). 77 test files, 891 tests, all passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
441 lines
12 KiB
TypeScript
441 lines
12 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/;
|
|
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<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("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");
|
|
});
|
|
});
|
|
});
|