// @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"; isFolded?: boolean; onToggleFold?: () => 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, isFolded: false, onToggleFold: vi.fn(), onPin: vi.fn(), onUnpin: vi.fn(), showPinButton: false, side: "right" as const, onDismiss: vi.fn(), ...overrides, }; render(); return props; } describe("Stat Block Panel Fold/Unfold and Pin", () => { beforeEach(() => { mockMatchMedia(true); // desktop by default }); afterEach(cleanup); describe("US1: Fold and Unfold", () => { it("shows fold button instead of close button on desktop", () => { renderPanel(); expect( screen.getByRole("button", { name: "Fold 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 folded tab with creature name when isFolded is true", () => { renderPanel({ isFolded: true }); expect(screen.getByText("Goblin")).toBeInTheDocument(); expect( screen.getByRole("button", { name: "Unfold stat block panel" }), ).toBeInTheDocument(); }); it("calls onToggleFold when fold button is clicked", () => { const props = renderPanel(); fireEvent.click( screen.getByRole("button", { name: "Fold stat block panel" }), ); expect(props.onToggleFold).toHaveBeenCalledTimes(1); }); it("calls onToggleFold when folded tab is clicked", () => { const props = renderPanel({ isFolded: true }); fireEvent.click( screen.getByRole("button", { name: "Unfold stat block panel" }), ); expect(props.onToggleFold).toHaveBeenCalledTimes(1); }); it("applies translate-x class when folded (right side)", () => { renderPanel({ isFolded: true, side: "right" }); const panel = screen .getByRole("button", { name: "Unfold stat block panel" }) .closest("div"); expect(panel?.className).toContain("translate-x-[calc(100%-40px)]"); }); it("applies translate-x-0 when expanded", () => { renderPanel({ isFolded: false }); const foldBtn = screen.getByRole("button", { name: "Fold 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 fold button instead of X close button on mobile drawer", () => { renderPanel(); expect( screen.getByRole("button", { name: "Fold stat block panel" }), ).toBeInTheDocument(); // No X close icon button — only backdrop dismiss and fold 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( , ); 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: "Fold 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: Fold independence with pinned panel", () => { it("pinned panel has no fold button", () => { renderPanel({ panelRole: "pinned", side: "left" }); expect( screen.queryByRole("button", { name: /fold/i }), ).not.toBeInTheDocument(); }); it("pinned panel is always expanded (no translate offset)", () => { renderPanel({ panelRole: "pinned", side: "left", isFolded: false }); const unpinBtn = screen.getByRole("button", { name: "Unpin creature", }); const panel = unpinBtn.closest("div.fixed") as HTMLElement; expect(panel?.className).toContain("translate-x-0"); }); }); });