feat(ui): Phase 29 map visibility toggles
Tests · Go / test (push) Successful in 2m31s
Tests · UI / test (push) Failing after 8m7s

Adds the gear-icon popover on the map view with per-game persistence
of every category toggle plus the wrap-mode radio. Hide-by-id and
visibility-fog facilities land on the renderer so every flip applies
within one frame without a Pixi remount; the wrap-mode toggle keeps
its existing remount + camera-preserve path. A new server-side turn
force-resets every flag to defaults so a hidden category never makes
the player miss the next turn's news.

Also fixes the FligthDistance → FlightDistance typo in pkg/calc/race.go
(plus the single Go caller); the TS side keeps duplicating the formula
until a race-level WASM bridge lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-19 21:33:53 +02:00
parent 65c0fbb87d
commit 2bd1b54936
32 changed files with 3046 additions and 63 deletions
+401
View File
@@ -0,0 +1,401 @@
// Phase 29 end-to-end coverage for the gear popover. The spec mocks
// the gateway with a mixed-kind report (local + foreign + uninhabited
// + unidentified planets, a battle, a bombing, a cargo route, a
// non-zero drive tech for fog math), then walks the popover through
// the toggles and asserts the renderer state via the
// `__galaxyDebug` accessors:
//
// * `getMapPrimitives()` — every primitive carries a `visible`
// flag mirroring the renderer's hide set. The spec counts the
// visible-foreign-planet primitives, etc.
// * `getMapFog()` — the current visibility-fog circle list.
// * `getMapCamera()` — the wrap-mode test reads the centre before
// and after the flip to confirm camera preservation.
import { fromJson, type JsonValue } from "@bufbuild/protobuf";
import { expect, test, type Page } from "@playwright/test";
import { ByteBuffer } from "flatbuffers";
import { ExecuteCommandRequestSchema } from "../../src/proto/galaxy/gateway/v1/edge_gateway_pb";
import { UUID } from "../../src/proto/galaxy/fbs/common";
import { GameReportRequest } from "../../src/proto/galaxy/fbs/report";
import { forgeExecuteCommandResponseJson } from "./fixtures/sign-response";
import {
buildMyGamesListPayload,
type GameFixture,
} from "./fixtures/lobby-fbs";
import { buildReportPayload } from "./fixtures/report-fbs";
const SESSION_ID = "phase-29-map-toggles-session";
const GAME_ID = "29292929-2929-2929-2929-292929292929";
const RACE = "Earthlings";
// FlightDistance = driveTech * 40; pick drive=10 → reach 400.
// VisibilityDistance = driveTech * 30 → fog radius 300.
const DRIVE_TECH = 10;
interface MockOpts {
currentTurn: number;
}
async function mockGateway(page: Page, opts: MockOpts): Promise<void> {
const game: GameFixture = {
gameId: GAME_ID,
gameName: "Phase 29 Game",
gameType: "private",
status: "running",
ownerUserId: "user-1",
minPlayers: 2,
maxPlayers: 8,
enrollmentEndsAtMs: BigInt(Date.now() + 86_400_000),
createdAtMs: BigInt(Date.now() - 86_400_000),
updatedAtMs: BigInt(Date.now()),
currentTurn: opts.currentTurn,
};
await page.route(
"**/galaxy.gateway.v1.EdgeGateway/ExecuteCommand",
async (route) => {
const reqText = route.request().postData();
if (reqText === null) {
await route.fulfill({ status: 400 });
return;
}
const req = fromJson(
ExecuteCommandRequestSchema,
JSON.parse(reqText) as JsonValue,
);
let resultCode = "ok";
let payload: Uint8Array;
switch (req.messageType) {
case "lobby.my.games.list":
payload = buildMyGamesListPayload([game]);
break;
case "user.games.report": {
GameReportRequest.getRootAsGameReportRequest(
new ByteBuffer(req.payloadBytes),
).gameId(new UUID());
payload = buildReportPayload({
turn: opts.currentTurn,
mapWidth: 4000,
mapHeight: 4000,
race: RACE,
players: [{ name: RACE, drive: DRIVE_TECH }],
// Two LOCAL planets near the centre so the reach +
// fog math has anchors. The foreign planet at
// (1500, 1000) is 500 units from the closest LOCAL
// — outside reach (400), so the unreachable filter
// toggles flag this one when enabled.
localPlanets: [
{
number: 1,
name: "Earth",
x: 1000,
y: 1000,
size: 1000,
resources: 10,
},
{
number: 2,
name: "Mars",
x: 1200,
y: 1000,
size: 1000,
resources: 10,
},
],
otherPlanets: [
{
number: 3,
name: "Frontier",
x: 1500,
y: 1000,
owner: "Federation",
size: 800,
resources: 5,
},
],
uninhabitedPlanets: [
{
number: 4,
name: "Rock",
x: 1100,
y: 1100,
size: 500,
resources: 1,
},
],
unidentifiedPlanets: [
{ number: 5, x: 2500, y: 1000 },
],
battles: [
{ id: "8c0c1f64-b0f8-4e7d-8c2c-3e1d0a0b0001", planet: 3, shots: 4 },
],
bombings: [
{
planetNumber: 3,
planet: "Frontier",
owner: "Federation",
attacker: RACE,
production: "",
industry: 100,
population: 200,
colonists: 50,
attackPower: 5,
wiped: false,
},
],
routes: [
{
sourcePlanetNumber: 1,
entries: [
{ loadType: "COL", destinationPlanetNumber: 2 },
],
},
],
});
break;
}
default:
resultCode = "internal_error";
payload = new Uint8Array();
}
const body = await forgeExecuteCommandResponseJson({
requestId: req.requestId,
timestampMs: BigInt(Date.now()),
resultCode,
payloadBytes: payload,
});
await route.fulfill({
status: 200,
contentType: "application/json",
body,
});
},
);
// Keep the push stream open so the revocation watcher does not
// sign the session out mid-test (same convention as
// `game-shell-map.spec.ts`).
await page.route(
"**/galaxy.gateway.v1.EdgeGateway/SubscribeEvents",
async () => {
await new Promise<void>(() => {});
},
);
}
async function bootSession(page: Page): Promise<void> {
await page.goto("/__debug/store");
await expect(page.getByTestId("debug-store-ready")).toBeVisible();
await page.waitForFunction(() => window.__galaxyDebug?.ready === true);
await page.evaluate(() => window.__galaxyDebug!.clearSession());
await page.evaluate(
(id) => window.__galaxyDebug!.setDeviceSessionId(id),
SESSION_ID,
);
}
async function openGame(page: Page): Promise<void> {
await page.goto(`/games/${GAME_ID}/map`);
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status",
"ready",
);
// Wait for the renderer's debug accessor to register so the
// `getMapPrimitives` call below picks up real data instead of an
// empty stub. The renderer registers it inside
// `runSerializedMount`, which awaits Pixi init.
await page.waitForFunction(() => {
const prims = window.__galaxyDebug?.getMapPrimitives?.() ?? [];
return prims.length > 0;
});
}
interface PrimitiveLite {
id: number;
visible: boolean;
}
async function visiblePlanets(page: Page): Promise<number[]> {
return await page.evaluate(() => {
const prims = window.__galaxyDebug!.getMapPrimitives!() as readonly PrimitiveLite[];
// Planet primitive ids are the engine planet numbers (small);
// every other category uses a high-bit prefix.
return prims
.filter((p) => p.visible && p.id < 1_000_000)
.map((p) => p.id)
.sort((a, b) => a - b);
});
}
async function visibleCount(
page: Page,
predicate: (id: number) => boolean,
): Promise<number> {
return await page.evaluate((pred: string) => {
const fn = new Function("id", `return (${pred})(id);`) as (
id: number,
) => boolean;
const prims = window.__galaxyDebug!.getMapPrimitives!() as readonly PrimitiveLite[];
return prims.filter((p) => p.visible && fn(p.id)).length;
}, predicate.toString());
}
test("gear popover toggles a planet kind off and cascades onto its markers", async ({
page,
}) => {
await mockGateway(page, { currentTurn: 1 });
await bootSession(page);
await openGame(page);
// Baseline — every planet shows up, plus the battle X-cross (2
// LinePrim) and the bombing ring on the foreign planet.
expect(await visiblePlanets(page)).toEqual([1, 2, 3, 4, 5]);
// Two battle marker line primitives (high-bit prefix 0xa0000000).
expect(
await visibleCount(
page,
(id) => (id & 0xf0000000) === 0xa0000000,
),
).toBe(2);
// One bombing ring (prefix 0xc0000000).
expect(
await visibleCount(
page,
(id) => (id & 0xf0000000) === 0xc0000000,
),
).toBe(1);
await page.getByTestId("map-toggles-trigger").click();
await expect(page.getByTestId("map-toggles-surface")).toBeVisible();
await page.getByTestId("map-toggles-foreign-planets").click();
// The foreign planet (id 3) is gone — and its battle / bombing
// markers cascaded with it.
expect(await visiblePlanets(page)).toEqual([1, 2, 4, 5]);
expect(
await visibleCount(
page,
(id) => (id & 0xf0000000) === 0xa0000000,
),
).toBe(0);
expect(
await visibleCount(
page,
(id) => (id & 0xf0000000) === 0xc0000000,
),
).toBe(0);
});
test("visibility fog toggles between the LOCAL-planet circle list and an empty overlay", async ({
page,
}) => {
await mockGateway(page, { currentTurn: 1 });
await bootSession(page);
await openGame(page);
// Defaults: fog on; one circle per LOCAL planet, radius
// `30 * driveTech = 300`.
const initialFog = await page.evaluate(
() => window.__galaxyDebug!.getMapFog!().circles,
);
expect(initialFog.length).toBe(2);
expect(initialFog[0].radius).toBe(300);
expect(initialFog[1].radius).toBe(300);
await page.getByTestId("map-toggles-trigger").click();
await page.getByTestId("map-toggles-visibility-fog").click();
const offFog = await page.evaluate(
() => window.__galaxyDebug!.getMapFog!().circles,
);
expect(offFog).toEqual([]);
// Toggling back on rebuilds the fog circles for the same planets.
await page.getByTestId("map-toggles-visibility-fog").click();
const onAgain = await page.evaluate(
() => window.__galaxyDebug!.getMapFog!().circles,
);
expect(onAgain.length).toBe(2);
});
test("wrap mode radios flip the renderer and the camera centre survives", async ({
page,
}) => {
await mockGateway(page, { currentTurn: 1 });
await bootSession(page);
await openGame(page);
const initial = await page.evaluate(() =>
window.__galaxyDebug!.getMapCamera!(),
);
expect(initial).not.toBeNull();
const startCentre = initial!.camera;
await page.getByTestId("map-toggles-trigger").click();
await page.getByTestId("map-toggles-wrap-no-wrap").click();
// Mount path is async (Pixi re-init takes a frame). Wait for the
// camera reading to settle into the new mount and assert the
// centre is within 1 px of the pre-toggle value.
await page.waitForFunction(() => {
const c = window.__galaxyDebug?.getMapCamera?.();
return c !== null && c !== undefined && c.camera.centerX !== undefined;
});
const after = await page.evaluate(() =>
window.__galaxyDebug!.getMapCamera!(),
);
expect(after).not.toBeNull();
expect(Math.abs(after!.camera.centerX - startCentre.centerX)).toBeLessThanOrEqual(1);
expect(Math.abs(after!.camera.centerY - startCentre.centerY)).toBeLessThanOrEqual(1);
});
test("toggle state persists across a page reload", async ({ page }) => {
await mockGateway(page, { currentTurn: 1 });
await bootSession(page);
await openGame(page);
await page.getByTestId("map-toggles-trigger").click();
await page.getByTestId("map-toggles-battle-markers").click();
await page.getByTestId("map-toggles-bombing-markers").click();
// Independent flips: turning battle off must not touch bombing.
expect(
await page.getByTestId("map-toggles-battle-markers").isChecked(),
).toBe(false);
expect(
await page.getByTestId("map-toggles-bombing-markers").isChecked(),
).toBe(false);
await page.reload();
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status",
"ready",
);
await page.waitForFunction(() => {
const prims = window.__galaxyDebug?.getMapPrimitives?.() ?? [];
return prims.length > 0;
});
await page.getByTestId("map-toggles-trigger").click();
expect(
await page.getByTestId("map-toggles-battle-markers").isChecked(),
).toBe(false);
expect(
await page.getByTestId("map-toggles-bombing-markers").isChecked(),
).toBe(false);
// Battle X-cross and bombing ring are hidden in the renderer.
expect(
await visibleCount(
page,
(id) => (id & 0xf0000000) === 0xa0000000,
),
).toBe(0);
expect(
await visibleCount(
page,
(id) => (id & 0xf0000000) === 0xc0000000,
),
).toBe(0);
});
@@ -15,6 +15,7 @@ interface DebugSnapshot {
import type {
MapCameraSnapshot,
MapFogSnapshot,
MapPickStateSnapshot,
MapPrimitiveSnapshot,
} from "../../src/lib/debug-surface.svelte";
@@ -46,6 +47,7 @@ interface DebugSurface {
getMapPrimitives(): readonly MapPrimitiveSnapshot[];
getMapPickState(): MapPickStateSnapshot;
getMapCamera(): MapCameraSnapshot | null;
getMapFog(): MapFogSnapshot;
}
declare global {
+53
View File
@@ -280,3 +280,56 @@ describe("hitTest — empty results and scale", () => {
expect(ids(wFar, "torus", cam05, cursorOver(500, 500, cam05))).toBe(null);
});
});
describe("hitTest — Phase 29 hiddenIds parameter", () => {
const cam = camAt(500, 500);
test("a hidden primitive is skipped entirely", () => {
const w = new World(1000, 1000, [
point(1, 500, 500),
point(2, 500, 500, { priority: -1 }),
]);
// Without filtering, primitive 1 wins (higher priority).
expect(hitTest(w, cam, VP, cursorOver(500, 500, cam), "torus")?.primitive.id)
.toBe(1);
// With 1 hidden, the cursor falls through to primitive 2.
expect(
hitTest(
w,
cam,
VP,
cursorOver(500, 500, cam),
"torus",
new Set([1]),
)?.primitive.id,
).toBe(2);
});
test("hiding every match returns null", () => {
const w = new World(1000, 1000, [point(1, 500, 500)]);
expect(
hitTest(
w,
cam,
VP,
cursorOver(500, 500, cam),
"torus",
new Set([1]),
),
).toBeNull();
});
test("an empty hidden set is equivalent to omitting the parameter", () => {
const w = new World(1000, 1000, [point(1, 500, 500)]);
const a = hitTest(w, cam, VP, cursorOver(500, 500, cam), "torus");
const b = hitTest(
w,
cam,
VP,
cursorOver(500, 500, cam),
"torus",
new Set(),
);
expect(a?.primitive.id).toBe(1);
expect(b?.primitive.id).toBe(1);
});
});
+20
View File
@@ -12,6 +12,7 @@ import {
distSqPointToSegment,
screenToWorld,
torusShortestDelta,
torusShortestDistance,
worldToScreen,
} from "../src/map/math";
@@ -104,3 +105,22 @@ describe("screenToWorld and worldToScreen", () => {
expect(w1.x - w0.x).toBeCloseTo(1, 12);
});
});
describe("torusShortestDistance", () => {
test("returns plain Euclidean distance when no wrap is shorter", () => {
const d = torusShortestDistance(0, 0, 3, 4, 1000, 1000);
expect(d).toBeCloseTo(5, 12);
});
test("respects torus wrap on both axes", () => {
// Wrap on x: 50 → 950 across the seam is 100 units.
// Wrap on y: 100 → 900 across the seam is 200 units.
// Hypot(100, 200) ≈ 223.606.
const d = torusShortestDistance(50, 100, 950, 900, 1000, 1000);
expect(d).toBeCloseTo(Math.hypot(100, 200), 9);
});
test("zero when both points coincide", () => {
expect(torusShortestDistance(123, 456, 123, 456, 1000, 1000)).toBe(0);
});
});
@@ -0,0 +1,123 @@
// Phase 29 component coverage for `lib/active-view/map-toggles.svelte`.
// The popover is a thin view of the `GameStateStore` runes —
// every control fires `setMapToggle` / `setWrapMode` on the store
// and reads the current state through `store.mapToggles` /
// `store.wrapMode`. The tests assert the wiring, the default
// rendering, and the popover lifecycle (open / Escape close).
import "@testing-library/jest-dom/vitest";
import { fireEvent, render } from "@testing-library/svelte";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { i18n } from "../src/lib/i18n/index.svelte";
import MapTogglesControl from "../src/lib/active-view/map-toggles.svelte";
import {
DEFAULT_MAP_TOGGLES,
GameStateStore,
} from "../src/lib/game-state.svelte";
function buildStore(): GameStateStore {
const store = new GameStateStore();
store.status = "ready";
store.wrapMode = "torus";
store.mapToggles = { ...DEFAULT_MAP_TOGGLES };
return store;
}
beforeEach(() => {
i18n.resetForTests("en");
});
describe("MapTogglesControl", () => {
test("trigger renders and the popover is closed by default", () => {
const store = buildStore();
const ui = render(MapTogglesControl, { props: { store } });
const trigger = ui.getByTestId("map-toggles-trigger");
expect(trigger).toBeInTheDocument();
expect(trigger).toHaveAttribute("aria-expanded", "false");
// The 44+ px touch-target is enforced through CSS; jsdom does
// not parse scoped Svelte styles for `getComputedStyle`, so the
// dimension is verified in the Playwright e2e where real
// browsers compute the rule.
expect(ui.queryByTestId("map-toggles-surface")).toBeNull();
});
test("clicking the trigger opens the popover with defaults applied", async () => {
const store = buildStore();
const ui = render(MapTogglesControl, { props: { store } });
await fireEvent.click(ui.getByTestId("map-toggles-trigger"));
const surface = ui.getByTestId("map-toggles-surface");
expect(surface).toBeInTheDocument();
expect(ui.getByTestId("map-toggles-hyperspace-groups")).toBeChecked();
expect(ui.getByTestId("map-toggles-incoming-groups")).toBeChecked();
expect(ui.getByTestId("map-toggles-unidentified-groups")).toBeChecked();
expect(ui.getByTestId("map-toggles-cargo-routes")).toBeChecked();
expect(ui.getByTestId("map-toggles-battle-markers")).toBeChecked();
expect(ui.getByTestId("map-toggles-bombing-markers")).toBeChecked();
expect(ui.getByTestId("map-toggles-foreign-planets")).toBeChecked();
expect(ui.getByTestId("map-toggles-uninhabited-planets")).toBeChecked();
expect(ui.getByTestId("map-toggles-unidentified-planets")).toBeChecked();
expect(ui.getByTestId("map-toggles-unreachable-planets")).toBeChecked();
expect(ui.getByTestId("map-toggles-visibility-fog")).toBeChecked();
expect(ui.getByTestId("map-toggles-wrap-torus")).toBeChecked();
expect(ui.getByTestId("map-toggles-wrap-no-wrap")).not.toBeChecked();
});
test("flipping a checkbox calls setMapToggle with the new value", async () => {
const store = buildStore();
const setMapToggle = vi
.spyOn(store, "setMapToggle")
.mockResolvedValue(undefined);
const ui = render(MapTogglesControl, { props: { store } });
await fireEvent.click(ui.getByTestId("map-toggles-trigger"));
await fireEvent.click(ui.getByTestId("map-toggles-battle-markers"));
expect(setMapToggle).toHaveBeenCalledWith("battleMarkers", false);
await fireEvent.click(ui.getByTestId("map-toggles-foreign-planets"));
expect(setMapToggle).toHaveBeenCalledWith("foreignPlanets", false);
});
test("battle and bombing toggles are independent", async () => {
const store = buildStore();
const setMapToggle = vi
.spyOn(store, "setMapToggle")
.mockResolvedValue(undefined);
const ui = render(MapTogglesControl, { props: { store } });
await fireEvent.click(ui.getByTestId("map-toggles-trigger"));
await fireEvent.click(ui.getByTestId("map-toggles-battle-markers"));
expect(setMapToggle).toHaveBeenCalledTimes(1);
expect(setMapToggle).toHaveBeenCalledWith("battleMarkers", false);
// No spillover into bombingMarkers.
expect(setMapToggle).not.toHaveBeenCalledWith("bombingMarkers", false);
});
test("selecting the no-wrap radio calls setWrapMode", async () => {
const store = buildStore();
const setWrapMode = vi
.spyOn(store, "setWrapMode")
.mockResolvedValue(undefined);
const ui = render(MapTogglesControl, { props: { store } });
await fireEvent.click(ui.getByTestId("map-toggles-trigger"));
await fireEvent.click(ui.getByTestId("map-toggles-wrap-no-wrap"));
expect(setWrapMode).toHaveBeenCalledWith("no-wrap");
});
test("Escape closes the popover", async () => {
const store = buildStore();
const ui = render(MapTogglesControl, { props: { store } });
await fireEvent.click(ui.getByTestId("map-toggles-trigger"));
expect(ui.getByTestId("map-toggles-surface")).toBeInTheDocument();
await fireEvent.keyDown(document, { key: "Escape" });
expect(ui.queryByTestId("map-toggles-surface")).toBeNull();
});
test("clicking outside the popover closes it", async () => {
const store = buildStore();
const ui = render(MapTogglesControl, { props: { store } });
await fireEvent.click(ui.getByTestId("map-toggles-trigger"));
expect(ui.getByTestId("map-toggles-surface")).toBeInTheDocument();
// Synthetic outside click — fire on document with the trigger
// removed from the click target chain.
await fireEvent.click(document.body);
expect(ui.queryByTestId("map-toggles-surface")).toBeNull();
});
});
+213
View File
@@ -0,0 +1,213 @@
// 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("visibilityFog", 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.visibilityFog).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.visibilityFog).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,
visibilityFog: 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();
});
});
@@ -0,0 +1,311 @@
// Phase 29 coverage for the categories + planetDependents maps that
// `reportToWorld` now returns. The map view consumes both to feed
// `RendererHandle.setHiddenPrimitiveIds`: categories drive the
// per-category toggle, planetDependents drive the cascade (planet
// hidden → markers + in-space + incoming groups flying to it hide
// together).
import { describe, expect, test } from "vitest";
import type {
GameReport,
ReportBattle,
ReportBombing,
ReportIncomingShipGroup,
ReportLocalShipGroup,
ReportOtherShipGroup,
ReportPlanet,
ReportUnidentifiedShipGroup,
} from "../src/api/game-state";
import { BATTLE_MARKER_ID_PREFIX, BOMBING_MARKER_ID_PREFIX } from "../src/map/battle-markers";
import { SHIP_GROUP_ID_OFFSETS } from "../src/map/ship-groups";
import { reportToWorld } from "../src/map/state-binding";
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
function makeReport(overrides: Partial<GameReport> = {}): GameReport {
return {
turn: 1,
mapWidth: 4000,
mapHeight: 4000,
planetCount: 0,
planets: [],
race: "",
localShipClass: [],
routes: [],
localPlayerDrive: 0,
localPlayerWeapons: 0,
localPlayerShields: 0,
localPlayerCargo: 0,
...EMPTY_SHIP_GROUPS,
...overrides,
};
}
function makePlanet(overrides: Partial<ReportPlanet>): ReportPlanet {
return {
number: 0,
name: "",
x: 0,
y: 0,
kind: "local",
owner: null,
size: null,
resources: null,
industryStockpile: null,
materialsStockpile: null,
industry: null,
population: null,
colonists: null,
production: null,
freeIndustry: null,
...overrides,
};
}
function makeLocalShipGroup(
overrides: Partial<ReportLocalShipGroup>,
): ReportLocalShipGroup {
return {
id: "00000000-0000-0000-0000-000000000000",
count: 1,
class: "Scout",
tech: { drive: 1, weapons: 0, shields: 0, cargo: 0 },
cargo: "NONE",
load: 0,
destination: 0,
origin: null,
range: null,
speed: 1,
mass: 0,
state: "InOrbit",
fleet: null,
...overrides,
};
}
function makeOtherShipGroup(
overrides: Partial<ReportOtherShipGroup>,
): ReportOtherShipGroup {
return {
count: 1,
class: "Cruiser",
tech: { drive: 1, weapons: 0, shields: 0, cargo: 0 },
cargo: "NONE",
load: 0,
destination: 0,
origin: null,
range: null,
speed: 1,
mass: 0,
...overrides,
};
}
function makeIncoming(
overrides: Partial<ReportIncomingShipGroup>,
): ReportIncomingShipGroup {
return {
origin: 0,
destination: 0,
distance: 0,
speed: 1,
mass: 1,
...overrides,
};
}
function makeUnidentified(
overrides: Partial<ReportUnidentifiedShipGroup>,
): ReportUnidentifiedShipGroup {
return { x: 0, y: 0, ...overrides };
}
function makeBattle(overrides: Partial<ReportBattle>): ReportBattle {
return {
id: "battle",
planet: 0,
shots: 1,
...overrides,
};
}
function makeBombing(overrides: Partial<ReportBombing>): ReportBombing {
return {
planetNumber: 0,
planet: "",
owner: "",
attacker: "",
production: "",
industry: 0,
population: 0,
colonists: 0,
industryStockpile: 0,
materialsStockpile: 0,
attackPower: 0,
wiped: false,
...overrides,
};
}
describe("reportToWorld — categories", () => {
test("planet primitives carry their kind-flavoured category", () => {
const { categories } = reportToWorld(
makeReport({
planets: [
makePlanet({ number: 1, kind: "local", x: 100, y: 100 }),
makePlanet({ number: 2, kind: "other", x: 200, y: 200 }),
makePlanet({ number: 3, kind: "uninhabited", x: 300, y: 300 }),
makePlanet({ number: 4, kind: "unidentified", x: 400, y: 400 }),
],
}),
);
expect(categories.get(1)).toBe("planet-local");
expect(categories.get(2)).toBe("planet-foreign");
expect(categories.get(3)).toBe("planet-uninhabited");
expect(categories.get(4)).toBe("planet-unidentified");
});
test("ship-group sub-builder tags every primitive id", () => {
const localGroupPrimId = SHIP_GROUP_ID_OFFSETS.local + 0;
const localLineId = SHIP_GROUP_ID_OFFSETS.localLine + 0;
const otherId = SHIP_GROUP_ID_OFFSETS.other + 0;
const incomingPointId = SHIP_GROUP_ID_OFFSETS.incoming + 0;
const incomingLineId = SHIP_GROUP_ID_OFFSETS.incomingLine + 0;
const unidentifiedId = SHIP_GROUP_ID_OFFSETS.unidentified + 0;
const { categories } = reportToWorld(
makeReport({
planets: [
makePlanet({ number: 1, kind: "local", x: 100, y: 100 }),
makePlanet({ number: 2, kind: "other", x: 200, y: 200 }),
],
localShipGroups: [
makeLocalShipGroup({ origin: 1, range: 10, destination: 2 }),
],
otherShipGroups: [
makeOtherShipGroup({ origin: 1, range: 10, destination: 2 }),
],
incomingShipGroups: [
makeIncoming({ origin: 1, destination: 2, distance: 5 }),
],
unidentifiedShipGroups: [makeUnidentified({ x: 500, y: 500 })],
}),
);
expect(categories.get(localGroupPrimId)).toBe("hyperspaceGroup");
expect(categories.get(localLineId)).toBe("hyperspaceGroup");
expect(categories.get(otherId)).toBe("hyperspaceGroup");
expect(categories.get(incomingPointId)).toBe("incomingGroup");
expect(categories.get(incomingLineId)).toBe("incomingGroup");
expect(categories.get(unidentifiedId)).toBe("unidentifiedGroup");
});
test("battle markers and bombing markers each carry their own category", () => {
const { categories } = reportToWorld(
makeReport({
planets: [
makePlanet({ number: 1, kind: "local", x: 100, y: 100 }),
makePlanet({ number: 2, kind: "other", x: 200, y: 200 }),
],
battles: [makeBattle({ id: "b1", planet: 2 })],
bombings: [makeBombing({ planetNumber: 2 })],
}),
);
// Battle marker emits two LinePrims at `BATTLE_MARKER_ID_PREFIX | (i << 4) | (A|B)`.
const battleA = BATTLE_MARKER_ID_PREFIX | (0 << 4) | 0;
const battleB = BATTLE_MARKER_ID_PREFIX | (0 << 4) | 1;
expect(categories.get(battleA)).toBe("battleMarker");
expect(categories.get(battleB)).toBe("battleMarker");
const bombingId = BOMBING_MARKER_ID_PREFIX | 0;
expect(categories.get(bombingId)).toBe("bombingMarker");
});
});
describe("reportToWorld — planetDependents", () => {
test("every planet seeds its own dependents entry with its own id", () => {
const { planetDependents } = reportToWorld(
makeReport({
planets: [
makePlanet({ number: 1, kind: "local", x: 100, y: 100 }),
makePlanet({ number: 7, kind: "other", x: 200, y: 200 }),
],
}),
);
expect(planetDependents.get(1)?.has(1)).toBe(true);
expect(planetDependents.get(7)?.has(7)).toBe(true);
});
test("battle / bombing markers cascade onto their anchor planet", () => {
const { planetDependents } = reportToWorld(
makeReport({
planets: [
makePlanet({ number: 1, kind: "local", x: 100, y: 100 }),
makePlanet({ number: 2, kind: "other", x: 200, y: 200 }),
],
battles: [makeBattle({ planet: 2 })],
bombings: [makeBombing({ planetNumber: 2 })],
}),
);
const battleA = BATTLE_MARKER_ID_PREFIX | (0 << 4) | 0;
const battleB = BATTLE_MARKER_ID_PREFIX | (0 << 4) | 1;
const bombingId = BOMBING_MARKER_ID_PREFIX | 0;
const deps = planetDependents.get(2) ?? new Set();
expect(deps.has(2)).toBe(true);
expect(deps.has(battleA)).toBe(true);
expect(deps.has(battleB)).toBe(true);
expect(deps.has(bombingId)).toBe(true);
});
test("in-space groups cascade onto their destination planet", () => {
const { planetDependents } = reportToWorld(
makeReport({
planets: [
makePlanet({ number: 1, kind: "local", x: 100, y: 100 }),
makePlanet({ number: 2, kind: "other", x: 200, y: 200 }),
],
localShipGroups: [
makeLocalShipGroup({ origin: 1, range: 10, destination: 2 }),
],
otherShipGroups: [
makeOtherShipGroup({ origin: 1, range: 10, destination: 2 }),
],
}),
);
const localPointId = SHIP_GROUP_ID_OFFSETS.local + 0;
const localLineId = SHIP_GROUP_ID_OFFSETS.localLine + 0;
const otherId = SHIP_GROUP_ID_OFFSETS.other + 0;
const deps = planetDependents.get(2) ?? new Set();
expect(deps.has(localPointId)).toBe(true);
expect(deps.has(localLineId)).toBe(true);
expect(deps.has(otherId)).toBe(true);
});
test("incoming groups cascade onto their destination planet", () => {
const { planetDependents } = reportToWorld(
makeReport({
planets: [
makePlanet({ number: 1, kind: "local", x: 100, y: 100 }),
makePlanet({ number: 2, kind: "other", x: 200, y: 200 }),
],
incomingShipGroups: [
makeIncoming({ origin: 1, destination: 2, distance: 5 }),
],
}),
);
const incomingPointId = SHIP_GROUP_ID_OFFSETS.incoming + 0;
const incomingLineId = SHIP_GROUP_ID_OFFSETS.incomingLine + 0;
const deps = planetDependents.get(2) ?? new Set();
expect(deps.has(incomingPointId)).toBe(true);
expect(deps.has(incomingLineId)).toBe(true);
});
test("unidentified groups do not contribute to any planet's dependents", () => {
const { planetDependents } = reportToWorld(
makeReport({
planets: [makePlanet({ number: 1, kind: "local", x: 100, y: 100 })],
unidentifiedShipGroups: [makeUnidentified({ x: 500, y: 500 })],
}),
);
// Only the local planet seeds its own entry; no other entries.
expect(planetDependents.size).toBe(1);
expect(planetDependents.get(1)?.size).toBe(1);
});
});
@@ -0,0 +1,314 @@
// Phase 29 pure helpers in `src/map/visibility.ts`. The tests exercise
// `computeHiddenPlanetNumbers`, `computeHiddenIds`, `computeFogCircles`,
// and `isCategoryVisible` directly so the map view can stay a thin
// wiring layer.
import { describe, expect, test } from "vitest";
import type { GameReport, ReportPlanet } from "../src/api/game-state";
import { DEFAULT_MAP_TOGGLES, type MapToggles } from "../src/lib/game-state.svelte";
import type { MapCategory } from "../src/map/state-binding";
import {
FLIGHT_DISTANCE_PER_DRIVE,
VISIBILITY_DISTANCE_PER_DRIVE,
computeFogCircles,
computeHiddenIds,
computeHiddenPlanetNumbers,
fingerprintHiddenPlanets,
isCategoryVisible,
} from "../src/map/visibility";
import type { PrimitiveID } from "../src/map/world";
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
function makeReport(overrides: Partial<GameReport> = {}): GameReport {
return {
turn: 1,
mapWidth: 4000,
mapHeight: 4000,
planetCount: 0,
planets: [],
race: "",
localShipClass: [],
routes: [],
localPlayerDrive: 0,
localPlayerWeapons: 0,
localPlayerShields: 0,
localPlayerCargo: 0,
...EMPTY_SHIP_GROUPS,
...overrides,
};
}
function makePlanet(overrides: Partial<ReportPlanet>): ReportPlanet {
return {
number: 0,
name: "",
x: 0,
y: 0,
kind: "local",
owner: null,
size: null,
resources: null,
industryStockpile: null,
materialsStockpile: null,
industry: null,
population: null,
colonists: null,
production: null,
freeIndustry: null,
...overrides,
};
}
function toggles(overrides: Partial<MapToggles> = {}): MapToggles {
return { ...DEFAULT_MAP_TOGGLES, ...overrides };
}
describe("isCategoryVisible", () => {
test("local planets are always visible regardless of toggles", () => {
expect(
isCategoryVisible("planet-local", toggles({ foreignPlanets: false })),
).toBe(true);
});
test("each kind toggle controls its planet category", () => {
const t = toggles({
foreignPlanets: false,
uninhabitedPlanets: false,
unidentifiedPlanets: false,
});
expect(isCategoryVisible("planet-foreign", t)).toBe(false);
expect(isCategoryVisible("planet-uninhabited", t)).toBe(false);
expect(isCategoryVisible("planet-unidentified", t)).toBe(false);
});
test("battle and bombing markers have independent toggles", () => {
const t = toggles({ battleMarkers: false, bombingMarkers: true });
expect(isCategoryVisible("battleMarker", t)).toBe(false);
expect(isCategoryVisible("bombingMarker", t)).toBe(true);
});
});
describe("computeHiddenPlanetNumbers", () => {
test("returns an empty set when defaults are in effect", () => {
const report = makeReport({
localPlayerDrive: 10,
planets: [
makePlanet({ number: 1, kind: "local", x: 100, y: 100 }),
makePlanet({ number: 2, kind: "other", x: 200, y: 100 }),
],
});
expect(computeHiddenPlanetNumbers(report, toggles())).toEqual(new Set());
});
test("kind-toggle off hides every planet of that kind", () => {
const report = makeReport({
localPlayerDrive: 10,
planets: [
makePlanet({ number: 1, kind: "local", x: 100, y: 100 }),
makePlanet({ number: 2, kind: "other", x: 200, y: 100 }),
makePlanet({ number: 3, kind: "uninhabited", x: 300, y: 100 }),
makePlanet({ number: 4, kind: "unidentified", x: 400, y: 100 }),
],
});
const hidden = computeHiddenPlanetNumbers(
report,
toggles({ foreignPlanets: false, unidentifiedPlanets: false }),
);
expect(hidden).toEqual(new Set([2, 4]));
});
test("unreachablePlanets=off hides planets beyond FlightDistance", () => {
const report = makeReport({
localPlayerDrive: 10,
mapWidth: 4000,
mapHeight: 4000,
planets: [
makePlanet({ number: 1, kind: "local", x: 100, y: 100 }),
// Foreign within reach: distance ≈ 100 < 400.
makePlanet({ number: 2, kind: "other", x: 200, y: 100 }),
// Foreign beyond reach: distance ≈ 500 > 400.
makePlanet({ number: 3, kind: "other", x: 600, y: 100 }),
],
});
const reachLimit = 10 * FLIGHT_DISTANCE_PER_DRIVE;
expect(reachLimit).toBe(400);
const hidden = computeHiddenPlanetNumbers(
report,
toggles({ unreachablePlanets: false }),
);
expect(hidden).toEqual(new Set([3]));
});
test("torus wrap shortens reach distance across the seam", () => {
const report = makeReport({
localPlayerDrive: 10,
mapWidth: 1000,
mapHeight: 1000,
planets: [
makePlanet({ number: 1, kind: "local", x: 50, y: 500 }),
// Wrap distance is 100 (50 → 950 via the left seam), well
// inside the 400-unit reach. Without the torus metric this
// would resolve to 900 and the planet would hide.
makePlanet({ number: 2, kind: "other", x: 950, y: 500 }),
],
});
const hidden = computeHiddenPlanetNumbers(
report,
toggles({ unreachablePlanets: false }),
);
expect(hidden).toEqual(new Set());
});
test("localPlayerDrive=0 hides every non-local planet when reach filter is on", () => {
const report = makeReport({
localPlayerDrive: 0,
planets: [
makePlanet({ number: 1, kind: "local", x: 100, y: 100 }),
makePlanet({ number: 2, kind: "other", x: 101, y: 100 }),
],
});
const hidden = computeHiddenPlanetNumbers(
report,
toggles({ unreachablePlanets: false }),
);
expect(hidden).toEqual(new Set([2]));
});
test("a report with no LOCAL planets keeps everything visible (no reach anchor)", () => {
const report = makeReport({
localPlayerDrive: 10,
planets: [makePlanet({ number: 9, kind: "other", x: 9000, y: 9000 })],
});
const hidden = computeHiddenPlanetNumbers(
report,
toggles({ unreachablePlanets: false }),
);
expect(hidden).toEqual(new Set());
});
test("LOCAL planets are never hidden", () => {
const report = makeReport({
localPlayerDrive: 10,
planets: [makePlanet({ number: 1, kind: "local", x: 1, y: 1 })],
});
expect(
computeHiddenPlanetNumbers(
report,
toggles({ foreignPlanets: false, unreachablePlanets: false }),
),
).toEqual(new Set());
});
});
describe("computeHiddenIds", () => {
const categories: Map<PrimitiveID, MapCategory> = new Map<
PrimitiveID,
MapCategory
>([
[1, "planet-local"],
[2, "planet-foreign"],
[100, "hyperspaceGroup"],
[150, "hyperspaceGroup"],
[200, "incomingGroup"],
[300, "battleMarker"],
[400, "bombingMarker"],
]);
const planetDependents = new Map<number, ReadonlySet<PrimitiveID>>([
[1, new Set([1])],
[2, new Set([2, 100, 150, 200, 300, 400])],
]);
test("category-toggle off hides every primitive in that category", () => {
const hidden = computeHiddenIds(
categories,
planetDependents,
new Set(),
toggles({ hyperspaceGroups: false }),
);
expect(hidden.has(100)).toBe(true);
expect(hidden.has(150)).toBe(true);
expect(hidden.has(200)).toBe(false);
expect(hidden.has(2)).toBe(false);
});
test("hiding a planet cascades onto its dependent primitives", () => {
const hidden = computeHiddenIds(
categories,
planetDependents,
new Set([2]),
toggles(),
);
expect(hidden).toEqual(new Set([2, 100, 150, 200, 300, 400]));
});
test("battle / bombing markers have independent toggles", () => {
const hidden = computeHiddenIds(
categories,
planetDependents,
new Set(),
toggles({ battleMarkers: false }),
);
expect(hidden.has(300)).toBe(true);
expect(hidden.has(400)).toBe(false);
});
test("planet cascade and category toggle compose without duplicates", () => {
const hidden = computeHiddenIds(
categories,
planetDependents,
new Set([2]),
toggles({ battleMarkers: false }),
);
// 300 is already present from the cascade; the category toggle
// re-adds it but Set semantics dedupe.
expect(hidden).toEqual(new Set([2, 100, 150, 200, 300, 400]));
});
});
describe("computeFogCircles", () => {
test("disabled toggle returns an empty list", () => {
const report = makeReport({
localPlayerDrive: 10,
planets: [makePlanet({ number: 1, kind: "local", x: 100, y: 100 })],
});
expect(
computeFogCircles(report, toggles({ visibilityFog: false })),
).toEqual([]);
});
test("zero drive returns an empty list (radius would be zero)", () => {
const report = makeReport({
localPlayerDrive: 0,
planets: [makePlanet({ number: 1, kind: "local", x: 100, y: 100 })],
});
expect(computeFogCircles(report, toggles())).toEqual([]);
});
test("emits one circle per LOCAL planet at VisibilityDistance", () => {
const report = makeReport({
localPlayerDrive: 10,
planets: [
makePlanet({ number: 1, kind: "local", x: 100, y: 100 }),
makePlanet({ number: 2, kind: "local", x: 300, y: 200 }),
makePlanet({ number: 3, kind: "other", x: 500, y: 500 }),
],
});
const radius = 10 * VISIBILITY_DISTANCE_PER_DRIVE;
expect(radius).toBe(300);
expect(computeFogCircles(report, toggles())).toEqual([
{ x: 100, y: 100, radius },
{ x: 300, y: 200, radius },
]);
});
});
describe("fingerprintHiddenPlanets", () => {
test("sorts numerically for deterministic fingerprint", () => {
expect(fingerprintHiddenPlanets(new Set([3, 1, 2]))).toBe("1,2,3");
});
test("empty set returns an empty string", () => {
expect(fingerprintHiddenPlanets(new Set())).toBe("");
});
});