Combatants can now be assigned to party or enemy side via a toggle in the difficulty breakdown panel. Party-side NPCs subtract their XP from the encounter total, letting allied NPCs reduce difficulty. PCs default to party, non-PCs to enemy — users who don't use sides see no change. Side persists across reload and export/import. Closes #22 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
286 lines
7.7 KiB
TypeScript
286 lines
7.7 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { rehydrateCombatant } from "../rehydrate-combatant.js";
|
|
|
|
function validCombatant(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: "c-1",
|
|
name: "Goblin",
|
|
initiative: 12,
|
|
ac: 15,
|
|
maxHp: 7,
|
|
currentHp: 5,
|
|
tempHp: 3,
|
|
conditions: ["poisoned"],
|
|
isConcentrating: true,
|
|
creatureId: "creature-goblin",
|
|
color: "red",
|
|
icon: "skull",
|
|
playerCharacterId: "pc-1",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function minimalCombatant() {
|
|
return { id: "c-1", name: "Goblin" };
|
|
}
|
|
|
|
describe("rehydrateCombatant", () => {
|
|
describe("valid input", () => {
|
|
it("accepts a combatant with all fields", () => {
|
|
const result = rehydrateCombatant(validCombatant());
|
|
expect(result).not.toBeNull();
|
|
expect(result?.name).toBe("Goblin");
|
|
expect(result?.initiative).toBe(12);
|
|
expect(result?.ac).toBe(15);
|
|
expect(result?.maxHp).toBe(7);
|
|
expect(result?.currentHp).toBe(5);
|
|
expect(result?.tempHp).toBe(3);
|
|
expect(result?.conditions).toEqual(["poisoned"]);
|
|
expect(result?.isConcentrating).toBe(true);
|
|
expect(result?.creatureId).toBe("creature-goblin");
|
|
expect(result?.color).toBe("red");
|
|
expect(result?.icon).toBe("skull");
|
|
expect(result?.playerCharacterId).toBe("pc-1");
|
|
});
|
|
|
|
it("accepts a minimal combatant (id + name only)", () => {
|
|
const result = rehydrateCombatant(minimalCombatant());
|
|
expect(result).not.toBeNull();
|
|
expect(result?.id).toBe("c-1");
|
|
expect(result?.name).toBe("Goblin");
|
|
expect(result?.initiative).toBeUndefined();
|
|
expect(result?.ac).toBeUndefined();
|
|
expect(result?.maxHp).toBeUndefined();
|
|
});
|
|
|
|
it("preserves branded CombatantId", () => {
|
|
const result = rehydrateCombatant(minimalCombatant());
|
|
expect(result?.id).toBe("c-1");
|
|
});
|
|
});
|
|
|
|
describe("required field rejection", () => {
|
|
it.each([
|
|
null,
|
|
42,
|
|
"string",
|
|
[1, 2],
|
|
undefined,
|
|
])("rejects non-object input: %j", (input) => {
|
|
expect(rehydrateCombatant(input)).toBeNull();
|
|
});
|
|
|
|
it("rejects missing id", () => {
|
|
const { id: _, ...rest } = minimalCombatant();
|
|
expect(rehydrateCombatant(rest)).toBeNull();
|
|
});
|
|
|
|
it("rejects empty id", () => {
|
|
expect(rehydrateCombatant({ ...minimalCombatant(), id: "" })).toBeNull();
|
|
});
|
|
|
|
it("rejects missing name", () => {
|
|
const { name: _, ...rest } = minimalCombatant();
|
|
expect(rehydrateCombatant(rest)).toBeNull();
|
|
});
|
|
|
|
it("rejects non-string name", () => {
|
|
expect(
|
|
rehydrateCombatant({ ...minimalCombatant(), name: 42 }),
|
|
).toBeNull();
|
|
expect(
|
|
rehydrateCombatant({ ...minimalCombatant(), name: null }),
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("optional field leniency", () => {
|
|
it("drops invalid ac — keeps combatant", () => {
|
|
for (const ac of [-1, 1.5, "15"]) {
|
|
const result = rehydrateCombatant({ ...minimalCombatant(), ac });
|
|
expect(result).not.toBeNull();
|
|
expect(result?.ac).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it("drops invalid maxHp — keeps combatant", () => {
|
|
for (const maxHp of [0, 1.5, "7"]) {
|
|
const result = rehydrateCombatant({ ...minimalCombatant(), maxHp });
|
|
expect(result).not.toBeNull();
|
|
expect(result?.maxHp).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it("falls back currentHp to maxHp when currentHp invalid", () => {
|
|
const result = rehydrateCombatant({
|
|
...minimalCombatant(),
|
|
maxHp: 10,
|
|
currentHp: "bad",
|
|
});
|
|
expect(result?.maxHp).toBe(10);
|
|
expect(result?.currentHp).toBe(10);
|
|
});
|
|
|
|
it("falls back currentHp to maxHp when currentHp > maxHp", () => {
|
|
const result = rehydrateCombatant({
|
|
...minimalCombatant(),
|
|
maxHp: 10,
|
|
currentHp: 15,
|
|
});
|
|
expect(result?.maxHp).toBe(10);
|
|
expect(result?.currentHp).toBe(10);
|
|
});
|
|
|
|
it("drops invalid initiative — keeps combatant", () => {
|
|
for (const initiative of [1.5, "12"]) {
|
|
const result = rehydrateCombatant({
|
|
...minimalCombatant(),
|
|
initiative,
|
|
});
|
|
expect(result).not.toBeNull();
|
|
expect(result?.initiative).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it("drops invalid conditions — keeps combatant", () => {
|
|
const result = rehydrateCombatant({
|
|
...minimalCombatant(),
|
|
conditions: "poisoned",
|
|
});
|
|
expect(result).not.toBeNull();
|
|
expect(result?.conditions).toBeUndefined();
|
|
});
|
|
|
|
it("drops unknown condition IDs", () => {
|
|
const result = rehydrateCombatant({
|
|
...minimalCombatant(),
|
|
conditions: ["fake-condition"],
|
|
});
|
|
expect(result).not.toBeNull();
|
|
expect(result?.conditions).toBeUndefined();
|
|
});
|
|
|
|
it("filters valid conditions from mixed array", () => {
|
|
const result = rehydrateCombatant({
|
|
...minimalCombatant(),
|
|
conditions: ["poisoned", "fake", "blinded"],
|
|
});
|
|
expect(result?.conditions).toEqual(["poisoned", "blinded"]);
|
|
});
|
|
|
|
it("drops invalid color — keeps combatant", () => {
|
|
const result = rehydrateCombatant({
|
|
...minimalCombatant(),
|
|
color: "neon",
|
|
});
|
|
expect(result).not.toBeNull();
|
|
expect(result?.color).toBeUndefined();
|
|
});
|
|
|
|
it("drops invalid icon — keeps combatant", () => {
|
|
const result = rehydrateCombatant({
|
|
...minimalCombatant(),
|
|
icon: "rocket",
|
|
});
|
|
expect(result).not.toBeNull();
|
|
expect(result?.icon).toBeUndefined();
|
|
});
|
|
|
|
it("drops isConcentrating when not strictly true", () => {
|
|
for (const isConcentrating of [false, "true", 1]) {
|
|
const result = rehydrateCombatant({
|
|
...minimalCombatant(),
|
|
isConcentrating,
|
|
});
|
|
expect(result).not.toBeNull();
|
|
expect(result?.isConcentrating).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it("drops invalid creatureId", () => {
|
|
for (const creatureId of ["", 42]) {
|
|
const result = rehydrateCombatant({
|
|
...minimalCombatant(),
|
|
creatureId,
|
|
});
|
|
expect(result).not.toBeNull();
|
|
expect(result?.creatureId).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it("drops invalid playerCharacterId", () => {
|
|
for (const playerCharacterId of ["", 42]) {
|
|
const result = rehydrateCombatant({
|
|
...minimalCombatant(),
|
|
playerCharacterId,
|
|
});
|
|
expect(result).not.toBeNull();
|
|
expect(result?.playerCharacterId).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it("preserves valid cr field", () => {
|
|
for (const cr of ["5", "1/4", "0", "30"]) {
|
|
const result = rehydrateCombatant({ ...minimalCombatant(), cr });
|
|
expect(result).not.toBeNull();
|
|
expect(result?.cr).toBe(cr);
|
|
}
|
|
});
|
|
|
|
it("drops invalid cr field", () => {
|
|
for (const cr of ["99", "", 42, null, "abc"]) {
|
|
const result = rehydrateCombatant({ ...minimalCombatant(), cr });
|
|
expect(result).not.toBeNull();
|
|
expect(result?.cr).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it("combatant without cr rehydrates as before", () => {
|
|
const result = rehydrateCombatant(minimalCombatant());
|
|
expect(result).not.toBeNull();
|
|
expect(result?.cr).toBeUndefined();
|
|
});
|
|
|
|
it("preserves valid side field", () => {
|
|
for (const side of ["party", "enemy"]) {
|
|
const result = rehydrateCombatant({ ...minimalCombatant(), side });
|
|
expect(result).not.toBeNull();
|
|
expect(result?.side).toBe(side);
|
|
}
|
|
});
|
|
|
|
it("drops invalid side field", () => {
|
|
for (const side of ["ally", "", 42, null, true]) {
|
|
const result = rehydrateCombatant({ ...minimalCombatant(), side });
|
|
expect(result).not.toBeNull();
|
|
expect(result?.side).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it("combatant without side rehydrates as before", () => {
|
|
const result = rehydrateCombatant(minimalCombatant());
|
|
expect(result).not.toBeNull();
|
|
expect(result?.side).toBeUndefined();
|
|
});
|
|
|
|
it("drops invalid tempHp — keeps combatant", () => {
|
|
for (const tempHp of [-1, 1.5, "3"]) {
|
|
const result = rehydrateCombatant({
|
|
...minimalCombatant(),
|
|
tempHp,
|
|
});
|
|
expect(result).not.toBeNull();
|
|
expect(result?.tempHp).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it("preserves valid tempHp of 0", () => {
|
|
const result = rehydrateCombatant({
|
|
...minimalCombatant(),
|
|
tempHp: 0,
|
|
});
|
|
expect(result?.tempHp).toBe(0);
|
|
});
|
|
});
|
|
});
|