// @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); 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); 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(); 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 ; })(), ); 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(); 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(); 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"); }); }); });