// @vitest-environment jsdom import "@testing-library/jest-dom/vitest"; import { playerCharacterId } from "@initiative/domain"; import { cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { ReactNode } from "react"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js"; import { polyfillDialog } from "../../__tests__/polyfill-dialog.js"; import { AllProviders } from "../../__tests__/test-providers.js"; import { ActionBar } from "../action-bar.js"; // 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(), })), }); polyfillDialog(); }); afterEach(cleanup); function renderBar(props: Partial[0]> = {}) { return render(, { wrapper: AllProviders }); } function renderBarWithBestiary( props: Partial[0]> = {}, ) { const adapters = createTestAdapters(); adapters.bestiaryIndex = { ...adapters.bestiaryIndex, loadIndex: () => ({ sources: { MM: "Monster Manual" }, creatures: [ { name: "Goblin", source: "MM", ac: 15, hp: 7, dex: 14, cr: "1/4", initiativeProficiency: 0, size: "Small", type: "humanoid", }, { name: "Golem, Iron", source: "MM", ac: 20, hp: 210, dex: 9, cr: "16", initiativeProficiency: 0, size: "Large", type: "construct", }, ], }), getSourceDisplayName: (code: string) => code === "MM" ? "Monster Manual" : code, }; return render(, { wrapper: ({ children }: { children: ReactNode }) => ( {children} ), }); } function renderBarWithPCs( props: Partial[0]> = {}, ) { const adapters = createTestAdapters({ playerCharacters: [ { id: playerCharacterId("pc-1"), name: "Gandalf", ac: 15, maxHp: 40, }, ], }); adapters.bestiaryIndex = { ...adapters.bestiaryIndex, loadIndex: () => ({ sources: { MM: "Monster Manual" }, creatures: [ { name: "Goblin", source: "MM", ac: 15, hp: 7, dex: 14, cr: "1/4", initiativeProficiency: 0, size: "Small", type: "humanoid", }, ], }), getSourceDisplayName: (code: string) => code === "MM" ? "Monster Manual" : code, }; return render(, { wrapper: ({ children }: { children: ReactNode }) => ( {children} ), }); } describe("ActionBar", () => { describe("basic rendering and custom add", () => { it("renders input with placeholder '+ Add combatants'", () => { renderBar(); expect( screen.getByPlaceholderText("+ Add combatants"), ).toBeInTheDocument(); }); it("submitting with a name adds a combatant", async () => { const user = userEvent.setup(); renderBar(); const input = screen.getByPlaceholderText("+ Add combatants"); await user.type(input, "Goblin"); const addButton = screen.getByRole("button", { name: "Add" }); await user.click(addButton); expect(input).toHaveValue(""); }); it("submitting with empty name does nothing", async () => { const user = userEvent.setup(); renderBar(); const input = screen.getByPlaceholderText("+ Add combatants"); await user.type(input, "{Enter}"); expect(input).toHaveValue(""); }); it("shows custom fields (Init, AC, MaxHP) when name >= 2 chars and no bestiary suggestions", async () => { const user = userEvent.setup(); renderBar(); const input = screen.getByPlaceholderText("+ Add combatants"); await user.type(input, "Go"); expect(screen.getByPlaceholderText("Init")).toBeInTheDocument(); expect(screen.getByPlaceholderText("AC")).toBeInTheDocument(); expect(screen.getByPlaceholderText("MaxHP")).toBeInTheDocument(); }); it("shows Add button when name >= 2 chars and no suggestions", async () => { const user = userEvent.setup(); renderBar(); const input = screen.getByPlaceholderText("+ Add combatants"); await user.type(input, "Go"); expect(screen.getByRole("button", { name: "Add" })).toBeInTheDocument(); }); it("submits custom stats with combatant", async () => { const user = userEvent.setup(); renderBar(); const input = screen.getByPlaceholderText("+ Add combatants"); await user.type(input, "Fighter"); await user.type(screen.getByPlaceholderText("Init"), "15"); await user.type(screen.getByPlaceholderText("AC"), "18"); await user.type(screen.getByPlaceholderText("MaxHP"), "45"); await user.click(screen.getByRole("button", { name: "Add" })); expect(input).toHaveValue(""); }); }); describe("bestiary suggestions and queuing", () => { it("shows bestiary suggestions when typing a matching name", async () => { const user = userEvent.setup(); renderBarWithBestiary(); const input = screen.getByPlaceholderText("+ Add combatants"); await user.type(input, "Go"); await waitFor(() => { expect(screen.getByText("Goblin")).toBeInTheDocument(); }); expect(screen.getByText("Golem, Iron")).toBeInTheDocument(); }); it("clicking a suggestion queues it with count badge", async () => { const user = userEvent.setup(); renderBarWithBestiary(); const input = screen.getByPlaceholderText("+ Add combatants"); await user.type(input, "Go"); await waitFor(() => { expect(screen.getByText("Goblin")).toBeInTheDocument(); }); // Click the Goblin suggestion await user.click(screen.getByText("Goblin")); // Should show count badge "1" expect(screen.getByText("1")).toBeInTheDocument(); }); it("clicking same suggestion again increments count", async () => { const user = userEvent.setup(); renderBarWithBestiary(); const input = screen.getByPlaceholderText("+ Add combatants"); await user.type(input, "Go"); await waitFor(() => { expect(screen.getByText("Goblin")).toBeInTheDocument(); }); await user.click(screen.getByText("Goblin")); await user.click(screen.getByText("Goblin")); expect(screen.getByText("2")).toBeInTheDocument(); }); it("confirming queued creatures adds them to the encounter", async () => { const user = userEvent.setup(); renderBarWithBestiary(); const input = screen.getByPlaceholderText("+ Add combatants"); await user.type(input, "Go"); await waitFor(() => { expect(screen.getByText("Goblin")).toBeInTheDocument(); }); // Queue 1 Goblin await user.click(screen.getByText("Goblin")); // Press Enter to confirm the queued creature await user.keyboard("{Enter}"); // Input should be cleared after confirming expect(input).toHaveValue(""); }); it("clears queued when search text no longer matches", async () => { const user = userEvent.setup(); renderBarWithBestiary(); const input = screen.getByPlaceholderText("+ Add combatants"); await user.type(input, "Go"); await waitFor(() => { expect(screen.getByText("Goblin")).toBeInTheDocument(); }); await user.click(screen.getByText("Goblin")); expect(screen.getByText("1")).toBeInTheDocument(); // Change search to something with no matches await user.clear(input); await user.type(input, "xyz"); // Count badge should be gone expect(screen.queryByText("1")).not.toBeInTheDocument(); }); }); describe("player character matching", () => { it("shows matching player characters in suggestions", async () => { const user = userEvent.setup(); renderBarWithPCs(); const input = screen.getByPlaceholderText("+ Add combatants"); await user.type(input, "Gan"); await waitFor(() => { expect(screen.getByText("Gandalf")).toBeInTheDocument(); }); expect(screen.getByText("Player")).toBeInTheDocument(); }); }); describe("browse mode", () => { it("toggles browse mode via eye icon button", async () => { const user = userEvent.setup(); renderBarWithBestiary(); const browseButton = screen.getByRole("button", { name: "Browse stat blocks", }); await user.click(browseButton); expect( screen.getByPlaceholderText("Search stat blocks..."), ).toBeInTheDocument(); expect( screen.getByRole("button", { name: "Switch to add mode" }), ).toBeInTheDocument(); }); it("browse mode shows suggestions without add UI", async () => { const user = userEvent.setup(); renderBarWithBestiary(); await user.click( screen.getByRole("button", { name: "Browse stat blocks" }), ); const input = screen.getByPlaceholderText("Search stat blocks..."); await user.type(input, "Go"); await waitFor(() => { expect(screen.getByText("Goblin")).toBeInTheDocument(); }); // No Add button in browse mode expect( screen.queryByRole("button", { name: "Add" }), ).not.toBeInTheDocument(); }); }); describe("overflow menu", () => { it("does not show roll all initiative button when no creature combatants", () => { renderBar(); expect( screen.queryByRole("button", { name: "Roll all initiative" }), ).not.toBeInTheDocument(); }); it("shows overflow menu items", () => { renderBar({ onManagePlayers: vi.fn() }); expect( screen.getByRole("button", { name: "More actions" }), ).toBeInTheDocument(); }); it("opens export method dialog via overflow menu", async () => { const user = userEvent.setup(); renderBar(); await user.click(screen.getByRole("button", { name: "More actions" })); const items = screen.getAllByText("Export Encounter"); await user.click(items[0]); expect( screen.getAllByText("Export Encounter").length, ).toBeGreaterThanOrEqual(1); }); it("opens import method dialog via overflow menu", async () => { const user = userEvent.setup(); renderBar(); await user.click(screen.getByRole("button", { name: "More actions" })); const items = screen.getAllByText("Import Encounter"); await user.click(items[0]); expect( screen.getAllByText("Import Encounter").length, ).toBeGreaterThanOrEqual(1); }); it("calls onManagePlayers from overflow menu", async () => { const onManagePlayers = vi.fn(); const user = userEvent.setup(); renderBar({ onManagePlayers }); await user.click(screen.getByRole("button", { name: "More actions" })); await user.click(screen.getByText("Player Characters")); expect(onManagePlayers).toHaveBeenCalledOnce(); }); it("calls onOpenSettings from overflow menu", async () => { const onOpenSettings = vi.fn(); const user = userEvent.setup(); renderBar({ onOpenSettings }); await user.click(screen.getByRole("button", { name: "More actions" })); await user.click(screen.getByText("Settings")); expect(onOpenSettings).toHaveBeenCalledOnce(); }); }); });