// @vitest-environment jsdom import type { PlayerCharacter } from "@initiative/domain"; import { playerCharacterId } from "@initiative/domain"; import { act, renderHook } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { SearchResult } from "../../contexts/bestiary-context.js"; import { useActionBarState } from "../use-action-bar-state.js"; const mockAddCombatant = vi.fn(); const mockAddFromBestiary = vi.fn(); const mockAddMultipleFromBestiary = vi.fn(); const mockAddFromPlayerCharacter = vi.fn(); const mockBestiarySearch = vi.fn<(q: string) => SearchResult[]>(); const mockShowCreature = vi.fn(); const mockShowBulkImport = vi.fn(); const mockShowSourceManager = vi.fn(); vi.mock("../../contexts/encounter-context.js", () => ({ useEncounterContext: () => ({ addCombatant: mockAddCombatant, addFromBestiary: mockAddFromBestiary, addMultipleFromBestiary: mockAddMultipleFromBestiary, addFromPlayerCharacter: mockAddFromPlayerCharacter, lastCreatureId: null, }), })); vi.mock("../../contexts/bestiary-context.js", () => ({ useBestiaryContext: () => ({ search: mockBestiarySearch, isLoaded: true, }), })); vi.mock("../../contexts/player-characters-context.js", () => ({ usePlayerCharactersContext: () => ({ characters: mockPlayerCharacters, }), })); vi.mock("../../contexts/side-panel-context.js", () => ({ useSidePanelContext: () => ({ showCreature: mockShowCreature, showBulkImport: mockShowBulkImport, showSourceManager: mockShowSourceManager, panelView: { mode: "closed" }, }), })); let mockPlayerCharacters: PlayerCharacter[] = []; const GOBLIN: SearchResult = { name: "Goblin", source: "MM", sourceDisplayName: "Monster Manual", ac: 15, hp: 7, dex: 14, cr: "1/4", initiativeProficiency: 0, size: "Small", type: "humanoid", }; const ORC: SearchResult = { name: "Orc", source: "MM", sourceDisplayName: "Monster Manual", ac: 13, hp: 15, dex: 12, cr: "1/2", initiativeProficiency: 0, size: "Medium", type: "humanoid", }; function renderActionBar() { return renderHook(() => useActionBarState()); } describe("useActionBarState", () => { beforeEach(() => { vi.clearAllMocks(); mockBestiarySearch.mockReturnValue([]); mockPlayerCharacters = []; }); describe("search and suggestions", () => { it("starts with empty state", () => { const { result } = renderActionBar(); expect(result.current.nameInput).toBe(""); expect(result.current.suggestions).toEqual([]); expect(result.current.queued).toBeNull(); expect(result.current.browseMode).toBe(false); }); it("searches bestiary when input >= 2 chars", () => { mockBestiarySearch.mockReturnValue([GOBLIN]); const { result } = renderActionBar(); act(() => result.current.handleNameChange("go")); expect(mockBestiarySearch).toHaveBeenCalledWith("go"); expect(result.current.nameInput).toBe("go"); }); it("does not search when input < 2 chars", () => { const { result } = renderActionBar(); act(() => result.current.handleNameChange("g")); expect(mockBestiarySearch).not.toHaveBeenCalled(); }); it("matches player characters by name", () => { mockPlayerCharacters = [ { id: playerCharacterId("pc-1"), name: "Gandalf", ac: 15, maxHp: 40, }, ]; mockBestiarySearch.mockReturnValue([]); const { result } = renderActionBar(); act(() => result.current.handleNameChange("gan")); expect(result.current.pcMatches).toHaveLength(1); }); }); describe("queued creatures", () => { it("queues a creature on click", () => { const { result } = renderActionBar(); act(() => result.current.suggestionActions.clickSuggestion(GOBLIN)); expect(result.current.queued).toEqual({ result: GOBLIN, count: 1, }); }); it("increments count when same creature clicked again", () => { const { result } = renderActionBar(); act(() => result.current.suggestionActions.clickSuggestion(GOBLIN)); act(() => result.current.suggestionActions.clickSuggestion(GOBLIN)); expect(result.current.queued?.count).toBe(2); }); it("resets queue when different creature clicked", () => { const { result } = renderActionBar(); act(() => result.current.suggestionActions.clickSuggestion(GOBLIN)); act(() => result.current.suggestionActions.clickSuggestion(ORC)); expect(result.current.queued).toEqual({ result: ORC, count: 1, }); }); it("confirmQueued calls addFromBestiary for count=1", () => { const { result } = renderActionBar(); act(() => result.current.suggestionActions.clickSuggestion(GOBLIN)); act(() => result.current.suggestionActions.confirmQueued()); expect(mockAddFromBestiary).toHaveBeenCalledWith(GOBLIN); expect(result.current.queued).toBeNull(); }); it("confirmQueued calls addMultipleFromBestiary for count>1", () => { const { result } = renderActionBar(); act(() => result.current.suggestionActions.clickSuggestion(GOBLIN)); act(() => result.current.suggestionActions.clickSuggestion(GOBLIN)); act(() => result.current.suggestionActions.clickSuggestion(GOBLIN)); act(() => result.current.suggestionActions.confirmQueued()); expect(mockAddMultipleFromBestiary).toHaveBeenCalledWith(GOBLIN, 3); }); it("clears queued when search text changes and creature no longer visible", () => { mockBestiarySearch.mockReturnValue([GOBLIN]); const { result } = renderActionBar(); act(() => result.current.handleNameChange("go")); act(() => result.current.suggestionActions.clickSuggestion(GOBLIN)); // Change search to something that won't match mockBestiarySearch.mockReturnValue([]); act(() => result.current.handleNameChange("xyz")); expect(result.current.queued).toBeNull(); }); }); describe("form submission", () => { it("adds custom combatant on submit", () => { const { result } = renderActionBar(); act(() => result.current.handleNameChange("Fighter")); const event = { preventDefault: vi.fn(), } as unknown as React.SubmitEvent; act(() => result.current.handleAdd(event)); expect(mockAddCombatant).toHaveBeenCalledWith("Fighter", undefined); expect(result.current.nameInput).toBe(""); }); it("does not add when name is empty", () => { const { result } = renderActionBar(); const event = { preventDefault: vi.fn(), } as unknown as React.SubmitEvent; act(() => result.current.handleAdd(event)); expect(mockAddCombatant).not.toHaveBeenCalled(); }); it("passes custom init/ac/maxHp when set", () => { const { result } = renderActionBar(); act(() => result.current.handleNameChange("Fighter")); act(() => result.current.setCustomInit("15")); act(() => result.current.setCustomAc("18")); act(() => result.current.setCustomMaxHp("45")); const event = { preventDefault: vi.fn(), } as unknown as React.SubmitEvent; act(() => result.current.handleAdd(event)); expect(mockAddCombatant).toHaveBeenCalledWith("Fighter", { initiative: 15, ac: 18, maxHp: 45, }); }); it("does not submit in browse mode", () => { const { result } = renderActionBar(); act(() => result.current.toggleBrowseMode()); act(() => result.current.handleNameChange("Fighter")); const event = { preventDefault: vi.fn(), } as unknown as React.SubmitEvent; act(() => result.current.handleAdd(event)); expect(mockAddCombatant).not.toHaveBeenCalled(); }); it("confirms queued on submit instead of adding by name", () => { const { result } = renderActionBar(); act(() => result.current.suggestionActions.clickSuggestion(GOBLIN)); const event = { preventDefault: vi.fn(), } as unknown as React.SubmitEvent; act(() => result.current.handleAdd(event)); expect(mockAddFromBestiary).toHaveBeenCalledWith(GOBLIN); expect(mockAddCombatant).not.toHaveBeenCalled(); }); }); describe("browse mode", () => { it("toggles browse mode", () => { const { result } = renderActionBar(); act(() => result.current.toggleBrowseMode()); expect(result.current.browseMode).toBe(true); act(() => result.current.toggleBrowseMode()); expect(result.current.browseMode).toBe(false); }); it("handleBrowseSelect shows creature and exits browse mode", () => { const { result } = renderActionBar(); act(() => result.current.toggleBrowseMode()); act(() => result.current.handleBrowseSelect(GOBLIN)); expect(mockShowCreature).toHaveBeenCalledWith("mm:goblin"); expect(result.current.browseMode).toBe(false); expect(result.current.nameInput).toBe(""); }); }); describe("dismiss and clear", () => { it("dismissSuggestions clears suggestions and queued", () => { mockBestiarySearch.mockReturnValue([GOBLIN]); const { result } = renderActionBar(); act(() => result.current.handleNameChange("go")); act(() => result.current.suggestionActions.clickSuggestion(GOBLIN)); act(() => result.current.suggestionActions.dismiss()); expect(result.current.queued).toBeNull(); expect(result.current.suggestionIndex).toBe(-1); }); it("clear resets everything", () => { mockBestiarySearch.mockReturnValue([GOBLIN]); const { result } = renderActionBar(); act(() => result.current.handleNameChange("go")); act(() => result.current.suggestionActions.clickSuggestion(GOBLIN)); act(() => result.current.suggestionActions.clear()); expect(result.current.nameInput).toBe(""); expect(result.current.queued).toBeNull(); expect(result.current.suggestionIndex).toBe(-1); }); }); });