Move entity rehydration to domain layer, fix tempHp gap
All checks were successful
CI / check (push) Successful in 1m16s
CI / build-image (push) Has been skipped

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:
Lukas
2026-03-28 11:12:41 +01:00
parent f4fb69dbc7
commit 1de00e3d8e
12 changed files with 799 additions and 426 deletions

View File

@@ -122,64 +122,7 @@ describe("loadEncounter", () => {
expect(loadEncounter()).toBeNull();
});
// US3: Corrupt data scenarios
it("returns null for non-object JSON (string)", () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify("hello"));
expect(loadEncounter()).toBeNull();
});
it("returns null for non-object JSON (number)", () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(42));
expect(loadEncounter()).toBeNull();
});
it("returns null for non-object JSON (array)", () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify([1, 2, 3]));
expect(loadEncounter()).toBeNull();
});
it("returns null for non-object JSON (null)", () => {
localStorage.setItem(STORAGE_KEY, "null");
expect(loadEncounter()).toBeNull();
});
it("returns null when combatants is a string instead of array", () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
combatants: "not-array",
activeIndex: 0,
roundNumber: 1,
}),
);
expect(loadEncounter()).toBeNull();
});
it("returns null when activeIndex is a string instead of number", () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
combatants: [{ id: "1", name: "Aria" }],
activeIndex: "zero",
roundNumber: 1,
}),
);
expect(loadEncounter()).toBeNull();
});
it("returns null when combatant entry is missing id", () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
combatants: [{ name: "Aria" }],
activeIndex: 0,
roundNumber: 1,
}),
);
expect(loadEncounter()).toBeNull();
});
it("returns null when combatant entry is missing name", () => {
it("returns null when combatant has invalid required fields", () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
@@ -191,88 +134,6 @@ describe("loadEncounter", () => {
expect(loadEncounter()).toBeNull();
});
it("returns null for negative roundNumber", () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
combatants: [{ id: "1", name: "Aria" }],
activeIndex: 0,
roundNumber: -1,
}),
);
expect(loadEncounter()).toBeNull();
});
it("returns empty encounter for zero combatants (cleared state)", () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ combatants: [], activeIndex: 0, roundNumber: 1 }),
);
const result = loadEncounter();
expect(result).toEqual({
combatants: [],
activeIndex: 0,
roundNumber: 1,
});
});
it("round-trip preserves combatant AC value", () => {
const result = createEncounter(
[{ id: combatantId("1"), name: "Aria", ac: 18 }],
0,
1,
);
if (isDomainError(result)) throw new Error("unreachable");
saveEncounter(result);
const loaded = loadEncounter();
expect(loaded?.combatants[0].ac).toBe(18);
});
it("round-trip preserves combatant without AC", () => {
const result = createEncounter(
[{ id: combatantId("1"), name: "Aria" }],
0,
1,
);
if (isDomainError(result)) throw new Error("unreachable");
saveEncounter(result);
const loaded = loadEncounter();
expect(loaded?.combatants[0].ac).toBeUndefined();
});
it("discards invalid AC values during rehydration", () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
combatants: [
{ id: "1", name: "Neg", ac: -1 },
{ id: "2", name: "Float", ac: 3.5 },
{ id: "3", name: "Str", ac: "high" },
],
activeIndex: 0,
roundNumber: 1,
}),
);
const loaded = loadEncounter();
expect(loaded).not.toBeNull();
expect(loaded?.combatants[0].ac).toBeUndefined();
expect(loaded?.combatants[1].ac).toBeUndefined();
expect(loaded?.combatants[2].ac).toBeUndefined();
});
it("preserves AC of 0 during rehydration", () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
combatants: [{ id: "1", name: "Aria", ac: 0 }],
activeIndex: 0,
roundNumber: 1,
}),
);
const loaded = loadEncounter();
expect(loaded?.combatants[0].ac).toBe(0);
});
it("saving after modifications persists the latest state", () => {
const encounter = makeEncounter();
saveEncounter(encounter);