Files
initiative/packages/domain/src/__tests__/set-hp.test.ts
Lukas 36768d3aa1 Upgrade Biome to 2.4.7 and enable 54 additional lint rules
Add rules covering bug prevention (noLeakedRender, noFloatingPromises,
noImportCycles, noReactForwardRef), security (noScriptUrl, noAlert),
performance (noAwaitInLoops, useTopLevelRegex), and code style
(noNestedTernary, useGlobalThis, useNullishCoalescing, useSortedClasses,
plus ~40 more). Fix all violations: extract top-level regex constants,
guard React && renders with boolean coercion, refactor nested ternaries,
replace window with globalThis, sort Tailwind classes, and introduce
expectDomainError test helper to eliminate conditional expects.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 14:25:09 +01:00

187 lines
6.2 KiB
TypeScript

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