import { describe, expect, it } from "vitest"; import { setTempHp } from "../set-temp-hp.js"; import type { Combatant, Encounter } from "../types.js"; import { combatantId, isDomainError } from "../types.js"; import { expectDomainError } from "./test-helpers.js"; function makeCombatant( name: string, opts?: { maxHp: number; currentHp: number; tempHp?: number }, ): Combatant { return { id: combatantId(name), name, ...(opts ? { maxHp: opts.maxHp, currentHp: opts.currentHp, tempHp: opts.tempHp, } : {}), }; } function enc(combatants: Combatant[]): Encounter { return { combatants, activeIndex: 0, roundNumber: 1 }; } function successResult( encounter: Encounter, id: string, tempHp: number | undefined, ) { const result = setTempHp(encounter, combatantId(id), tempHp); if (isDomainError(result)) { throw new Error(`Expected success, got error: ${result.message}`); } return result; } describe("setTempHp", () => { describe("acceptance scenarios", () => { it("sets temp HP on a combatant with HP tracking enabled", () => { const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]); const { encounter } = successResult(e, "A", 8); expect(encounter.combatants[0].tempHp).toBe(8); }); it("keeps higher value when existing temp HP is greater", () => { const e = enc([ makeCombatant("A", { maxHp: 20, currentHp: 10, tempHp: 5 }), ]); const { encounter } = successResult(e, "A", 3); expect(encounter.combatants[0].tempHp).toBe(5); }); it("replaces when new value is higher", () => { const e = enc([ makeCombatant("A", { maxHp: 20, currentHp: 10, tempHp: 3 }), ]); const { encounter } = successResult(e, "A", 7); expect(encounter.combatants[0].tempHp).toBe(7); }); it("clears temp HP when set to undefined", () => { const e = enc([ makeCombatant("A", { maxHp: 20, currentHp: 10, tempHp: 5 }), ]); const { encounter } = successResult(e, "A", undefined); expect(encounter.combatants[0].tempHp).toBeUndefined(); }); }); describe("invariants", () => { it("is pure — same input produces same output", () => { const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]); const r1 = setTempHp(e, combatantId("A"), 5); const r2 = setTempHp(e, combatantId("A"), 5); expect(r1).toEqual(r2); }); it("does not mutate input encounter", () => { const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]); const original = JSON.parse(JSON.stringify(e)); setTempHp(e, combatantId("A"), 5); expect(e).toEqual(original); }); it("emits TempHpSet event with correct shape", () => { const e = enc([ makeCombatant("A", { maxHp: 20, currentHp: 15, tempHp: 3 }), ]); const { events } = successResult(e, "A", 7); expect(events).toEqual([ { type: "TempHpSet", combatantId: combatantId("A"), previousTempHp: 3, newTempHp: 7, }, ]); }); it("preserves activeIndex and roundNumber", () => { const e = { combatants: [ makeCombatant("A", { maxHp: 20, currentHp: 10 }), makeCombatant("B"), ], activeIndex: 1, roundNumber: 5, }; const { encounter } = successResult(e, "A", 5); expect(encounter.activeIndex).toBe(1); expect(encounter.roundNumber).toBe(5); }); }); describe("error cases", () => { it("returns error for nonexistent combatant", () => { const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]); const result = setTempHp(e, combatantId("Z"), 5); expectDomainError(result, "combatant-not-found"); }); it("returns error when HP tracking is not enabled", () => { const e = enc([makeCombatant("A")]); const result = setTempHp(e, combatantId("A"), 5); expectDomainError(result, "no-hp-tracking"); }); it("rejects temp HP of 0", () => { const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]); const result = setTempHp(e, combatantId("A"), 0); expectDomainError(result, "invalid-temp-hp"); }); it("rejects negative temp HP", () => { const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]); const result = setTempHp(e, combatantId("A"), -3); expectDomainError(result, "invalid-temp-hp"); }); it("rejects non-integer temp HP", () => { const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]); const result = setTempHp(e, combatantId("A"), 2.5); expectDomainError(result, "invalid-temp-hp"); }); }); describe("edge cases", () => { it("does not affect other combatants", () => { const e = enc([ makeCombatant("A", { maxHp: 20, currentHp: 15 }), makeCombatant("B", { maxHp: 30, currentHp: 25, tempHp: 4 }), ]); const { encounter } = successResult(e, "A", 5); expect(encounter.combatants[1].tempHp).toBe(4); }); it("does not affect currentHp or maxHp", () => { const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]); const { encounter } = successResult(e, "A", 8); expect(encounter.combatants[0].maxHp).toBe(20); expect(encounter.combatants[0].currentHp).toBe(15); }); it("event reflects no change when existing value equals new value", () => { const e = enc([ makeCombatant("A", { maxHp: 20, currentHp: 10, tempHp: 5 }), ]); const { events } = successResult(e, "A", 5); expect(events).toEqual([ { type: "TempHpSet", combatantId: combatantId("A"), previousTempHp: 5, newTempHp: 5, }, ]); }); }); });