198 lines
6.5 KiB
TypeScript
198 lines
6.5 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";
|
|
|
|
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);
|
|
expect(isDomainError(result)).toBe(true);
|
|
if (isDomainError(result)) {
|
|
expect(result.code).toBe("combatant-not-found");
|
|
}
|
|
});
|
|
|
|
it("rejects maxHp of 0", () => {
|
|
const e = enc([makeCombatant("A")]);
|
|
const result = setHp(e, combatantId("A"), 0);
|
|
expect(isDomainError(result)).toBe(true);
|
|
if (isDomainError(result)) {
|
|
expect(result.code).toBe("invalid-max-hp");
|
|
}
|
|
});
|
|
|
|
it("rejects negative maxHp", () => {
|
|
const e = enc([makeCombatant("A")]);
|
|
const result = setHp(e, combatantId("A"), -5);
|
|
expect(isDomainError(result)).toBe(true);
|
|
if (isDomainError(result)) {
|
|
expect(result.code).toBe("invalid-max-hp");
|
|
}
|
|
});
|
|
|
|
it("rejects non-integer maxHp", () => {
|
|
const e = enc([makeCombatant("A")]);
|
|
const result = setHp(e, combatantId("A"), 3.5);
|
|
expect(isDomainError(result)).toBe(true);
|
|
if (isDomainError(result)) {
|
|
expect(result.code).toBe("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);
|
|
});
|
|
});
|
|
});
|