Add integration tests for App.tsx with accessible HP status labels
3 integration tests render the full <App /> and exercise multi-component flows: add/remove combatant, turn tracking across two combatants, and HP adjustment with unconscious state. Add aria-label to the clickable HP button so tests query accessible names instead of CSS classes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
163
apps/web/src/__tests__/app-integration.test.tsx
Normal file
163
apps/web/src/__tests__/app-integration.test.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
// @vitest-environment jsdom
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
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 { App } from "../App";
|
||||
|
||||
// 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 — jsdom doesn't implement these
|
||||
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(),
|
||||
})),
|
||||
});
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
async function addCombatant(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
name: string,
|
||||
opts?: { maxHp?: string },
|
||||
) {
|
||||
const inputs = screen.getAllByPlaceholderText("+ Add combatants");
|
||||
// biome-ignore lint/style/noNonNullAssertion: getAllBy always returns at least one
|
||||
const input = inputs.at(-1)!;
|
||||
await user.type(input, name);
|
||||
|
||||
if (opts?.maxHp) {
|
||||
const maxHpInput = screen.getByPlaceholderText("MaxHP");
|
||||
await user.type(maxHpInput, opts.maxHp);
|
||||
}
|
||||
|
||||
const addButton = screen.getByRole("button", { name: "Add" });
|
||||
await user.click(addButton);
|
||||
}
|
||||
|
||||
describe("App integration", () => {
|
||||
it("adds a combatant and removes it, returning to empty state", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
|
||||
// Empty state: centered input visible, no TurnNavigation
|
||||
expect(screen.getByPlaceholderText("+ Add combatants")).toBeInTheDocument();
|
||||
expect(screen.queryByText("R1")).not.toBeInTheDocument();
|
||||
|
||||
// Add a combatant
|
||||
await addCombatant(user, "Goblin");
|
||||
|
||||
// Verify combatant appears and TurnNavigation shows
|
||||
expect(screen.getByRole("button", { name: "Goblin" })).toBeInTheDocument();
|
||||
expect(screen.getByText("R1")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Goblin").length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Remove combatant via ConfirmButton (two clicks)
|
||||
const removeBtn = screen.getByRole("button", {
|
||||
name: "Remove combatant",
|
||||
});
|
||||
await user.click(removeBtn);
|
||||
const confirmBtn = screen.getByRole("button", {
|
||||
name: "Confirm remove combatant",
|
||||
});
|
||||
await user.click(confirmBtn);
|
||||
|
||||
// Back to empty state (R1 badge may linger due to exit animation in jsdom)
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Goblin" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("+ Add combatants")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("advances and retreats turns across two combatants", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
|
||||
await addCombatant(user, "Fighter");
|
||||
await addCombatant(user, "Wizard");
|
||||
|
||||
// Initial state — R1, Fighter active (Previous turn disabled)
|
||||
expect(screen.getByText("R1")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Previous turn" }),
|
||||
).toBeDisabled();
|
||||
|
||||
// Advance turn — Wizard becomes active
|
||||
await user.click(screen.getByRole("button", { name: "Next turn" }));
|
||||
expect(screen.getByText("R1")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Previous turn" })).toBeEnabled();
|
||||
|
||||
// Advance again — wraps to R2, Fighter active
|
||||
await user.click(screen.getByRole("button", { name: "Next turn" }));
|
||||
expect(screen.getByText("R2")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Previous turn" })).toBeEnabled();
|
||||
|
||||
// Retreat — back to R1, Wizard active
|
||||
await user.click(screen.getByRole("button", { name: "Previous turn" }));
|
||||
expect(screen.getByText("R1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("adds a combatant with HP, applies damage, and shows unconscious state", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
|
||||
await addCombatant(user, "Ogre", { maxHp: "59" });
|
||||
|
||||
// Verify HP displays — currentHp and maxHp both show "59"
|
||||
expect(screen.getByText("/")).toBeInTheDocument();
|
||||
const hpButton = screen.getByRole("button", {
|
||||
name: "Current HP: 59 (healthy)",
|
||||
});
|
||||
expect(hpButton).toBeInTheDocument();
|
||||
|
||||
// Click currentHp to open HpAdjustPopover, apply full damage
|
||||
await user.click(hpButton);
|
||||
const hpInput = screen.getByPlaceholderText("HP");
|
||||
expect(hpInput).toBeInTheDocument();
|
||||
await user.type(hpInput, "59");
|
||||
await user.click(screen.getByRole("button", { name: "Apply damage" }));
|
||||
|
||||
// Verify HP decreased to 0 and unconscious state
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Current HP: 0 (unconscious)" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -244,6 +244,7 @@ function ClickableHp({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPopoverOpen(true)}
|
||||
aria-label={`Current HP: ${currentHp} (${status})`}
|
||||
className={cn(
|
||||
"inline-block h-7 min-w-[3ch] text-center font-medium text-sm tabular-nums leading-7 transition-colors hover:text-hover-neutral",
|
||||
status === "bloodied" && "text-amber-400",
|
||||
|
||||
Reference in New Issue
Block a user