Files
initiative/apps/web/src/persistence/__tests__/encounter-storage.test.ts
T
Lukas 1de00e3d8e
CI / check (push) Successful in 1m16s
CI / build-image (push) Has been skipped
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>
2026-03-28 11:12:41 +01:00

158 lines
4.1 KiB
TypeScript

import {
combatantId,
createEncounter,
isDomainError,
} from "@initiative/domain";
import { beforeEach, describe, expect, it } from "vitest";
import { loadEncounter, saveEncounter } from "../encounter-storage.js";
const STORAGE_KEY = "initiative:encounter";
function makeEncounter() {
const result = createEncounter(
[
{ id: combatantId("1"), name: "Aria", initiative: 18 },
{ id: combatantId("c-2"), name: "Brak", initiative: 12 },
{ id: combatantId("3"), name: "Cael" },
],
1,
3,
);
if (isDomainError(result)) throw new Error("Failed to create test encounter");
return result;
}
function createMockLocalStorage() {
const store = new Map<string, string>();
return {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => store.set(key, value),
removeItem: (key: string) => store.delete(key),
clear: () => store.clear(),
get length() {
return store.size;
},
key: (_index: number) => null,
} as Storage;
}
beforeEach(() => {
Object.defineProperty(globalThis, "localStorage", {
value: createMockLocalStorage(),
writable: true,
configurable: true,
});
});
describe("saveEncounter", () => {
it("writes encounter to localStorage", () => {
const encounter = makeEncounter();
saveEncounter(encounter);
expect(localStorage.getItem(STORAGE_KEY)).not.toBeNull();
});
});
describe("loadEncounter", () => {
it("returns null when localStorage is empty", () => {
expect(loadEncounter()).toBeNull();
});
it("round-trip save/load preserves encounter state", () => {
const encounter = makeEncounter();
saveEncounter(encounter);
const loaded = loadEncounter();
expect(loaded).not.toBeNull();
expect(loaded?.combatants).toHaveLength(3);
expect(loaded?.activeIndex).toBe(1);
expect(loaded?.roundNumber).toBe(3);
});
it("round-trip preserves combatant IDs, names, and initiative values", () => {
const encounter = makeEncounter();
saveEncounter(encounter);
const loaded = loadEncounter();
expect(loaded).not.toBeNull();
expect(loaded?.combatants[0].id).toBe("1");
expect(loaded?.combatants[0].name).toBe("Aria");
expect(loaded?.combatants[0].initiative).toBe(18);
expect(loaded?.combatants[1].id).toBe("c-2");
expect(loaded?.combatants[1].name).toBe("Brak");
expect(loaded?.combatants[1].initiative).toBe(12);
expect(loaded?.combatants[2].id).toBe("3");
expect(loaded?.combatants[2].name).toBe("Cael");
expect(loaded?.combatants[2].initiative).toBeUndefined();
});
it("returns null for non-JSON strings", () => {
localStorage.setItem(STORAGE_KEY, "not json at all");
expect(loadEncounter()).toBeNull();
});
it("returns null for JSON missing required fields", () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ foo: "bar" }));
expect(loadEncounter()).toBeNull();
});
it("returns empty encounter for cleared state (empty combatants)", () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ combatants: [], activeIndex: 0, roundNumber: 1 }),
);
const result = loadEncounter();
expect(result).toEqual({
combatants: [],
activeIndex: 0,
roundNumber: 1,
});
});
it("returns null for out-of-bounds activeIndex", () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
combatants: [{ id: "1", name: "Aria" }],
activeIndex: 5,
roundNumber: 1,
}),
);
expect(loadEncounter()).toBeNull();
});
it("returns null when combatant has invalid required fields", () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
combatants: [{ id: "1" }],
activeIndex: 0,
roundNumber: 1,
}),
);
expect(loadEncounter()).toBeNull();
});
it("saving after modifications persists the latest state", () => {
const encounter = makeEncounter();
saveEncounter(encounter);
const modified = createEncounter(
[
{ id: combatantId("1"), name: "Aria", initiative: 18 },
{ id: combatantId("c-2"), name: "Brak", initiative: 12 },
],
0,
5,
);
if (isDomainError(modified)) throw new Error("unreachable");
saveEncounter(modified);
const loaded = loadEncounter();
expect(loaded?.combatants).toHaveLength(2);
expect(loaded?.roundNumber).toBe(5);
});
});