29 tests covering state transitions, persistence sync, domain error propagation, bestiary/PC add flows, and panel state machine logic. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
218 lines
6.4 KiB
TypeScript
218 lines
6.4 KiB
TypeScript
// @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<typeof import("../../persistence/encounter-storage.js")>(
|
|
"../../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"));
|
|
});
|
|
});
|