Files
galaxy-game/ui/frontend/tests/visibility-helpers.test.ts
T
Ilia Denisov 2bd1b54936
Tests · Go / test (push) Successful in 2m31s
Tests · UI / test (push) Failing after 8m7s
feat(ui): Phase 29 map visibility toggles
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>
2026-05-19 21:33:53 +02:00

315 lines
9.0 KiB
TypeScript

// 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("");
});
});