Replace prop drilling with React context providers. App.tsx shrinks from 427 lines to ~80 lines of pure layout. Components consume shared state directly via 7 context providers instead of threading 50+ props. Key changes: - 7 context providers wrapping existing hooks (encounter, bestiary, player characters, side panel, theme, bulk import, initiative rolls) - 2 coordinating hooks extracted from App.tsx (useInitiativeRolls, useAutoStatBlock) - All 9 affected components refactored from prop-based to context-based - 6 test files updated to use providers or context mocks - Prop count enforcement script (max 8 per component interface) - Constitution principle II-A added (context-based state flow) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
197 lines
5.1 KiB
TypeScript
197 lines
5.1 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";
|
|
|
|
// 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 '--' 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();
|
|
});
|
|
});
|