From a97044ec3e76da541cd4253f8252d3f1a434bb56 Mon Sep 17 00:00:00 2001 From: Lukas Date: Sun, 29 Mar 2026 00:16:24 +0100 Subject: [PATCH] Add tests for useActionBarState hook Tests search/suggestion filtering, queued creature counting, form submission with custom stats, browse mode, and dismiss/clear behavior. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/use-action-bar-state.test.ts | 328 ++++++++++++++++++ vitest.config.ts | 4 +- 2 files changed, 330 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/hooks/__tests__/use-action-bar-state.test.ts diff --git a/apps/web/src/hooks/__tests__/use-action-bar-state.test.ts b/apps/web/src/hooks/__tests__/use-action-bar-state.test.ts new file mode 100644 index 0000000..e0e5492 --- /dev/null +++ b/apps/web/src/hooks/__tests__/use-action-bar-state.test.ts @@ -0,0 +1,328 @@ +// @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); + }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 89aadb6..638abf6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -26,8 +26,8 @@ export default defineConfig({ branches: 70, }, "apps/web/src/hooks": { - lines: 64, - branches: 48, + lines: 72, + branches: 55, }, "apps/web/src/components": { lines: 52,