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) <noreply@anthropic.com>
This commit is contained in:
328
apps/web/src/hooks/__tests__/use-action-bar-state.test.ts
Normal file
328
apps/web/src/hooks/__tests__/use-action-bar-state.test.ts
Normal file
@@ -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<HTMLFormElement>;
|
||||
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<HTMLFormElement>;
|
||||
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<HTMLFormElement>;
|
||||
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<HTMLFormElement>;
|
||||
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<HTMLFormElement>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user