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:
@@ -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);
|
||||
|
||||
@@ -90,134 +90,7 @@ describe("player-character-storage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("per-character validation", () => {
|
||||
it("discards character with missing name", () => {
|
||||
mockStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
{ id: "pc-1", ac: 10, maxHp: 50, color: "blue", icon: "sword" },
|
||||
]),
|
||||
);
|
||||
expect(loadPlayerCharacters()).toEqual([]);
|
||||
});
|
||||
|
||||
it("discards character with empty name", () => {
|
||||
mockStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "pc-1",
|
||||
name: "",
|
||||
ac: 10,
|
||||
maxHp: 50,
|
||||
color: "blue",
|
||||
icon: "sword",
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(loadPlayerCharacters()).toEqual([]);
|
||||
});
|
||||
|
||||
it("discards character with invalid color", () => {
|
||||
mockStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "pc-1",
|
||||
name: "Test",
|
||||
ac: 10,
|
||||
maxHp: 50,
|
||||
color: "neon",
|
||||
icon: "sword",
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(loadPlayerCharacters()).toEqual([]);
|
||||
});
|
||||
|
||||
it("discards character with invalid icon", () => {
|
||||
mockStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "pc-1",
|
||||
name: "Test",
|
||||
ac: 10,
|
||||
maxHp: 50,
|
||||
color: "blue",
|
||||
icon: "banana",
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(loadPlayerCharacters()).toEqual([]);
|
||||
});
|
||||
|
||||
it("discards character with negative AC", () => {
|
||||
mockStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "pc-1",
|
||||
name: "Test",
|
||||
ac: -1,
|
||||
maxHp: 50,
|
||||
color: "blue",
|
||||
icon: "sword",
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(loadPlayerCharacters()).toEqual([]);
|
||||
});
|
||||
|
||||
it("discards character with maxHp of 0", () => {
|
||||
mockStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "pc-1",
|
||||
name: "Test",
|
||||
ac: 10,
|
||||
maxHp: 0,
|
||||
color: "blue",
|
||||
icon: "sword",
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(loadPlayerCharacters()).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves level through save/load round-trip", () => {
|
||||
const pc = makePC({ level: 5 });
|
||||
savePlayerCharacters([pc]);
|
||||
const loaded = loadPlayerCharacters();
|
||||
expect(loaded[0].level).toBe(5);
|
||||
});
|
||||
|
||||
it("preserves undefined level through save/load round-trip", () => {
|
||||
const pc = makePC();
|
||||
savePlayerCharacters([pc]);
|
||||
const loaded = loadPlayerCharacters();
|
||||
expect(loaded[0].level).toBeUndefined();
|
||||
});
|
||||
|
||||
it("discards character with invalid level", () => {
|
||||
mockStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "pc-1",
|
||||
name: "Test",
|
||||
ac: 10,
|
||||
maxHp: 50,
|
||||
color: "blue",
|
||||
icon: "sword",
|
||||
level: 25,
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(loadPlayerCharacters()).toEqual([]);
|
||||
});
|
||||
|
||||
describe("delegation to domain rehydration", () => {
|
||||
it("keeps valid characters and discards invalid ones", () => {
|
||||
mockStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import {
|
||||
type ConditionId,
|
||||
combatantId,
|
||||
type Combatant,
|
||||
createEncounter,
|
||||
creatureId,
|
||||
type Encounter,
|
||||
isDomainError,
|
||||
playerCharacterId,
|
||||
VALID_CONDITION_IDS,
|
||||
VALID_PLAYER_COLORS,
|
||||
VALID_PLAYER_ICONS,
|
||||
rehydrateCombatant,
|
||||
} from "@initiative/domain";
|
||||
|
||||
const STORAGE_KEY = "initiative:encounter";
|
||||
@@ -21,93 +16,6 @@ export function saveEncounter(encounter: Encounter): void {
|
||||
}
|
||||
}
|
||||
|
||||
function validateAc(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isInteger(value) && value >= 0
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function validateConditions(value: unknown): ConditionId[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
const valid = value.filter(
|
||||
(v): v is ConditionId =>
|
||||
typeof v === "string" && VALID_CONDITION_IDS.has(v),
|
||||
);
|
||||
return valid.length > 0 ? valid : undefined;
|
||||
}
|
||||
|
||||
function validateCreatureId(value: unknown) {
|
||||
return typeof value === "string" && value.length > 0
|
||||
? creatureId(value)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function validateHp(
|
||||
rawMaxHp: unknown,
|
||||
rawCurrentHp: unknown,
|
||||
): { maxHp: number; currentHp: number } | undefined {
|
||||
if (
|
||||
typeof rawMaxHp !== "number" ||
|
||||
!Number.isInteger(rawMaxHp) ||
|
||||
rawMaxHp < 1
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const validCurrentHp =
|
||||
typeof rawCurrentHp === "number" &&
|
||||
Number.isInteger(rawCurrentHp) &&
|
||||
rawCurrentHp >= 0 &&
|
||||
rawCurrentHp <= rawMaxHp;
|
||||
return {
|
||||
maxHp: rawMaxHp,
|
||||
currentHp: validCurrentHp ? rawCurrentHp : rawMaxHp,
|
||||
};
|
||||
}
|
||||
|
||||
function rehydrateCombatant(c: unknown) {
|
||||
const entry = c as Record<string, unknown>;
|
||||
const base = {
|
||||
id: combatantId(entry.id as string),
|
||||
name: entry.name as string,
|
||||
initiative:
|
||||
typeof entry.initiative === "number" ? entry.initiative : undefined,
|
||||
};
|
||||
|
||||
const color =
|
||||
typeof entry.color === "string" && VALID_PLAYER_COLORS.has(entry.color)
|
||||
? entry.color
|
||||
: undefined;
|
||||
const icon =
|
||||
typeof entry.icon === "string" && VALID_PLAYER_ICONS.has(entry.icon)
|
||||
? entry.icon
|
||||
: undefined;
|
||||
const pcId =
|
||||
typeof entry.playerCharacterId === "string" &&
|
||||
entry.playerCharacterId.length > 0
|
||||
? playerCharacterId(entry.playerCharacterId)
|
||||
: undefined;
|
||||
|
||||
const shared = {
|
||||
...base,
|
||||
ac: validateAc(entry.ac),
|
||||
conditions: validateConditions(entry.conditions),
|
||||
isConcentrating: entry.isConcentrating === true ? true : undefined,
|
||||
creatureId: validateCreatureId(entry.creatureId),
|
||||
color,
|
||||
icon,
|
||||
playerCharacterId: pcId,
|
||||
};
|
||||
|
||||
const hp = validateHp(entry.maxHp, entry.currentHp);
|
||||
return hp ? { ...shared, ...hp } : shared;
|
||||
}
|
||||
|
||||
function isValidCombatantEntry(c: unknown): boolean {
|
||||
if (typeof c !== "object" || c === null || Array.isArray(c)) return false;
|
||||
const entry = c as Record<string, unknown>;
|
||||
return typeof entry.id === "string" && typeof entry.name === "string";
|
||||
}
|
||||
|
||||
export function rehydrateEncounter(parsed: unknown): Encounter | null {
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
||||
return null;
|
||||
@@ -129,14 +37,21 @@ export function rehydrateEncounter(parsed: unknown): Encounter | null {
|
||||
};
|
||||
}
|
||||
|
||||
if (!combatants.every(isValidCombatantEntry)) return null;
|
||||
const rehydrated: Combatant[] = [];
|
||||
for (const c of combatants) {
|
||||
const result = rehydrateCombatant(c);
|
||||
if (result === null) return null;
|
||||
rehydrated.push(result);
|
||||
}
|
||||
|
||||
const rehydrated = combatants.map(rehydrateCombatant);
|
||||
const encounter = createEncounter(
|
||||
rehydrated,
|
||||
obj.activeIndex,
|
||||
obj.roundNumber,
|
||||
);
|
||||
if (isDomainError(encounter)) return null;
|
||||
|
||||
const result = createEncounter(rehydrated, obj.activeIndex, obj.roundNumber);
|
||||
if (isDomainError(result)) return null;
|
||||
|
||||
return result;
|
||||
return encounter;
|
||||
}
|
||||
|
||||
export function loadEncounter(): Encounter | null {
|
||||
|
||||
@@ -4,8 +4,8 @@ import type {
|
||||
PlayerCharacter,
|
||||
UndoRedoState,
|
||||
} from "@initiative/domain";
|
||||
import { rehydratePlayerCharacter } from "@initiative/domain";
|
||||
import { rehydrateEncounter } from "./encounter-storage.js";
|
||||
import { rehydrateCharacter } from "./player-character-storage.js";
|
||||
|
||||
function rehydrateStack(raw: unknown[]): Encounter[] {
|
||||
const result: Encounter[] = [];
|
||||
@@ -21,7 +21,7 @@ function rehydrateStack(raw: unknown[]): Encounter[] {
|
||||
function rehydrateCharacters(raw: unknown[]): PlayerCharacter[] {
|
||||
const result: PlayerCharacter[] = [];
|
||||
for (const entry of raw) {
|
||||
const rehydrated = rehydrateCharacter(entry);
|
||||
const rehydrated = rehydratePlayerCharacter(entry);
|
||||
if (rehydrated !== null) {
|
||||
result.push(rehydrated);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import type { PlayerCharacter } from "@initiative/domain";
|
||||
import {
|
||||
playerCharacterId,
|
||||
VALID_PLAYER_COLORS,
|
||||
VALID_PLAYER_ICONS,
|
||||
} from "@initiative/domain";
|
||||
import { rehydratePlayerCharacter } from "@initiative/domain";
|
||||
|
||||
const STORAGE_KEY = "initiative:player-characters";
|
||||
|
||||
@@ -15,55 +11,6 @@ export function savePlayerCharacters(characters: PlayerCharacter[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
function isValidOptionalMember(
|
||||
value: unknown,
|
||||
valid: ReadonlySet<string>,
|
||||
): boolean {
|
||||
return value === undefined || (typeof value === "string" && valid.has(value));
|
||||
}
|
||||
|
||||
export function rehydrateCharacter(raw: unknown): PlayerCharacter | null {
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
||||
return null;
|
||||
const entry = raw as Record<string, unknown>;
|
||||
|
||||
if (typeof entry.id !== "string" || entry.id.length === 0) return null;
|
||||
if (typeof entry.name !== "string" || entry.name.trim().length === 0)
|
||||
return null;
|
||||
if (
|
||||
typeof entry.ac !== "number" ||
|
||||
!Number.isInteger(entry.ac) ||
|
||||
entry.ac < 0
|
||||
)
|
||||
return null;
|
||||
if (
|
||||
typeof entry.maxHp !== "number" ||
|
||||
!Number.isInteger(entry.maxHp) ||
|
||||
entry.maxHp < 1
|
||||
)
|
||||
return null;
|
||||
if (!isValidOptionalMember(entry.color, VALID_PLAYER_COLORS)) return null;
|
||||
if (!isValidOptionalMember(entry.icon, VALID_PLAYER_ICONS)) return null;
|
||||
if (
|
||||
entry.level !== undefined &&
|
||||
(typeof entry.level !== "number" ||
|
||||
!Number.isInteger(entry.level) ||
|
||||
entry.level < 1 ||
|
||||
entry.level > 20)
|
||||
)
|
||||
return null;
|
||||
|
||||
return {
|
||||
id: playerCharacterId(entry.id),
|
||||
name: entry.name,
|
||||
ac: entry.ac,
|
||||
maxHp: entry.maxHp,
|
||||
color: entry.color as PlayerCharacter["color"],
|
||||
icon: entry.icon as PlayerCharacter["icon"],
|
||||
level: entry.level,
|
||||
};
|
||||
}
|
||||
|
||||
export function loadPlayerCharacters(): PlayerCharacter[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
@@ -74,7 +21,7 @@ export function loadPlayerCharacters(): PlayerCharacter[] {
|
||||
|
||||
const characters: PlayerCharacter[] = [];
|
||||
for (const item of parsed) {
|
||||
const pc = rehydrateCharacter(item);
|
||||
const pc = rehydratePlayerCharacter(item);
|
||||
if (pc !== null) {
|
||||
characters.push(pc);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user