Move entity rehydration to domain layer, fix tempHp gap
Rehydration functions (reconstructing typed domain objects from untyped JSON) lived in persistence adapters, duplicating domain validation. Adding a field required updating both the domain type and a separate adapter function — the adapter was missed for `level`, silently dropping it on reload. Now adding a field only requires updating the domain type and its co-located rehydration function. - Add `rehydratePlayerCharacter` and `rehydrateCombatant` to domain - Persistence adapters delegate to domain instead of reimplementing - Add `tempHp` validation (was silently dropped during rehydration) - Tighten initiative validation to integer-only - Exhaustive domain tests (53 cases); adapter tests slimmed to round-trip - Remove stale `jsinspect-plus` Knip ignoreDependencies entry Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
241
packages/domain/src/__tests__/rehydrate-combatant.test.ts
Normal file
241
packages/domain/src/__tests__/rehydrate-combatant.test.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
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("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);
|
||||
});
|
||||
});
|
||||
});
|
||||
136
packages/domain/src/__tests__/rehydrate-player-character.test.ts
Normal file
136
packages/domain/src/__tests__/rehydrate-player-character.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { rehydratePlayerCharacter } from "../rehydrate-player-character.js";
|
||||
|
||||
function validPc(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "pc-1",
|
||||
name: "Aria",
|
||||
ac: 16,
|
||||
maxHp: 45,
|
||||
color: "blue",
|
||||
icon: "sword",
|
||||
level: 5,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("rehydratePlayerCharacter", () => {
|
||||
describe("valid input", () => {
|
||||
it("accepts a valid PC with all fields", () => {
|
||||
const result = rehydratePlayerCharacter(validPc());
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.name).toBe("Aria");
|
||||
expect(result?.ac).toBe(16);
|
||||
expect(result?.maxHp).toBe(45);
|
||||
expect(result?.color).toBe("blue");
|
||||
expect(result?.icon).toBe("sword");
|
||||
expect(result?.level).toBe(5);
|
||||
});
|
||||
|
||||
it("accepts a valid PC without optional color/icon/level", () => {
|
||||
const result = rehydratePlayerCharacter(
|
||||
validPc({ color: undefined, icon: undefined, level: undefined }),
|
||||
);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.color).toBeUndefined();
|
||||
expect(result?.icon).toBeUndefined();
|
||||
expect(result?.level).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves branded PlayerCharacterId", () => {
|
||||
const result = rehydratePlayerCharacter(validPc());
|
||||
expect(result?.id).toBe("pc-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("required field rejection", () => {
|
||||
it.each([
|
||||
null,
|
||||
42,
|
||||
"string",
|
||||
[1, 2],
|
||||
undefined,
|
||||
])("rejects non-object input: %j", (input) => {
|
||||
expect(rehydratePlayerCharacter(input)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects missing id", () => {
|
||||
const { id: _, ...rest } = validPc();
|
||||
expect(rehydratePlayerCharacter(rest)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects empty id", () => {
|
||||
expect(rehydratePlayerCharacter(validPc({ id: "" }))).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects missing name", () => {
|
||||
const { name: _, ...rest } = validPc();
|
||||
expect(rehydratePlayerCharacter(rest)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects empty/whitespace name", () => {
|
||||
expect(rehydratePlayerCharacter(validPc({ name: "" }))).toBeNull();
|
||||
expect(rehydratePlayerCharacter(validPc({ name: " " }))).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects missing ac", () => {
|
||||
const { ac: _, ...rest } = validPc();
|
||||
expect(rehydratePlayerCharacter(rest)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects negative ac", () => {
|
||||
expect(rehydratePlayerCharacter(validPc({ ac: -1 }))).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects float ac", () => {
|
||||
expect(rehydratePlayerCharacter(validPc({ ac: 1.5 }))).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects string ac", () => {
|
||||
expect(rehydratePlayerCharacter(validPc({ ac: "16" }))).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects missing maxHp", () => {
|
||||
const { maxHp: _, ...rest } = validPc();
|
||||
expect(rehydratePlayerCharacter(rest)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects maxHp of 0", () => {
|
||||
expect(rehydratePlayerCharacter(validPc({ maxHp: 0 }))).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects float maxHp", () => {
|
||||
expect(rehydratePlayerCharacter(validPc({ maxHp: 1.5 }))).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects string maxHp", () => {
|
||||
expect(rehydratePlayerCharacter(validPc({ maxHp: "45" }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("optional field rejection (strict)", () => {
|
||||
it("rejects invalid color", () => {
|
||||
expect(rehydratePlayerCharacter(validPc({ color: "neon" }))).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects invalid icon", () => {
|
||||
expect(rehydratePlayerCharacter(validPc({ icon: "rocket" }))).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects level 0", () => {
|
||||
expect(rehydratePlayerCharacter(validPc({ level: 0 }))).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects level 21", () => {
|
||||
expect(rehydratePlayerCharacter(validPc({ level: 21 }))).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects float level", () => {
|
||||
expect(rehydratePlayerCharacter(validPc({ level: 3.5 }))).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects string level", () => {
|
||||
expect(rehydratePlayerCharacter(validPc({ level: "5" }))).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user