feat(ui): F8-12 — map polish (zoom invariance, labels, selection, soft radius) (#55)
* 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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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]));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user