feat(ui): F8-12 — map polish (zoom invariance, labels, selection, soft radius) (#55)
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Failing after 5m16s

* Honest pixel-space sizing for `pointRadiusPx` / `strokeWidthPx`: the
  renderer divides by the current camera scale on every
  `viewport.zoomed` so thin lines / small markers stay the same on-screen
  size at any zoom.
* Known-size planets switch to `pointRadiusWorld`, softened against the
  reference scale by `PLANET_SIZE_ZOOM_ALPHA = 0.33`; unidentified
  planets pin to a 3-px disc.
* New planet label layer renders a two-line `name / #N` legend under
  each planet (`#N` only for unidentified or when the new `planetNames`
  toggle is off). Selection now paints an inverse-fill frame around the
  selected planet's label plus an outline on the disc; the old
  selection-ring primitive is retired.
* Bombing markers swap the separate CirclePrim for a planet-outline
  overlay (damaged / wiped colour); the report deep-link moves to a
  "view bombing report" link in the planet inspector.
* Docs + tests follow: `renderer.md` reflects the new sizing contract +
  label / outline layers, vitest covers the sizing math, label
  formatting, and the new toggle, and the map-toggles e2e adds a
  persistence case for `planetNames`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-27 23:51:16 +02:00
parent ba93a9092e
commit 680ebac919
30 changed files with 1240 additions and 322 deletions
+3 -21
View File
@@ -110,7 +110,7 @@ describe("buildBattleAndBombingMarkers", () => {
expect(out.primitives).toHaveLength(0);
});
it("emits one yellow ring per damaged bombing and red per wiped", () => {
it("does not emit bombing primitives (F8-12 / #30) — the planet outline is drawn elsewhere", () => {
const report = makeReport({
planets: [
{
@@ -163,28 +163,12 @@ describe("buildBattleAndBombingMarkers", () => {
attackPower: 1,
wiped: false,
},
{
planetNumber: 2,
planet: "B",
owner: "X",
attacker: "Y",
production: "MAT",
industry: 0,
population: 0,
colonists: 0,
industryStockpile: 0,
materialsStockpile: 0,
attackPower: 1,
wiped: true,
},
],
});
const out = buildBattleAndBombingMarkers(report);
const rings = out.primitives.filter((p) => p.kind === "circle");
expect(rings).toHaveLength(2);
expect(rings[0].style.strokeColor).toBe(DARK_THEME.bombingDamaged);
expect(rings[1].style.strokeColor).toBe(DARK_THEME.bombingWiped);
expect(out.primitives.filter((p) => p.kind === "circle")).toHaveLength(0);
// `setPlanetOutlines` in the renderer paints the bombing accent.
});
it("paints markers with the supplied palette's colours", () => {
@@ -231,11 +215,9 @@ describe("buildBattleAndBombingMarkers", () => {
const out = buildBattleAndBombingMarkers(report, LIGHT_THEME);
const lines = out.primitives.filter((p) => p.kind === "line");
const rings = out.primitives.filter((p) => p.kind === "circle");
for (const l of lines) {
expect(l.style.strokeColor).toBe(LIGHT_THEME.battleMarker);
}
expect(rings[0].style.strokeColor).toBe(LIGHT_THEME.bombingWiped);
// The accents are deliberately distinct between the palettes.
expect(LIGHT_THEME.battleMarker).not.toBe(DARK_THEME.battleMarker);
expect(LIGHT_THEME.bombingWiped).not.toBe(DARK_THEME.bombingWiped);
+32 -2
View File
@@ -365,9 +365,39 @@ test("toggle state persists across a page reload", async ({ page }) => {
expect(
await page.getByTestId("map-toggles-bombing-markers").isChecked(),
).toBe(false);
// Battle X-cross and bombing ring are hidden in the renderer.
// Battle X-cross primitives stay hidden in the renderer. F8-12 / #30
// retired the bombing CirclePrim — the toggle now hides a planet
// outline overlay, which sits outside the primitive surface; the
// high-bit 0xc… range is permanently empty.
expect(await visibleHighBitCount(page, 0xa0000000)).toBe(0);
expect(await visibleHighBitCount(page, 0xc0000000)).toBe(0);
});
test("planet-names toggle persists across a page reload (F8-12 / #29)", async ({
page,
}) => {
await mockGateway(page, { currentTurn: 1 });
await bootSession(page);
await openGame(page);
await page.getByTestId("map-toggles-trigger").click();
// Default ON; flip it OFF.
expect(await page.getByTestId("map-toggles-planet-names").isChecked()).toBe(
true,
);
await page.getByTestId("map-toggles-planet-names").click();
expect(await page.getByTestId("map-toggles-planet-names").isChecked()).toBe(
false,
);
await page.reload({ waitUntil: "commit" });
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status",
"ready",
);
await page.getByTestId("map-toggles-trigger").click();
expect(await page.getByTestId("map-toggles-planet-names").isChecked()).toBe(
false,
);
});
// settledRenderCount waits out the mount/resize paint burst and returns
@@ -0,0 +1,83 @@
// Coverage for the F8-12 sizing helpers in src/map/world.ts:
// `displayPointRadiusWorld` (the union of the pixel-space and the
// softened-by-zoom rules) and `displayStrokeWidthWorld` (pixel-space
// stroke widths). Both are pure math, so this file stays Pixi-free.
import { describe, expect, test } from "vitest";
import {
DEFAULT_POINT_RADIUS_PX,
PLANET_SIZE_ZOOM_ALPHA,
displayPointRadiusWorld,
displayStrokeWidthWorld,
} from "../src/map/world";
describe("displayPointRadiusWorld — pixel-space (pointRadiusPx)", () => {
test("returns pixel size divided by scale at scale=1", () => {
expect(displayPointRadiusWorld({ pointRadiusPx: 5 }, 1, 0.2)).toBe(5);
});
test("shrinks the world footprint as zoom grows", () => {
expect(displayPointRadiusWorld({ pointRadiusPx: 6 }, 3, 0.2)).toBeCloseTo(2);
});
test("falls back to DEFAULT_POINT_RADIUS_PX when the style is bare", () => {
expect(displayPointRadiusWorld({}, 2, 0.2)).toBeCloseTo(
DEFAULT_POINT_RADIUS_PX / 2,
);
});
test("zero-scale guard returns the raw pixel size", () => {
expect(displayPointRadiusWorld({ pointRadiusPx: 4 }, 0, 0.2)).toBe(4);
});
});
describe("displayPointRadiusWorld — softened by zoom (pointRadiusWorld)", () => {
test("at scale=scaleRef the visible radius equals the base radius", () => {
const radius = displayPointRadiusWorld(
{ pointRadiusWorld: 6 },
0.2,
0.2,
);
expect(radius).toBeCloseTo(6);
});
test("zooming in grows the radius sub-linearly", () => {
const r1 = displayPointRadiusWorld({ pointRadiusWorld: 6 }, 0.2, 0.2);
const r10 = displayPointRadiusWorld({ pointRadiusWorld: 6 }, 2.0, 0.2);
// On-screen pixel size grows by scale^α (α = 0.33) instead of
// linearly: 10x zoom → ~10^0.33 ≈ 2.15x growth.
const onScreenAt1 = r1 * 0.2;
const onScreenAt10 = r10 * 2.0;
expect(onScreenAt10 / onScreenAt1).toBeCloseTo(
Math.pow(10, PLANET_SIZE_ZOOM_ALPHA),
3,
);
});
test("ignores pointRadiusPx when pointRadiusWorld is set", () => {
const r = displayPointRadiusWorld(
{ pointRadiusPx: 99, pointRadiusWorld: 4 },
0.4,
0.2,
);
// World radius is the base softened by (0.4/0.2)^(α-1).
expect(r).toBeCloseTo(4 * Math.pow(2, PLANET_SIZE_ZOOM_ALPHA - 1), 4);
});
});
describe("displayStrokeWidthWorld", () => {
test("returns width / scale at any zoom", () => {
expect(displayStrokeWidthWorld({ strokeWidthPx: 2 }, 1)).toBe(2);
expect(displayStrokeWidthWorld({ strokeWidthPx: 2 }, 4)).toBeCloseTo(0.5);
expect(displayStrokeWidthWorld({ strokeWidthPx: 2 }, 0.5)).toBeCloseTo(4);
});
test("falls back to 1 when strokeWidthPx is omitted", () => {
expect(displayStrokeWidthWorld({}, 2)).toBeCloseTo(0.5);
});
test("zero-scale guard returns the raw pixel value", () => {
expect(displayStrokeWidthWorld({ strokeWidthPx: 3 }, 0)).toBe(3);
});
});
+39 -23
View File
@@ -5,11 +5,13 @@
// expected hit is obvious from the geometry; the camera is at scale=1
// in most cases so slop in pixels equals slop in world units.
//
// The point hit zone is `(pointRadiusPx + slopPx) / camera.scale`
// world units — the visible disc plus an ergonomic slop on top. The
// default `pointRadiusPx` (`DEFAULT_POINT_RADIUS_PX`) is 3 and the
// default point slop (`DEFAULT_HIT_SLOP_PX.point`) is 4, so a default
// point is hit out to 7 world units at scale=1.
// F8-12 / #28 made `pointRadiusPx` and `strokeWidthPx` honest screen-
// pixel sizes — the hit zone is `(pointRadiusPx + slopPx) / scale`
// world units, which equals `pointRadiusPx + slopPx` *pixels* on
// screen at any zoom. The default `pointRadiusPx`
// (`DEFAULT_POINT_RADIUS_PX`) is 3 and the default point slop
// (`DEFAULT_HIT_SLOP_PX.point`) is 4, so a default point is hit out
// to 7 *screen* pixels — equal to 7 world units at scale=1.
import { describe, expect, test } from "vitest";
import { hitTest } from "../src/map/hit-test";
@@ -256,28 +258,42 @@ describe("hitTest — empty results and scale", () => {
expect(ids(w, "torus", cam, cursorOver(500, 500, cam))).toBe(null);
});
test("higher zoom shrinks the on-screen slop in world units", () => {
// At scale=4, slopPx 4 = 1 world unit; visible radius stays 3
// world units. Threshold = 4 world units.
const w = new World(1000, 1000, [point(1, 503, 500)]);
test("higher zoom shrinks the world-unit footprint of the default disc", () => {
// At scale=4, pointRadiusPx 3 = 0.75 world units; slop 4 = 1
// world unit. Threshold = 1.75 world units.
const cam4 = camAt(500, 500, 4);
// 3 world units away → on the disc edge → hit.
expect(ids(w, "torus", cam4, cursorOver(503, 500, cam4))).toBe(1);
// 5 world units away → beyond radius+slop → null.
const wFar = new World(1000, 1000, [point(1, 505, 500)]);
expect(ids(wFar, "torus", cam4, cursorOver(500, 500, cam4))).toBe(null);
const w = new World(1000, 1000, [point(1, 500, 500)]);
// 1.5 world units away → within 1.75 → hit.
expect(ids(w, "torus", cam4, cursorOver(501.5, 500, cam4))).toBe(1);
// 2 world units away → beyond 1.75 → null.
expect(ids(w, "torus", cam4, cursorOver(502, 500, cam4))).toBe(null);
});
test("lower zoom widens the on-screen slop in world units", () => {
// At scale=0.5, slopPx 4 = 8 world units; visible radius
// stays 3 → threshold = 11 world units.
test("lower zoom inflates the world-unit footprint of the default disc", () => {
// At scale=0.5, pointRadiusPx 3 = 6 world units; slop 4 = 8
// world units. Threshold = 14 world units.
const cam05 = camAt(500, 500, 0.5);
const w = new World(1000, 1000, [point(1, 510, 500)]);
// 10 world units away → within 11 → hit.
expect(ids(w, "torus", cam05, cursorOver(500, 500, cam05))).toBe(1);
const wFar = new World(1000, 1000, [point(1, 514, 500)]);
// 14 world units away → beyond 11 → null.
expect(ids(wFar, "torus", cam05, cursorOver(500, 500, cam05))).toBe(null);
const w = new World(1000, 1000, [point(1, 500, 500)]);
// 13 world units away → within 14 → hit.
expect(ids(w, "torus", cam05, cursorOver(513, 500, cam05))).toBe(1);
// 16 world units away → beyond 14 → null.
expect(ids(w, "torus", cam05, cursorOver(516, 500, cam05))).toBe(null);
});
test("pointRadiusWorld scales softly with zoom (F8-12 / #31)", () => {
// world 1000×1000, viewport 200×200 → scaleRef = 0.2 (every
// world unit becomes 0.2 px on screen at the "whole world fits"
// zoom). PLANET_SIZE_ZOOM_ALPHA is 0.33: r_display =
// r_base * (scale / scaleRef)^(α - 1).
const cam05 = camAt(500, 500, 0.5);
const wBase = new World(1000, 1000, [
point(1, 500, 500, { style: { pointRadiusWorld: 6 } }),
]);
// At scale=0.5 the softening factor is (0.5/0.2)^(0.33-1) ≈ 0.554.
// Visible radius ≈ 3.32 world units, slop 8, threshold ≈ 11.32.
expect(ids(wBase, "torus", cam05, cursorOver(510, 500, cam05))).toBe(1);
// Cursor 12 world units away exceeds the threshold.
expect(ids(wBase, "torus", cam05, cursorOver(512, 500, cam05))).toBe(null);
});
});
+147
View File
@@ -0,0 +1,147 @@
// Coverage for the F8-12 / #29 planet-label formatting. The
// renderer's per-Pixi.Text drawing lives behind Pixi APIs (and is
// exercised by Playwright); this file pins the pure data step.
import { describe, expect, test } from "vitest";
import type { GameReport } from "../src/api/game-state";
import { buildPlanetLabels } from "../src/map/labels";
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
function makeReport(overrides: Partial<GameReport> = {}): GameReport {
return {
turn: 1,
mapWidth: 100,
mapHeight: 100,
planetCount: 0,
planets: [],
race: "",
localShipClass: [],
routes: [],
localPlayerDrive: 0,
localPlayerWeapons: 0,
localPlayerShields: 0,
localPlayerCargo: 0,
...EMPTY_SHIP_GROUPS,
...overrides,
};
}
describe("buildPlanetLabels", () => {
test("named planet with showNames=true emits both lines", () => {
const report = makeReport({
planets: [
{
number: 5,
name: "Tancordia",
kind: "local",
x: 10,
y: 20,
owner: null,
size: null,
resources: null,
industryStockpile: null,
materialsStockpile: null,
industry: null,
population: null,
colonists: null,
production: null,
freeIndustry: null,
},
],
});
const out = buildPlanetLabels(report, { showNames: true });
expect(out).toEqual([
{
planetNumber: 5,
x: 10,
y: 20,
name: "Tancordia",
numberLabel: "#5",
},
]);
});
test("named planet with showNames=false drops the name line", () => {
const report = makeReport({
planets: [
{
number: 5,
name: "Tancordia",
kind: "local",
x: 10,
y: 20,
owner: null,
size: null,
resources: null,
industryStockpile: null,
materialsStockpile: null,
industry: null,
population: null,
colonists: null,
production: null,
freeIndustry: null,
},
],
});
const out = buildPlanetLabels(report, { showNames: false });
expect(out[0].name).toBeNull();
expect(out[0].numberLabel).toBe("#5");
});
test("unidentified planet always renders #N only, ignoring the toggle", () => {
const report = makeReport({
planets: [
{
number: 42,
name: "Tancordia",
kind: "unidentified",
x: 5,
y: 5,
owner: null,
size: null,
resources: null,
industryStockpile: null,
materialsStockpile: null,
industry: null,
population: null,
colonists: null,
production: null,
freeIndustry: null,
},
],
});
const on = buildPlanetLabels(report, { showNames: true });
const off = buildPlanetLabels(report, { showNames: false });
expect(on[0].name).toBeNull();
expect(off[0].name).toBeNull();
expect(on[0].numberLabel).toBe("#42");
});
test("empty-name planet falls back to #N", () => {
const report = makeReport({
planets: [
{
number: 9,
name: "",
kind: "uninhabited",
x: 1,
y: 1,
owner: null,
size: null,
resources: null,
industryStockpile: null,
materialsStockpile: null,
industry: null,
population: null,
colonists: null,
production: null,
freeIndustry: null,
},
],
});
const out = buildPlanetLabels(report, { showNames: true });
expect(out[0].name).toBeNull();
expect(out[0].numberLabel).toBe("#9");
});
});
@@ -59,6 +59,7 @@ describe("MapTogglesControl", () => {
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-planet-names")).toBeChecked();
expect(ui.getByTestId("map-toggles-visible-hyperspace")).toBeChecked();
expect(ui.queryByTestId("map-toggles-wrap-torus")).toBeNull();
expect(ui.queryByTestId("map-toggles-wrap-no-wrap")).toBeNull();
@@ -91,6 +92,17 @@ describe("MapTogglesControl", () => {
expect(setMapToggle).not.toHaveBeenCalledWith("bombingMarkers", false);
});
test("planet-names checkbox flips the planetNames toggle (F8-12 / #29)", 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-planet-names"));
expect(setMapToggle).toHaveBeenCalledWith("planetNames", false);
});
test("Escape closes the popover", async () => {
const store = buildStore();
const ui = render(MapTogglesControl, { props: { store } });
@@ -113,6 +113,7 @@ describe("GameStateStore.mapToggles persistence", () => {
await a.init({ client: makeFakeClient(3), cache, gameId: GAME_ID });
await a.setMapToggle("hyperspaceGroups", false);
await a.setMapToggle("battleMarkers", false);
await a.setMapToggle("planetNames", false);
await a.setMapToggle("visibleHyperspace", false);
a.dispose();
@@ -121,6 +122,7 @@ describe("GameStateStore.mapToggles persistence", () => {
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.planetNames).toBe(false);
expect(b.mapToggles.visibleHyperspace).toBe(false);
// Untouched flags retain defaults.
expect(b.mapToggles.bombingMarkers).toBe(true);
@@ -141,6 +143,7 @@ describe("GameStateStore.mapToggles persistence", () => {
expect(store.mapToggles.hyperspaceGroups).toBe(false);
expect(store.mapToggles.battleMarkers).toBe(true);
expect(store.mapToggles.bombingMarkers).toBe(true);
expect(store.mapToggles.planetNames).toBe(true);
expect(store.mapToggles.visibleHyperspace).toBe(true);
store.dispose();
});
-40
View File
@@ -1,40 +0,0 @@
import { describe, expect, it } from "vitest";
import { computeSelectionRing, SELECTION_RING_ID } from "../src/map/selection-ring";
import { DARK_THEME, LIGHT_THEME } from "../src/map/world";
const planets = [
{ number: 1, x: 10, y: 20 },
{ number: 2, x: 30, y: 40 },
];
describe("computeSelectionRing", () => {
it("returns null when nothing is selected", () => {
expect(computeSelectionRing(planets, null)).toBeNull();
});
it("returns null when the selected planet is absent from the report", () => {
expect(computeSelectionRing(planets, 99)).toBeNull();
});
it("rings the selected planet at its coordinates", () => {
const ring = computeSelectionRing(planets, 2);
expect(ring).toMatchObject({
kind: "circle",
id: SELECTION_RING_ID,
x: 30,
y: 40,
hitSlopPx: 0,
});
// Defaults to the dark palette.
expect(ring?.style.strokeColor).toBe(DARK_THEME.selectionRing);
// Sits outside the planet marker (radius 6 world units).
expect(ring?.radius ?? 0).toBeGreaterThan(6);
});
it("uses the supplied palette's ring colour", () => {
const ring = computeSelectionRing(planets, 2, LIGHT_THEME);
expect(ring?.style.strokeColor).toBe(LIGHT_THEME.selectionRing);
expect(LIGHT_THEME.selectionRing).not.toBe(DARK_THEME.selectionRing);
});
});
@@ -17,7 +17,7 @@ import type {
ReportPlanet,
ReportUnidentifiedShipGroup,
} from "../src/api/game-state";
import { BATTLE_MARKER_ID_PREFIX, BOMBING_MARKER_ID_PREFIX } from "../src/map/battle-markers";
import { BATTLE_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";
@@ -200,7 +200,7 @@ describe("reportToWorld — categories", () => {
expect(categories.get(unidentifiedId)).toBe("unidentifiedGroup");
});
test("battle markers and bombing markers each carry their own category", () => {
test("battle markers carry the battleMarker category", () => {
const { categories } = reportToWorld(
makeReport({
planets: [
@@ -208,6 +208,9 @@ describe("reportToWorld — categories", () => {
makePlanet({ number: 2, kind: "other", x: 200, y: 200 }),
],
battles: [makeBattle({ id: "b1", planet: 2 })],
// F8-12 / #30: bombings no longer emit their own
// primitives — the planet outline is drawn by
// `setPlanetOutlines` from the map view.
bombings: [makeBombing({ planetNumber: 2 })],
}),
);
@@ -216,8 +219,6 @@ describe("reportToWorld — categories", () => {
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");
});
});
@@ -235,7 +236,7 @@ describe("reportToWorld — planetDependents", () => {
expect(planetDependents.get(7)?.has(7)).toBe(true);
});
test("battle / bombing markers cascade onto their anchor planet", () => {
test("battle markers cascade onto their anchor planet", () => {
const { planetDependents } = reportToWorld(
makeReport({
planets: [
@@ -243,17 +244,18 @@ describe("reportToWorld — planetDependents", () => {
makePlanet({ number: 2, kind: "other", x: 200, y: 200 }),
],
battles: [makeBattle({ planet: 2 })],
// Bombings are still in the report but no primitive
// rides the cascade now — they paint a planet outline
// straight from `map.svelte`.
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", () => {
+11 -9
View File
@@ -82,10 +82,10 @@ describe("isCategoryVisible", () => {
expect(isCategoryVisible("planet-unidentified", t)).toBe(false);
});
test("battle and bombing markers have independent toggles", () => {
const t = toggles({ battleMarkers: false, bombingMarkers: true });
test("battleMarker toggle hides battle X-crosses without touching other layers", () => {
const t = toggles({ battleMarkers: false });
expect(isCategoryVisible("battleMarker", t)).toBe(false);
expect(isCategoryVisible("bombingMarker", t)).toBe(true);
expect(isCategoryVisible("planet-foreign", t)).toBe(true);
});
});
@@ -202,6 +202,9 @@ describe("computeHiddenPlanetNumbers", () => {
});
describe("computeHiddenIds", () => {
// F8-12 / #30: bombings no longer ride the cascade as their own
// primitive — they paint a planet outline directly. The fixture
// here mirrors what `reportToWorld` currently emits.
const categories: Map<PrimitiveID, MapCategory> = new Map<
PrimitiveID,
MapCategory
@@ -212,11 +215,10 @@ describe("computeHiddenIds", () => {
[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])],
[2, new Set([2, 100, 150, 200, 300])],
]);
test("category-toggle off hides every primitive in that category", () => {
@@ -239,10 +241,10 @@ describe("computeHiddenIds", () => {
new Set([2]),
toggles(),
);
expect(hidden).toEqual(new Set([2, 100, 150, 200, 300, 400]));
expect(hidden).toEqual(new Set([2, 100, 150, 200, 300]));
});
test("battle / bombing markers have independent toggles", () => {
test("battle markers honour the battleMarkers toggle independently", () => {
const hidden = computeHiddenIds(
categories,
planetDependents,
@@ -250,7 +252,7 @@ describe("computeHiddenIds", () => {
toggles({ battleMarkers: false }),
);
expect(hidden.has(300)).toBe(true);
expect(hidden.has(400)).toBe(false);
expect(hidden.has(150)).toBe(false);
});
test("planet cascade and category toggle compose without duplicates", () => {
@@ -262,7 +264,7 @@ describe("computeHiddenIds", () => {
);
// 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]));
expect(hidden).toEqual(new Set([2, 100, 150, 200, 300]));
});
});