Add direct reducer tests for encounterReducer

Exports encounterReducer and EncounterState for testing. Adds 26
pure-function tests covering all action types: CRUD, turn navigation,
HP/AC/conditions, undo/redo, bestiary add with auto-numbering,
player character add, import, and event accumulation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-28 18:50:45 +01:00
parent 80dd68752e
commit d8c8a0c44d
2 changed files with 418 additions and 2 deletions

View File

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

View File

@@ -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 {