// Vitest coverage for the per-game runes store // (`lib/game-state.svelte.ts`). The test stubs `lobby.my.games.list` // and `user.games.report` at module level and drives the store // through its lifecycle: init → ready → error → setTurn → wrap-mode // persistence. import "@testing-library/jest-dom/vitest"; import "fake-indexeddb/auto"; import { afterEach, beforeEach, describe, expect, test, vi, } from "vitest"; import { Builder } from "flatbuffers"; import { GameStateStore } 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 { UUID } from "../src/proto/galaxy/fbs/common"; import { ByteBuffer } from "flatbuffers"; import { GameReportRequest, LocalPlanet, Report, ShipClass, } from "../src/proto/galaxy/fbs/report"; function decodeRequestedTurn(payload: Uint8Array): number { const req = GameReportRequest.getRootAsGameReportRequest( new ByteBuffer(payload), ); return req.turn(); } 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-game-state-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): { gameId: string; gameName: string; gameType: string; status: string; ownerUserId: string; minPlayers: number; maxPlayers: number; enrollmentEndsAt: Date; createdAt: Date; updatedAt: Date; 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, }; } interface PlanetFixture { number: number; name: string; x: number; y: number; } interface ShipClassFixture { name: string; drive?: number; armament?: number; weapons?: number; shields?: number; cargo?: number; } function buildReportPayload(opts: { turn: number; width?: number; height?: number; planets?: PlanetFixture[]; shipClasses?: ShipClassFixture[]; }): Uint8Array { const builder = new Builder(256); const planetOffsets = (opts.planets ?? []).map((planet) => { const name = builder.createString(planet.name); LocalPlanet.startLocalPlanet(builder); LocalPlanet.addNumber(builder, BigInt(planet.number)); LocalPlanet.addX(builder, planet.x); LocalPlanet.addY(builder, planet.y); LocalPlanet.addName(builder, name); LocalPlanet.addSize(builder, 10); LocalPlanet.addResources(builder, 0.5); return LocalPlanet.endLocalPlanet(builder); }); const shipClassOffsets = (opts.shipClasses ?? []).map((cls) => { const name = builder.createString(cls.name); ShipClass.startShipClass(builder); ShipClass.addName(builder, name); ShipClass.addDrive(builder, cls.drive ?? 0); ShipClass.addArmament(builder, BigInt(cls.armament ?? 0)); ShipClass.addWeapons(builder, cls.weapons ?? 0); ShipClass.addShields(builder, cls.shields ?? 0); ShipClass.addCargo(builder, cls.cargo ?? 0); return ShipClass.endShipClass(builder); }); const localPlanetVec = planetOffsets.length === 0 ? null : Report.createLocalPlanetVector(builder, planetOffsets); const localShipClassVec = shipClassOffsets.length === 0 ? null : Report.createLocalShipClassVector(builder, shipClassOffsets); Report.startReport(builder); Report.addTurn(builder, BigInt(opts.turn)); Report.addWidth(builder, opts.width ?? 4000); Report.addHeight(builder, opts.height ?? 4000); Report.addPlanetCount(builder, planetOffsets.length); if (localPlanetVec !== null) { Report.addLocalPlanet(builder, localPlanetVec); } if (localShipClassVec !== null) { Report.addLocalShipClass(builder, localShipClassVec); } const reportOff = Report.endReport(builder); builder.finish(reportOff); return builder.asUint8Array(); } function makeFakeClient( executeCommand: ( messageType: string, payload: Uint8Array, ) => Promise<{ resultCode: string; payloadBytes: Uint8Array }>, ): GalaxyClient { return { executeCommand } as unknown as GalaxyClient; } describe("GameStateStore", () => { test("init transitions through loading and ready when both calls succeed", async () => { listMyGamesSpy.mockResolvedValue([makeGameSummary(7)]); const calls: Array<{ messageType: string; payload: Uint8Array }> = []; const client = makeFakeClient(async (messageType, payload) => { calls.push({ messageType, payload }); return { resultCode: "ok", payloadBytes: buildReportPayload({ turn: 7, planets: [{ number: 1, name: "Home", x: 100, y: 100 }], }), }; }); const store = new GameStateStore(); expect(store.status).toBe("idle"); await store.init({ client, cache, gameId: GAME_ID }); expect(listMyGamesSpy).toHaveBeenCalledTimes(1); expect(calls.length).toBe(1); expect(calls[0]?.messageType).toBe("user.games.report"); expect(store.status).toBe("ready"); expect(store.report).not.toBeNull(); expect(store.report?.turn).toBe(7); expect(store.report?.planets.length).toBe(1); expect(store.report?.planets[0]?.kind).toBe("local"); store.dispose(); }); test("init surfaces an error when the game is missing from lobby", async () => { listMyGamesSpy.mockResolvedValue([makeGameSummary(0).gameId === "other" ? null : makeGameSummary(0)].filter(Boolean)); // Replace the helper above's awkward filter with an explicit // mismatched id so the lookup miss is unambiguous. listMyGamesSpy.mockResolvedValue([ { ...makeGameSummary(2), gameId: "different-game-id" }, ]); const client = makeFakeClient(async () => ({ resultCode: "ok", payloadBytes: buildReportPayload({ turn: 0 }), })); const store = new GameStateStore(); await store.init({ client, cache, gameId: GAME_ID }); expect(store.status).toBe("error"); expect(store.error).toMatch(/not in your list/); expect(store.report).toBeNull(); store.dispose(); }); test("init surfaces error when user.games.report returns a non-ok result", async () => { listMyGamesSpy.mockResolvedValue([makeGameSummary(0)]); const client = makeFakeClient(async () => ({ resultCode: "forbidden", payloadBytes: new TextEncoder().encode( JSON.stringify({ code: "forbidden", message: "no membership" }), ), })); const store = new GameStateStore(); await store.init({ client, cache, gameId: GAME_ID }); expect(store.status).toBe("error"); expect(store.error).toMatch(/no membership/); store.dispose(); }); test("setTurn loads a different turn snapshot", async () => { listMyGamesSpy.mockResolvedValue([makeGameSummary(3)]); const turns: number[] = []; const client = makeFakeClient(async () => { const turn = turns.length === 0 ? 3 : 1; turns.push(turn); return { resultCode: "ok", payloadBytes: buildReportPayload({ turn }), }; }); const store = new GameStateStore(); await store.init({ client, cache, gameId: GAME_ID }); expect(store.report?.turn).toBe(3); await store.setTurn(1); expect(store.status).toBe("ready"); expect(store.report?.turn).toBe(1); store.dispose(); }); test("setWrapMode persists across instances through Cache", async () => { listMyGamesSpy.mockResolvedValue([makeGameSummary(0)]); const client = makeFakeClient(async () => ({ resultCode: "ok", payloadBytes: buildReportPayload({ turn: 0 }), })); const a = new GameStateStore(); await a.init({ client, cache, gameId: GAME_ID }); expect(a.wrapMode).toBe("torus"); await a.setWrapMode("no-wrap"); expect(a.wrapMode).toBe("no-wrap"); a.dispose(); const b = new GameStateStore(); await b.init({ client, cache, gameId: GAME_ID }); expect(b.wrapMode).toBe("no-wrap"); b.dispose(); }); test("failBootstrap moves the store into the error state with the given message", () => { const store = new GameStateStore(); store.failBootstrap("device session missing"); expect(store.status).toBe("error"); expect(store.error).toBe("device session missing"); }); test("setGame opens last-viewed turn and surfaces pendingTurn when server is ahead", async () => { await cache.put("game-prefs", `${GAME_ID}/last-viewed-turn`, 4); listMyGamesSpy.mockResolvedValue([makeGameSummary(7)]); const requestedTurns: number[] = []; const client = makeFakeClient(async (_messageType, payload) => { const turn = decodeRequestedTurn(payload); requestedTurns.push(turn); return { resultCode: "ok", payloadBytes: buildReportPayload({ turn }), }; }); const store = new GameStateStore(); await store.init({ client, cache, gameId: GAME_ID }); expect(requestedTurns).toEqual([4]); expect(store.report?.turn).toBe(4); expect(store.currentTurn).toBe(4); expect(store.pendingTurn).toBe(7); store.dispose(); }); test("markPendingTurn records server-side advance without a network call", async () => { listMyGamesSpy.mockResolvedValue([makeGameSummary(3)]); let calls = 0; const client = makeFakeClient(async () => { calls += 1; return { resultCode: "ok", payloadBytes: buildReportPayload({ turn: 3 }), }; }); const store = new GameStateStore(); await store.init({ client, cache, gameId: GAME_ID }); expect(store.pendingTurn).toBeNull(); const before = calls; store.markPendingTurn(4); expect(store.pendingTurn).toBe(4); store.markPendingTurn(3); // not strictly ahead → ignored expect(store.pendingTurn).toBe(4); store.markPendingTurn(6); expect(store.pendingTurn).toBe(6); store.markPendingTurn(5); // not ahead of pending=6 → ignored expect(store.pendingTurn).toBe(6); expect(calls).toBe(before); store.dispose(); }); test("advanceToPending refetches and clears the pending indicator", async () => { await cache.put("game-prefs", `${GAME_ID}/last-viewed-turn`, 2); const summaries = [makeGameSummary(5), makeGameSummary(5)]; let listCalls = 0; listMyGamesSpy.mockImplementation(() => { const out = summaries[listCalls] ?? summaries.at(-1)!; listCalls += 1; return Promise.resolve([out]); }); const requestedTurns: number[] = []; const client = makeFakeClient(async (_messageType, payload) => { const turn = decodeRequestedTurn(payload); requestedTurns.push(turn); return { resultCode: "ok", payloadBytes: buildReportPayload({ turn }), }; }); const store = new GameStateStore(); await store.init({ client, cache, gameId: GAME_ID }); expect(store.currentTurn).toBe(2); expect(store.pendingTurn).toBe(5); await store.advanceToPending(); expect(store.currentTurn).toBe(5); expect(store.pendingTurn).toBeNull(); expect(requestedTurns).toEqual([2, 5]); store.dispose(); }); test("decodeReport surfaces the localShipClass projection with full attributes", async () => { listMyGamesSpy.mockResolvedValue([makeGameSummary(1)]); const client = makeFakeClient(async () => ({ resultCode: "ok", payloadBytes: buildReportPayload({ turn: 1, planets: [{ number: 1, name: "Earth", x: 100, y: 100 }], shipClasses: [ { name: "Scout", drive: 1, armament: 0, weapons: 0, shields: 0, cargo: 0, }, { name: "Destroyer", drive: 6, armament: 1, weapons: 8, shields: 4, cargo: 0, }, ], }), })); const store = new GameStateStore(); await store.init({ client, cache, gameId: GAME_ID }); expect(store.report?.localShipClass).toEqual([ { name: "Scout", drive: 1, armament: 0, weapons: 0, shields: 0, cargo: 0 }, { name: "Destroyer", drive: 6, armament: 1, weapons: 8, shields: 4, cargo: 0, }, ]); store.dispose(); }); });