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>
329 lines
9.4 KiB
TypeScript
329 lines
9.4 KiB
TypeScript
// @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);
|
|
});
|
|
});
|
|
});
|