import { describe, expect, it } from "vitest"; import { setHp } from "../set-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 }, ): Combatant { return { id: combatantId(name), name, ...(opts?.maxHp === undefined ? {} : { maxHp: opts.maxHp, currentHp: opts.currentHp ?? opts.maxHp }), }; } function enc(combatants: Combatant[]): Encounter { return { combatants, activeIndex: 0, roundNumber: 1 }; } function successResult( encounter: Encounter, id: string, maxHp: number | undefined, ) { const result = setHp(encounter, combatantId(id), maxHp); if (isDomainError(result)) { throw new Error(`Expected success, got error: ${result.message}`); } return result; } describe("setHp", () => { describe("acceptance scenarios", () => { it("sets maxHp on a combatant with no HP — currentHp defaults to maxHp", () => { const e = enc([makeCombatant("A")]); const { encounter } = successResult(e, "A", 20); expect(encounter.combatants[0].maxHp).toBe(20); expect(encounter.combatants[0].currentHp).toBe(20); }); it("increases maxHp while at full health — currentHp stays synced", () => { const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 20 })]); const { encounter } = successResult(e, "A", 30); expect(encounter.combatants[0].maxHp).toBe(30); expect(encounter.combatants[0].currentHp).toBe(30); }); it("increases maxHp while not at full health — currentHp unchanged", () => { const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 12 })]); const { encounter } = successResult(e, "A", 30); expect(encounter.combatants[0].maxHp).toBe(30); expect(encounter.combatants[0].currentHp).toBe(12); }); it("reduces maxHp below currentHp — clamps currentHp", () => { const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 18 })]); const { encounter } = successResult(e, "A", 10); expect(encounter.combatants[0].maxHp).toBe(10); expect(encounter.combatants[0].currentHp).toBe(10); }); it("clears maxHp — both maxHp and currentHp become undefined", () => { const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]); const { encounter } = successResult(e, "A", undefined); expect(encounter.combatants[0].maxHp).toBeUndefined(); expect(encounter.combatants[0].currentHp).toBeUndefined(); }); }); describe("invariants", () => { it("is pure — same input produces same output", () => { const e = enc([makeCombatant("A")]); const r1 = setHp(e, combatantId("A"), 10); const r2 = setHp(e, combatantId("A"), 10); expect(r1).toEqual(r2); }); it("does not mutate input encounter", () => { const e = enc([makeCombatant("A")]); const original = JSON.parse(JSON.stringify(e)); setHp(e, combatantId("A"), 10); expect(e).toEqual(original); }); it("emits MaxHpSet event with correct shape", () => { const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 18 })]); const { events } = successResult(e, "A", 10); expect(events).toEqual([ { type: "MaxHpSet", combatantId: combatantId("A"), previousMaxHp: 20, newMaxHp: 10, previousCurrentHp: 18, newCurrentHp: 10, }, ]); }); it("preserves activeIndex and roundNumber", () => { const e = { combatants: [makeCombatant("A"), makeCombatant("B")], activeIndex: 1, roundNumber: 3, }; const { encounter } = successResult(e, "A", 10); expect(encounter.activeIndex).toBe(1); expect(encounter.roundNumber).toBe(3); }); }); describe("error cases", () => { it("returns error for nonexistent combatant", () => { const e = enc([makeCombatant("A")]); const result = setHp(e, combatantId("Z"), 10); expectDomainError(result, "combatant-not-found"); }); it("rejects maxHp of 0", () => { const e = enc([makeCombatant("A")]); const result = setHp(e, combatantId("A"), 0); expectDomainError(result, "invalid-max-hp"); }); it("rejects negative maxHp", () => { const e = enc([makeCombatant("A")]); const result = setHp(e, combatantId("A"), -5); expectDomainError(result, "invalid-max-hp"); }); it("rejects non-integer maxHp", () => { const e = enc([makeCombatant("A")]); const result = setHp(e, combatantId("A"), 3.5); expectDomainError(result, "invalid-max-hp"); }); }); describe("edge cases", () => { it("maxHp=1 is valid", () => { const e = enc([makeCombatant("A")]); const { encounter } = successResult(e, "A", 1); expect(encounter.combatants[0].maxHp).toBe(1); expect(encounter.combatants[0].currentHp).toBe(1); }); it("setting same maxHp does not change currentHp", () => { const e = enc([makeCombatant("A", { maxHp: 10, currentHp: 7 })]); const { encounter } = successResult(e, "A", 10); expect(encounter.combatants[0].currentHp).toBe(7); }); it("clear then re-set loses currentHp — UI must commit on blur", () => { // Simulates: user clears max HP field then retypes a new value // If the domain sees clear→set as two calls, currentHp resets. // This is why the UI commits max HP only on blur, not per-keystroke. const e = enc([makeCombatant("A", { maxHp: 22, currentHp: 12 })]); const cleared = successResult(e, "A", undefined); expect(cleared.encounter.combatants[0].currentHp).toBeUndefined(); const retyped = successResult(cleared.encounter, "A", 122); // currentHp resets to 122 (first-set path) — original 12 is lost expect(retyped.encounter.combatants[0].currentHp).toBe(122); }); it("single committed change preserves currentHp", () => { // The blur-commit approach: domain only sees 22→122, not 22→undefined→122 const e = enc([makeCombatant("A", { maxHp: 22, currentHp: 12 })]); const { encounter } = successResult(e, "A", 122); expect(encounter.combatants[0].maxHp).toBe(122); expect(encounter.combatants[0].currentHp).toBe(12); }); it("does not affect other combatants", () => { const e = enc([ makeCombatant("A"), makeCombatant("B", { maxHp: 30, currentHp: 25 }), ]); const { encounter } = successResult(e, "A", 10); expect(encounter.combatants[1].maxHp).toBe(30); expect(encounter.combatants[1].currentHp).toBe(25); }); }); });