diff --git a/apps/web/src/hooks/__tests__/use-encounter.test.ts b/apps/web/src/hooks/__tests__/use-encounter.test.ts new file mode 100644 index 0000000..1181de0 --- /dev/null +++ b/apps/web/src/hooks/__tests__/use-encounter.test.ts @@ -0,0 +1,217 @@ +// @vitest-environment jsdom +import type { BestiaryIndexEntry, PlayerCharacter } from "@initiative/domain"; +import { combatantId, creatureId, playerCharacterId } from "@initiative/domain"; +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useEncounter } from "../use-encounter.js"; + +vi.mock("../../persistence/encounter-storage.js", () => ({ + loadEncounter: vi.fn().mockReturnValue(null), + saveEncounter: vi.fn(), +})); + +const { loadEncounter: mockLoad, saveEncounter: mockSave } = + await vi.importMock( + "../../persistence/encounter-storage.js", + ); + +describe("useEncounter", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockLoad.mockReturnValue(null); + }); + + it("initializes with empty encounter when persistence returns null", () => { + const { result } = renderHook(() => useEncounter()); + + expect(result.current.encounter.combatants).toEqual([]); + expect(result.current.encounter.activeIndex).toBe(0); + expect(result.current.encounter.roundNumber).toBe(1); + expect(result.current.isEmpty).toBe(true); + }); + + it("initializes from stored encounter", () => { + const stored = { + combatants: [{ id: combatantId("c-1"), name: "Goblin" }], + activeIndex: 0, + roundNumber: 2, + }; + mockLoad.mockReturnValue(stored); + + const { result } = renderHook(() => useEncounter()); + + expect(result.current.encounter.combatants).toHaveLength(1); + expect(result.current.encounter.roundNumber).toBe(2); + expect(result.current.isEmpty).toBe(false); + }); + + it("addCombatant adds a combatant with incremental IDs and persists", () => { + const { result } = renderHook(() => useEncounter()); + + act(() => result.current.addCombatant("Goblin")); + act(() => result.current.addCombatant("Orc")); + + expect(result.current.encounter.combatants).toHaveLength(2); + expect(result.current.encounter.combatants[0].name).toBe("Goblin"); + expect(result.current.encounter.combatants[1].name).toBe("Orc"); + expect(result.current.isEmpty).toBe(false); + expect(mockSave).toHaveBeenCalled(); + }); + + it("removeCombatant removes a combatant and persists", () => { + const { result } = renderHook(() => useEncounter()); + + act(() => result.current.addCombatant("Goblin")); + const id = result.current.encounter.combatants[0].id; + + act(() => result.current.removeCombatant(id)); + + expect(result.current.encounter.combatants).toHaveLength(0); + expect(result.current.isEmpty).toBe(true); + }); + + it("advanceTurn and retreatTurn update encounter state", () => { + const { result } = renderHook(() => useEncounter()); + + act(() => result.current.addCombatant("Goblin")); + act(() => result.current.addCombatant("Orc")); + + const initialActive = result.current.encounter.activeIndex; + + act(() => result.current.advanceTurn()); + expect(result.current.encounter.activeIndex).not.toBe(initialActive); + + act(() => result.current.retreatTurn()); + expect(result.current.encounter.activeIndex).toBe(initialActive); + }); + + it("clearEncounter resets to empty and resets ID counter", () => { + const { result } = renderHook(() => useEncounter()); + + act(() => result.current.addCombatant("Goblin")); + act(() => result.current.clearEncounter()); + + expect(result.current.encounter.combatants).toHaveLength(0); + expect(result.current.isEmpty).toBe(true); + + // After clear, IDs restart from c-1 + act(() => result.current.addCombatant("Orc")); + expect(result.current.encounter.combatants[0].id).toBe("c-1"); + }); + + it("addCombatant with opts applies initiative, ac, maxHp", () => { + const { result } = renderHook(() => useEncounter()); + + act(() => + result.current.addCombatant("Goblin", { + initiative: 15, + ac: 13, + maxHp: 7, + }), + ); + + const goblin = result.current.encounter.combatants[0]; + expect(goblin.initiative).toBe(15); + expect(goblin.ac).toBe(13); + expect(goblin.maxHp).toBe(7); + expect(goblin.currentHp).toBe(7); + }); + + it("derived flags: hasCreatureCombatants and canRollAllInitiative", () => { + const { result } = renderHook(() => useEncounter()); + + // No creatures yet + expect(result.current.hasCreatureCombatants).toBe(false); + expect(result.current.canRollAllInitiative).toBe(false); + + // Add from bestiary to get a creature combatant + const entry: BestiaryIndexEntry = { + name: "Goblin", + source: "MM", + ac: 15, + hp: 7, + dex: 14, + cr: "1/4", + initiativeProficiency: 0, + size: "Small", + type: "humanoid", + }; + + act(() => result.current.addFromBestiary(entry)); + + expect(result.current.hasCreatureCombatants).toBe(true); + expect(result.current.canRollAllInitiative).toBe(true); + }); + + it("addFromBestiary adds combatant with HP, AC, creatureId", () => { + const { result } = renderHook(() => useEncounter()); + + const entry: BestiaryIndexEntry = { + name: "Goblin", + source: "MM", + ac: 15, + hp: 7, + dex: 14, + cr: "1/4", + initiativeProficiency: 0, + size: "Small", + type: "humanoid", + }; + + act(() => result.current.addFromBestiary(entry)); + + const combatant = result.current.encounter.combatants[0]; + expect(combatant.name).toBe("Goblin"); + expect(combatant.maxHp).toBe(7); + expect(combatant.currentHp).toBe(7); + expect(combatant.ac).toBe(15); + expect(combatant.creatureId).toBe(creatureId("mm:goblin")); + }); + + it("addFromBestiary auto-numbers duplicate names", () => { + const { result } = renderHook(() => useEncounter()); + + const entry: BestiaryIndexEntry = { + name: "Goblin", + source: "MM", + ac: 15, + hp: 7, + dex: 14, + cr: "1/4", + initiativeProficiency: 0, + size: "Small", + type: "humanoid", + }; + + act(() => result.current.addFromBestiary(entry)); + act(() => result.current.addFromBestiary(entry)); + + const names = result.current.encounter.combatants.map((c) => c.name); + expect(names).toContain("Goblin 1"); + expect(names).toContain("Goblin 2"); + }); + + it("addFromPlayerCharacter adds combatant with HP, AC, color, icon", () => { + const { result } = renderHook(() => useEncounter()); + + const pc: PlayerCharacter = { + id: playerCharacterId("pc-1"), + name: "Aria", + ac: 16, + maxHp: 30, + color: "blue", + icon: "sword", + }; + + act(() => result.current.addFromPlayerCharacter(pc)); + + const combatant = result.current.encounter.combatants[0]; + expect(combatant.name).toBe("Aria"); + expect(combatant.maxHp).toBe(30); + expect(combatant.currentHp).toBe(30); + expect(combatant.ac).toBe(16); + expect(combatant.color).toBe("blue"); + expect(combatant.icon).toBe("sword"); + expect(combatant.playerCharacterId).toBe(playerCharacterId("pc-1")); + }); +}); diff --git a/apps/web/src/hooks/__tests__/use-player-characters.test.ts b/apps/web/src/hooks/__tests__/use-player-characters.test.ts new file mode 100644 index 0000000..4281fd6 --- /dev/null +++ b/apps/web/src/hooks/__tests__/use-player-characters.test.ts @@ -0,0 +1,100 @@ +// @vitest-environment jsdom +import { playerCharacterId } from "@initiative/domain"; +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { usePlayerCharacters } from "../use-player-characters.js"; + +vi.mock("../../persistence/player-character-storage.js", () => ({ + loadPlayerCharacters: vi.fn().mockReturnValue([]), + savePlayerCharacters: vi.fn(), +})); + +const { loadPlayerCharacters: mockLoad, savePlayerCharacters: mockSave } = + await vi.importMock< + typeof import("../../persistence/player-character-storage.js") + >("../../persistence/player-character-storage.js"); + +describe("usePlayerCharacters", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockLoad.mockReturnValue([]); + }); + + it("initializes with characters from persistence", () => { + const stored = [ + { + id: playerCharacterId("pc-1"), + name: "Aria", + ac: 16, + maxHp: 30, + color: undefined, + icon: undefined, + }, + ]; + mockLoad.mockReturnValue(stored); + + const { result } = renderHook(() => usePlayerCharacters()); + + expect(result.current.characters).toEqual(stored); + }); + + it("createCharacter adds a character and persists", () => { + const { result } = renderHook(() => usePlayerCharacters()); + + act(() => { + result.current.createCharacter("Vex", 15, 28, undefined, undefined); + }); + + expect(result.current.characters).toHaveLength(1); + expect(result.current.characters[0].name).toBe("Vex"); + expect(result.current.characters[0].ac).toBe(15); + expect(result.current.characters[0].maxHp).toBe(28); + expect(mockSave).toHaveBeenCalled(); + }); + + it("createCharacter returns domain error for empty name", () => { + const { result } = renderHook(() => usePlayerCharacters()); + + let error: unknown; + act(() => { + error = result.current.createCharacter("", 15, 28, undefined, undefined); + }); + + expect(error).toMatchObject({ kind: "domain-error" }); + expect(result.current.characters).toHaveLength(0); + }); + + it("editCharacter updates character and persists", () => { + const { result } = renderHook(() => usePlayerCharacters()); + + act(() => { + result.current.createCharacter("Vex", 15, 28, undefined, undefined); + }); + + const id = result.current.characters[0].id; + + act(() => { + result.current.editCharacter(id, { name: "Vex'ahlia" }); + }); + + expect(result.current.characters[0].name).toBe("Vex'ahlia"); + expect(mockSave).toHaveBeenCalled(); + }); + + it("deleteCharacter removes character and persists", () => { + const { result } = renderHook(() => usePlayerCharacters()); + + act(() => { + result.current.createCharacter("Vex", 15, 28, undefined, undefined); + }); + + const id = result.current.characters[0].id; + + act(() => { + result.current.deleteCharacter(id); + }); + + expect(result.current.characters).toHaveLength(0); + expect(mockSave).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/hooks/__tests__/use-side-panel-state.test.ts b/apps/web/src/hooks/__tests__/use-side-panel-state.test.ts new file mode 100644 index 0000000..2fdc9ff --- /dev/null +++ b/apps/web/src/hooks/__tests__/use-side-panel-state.test.ts @@ -0,0 +1,159 @@ +// @vitest-environment jsdom +import { creatureId } from "@initiative/domain"; +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { useSidePanelState } from "../use-side-panel-state.js"; + +function mockMatchMedia(matches: boolean) { + const listeners: Array<(e: MediaQueryListEvent) => void> = []; + const mql = { + matches, + addEventListener: vi.fn( + (_event: string, handler: (e: MediaQueryListEvent) => void) => { + listeners.push(handler); + }, + ), + removeEventListener: vi.fn(), + }; + globalThis.matchMedia = vi.fn().mockReturnValue(mql) as typeof matchMedia; + return { mql, listeners }; +} + +const CREATURE_A = creatureId("creature-a"); + +describe("useSidePanelState", () => { + it("starts with closed panel, no selection, not collapsed", () => { + mockMatchMedia(false); + const { result } = renderHook(() => useSidePanelState()); + + expect(result.current.panelView).toEqual({ mode: "closed" }); + expect(result.current.selectedCreatureId).toBeNull(); + expect(result.current.isRightPanelCollapsed).toBe(false); + expect(result.current.bulkImportMode).toBe(false); + expect(result.current.sourceManagerMode).toBe(false); + expect(result.current.pinnedCreatureId).toBeNull(); + }); + + it("showCreature sets creature mode and selectedCreatureId", () => { + mockMatchMedia(false); + const { result } = renderHook(() => useSidePanelState()); + + act(() => result.current.showCreature(CREATURE_A)); + + expect(result.current.panelView).toEqual({ + mode: "creature", + creatureId: CREATURE_A, + }); + expect(result.current.selectedCreatureId).toBe(CREATURE_A); + }); + + it("showBulkImport sets bulk-import mode, selectedCreatureId null", () => { + mockMatchMedia(false); + const { result } = renderHook(() => useSidePanelState()); + + act(() => result.current.showBulkImport()); + + expect(result.current.panelView).toEqual({ mode: "bulk-import" }); + expect(result.current.selectedCreatureId).toBeNull(); + expect(result.current.bulkImportMode).toBe(true); + }); + + it("showSourceManager sets source-manager mode", () => { + mockMatchMedia(false); + const { result } = renderHook(() => useSidePanelState()); + + act(() => result.current.showSourceManager()); + + expect(result.current.panelView).toEqual({ mode: "source-manager" }); + expect(result.current.sourceManagerMode).toBe(true); + }); + + it("dismissPanel sets mode to closed", () => { + mockMatchMedia(false); + const { result } = renderHook(() => useSidePanelState()); + + act(() => result.current.showCreature(CREATURE_A)); + act(() => result.current.dismissPanel()); + + expect(result.current.panelView).toEqual({ mode: "closed" }); + }); + + it("toggleCollapse flips isRightPanelCollapsed", () => { + mockMatchMedia(false); + const { result } = renderHook(() => useSidePanelState()); + + expect(result.current.isRightPanelCollapsed).toBe(false); + + act(() => result.current.toggleCollapse()); + expect(result.current.isRightPanelCollapsed).toBe(true); + + act(() => result.current.toggleCollapse()); + expect(result.current.isRightPanelCollapsed).toBe(false); + }); + + it("showCreature resets collapse state", () => { + mockMatchMedia(false); + const { result } = renderHook(() => useSidePanelState()); + + act(() => result.current.toggleCollapse()); + expect(result.current.isRightPanelCollapsed).toBe(true); + + act(() => result.current.showCreature(CREATURE_A)); + expect(result.current.isRightPanelCollapsed).toBe(false); + }); + + it("togglePin pins the selected creature", () => { + mockMatchMedia(false); + const { result } = renderHook(() => useSidePanelState()); + + act(() => result.current.showCreature(CREATURE_A)); + act(() => result.current.togglePin()); + + expect(result.current.pinnedCreatureId).toBe(CREATURE_A); + }); + + it("togglePin unpins when already pinned to same creature", () => { + mockMatchMedia(false); + const { result } = renderHook(() => useSidePanelState()); + + act(() => result.current.showCreature(CREATURE_A)); + act(() => result.current.togglePin()); + act(() => result.current.togglePin()); + + expect(result.current.pinnedCreatureId).toBeNull(); + }); + + it("togglePin does nothing when no creature is selected", () => { + mockMatchMedia(false); + const { result } = renderHook(() => useSidePanelState()); + + act(() => result.current.togglePin()); + + expect(result.current.pinnedCreatureId).toBeNull(); + }); + + it("unpin clears pinned creature", () => { + mockMatchMedia(false); + const { result } = renderHook(() => useSidePanelState()); + + act(() => result.current.showCreature(CREATURE_A)); + act(() => result.current.togglePin()); + act(() => result.current.unpin()); + + expect(result.current.pinnedCreatureId).toBeNull(); + }); + + it("isWideDesktop reflects matchMedia result", () => { + mockMatchMedia(true); + const { result } = renderHook(() => useSidePanelState()); + + expect(result.current.isWideDesktop).toBe(true); + }); + + it("isWideDesktop is false on narrow viewport", () => { + mockMatchMedia(false); + const { result } = renderHook(() => useSidePanelState()); + + expect(result.current.isWideDesktop).toBe(false); + }); +});