167 lines
5.3 KiB
TypeScript
167 lines
5.3 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { adjustHp } from "../adjust-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: opts.maxHp, currentHp: opts.currentHp } : {}),
|
|
};
|
|
}
|
|
|
|
function enc(combatants: Combatant[]): Encounter {
|
|
return { combatants, activeIndex: 0, roundNumber: 1 };
|
|
}
|
|
|
|
function successResult(encounter: Encounter, id: string, delta: number) {
|
|
const result = adjustHp(encounter, combatantId(id), delta);
|
|
if (isDomainError(result)) {
|
|
throw new Error(`Expected success, got error: ${result.message}`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
describe("adjustHp", () => {
|
|
describe("acceptance scenarios", () => {
|
|
it("+1 increases currentHp by 1", () => {
|
|
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]);
|
|
const { encounter } = successResult(e, "A", 1);
|
|
expect(encounter.combatants[0].currentHp).toBe(16);
|
|
});
|
|
|
|
it("-1 decreases currentHp by 1", () => {
|
|
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]);
|
|
const { encounter } = successResult(e, "A", -1);
|
|
expect(encounter.combatants[0].currentHp).toBe(14);
|
|
});
|
|
|
|
it("clamps at 0 — cannot go below zero", () => {
|
|
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 3 })]);
|
|
const { encounter } = successResult(e, "A", -10);
|
|
expect(encounter.combatants[0].currentHp).toBe(0);
|
|
});
|
|
|
|
it("clamps at maxHp — cannot exceed max", () => {
|
|
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 18 })]);
|
|
const { encounter } = successResult(e, "A", 10);
|
|
expect(encounter.combatants[0].currentHp).toBe(20);
|
|
});
|
|
});
|
|
|
|
describe("invariants", () => {
|
|
it("is pure — same input produces same output", () => {
|
|
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
|
|
const r1 = adjustHp(e, combatantId("A"), -5);
|
|
const r2 = adjustHp(e, combatantId("A"), -5);
|
|
expect(r1).toEqual(r2);
|
|
});
|
|
|
|
it("does not mutate input encounter", () => {
|
|
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
|
|
const original = JSON.parse(JSON.stringify(e));
|
|
adjustHp(e, combatantId("A"), -3);
|
|
expect(e).toEqual(original);
|
|
});
|
|
|
|
it("emits CurrentHpAdjusted event with delta", () => {
|
|
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]);
|
|
const { events } = successResult(e, "A", -5);
|
|
expect(events).toEqual([
|
|
{
|
|
type: "CurrentHpAdjusted",
|
|
combatantId: combatantId("A"),
|
|
previousHp: 15,
|
|
newHp: 10,
|
|
delta: -5,
|
|
},
|
|
]);
|
|
});
|
|
|
|
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", -3);
|
|
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 = adjustHp(e, combatantId("Z"), -1);
|
|
expect(isDomainError(result)).toBe(true);
|
|
if (isDomainError(result)) {
|
|
expect(result.code).toBe("combatant-not-found");
|
|
}
|
|
});
|
|
|
|
it("returns error when combatant has no HP tracking", () => {
|
|
const e = enc([makeCombatant("A")]);
|
|
const result = adjustHp(e, combatantId("A"), -1);
|
|
expect(isDomainError(result)).toBe(true);
|
|
if (isDomainError(result)) {
|
|
expect(result.code).toBe("no-hp-tracking");
|
|
}
|
|
});
|
|
|
|
it("returns error for zero delta", () => {
|
|
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
|
|
const result = adjustHp(e, combatantId("A"), 0);
|
|
expect(isDomainError(result)).toBe(true);
|
|
if (isDomainError(result)) {
|
|
expect(result.code).toBe("zero-delta");
|
|
}
|
|
});
|
|
|
|
it("returns error for non-integer delta", () => {
|
|
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
|
|
const result = adjustHp(e, combatantId("A"), 1.5);
|
|
expect(isDomainError(result)).toBe(true);
|
|
if (isDomainError(result)) {
|
|
expect(result.code).toBe("invalid-delta");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("edge cases", () => {
|
|
it("large negative delta beyond currentHp clamps to 0", () => {
|
|
const e = enc([makeCombatant("A", { maxHp: 100, currentHp: 50 })]);
|
|
const { encounter } = successResult(e, "A", -9999);
|
|
expect(encounter.combatants[0].currentHp).toBe(0);
|
|
});
|
|
|
|
it("large positive delta beyond maxHp clamps to maxHp", () => {
|
|
const e = enc([makeCombatant("A", { maxHp: 100, currentHp: 50 })]);
|
|
const { encounter } = successResult(e, "A", 9999);
|
|
expect(encounter.combatants[0].currentHp).toBe(100);
|
|
});
|
|
|
|
it("does not affect other combatants", () => {
|
|
const e = enc([
|
|
makeCombatant("A", { maxHp: 20, currentHp: 15 }),
|
|
makeCombatant("B", { maxHp: 30, currentHp: 25 }),
|
|
]);
|
|
const { encounter } = successResult(e, "A", -5);
|
|
expect(encounter.combatants[1].currentHp).toBe(25);
|
|
});
|
|
|
|
it("adjusting from 0 upward works", () => {
|
|
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 0 })]);
|
|
const { encounter } = successResult(e, "A", 5);
|
|
expect(encounter.combatants[0].currentHp).toBe(5);
|
|
});
|
|
});
|
|
});
|