37580b7699
Tests · UI / test (push) Waiting to run
The Phase 29 fog overlay rendered as a handful of random arc segments instead of a clean union of holes around LOCAL planets — Pixi v8's `Graphics.cut()` does not reliably subtract multiple overlapping circles from a base path. Replaced the cut-based approach with a layered overpaint: a fog-tinted rectangle fills the world, then opaque background- coloured circles are painted on top for every visibility circle. The natural rendering order unions overlapping circles for free — no geometry, no `cut()` quirks, one extra fill per circle. Renamed the toggle from `visibilityFog` to `visibleHyperspace` across the store, i18n strings, popover, tests, and docs. The overlay still implements the visual "fog" effect at the renderer level (FOG_COLOR, setVisibilityFog, getMapFog); the toggle is named after the player-facing concept it controls — the portion of the map that is visible (intelligence/scan coverage) — rather than the obscured part. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
214 lines
7.0 KiB
TypeScript
214 lines
7.0 KiB
TypeScript
// 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<typeof import("../src/api/lobby")>(
|
|
"../src/api/lobby",
|
|
);
|
|
return {
|
|
...actual,
|
|
listMyGames: (...args: unknown[]) => listMyGamesSpy(...args),
|
|
};
|
|
});
|
|
|
|
let db: IDBPDatabase<GalaxyDB>;
|
|
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<void>((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<MapToggles>,
|
|
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();
|
|
});
|
|
});
|