// Phase 29 persistence + new-turn reset coverage for the // `GameStateStore.mapToggles` rune. The tests drive the store // against a real `fake-indexeddb`-backed Cache (the same harness // `game-state.test.ts` uses) so the JSON-blob round-trip and the // stale-`lastResetTurn` branch are exercised end-to-end. import "@testing-library/jest-dom/vitest"; import "fake-indexeddb/auto"; import { afterEach, beforeEach, describe, expect, test, vi, } from "vitest"; import { Builder } from "flatbuffers"; import { DEFAULT_MAP_TOGGLES, GameStateStore, type MapToggles, } from "../src/lib/game-state.svelte"; import type { GalaxyClient } from "../src/api/galaxy-client"; import type { Cache } from "../src/platform/store/index"; import { IDBCache } from "../src/platform/store/idb-cache"; import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb"; import type { IDBPDatabase } from "idb"; import { Report } from "../src/proto/galaxy/fbs/report"; const listMyGamesSpy = vi.fn(); vi.mock("../src/api/lobby", async () => { const actual = await vi.importActual( "../src/api/lobby", ); return { ...actual, listMyGames: (...args: unknown[]) => listMyGamesSpy(...args), }; }); let db: IDBPDatabase; let dbName: string; let cache: Cache; beforeEach(async () => { dbName = `galaxy-map-toggles-test-${crypto.randomUUID()}`; db = await openGalaxyDB(dbName); cache = new IDBCache(db); listMyGamesSpy.mockReset(); }); afterEach(async () => { db.close(); await new Promise((resolve) => { const req = indexedDB.deleteDatabase(dbName); req.onsuccess = () => resolve(); req.onerror = () => resolve(); req.onblocked = () => resolve(); }); }); const GAME_ID = "11111111-2222-3333-4444-555555555555"; function makeGameSummary(currentTurn: number) { return { gameId: GAME_ID, gameName: "Test Game", gameType: "private", status: "running", ownerUserId: "owner-1", minPlayers: 2, maxPlayers: 8, enrollmentEndsAt: new Date(), createdAt: new Date(), updatedAt: new Date(), currentTurn, }; } function buildReportPayload(turn: number): Uint8Array { const builder = new Builder(64); Report.startReport(builder); Report.addTurn(builder, BigInt(turn)); Report.addWidth(builder, 4000); Report.addHeight(builder, 4000); Report.addPlanetCount(builder, 0); builder.finish(Report.endReport(builder)); return builder.asUint8Array(); } function makeFakeClient(turn: number): GalaxyClient { return { executeCommand: async () => ({ resultCode: "ok", payloadBytes: buildReportPayload(turn), }), } as unknown as GalaxyClient; } describe("GameStateStore.mapToggles persistence", () => { test("defaults apply when no blob is persisted", async () => { listMyGamesSpy.mockResolvedValue([makeGameSummary(3)]); const store = new GameStateStore(); await store.init({ client: makeFakeClient(3), cache, gameId: GAME_ID }); expect(store.mapToggles).toEqual(DEFAULT_MAP_TOGGLES); store.dispose(); }); test("setMapToggle round-trips through Cache across instances", async () => { listMyGamesSpy.mockResolvedValue([makeGameSummary(3)]); const a = new GameStateStore(); await a.init({ client: makeFakeClient(3), cache, gameId: GAME_ID }); await a.setMapToggle("hyperspaceGroups", false); await a.setMapToggle("battleMarkers", false); await a.setMapToggle("visibleHyperspace", false); a.dispose(); listMyGamesSpy.mockResolvedValue([makeGameSummary(3)]); const b = new GameStateStore(); await b.init({ client: makeFakeClient(3), cache, gameId: GAME_ID }); expect(b.mapToggles.hyperspaceGroups).toBe(false); expect(b.mapToggles.battleMarkers).toBe(false); expect(b.mapToggles.visibleHyperspace).toBe(false); // Untouched flags retain defaults. expect(b.mapToggles.bombingMarkers).toBe(true); b.dispose(); }); test("missing fields in a persisted blob fall back to defaults", async () => { // Simulate an older client persisting a partial blob — only // `hyperspaceGroups` is set, every other field must inherit // the current default. await cache.put("game-map-toggles", GAME_ID, { toggles: { hyperspaceGroups: false } as Partial, lastResetTurn: 5, }); listMyGamesSpy.mockResolvedValue([makeGameSummary(5)]); const store = new GameStateStore(); await store.init({ client: makeFakeClient(5), cache, gameId: GAME_ID }); expect(store.mapToggles.hyperspaceGroups).toBe(false); expect(store.mapToggles.battleMarkers).toBe(true); expect(store.mapToggles.bombingMarkers).toBe(true); expect(store.mapToggles.visibleHyperspace).toBe(true); store.dispose(); }); }); describe("GameStateStore.mapToggles new-turn reset", () => { test("a server turn newer than lastResetTurn resets every flag", async () => { await cache.put("game-map-toggles", GAME_ID, { toggles: { ...DEFAULT_MAP_TOGGLES, hyperspaceGroups: false, battleMarkers: false, visibleHyperspace: false, }, lastResetTurn: 4, }); listMyGamesSpy.mockResolvedValue([makeGameSummary(5)]); const store = new GameStateStore(); await store.init({ client: makeFakeClient(5), cache, gameId: GAME_ID }); expect(store.mapToggles).toEqual(DEFAULT_MAP_TOGGLES); // The reset write back to cache so a subsequent reload sees the // fresh state. const persisted = await cache.get<{ toggles: MapToggles; lastResetTurn: number; }>("game-map-toggles", GAME_ID); expect(persisted?.toggles).toEqual(DEFAULT_MAP_TOGGLES); expect(persisted?.lastResetTurn).toBe(5); store.dispose(); }); test("matching lastResetTurn restores persisted overrides", async () => { await cache.put("game-map-toggles", GAME_ID, { toggles: { ...DEFAULT_MAP_TOGGLES, hyperspaceGroups: false }, lastResetTurn: 5, }); listMyGamesSpy.mockResolvedValue([makeGameSummary(5)]); const store = new GameStateStore(); await store.init({ client: makeFakeClient(5), cache, gameId: GAME_ID }); expect(store.mapToggles.hyperspaceGroups).toBe(false); store.dispose(); }); test("advanceToPending resets toggles after jumping onto the new turn", async () => { await cache.put("game-prefs", `${GAME_ID}/last-viewed-turn`, 4); await cache.put("game-map-toggles", GAME_ID, { toggles: DEFAULT_MAP_TOGGLES, lastResetTurn: 4, }); // First setGame opens the user on turn 4 with currentTurn=5 // (last-viewed-turn bookmark < currentTurn). The new-turn // reset path fires immediately because lastResetTurn=4 < 5. listMyGamesSpy.mockResolvedValue([makeGameSummary(5)]); const store = new GameStateStore(); await store.init({ client: makeFakeClient(4), cache, gameId: GAME_ID }); // Drift the toggles after the setGame reset so we can verify // that advanceToPending resets them again on the user's // explicit jump onto turn 5. await store.setMapToggle("battleMarkers", false); expect(store.mapToggles.battleMarkers).toBe(false); expect(store.pendingTurn).toBe(5); // User clicks "Return to current turn" — the store fetches the // turn-5 report and resets toggles. await store.advanceToPending(); expect(store.mapToggles).toEqual(DEFAULT_MAP_TOGGLES); expect(store.pendingTurn).toBeNull(); store.dispose(); }); });