318 lines
10 KiB
TypeScript
318 lines
10 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";
|
|
|
|
// Mock the context modules
|
|
vi.mock("../contexts/side-panel-context.js", () => ({
|
|
useSidePanelContext: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../contexts/bestiary-context.js", () => ({
|
|
useBestiaryContext: vi.fn(),
|
|
}));
|
|
|
|
// Mock adapters to avoid IndexedDB
|
|
vi.mock("../adapters/bestiary-index-adapter.js", () => ({
|
|
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
|
getAllSourceCodes: () => [],
|
|
getDefaultFetchUrl: () => "",
|
|
getSourceDisplayName: (code: string) => code,
|
|
}));
|
|
|
|
import { StatBlockPanel } from "../components/stat-block-panel.js";
|
|
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
|
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
|
|
|
const mockUseSidePanelContext = vi.mocked(useSidePanelContext);
|
|
const mockUseBestiaryContext = vi.mocked(useBestiaryContext);
|
|
|
|
const CLOSE_REGEX = /close/i;
|
|
const COLLAPSE_REGEX = /collapse/i;
|
|
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(globalThis, "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 PanelOverrides {
|
|
creatureId?: CreatureId | null;
|
|
creature?: Creature | null;
|
|
panelRole?: "browse" | "pinned";
|
|
isCollapsed?: boolean;
|
|
side?: "left" | "right";
|
|
bulkImportMode?: boolean;
|
|
}
|
|
|
|
function setupMocks(overrides: PanelOverrides = {}) {
|
|
const panelRole = overrides.panelRole ?? "browse";
|
|
const creatureId = overrides.creatureId ?? CREATURE_ID;
|
|
const creature = overrides.creature ?? CREATURE;
|
|
const isCollapsed = overrides.isCollapsed ?? false;
|
|
|
|
const onToggleCollapse = vi.fn();
|
|
const onPin = vi.fn();
|
|
const onUnpin = vi.fn();
|
|
const onDismiss = vi.fn();
|
|
|
|
mockUseSidePanelContext.mockReturnValue({
|
|
selectedCreatureId: panelRole === "browse" ? creatureId : null,
|
|
pinnedCreatureId: panelRole === "pinned" ? creatureId : null,
|
|
isRightPanelCollapsed: panelRole === "browse" ? isCollapsed : false,
|
|
isWideDesktop: false,
|
|
bulkImportMode: overrides.bulkImportMode ?? false,
|
|
sourceManagerMode: false,
|
|
panelView: creatureId
|
|
? { mode: "creature" as const, creatureId }
|
|
: { mode: "closed" as const },
|
|
showCreature: vi.fn(),
|
|
updateCreature: vi.fn(),
|
|
showBulkImport: vi.fn(),
|
|
showSourceManager: vi.fn(),
|
|
dismissPanel: onDismiss,
|
|
toggleCollapse: onToggleCollapse,
|
|
togglePin: onPin,
|
|
unpin: onUnpin,
|
|
} as ReturnType<typeof useSidePanelContext>);
|
|
|
|
mockUseBestiaryContext.mockReturnValue({
|
|
getCreature: (id: CreatureId) => (id === creatureId ? creature : undefined),
|
|
isSourceCached: vi.fn().mockResolvedValue(true),
|
|
search: vi.fn().mockReturnValue([]),
|
|
isLoaded: true,
|
|
fetchAndCacheSource: vi.fn(),
|
|
uploadAndCacheSource: vi.fn(),
|
|
refreshCache: vi.fn(),
|
|
} as ReturnType<typeof useBestiaryContext>);
|
|
|
|
return { onToggleCollapse, onPin, onUnpin, onDismiss };
|
|
}
|
|
|
|
function renderPanel(overrides: PanelOverrides = {}) {
|
|
const callbacks = setupMocks(overrides);
|
|
const panelRole = overrides.panelRole ?? "browse";
|
|
const side = overrides.side ?? (panelRole === "pinned" ? "left" : "right");
|
|
render(<StatBlockPanel panelRole={panelRole} side={side} />);
|
|
return callbacks;
|
|
}
|
|
|
|
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_REGEX }),
|
|
).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 callbacks = renderPanel();
|
|
fireEvent.click(
|
|
screen.getByRole("button", { name: "Collapse stat block panel" }),
|
|
);
|
|
expect(callbacks.onToggleCollapse).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("calls onToggleCollapse when collapsed tab is clicked", () => {
|
|
const callbacks = renderPanel({ isCollapsed: true });
|
|
fireEvent.click(
|
|
screen.getByRole("button", { name: "Expand stat block panel" }),
|
|
);
|
|
expect(callbacks.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 callbacks = renderPanel();
|
|
fireEvent.click(screen.getByRole("button", { name: "Close stat block" }));
|
|
expect(callbacks.onDismiss).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does not render pinned panel on mobile", () => {
|
|
const { container } = render(
|
|
(() => {
|
|
setupMocks({ panelRole: "pinned" });
|
|
return <StatBlockPanel panelRole="pinned" side="left" />;
|
|
})(),
|
|
);
|
|
expect(container.innerHTML).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("US2: Pin and Unpin", () => {
|
|
it("shows pin button when isWideDesktop is true on desktop", () => {
|
|
setupMocks();
|
|
// Override to set isWideDesktop
|
|
const ctx = mockUseSidePanelContext.mock.results[0]?.value as ReturnType<
|
|
typeof useSidePanelContext
|
|
>;
|
|
mockUseSidePanelContext.mockReturnValue({
|
|
...ctx,
|
|
isWideDesktop: true,
|
|
});
|
|
render(<StatBlockPanel panelRole="browse" side="right" />);
|
|
expect(
|
|
screen.getByRole("button", { name: "Pin creature" }),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("hides pin button when isWideDesktop is false", () => {
|
|
renderPanel();
|
|
expect(
|
|
screen.queryByRole("button", { name: "Pin creature" }),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("calls onPin when pin button is clicked", () => {
|
|
setupMocks();
|
|
const ctx = mockUseSidePanelContext.mock.results[0]?.value as ReturnType<
|
|
typeof useSidePanelContext
|
|
>;
|
|
mockUseSidePanelContext.mockReturnValue({
|
|
...ctx,
|
|
isWideDesktop: true,
|
|
});
|
|
render(<StatBlockPanel panelRole="browse" side="right" />);
|
|
fireEvent.click(screen.getByRole("button", { name: "Pin creature" }));
|
|
expect(ctx.togglePin).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 callbacks = renderPanel({ panelRole: "pinned", side: "left" });
|
|
fireEvent.click(screen.getByRole("button", { name: "Unpin creature" }));
|
|
expect(callbacks.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_REGEX }),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("pinned panel is always expanded (no translate offset)", () => {
|
|
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("translate-x-0");
|
|
});
|
|
});
|
|
});
|