Aligns terminology with standard UI conventions. Renames props, state, handlers, aria-labels, test descriptions, and the test file. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
264 lines
7.9 KiB
TypeScript
264 lines
7.9 KiB
TypeScript
// @vitest-environment jsdom
|
|
import "@testing-library/jest-dom/vitest";
|
|
|
|
import type { Creature, CreatureId } from "@initiative/domain";
|
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { StatBlockPanel } from "../components/stat-block-panel";
|
|
|
|
const CREATURE_ID = "srd:goblin" as CreatureId;
|
|
const CREATURE: Creature = {
|
|
id: CREATURE_ID,
|
|
name: "Goblin",
|
|
source: "SRD",
|
|
sourceDisplayName: "SRD",
|
|
size: "Small",
|
|
type: "humanoid",
|
|
alignment: "neutral evil",
|
|
ac: 15,
|
|
hp: { average: 7, formula: "2d6" },
|
|
speed: "30 ft.",
|
|
abilities: { str: 8, dex: 14, con: 10, int: 10, wis: 8, cha: 8 },
|
|
cr: "1/4",
|
|
initiativeProficiency: 0,
|
|
proficiencyBonus: 2,
|
|
passive: 9,
|
|
};
|
|
|
|
function mockMatchMedia(matches: boolean) {
|
|
Object.defineProperty(window, "matchMedia", {
|
|
writable: true,
|
|
value: vi.fn().mockImplementation((query: string) => ({
|
|
matches,
|
|
media: query,
|
|
onchange: null,
|
|
addListener: vi.fn(),
|
|
removeListener: vi.fn(),
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
dispatchEvent: vi.fn(),
|
|
})),
|
|
});
|
|
}
|
|
|
|
interface PanelProps {
|
|
creatureId?: CreatureId | null;
|
|
creature?: Creature | null;
|
|
panelRole?: "browse" | "pinned";
|
|
isCollapsed?: boolean;
|
|
onToggleCollapse?: () => void;
|
|
onPin?: () => void;
|
|
onUnpin?: () => void;
|
|
showPinButton?: boolean;
|
|
side?: "left" | "right";
|
|
onDismiss?: () => void;
|
|
bulkImportMode?: boolean;
|
|
}
|
|
|
|
function renderPanel(overrides: PanelProps = {}) {
|
|
const props = {
|
|
creatureId: CREATURE_ID,
|
|
creature: CREATURE,
|
|
isSourceCached: vi.fn().mockResolvedValue(true),
|
|
fetchAndCacheSource: vi.fn(),
|
|
uploadAndCacheSource: vi.fn(),
|
|
refreshCache: vi.fn(),
|
|
panelRole: "browse" as const,
|
|
isCollapsed: false,
|
|
onToggleCollapse: vi.fn(),
|
|
onPin: vi.fn(),
|
|
onUnpin: vi.fn(),
|
|
showPinButton: false,
|
|
side: "right" as const,
|
|
onDismiss: vi.fn(),
|
|
...overrides,
|
|
};
|
|
|
|
render(<StatBlockPanel {...props} />);
|
|
return props;
|
|
}
|
|
|
|
describe("Stat Block Panel Collapse/Expand and Pin", () => {
|
|
beforeEach(() => {
|
|
mockMatchMedia(true); // desktop by default
|
|
});
|
|
|
|
afterEach(cleanup);
|
|
|
|
describe("US1: Collapse and Expand", () => {
|
|
it("shows collapse button instead of close button on desktop", () => {
|
|
renderPanel();
|
|
expect(
|
|
screen.getByRole("button", { name: "Collapse stat block panel" }),
|
|
).toBeInTheDocument();
|
|
expect(
|
|
screen.queryByRole("button", { name: /close/i }),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("does not show 'Stat Block' heading", () => {
|
|
renderPanel();
|
|
expect(screen.queryByText("Stat Block")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("renders collapsed tab with creature name when isCollapsed is true", () => {
|
|
renderPanel({ isCollapsed: true });
|
|
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
|
expect(
|
|
screen.getByRole("button", { name: "Expand stat block panel" }),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("calls onToggleCollapse when collapse button is clicked", () => {
|
|
const props = renderPanel();
|
|
fireEvent.click(
|
|
screen.getByRole("button", { name: "Collapse stat block panel" }),
|
|
);
|
|
expect(props.onToggleCollapse).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("calls onToggleCollapse when collapsed tab is clicked", () => {
|
|
const props = renderPanel({ isCollapsed: true });
|
|
fireEvent.click(
|
|
screen.getByRole("button", { name: "Expand stat block panel" }),
|
|
);
|
|
expect(props.onToggleCollapse).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("applies translate-x class when collapsed (right side)", () => {
|
|
renderPanel({ isCollapsed: true, side: "right" });
|
|
const panel = screen
|
|
.getByRole("button", { name: "Expand stat block panel" })
|
|
.closest("div");
|
|
expect(panel?.className).toContain("translate-x-[calc(100%-40px)]");
|
|
});
|
|
|
|
it("applies translate-x-0 when expanded", () => {
|
|
renderPanel({ isCollapsed: false });
|
|
const foldBtn = screen.getByRole("button", {
|
|
name: "Collapse stat block panel",
|
|
});
|
|
const panel = foldBtn.closest("div.fixed") as HTMLElement;
|
|
expect(panel?.className).toContain("translate-x-0");
|
|
});
|
|
});
|
|
|
|
describe("US1: Mobile behavior", () => {
|
|
beforeEach(() => {
|
|
mockMatchMedia(false); // mobile
|
|
});
|
|
|
|
it("shows collapse button instead of X close button on mobile drawer", () => {
|
|
renderPanel();
|
|
expect(
|
|
screen.getByRole("button", { name: "Collapse stat block panel" }),
|
|
).toBeInTheDocument();
|
|
// No X close icon button — only backdrop dismiss and collapse toggle
|
|
const buttons = screen.getAllByRole("button");
|
|
const buttonLabels = buttons.map((b) => b.getAttribute("aria-label"));
|
|
expect(buttonLabels).not.toContain("Close");
|
|
});
|
|
|
|
it("calls onDismiss when backdrop is clicked on mobile", () => {
|
|
const props = renderPanel();
|
|
fireEvent.click(screen.getByRole("button", { name: "Close stat block" }));
|
|
expect(props.onDismiss).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does not render pinned panel on mobile", () => {
|
|
const { container } = render(
|
|
<StatBlockPanel
|
|
creatureId={CREATURE_ID}
|
|
creature={CREATURE}
|
|
isSourceCached={vi.fn().mockResolvedValue(true)}
|
|
fetchAndCacheSource={vi.fn()}
|
|
uploadAndCacheSource={vi.fn()}
|
|
refreshCache={vi.fn()}
|
|
panelRole="pinned"
|
|
isCollapsed={false}
|
|
onToggleCollapse={vi.fn()}
|
|
onPin={vi.fn()}
|
|
onUnpin={vi.fn()}
|
|
showPinButton={false}
|
|
side="left"
|
|
onDismiss={vi.fn()}
|
|
/>,
|
|
);
|
|
expect(container.innerHTML).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("US2: Pin and Unpin", () => {
|
|
it("shows pin button when showPinButton is true on desktop", () => {
|
|
renderPanel({ showPinButton: true });
|
|
expect(
|
|
screen.getByRole("button", { name: "Pin creature" }),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("hides pin button when showPinButton is false", () => {
|
|
renderPanel({ showPinButton: false });
|
|
expect(
|
|
screen.queryByRole("button", { name: "Pin creature" }),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("calls onPin when pin button is clicked", () => {
|
|
const props = renderPanel({ showPinButton: true });
|
|
fireEvent.click(screen.getByRole("button", { name: "Pin creature" }));
|
|
expect(props.onPin).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("shows unpin button for pinned role", () => {
|
|
renderPanel({ panelRole: "pinned", side: "left" });
|
|
expect(
|
|
screen.getByRole("button", { name: "Unpin creature" }),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("calls onUnpin when unpin button is clicked", () => {
|
|
const props = renderPanel({ panelRole: "pinned", side: "left" });
|
|
fireEvent.click(screen.getByRole("button", { name: "Unpin creature" }));
|
|
expect(props.onUnpin).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("positions pinned panel on the left side", () => {
|
|
renderPanel({ panelRole: "pinned", side: "left" });
|
|
const unpinBtn = screen.getByRole("button", {
|
|
name: "Unpin creature",
|
|
});
|
|
const panel = unpinBtn.closest("div.fixed") as HTMLElement;
|
|
expect(panel?.className).toContain("left-0");
|
|
expect(panel?.className).toContain("border-r");
|
|
});
|
|
|
|
it("positions browse panel on the right side", () => {
|
|
renderPanel({ panelRole: "browse", side: "right" });
|
|
const foldBtn = screen.getByRole("button", {
|
|
name: "Collapse stat block panel",
|
|
});
|
|
const panel = foldBtn.closest("div.fixed") as HTMLElement;
|
|
expect(panel?.className).toContain("right-0");
|
|
expect(panel?.className).toContain("border-l");
|
|
});
|
|
});
|
|
|
|
describe("US3: Collapse independence with pinned panel", () => {
|
|
it("pinned panel has no collapse button", () => {
|
|
renderPanel({ panelRole: "pinned", side: "left" });
|
|
expect(
|
|
screen.queryByRole("button", { name: /collapse/i }),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("pinned panel is always expanded (no translate offset)", () => {
|
|
renderPanel({ panelRole: "pinned", side: "left", isCollapsed: false });
|
|
const unpinBtn = screen.getByRole("button", {
|
|
name: "Unpin creature",
|
|
});
|
|
const panel = unpinBtn.closest("div.fixed") as HTMLElement;
|
|
expect(panel?.className).toContain("translate-x-0");
|
|
});
|
|
});
|
|
});
|