// @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")); }); });