diff --git a/apps/web/src/hooks/__tests__/encounter-reducer.test.ts b/apps/web/src/hooks/__tests__/encounter-reducer.test.ts new file mode 100644 index 0000000..350fb86 --- /dev/null +++ b/apps/web/src/hooks/__tests__/encounter-reducer.test.ts @@ -0,0 +1,416 @@ +import type { + BestiaryIndexEntry, + ConditionId, + PlayerCharacter, +} from "@initiative/domain"; +import { + combatantId, + createEncounter, + EMPTY_UNDO_REDO_STATE, + isDomainError, + playerCharacterId, +} from "@initiative/domain"; +import { describe, expect, it, vi } from "vitest"; +import { type EncounterState, encounterReducer } from "../use-encounter.js"; + +vi.mock("../../persistence/encounter-storage.js", () => ({ + loadEncounter: vi.fn().mockReturnValue(null), + saveEncounter: vi.fn(), +})); + +vi.mock("../../persistence/undo-redo-storage.js", () => ({ + loadUndoRedoStacks: vi.fn().mockReturnValue(EMPTY_UNDO_REDO_STATE), + saveUndoRedoStacks: vi.fn(), +})); + +function emptyState(): EncounterState { + return { + encounter: { + combatants: [], + activeIndex: 0, + roundNumber: 1, + }, + undoRedoState: EMPTY_UNDO_REDO_STATE, + events: [], + nextId: 0, + lastCreatureId: null, + }; +} + +function stateWith(...names: string[]): EncounterState { + let state = emptyState(); + for (const name of names) { + state = encounterReducer(state, { type: "add-combatant", name }); + } + return state; +} + +function stateWithHp(name: string, maxHp: number): EncounterState { + const state = stateWith(name); + const id = state.encounter.combatants[0].id; + return encounterReducer(state, { + type: "set-hp", + id, + maxHp, + }); +} + +const BESTIARY_ENTRY: BestiaryIndexEntry = { + name: "Goblin", + source: "MM", + ac: 15, + hp: 7, + dex: 14, + cr: "1/4", + initiativeProficiency: 0, + size: "Small", + type: "humanoid", +}; + +describe("encounterReducer", () => { + describe("add-combatant", () => { + it("adds a combatant and pushes undo", () => { + const next = encounterReducer(emptyState(), { + type: "add-combatant", + name: "Goblin", + }); + + expect(next.encounter.combatants).toHaveLength(1); + expect(next.encounter.combatants[0].name).toBe("Goblin"); + expect(next.undoRedoState.undoStack).toHaveLength(1); + expect(next.nextId).toBe(1); + }); + + it("applies optional init values", () => { + const next = encounterReducer(emptyState(), { + type: "add-combatant", + name: "Goblin", + init: { initiative: 15, ac: 13, maxHp: 7 }, + }); + + const c = next.encounter.combatants[0]; + expect(c.initiative).toBe(15); + expect(c.ac).toBe(13); + expect(c.maxHp).toBe(7); + expect(c.currentHp).toBe(7); + }); + + it("increments IDs", () => { + const s1 = encounterReducer(emptyState(), { + type: "add-combatant", + name: "A", + }); + const s2 = encounterReducer(s1, { + type: "add-combatant", + name: "B", + }); + + expect(s2.encounter.combatants[0].id).toBe("c-1"); + expect(s2.encounter.combatants[1].id).toBe("c-2"); + }); + + it("returns unchanged state for invalid name", () => { + const state = emptyState(); + const next = encounterReducer(state, { + type: "add-combatant", + name: "", + }); + + expect(next).toBe(state); + }); + }); + + describe("remove-combatant", () => { + it("removes combatant and pushes undo", () => { + const state = stateWith("Goblin"); + const id = state.encounter.combatants[0].id; + const next = encounterReducer(state, { + type: "remove-combatant", + id, + }); + + expect(next.encounter.combatants).toHaveLength(0); + expect(next.undoRedoState.undoStack).toHaveLength(2); + }); + }); + + describe("edit-combatant", () => { + it("renames combatant", () => { + const state = stateWith("Goblin"); + const id = state.encounter.combatants[0].id; + const next = encounterReducer(state, { + type: "edit-combatant", + id, + newName: "Hobgoblin", + }); + + expect(next.encounter.combatants[0].name).toBe("Hobgoblin"); + }); + }); + + describe("advance-turn / retreat-turn", () => { + it("advances and retreats turn", () => { + const state = stateWith("A", "B"); + const advanced = encounterReducer(state, { + type: "advance-turn", + }); + expect(advanced.encounter.activeIndex).toBe(1); + + const retreated = encounterReducer(advanced, { + type: "retreat-turn", + }); + expect(retreated.encounter.activeIndex).toBe(0); + }); + + it("returns unchanged state on empty encounter", () => { + const state = emptyState(); + const next = encounterReducer(state, { type: "advance-turn" }); + expect(next).toBe(state); + }); + }); + + describe("set-hp / adjust-hp / set-temp-hp", () => { + it("sets max HP", () => { + const state = stateWith("Goblin"); + const id = state.encounter.combatants[0].id; + const next = encounterReducer(state, { + type: "set-hp", + id, + maxHp: 20, + }); + + expect(next.encounter.combatants[0].maxHp).toBe(20); + expect(next.encounter.combatants[0].currentHp).toBe(20); + }); + + it("adjusts HP", () => { + const state = stateWithHp("Goblin", 20); + const id = state.encounter.combatants[0].id; + const next = encounterReducer(state, { + type: "adjust-hp", + id, + delta: -5, + }); + + expect(next.encounter.combatants[0].currentHp).toBe(15); + }); + + it("sets temp HP", () => { + const state = stateWithHp("Goblin", 20); + const id = state.encounter.combatants[0].id; + const next = encounterReducer(state, { + type: "set-temp-hp", + id, + tempHp: 5, + }); + + expect(next.encounter.combatants[0].tempHp).toBe(5); + }); + }); + + describe("set-ac", () => { + it("sets AC", () => { + const state = stateWith("Goblin"); + const id = state.encounter.combatants[0].id; + const next = encounterReducer(state, { + type: "set-ac", + id, + value: 15, + }); + + expect(next.encounter.combatants[0].ac).toBe(15); + }); + }); + + describe("set-initiative", () => { + it("sets initiative", () => { + const state = stateWith("Goblin"); + const id = state.encounter.combatants[0].id; + const next = encounterReducer(state, { + type: "set-initiative", + id, + value: 18, + }); + + expect(next.encounter.combatants[0].initiative).toBe(18); + }); + }); + + describe("toggle-condition / toggle-concentration", () => { + it("toggles condition", () => { + const state = stateWith("Goblin"); + const id = state.encounter.combatants[0].id; + const next = encounterReducer(state, { + type: "toggle-condition", + id, + conditionId: "blinded" as ConditionId, + }); + + expect(next.encounter.combatants[0].conditions).toContain("blinded"); + }); + + it("toggles concentration", () => { + const state = stateWith("Wizard"); + const id = state.encounter.combatants[0].id; + const next = encounterReducer(state, { + type: "toggle-concentration", + id, + }); + + expect(next.encounter.combatants[0].isConcentrating).toBe(true); + }); + }); + + describe("clear-encounter", () => { + it("clears combatants, resets history and nextId", () => { + const state = stateWith("A", "B"); + const next = encounterReducer(state, { + type: "clear-encounter", + }); + + expect(next.encounter.combatants).toHaveLength(0); + expect(next.undoRedoState.undoStack).toHaveLength(0); + expect(next.undoRedoState.redoStack).toHaveLength(0); + expect(next.nextId).toBe(0); + }); + }); + + describe("undo / redo", () => { + it("undo restores previous state", () => { + const state = stateWith("Goblin"); + const next = encounterReducer(state, { type: "undo" }); + + expect(next.encounter.combatants).toHaveLength(0); + expect(next.undoRedoState.undoStack).toHaveLength(0); + expect(next.undoRedoState.redoStack).toHaveLength(1); + }); + + it("redo restores undone state", () => { + const state = stateWith("Goblin"); + const undone = encounterReducer(state, { type: "undo" }); + const redone = encounterReducer(undone, { type: "redo" }); + + expect(redone.encounter.combatants).toHaveLength(1); + expect(redone.encounter.combatants[0].name).toBe("Goblin"); + }); + + it("undo returns unchanged state when stack is empty", () => { + const state = emptyState(); + const next = encounterReducer(state, { type: "undo" }); + expect(next).toBe(state); + }); + + it("redo returns unchanged state when stack is empty", () => { + const state = emptyState(); + const next = encounterReducer(state, { type: "redo" }); + expect(next).toBe(state); + }); + }); + + describe("add-from-bestiary", () => { + it("adds creature with HP, AC, and creatureId", () => { + const next = encounterReducer(emptyState(), { + type: "add-from-bestiary", + entry: BESTIARY_ENTRY, + }); + + const c = next.encounter.combatants[0]; + expect(c.name).toBe("Goblin"); + expect(c.maxHp).toBe(7); + expect(c.ac).toBe(15); + expect(c.creatureId).toBe("mm:goblin"); + expect(next.lastCreatureId).toBe("mm:goblin"); + expect(next.undoRedoState.undoStack).toHaveLength(1); + }); + + it("auto-numbers duplicate names", () => { + const s1 = encounterReducer(emptyState(), { + type: "add-from-bestiary", + entry: BESTIARY_ENTRY, + }); + const s2 = encounterReducer(s1, { + type: "add-from-bestiary", + entry: BESTIARY_ENTRY, + }); + + const names = s2.encounter.combatants.map((c) => c.name); + expect(names).toContain("Goblin 1"); + expect(names).toContain("Goblin 2"); + }); + }); + + describe("add-multiple-from-bestiary", () => { + it("adds multiple creatures in one action", () => { + const next = encounterReducer(emptyState(), { + type: "add-multiple-from-bestiary", + entry: BESTIARY_ENTRY, + count: 3, + }); + + expect(next.encounter.combatants).toHaveLength(3); + expect(next.undoRedoState.undoStack).toHaveLength(1); + expect(next.lastCreatureId).toBe("mm:goblin"); + }); + }); + + describe("add-from-player-character", () => { + it("adds combatant with PC attributes", () => { + const pc: PlayerCharacter = { + id: playerCharacterId("pc-1"), + name: "Aria", + ac: 16, + maxHp: 30, + color: "blue", + icon: "sword", + }; + const next = encounterReducer(emptyState(), { + type: "add-from-player-character", + pc, + }); + + const c = next.encounter.combatants[0]; + expect(c.name).toBe("Aria"); + expect(c.maxHp).toBe(30); + expect(c.ac).toBe(16); + expect(c.color).toBe("blue"); + expect(c.icon).toBe("sword"); + expect(c.playerCharacterId).toBe("pc-1"); + expect(next.lastCreatureId).toBeNull(); + }); + }); + + describe("import", () => { + it("replaces encounter and undo/redo state", () => { + const state = stateWith("A", "B"); + const enc = createEncounter([ + { id: combatantId("c-5"), name: "Imported" }, + ]); + if (isDomainError(enc)) throw new Error("Setup failed"); + + const next = encounterReducer(state, { + type: "import", + encounter: enc, + undoRedoState: EMPTY_UNDO_REDO_STATE, + }); + + expect(next.encounter.combatants).toHaveLength(1); + expect(next.encounter.combatants[0].name).toBe("Imported"); + expect(next.nextId).toBe(5); + }); + }); + + describe("events accumulation", () => { + it("accumulates events across actions", () => { + const s1 = encounterReducer(emptyState(), { + type: "add-combatant", + name: "A", + }); + const s2 = encounterReducer(s1, { + type: "add-combatant", + name: "B", + }); + + expect(s2.events.length).toBeGreaterThanOrEqual(2); + }); + }); +}); diff --git a/apps/web/src/hooks/use-encounter.ts b/apps/web/src/hooks/use-encounter.ts index db5db1f..34602ef 100644 --- a/apps/web/src/hooks/use-encounter.ts +++ b/apps/web/src/hooks/use-encounter.ts @@ -81,7 +81,7 @@ type EncounterAction = undoRedoState: UndoRedoState; }; -interface EncounterState { +export interface EncounterState { readonly encounter: Encounter; readonly undoRedoState: UndoRedoState; readonly events: readonly DomainEvent[]; @@ -268,7 +268,7 @@ function handleAddFromPlayerCharacter( // -- Reducer -- -function encounterReducer( +export function encounterReducer( state: EncounterState, action: EncounterAction, ): EncounterState {