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(); 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(); }); // 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", () => { localStorage.setItem( STORAGE_KEY, JSON.stringify({ combatants: [{ id: "1" }], activeIndex: 0, roundNumber: 1, }), ); 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); 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); }); });