Files
galaxy-game/ui/frontend/tests/fog-paint-ops.test.ts
T
Ilia Denisov f6e4a4f6bd
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Successful in 2m45s
feat(ui): map canvas follows light/dark theme; fix invisible gear control
The map view now selects a DARK_THEME or LIGHT_THEME palette from the
resolved app theme and threads it through every primitive builder, so
the canvas, planets, ship groups, cargo routes, battle/bombing markers,
fog, reach + selection rings, pending-Send tracks, and the pick overlay
all switch with the rest of the chrome. A theme flip remounts the
renderer preserving the camera — Pixi bakes the background at init and
every primitive bakes its colour at build, so a live re-tint is not
possible on the same instance.

This also fixes the reported bug: the gear-popover trigger and the
loading overlay hardcoded a dark navy background, so in light theme the
gear was invisible (dark icon on dark chip) until hover flipped it to a
white chip. Both now use the --color-surface-overlay token and read
correctly in both themes.

The light palette mirrors the dark one role-for-role, darkened /
saturated for contrast on a light background while keeping the incoming,
battle, and bombing accents vivid. The values are a first pass meant to
be refined during the F8 manual-QA loop.

Removes the now-dead "Phase 35" references from the code and lifts the
map-recoloring prohibition from the design-system / renderer docs; the
battle scene stays a fixed-palette data-viz surface.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 08:49:37 +02:00

205 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Phase 29 unit coverage for the visible-hyperspace overlay's paint
// ops. `fogPaintOps` lives in `src/map/render.ts` next to its sole
// consumer (`RendererHandle.setVisibilityFog`): the renderer draws
// the rectangle ops into a `fogLayer` container (below every
// primitive copy) and feeds the circle ops into an inverse stencil
// mask that cuts the visibility holes out of the fog. `fogPaintOps`
// only produces the ordered op list — rect(s) first, then one circle
// per visibility circle — which is what these tests pin; earlier
// renderer implementations used Pixi `cut()` (disconnected arcs) and
// then opaque overpaint (a fill-rate cliff under Safari's WebGPU).
//
// Coordinates returned by `fogPaintOps` are in world space because
// `fogLayer` and the mask have no transform — wraps for torus mode
// are baked into the ops directly.
import { describe, expect, test } from "vitest";
import { fogPaintOps } from "../src/map/render";
import { DARK_THEME } from "../src/map/world";
// The fog colour now lives on the theme; the renderer passes
// `theme.fog` to `fogPaintOps`. These ops tests pin the pure
// projection, so they reference the dark palette's value directly.
const FOG_COLOR = DARK_THEME.fog;
const BG_COLOR = DARK_THEME.background;
const WORLD = { width: 1000, height: 800 };
describe("fogPaintOps — no-wrap mode", () => {
test("empty input returns no ops", () => {
expect(fogPaintOps(WORLD, [], FOG_COLOR, BG_COLOR, "no-wrap")).toEqual([]);
});
test("single circle emits a single fog rect + one bg circle", () => {
const ops = fogPaintOps(
WORLD,
[{ x: 100, y: 200, radius: 50 }],
FOG_COLOR,
BG_COLOR,
"no-wrap",
);
expect(ops).toEqual([
{
kind: "fillRect",
x: 0,
y: 0,
width: 1000,
height: 800,
color: FOG_COLOR,
alpha: 1,
},
{
kind: "fillCircle",
x: 100,
y: 200,
radius: 50,
color: BG_COLOR,
alpha: 1,
},
]);
});
test("multiple circles produce one fog rect followed by N bg circles", () => {
const ops = fogPaintOps(
WORLD,
[
{ x: 100, y: 100, radius: 50 },
{ x: 300, y: 200, radius: 80 },
{ x: 500, y: 600, radius: 30 },
],
FOG_COLOR,
BG_COLOR,
"no-wrap",
);
expect(ops.length).toBe(4);
expect(ops[0].kind).toBe("fillRect");
for (let i = 1; i < ops.length; i++) {
expect(ops[i].kind).toBe("fillCircle");
const op = ops[i];
if (op.kind === "fillCircle") {
expect(op.color).toBe(BG_COLOR);
expect(op.alpha).toBe(1);
}
}
});
test("zero or negative world dimensions return no ops", () => {
expect(
fogPaintOps(
{ width: 0, height: 800 },
[{ x: 0, y: 0, radius: 10 }],
FOG_COLOR,
BG_COLOR,
"no-wrap",
),
).toEqual([]);
expect(
fogPaintOps(
{ width: 1000, height: -1 },
[{ x: 0, y: 0, radius: 10 }],
FOG_COLOR,
BG_COLOR,
"no-wrap",
),
).toEqual([]);
});
});
describe("fogPaintOps — torus mode", () => {
test("single circle expands to 9 fog rects + 9 bg circles in world space", () => {
const ops = fogPaintOps(
WORLD,
[{ x: 100, y: 200, radius: 50 }],
FOG_COLOR,
BG_COLOR,
"torus",
);
// 9 fog rects + 9 wrapped circles.
expect(ops.length).toBe(18);
// The first 9 ops are fog rects, one per neighbour tile.
const rectPositions = ops
.slice(0, 9)
.map((op) =>
op.kind === "fillRect" ? `${op.x},${op.y}` : "non-rect",
)
.sort();
const expectedRectPositions: string[] = [];
for (const dx of [-1, 0, 1]) {
for (const dy of [-1, 0, 1]) {
expectedRectPositions.push(`${dx * 1000},${dy * 800}`);
}
}
expectedRectPositions.sort();
expect(rectPositions).toEqual(expectedRectPositions);
// The next 9 ops are bg circles at every wrapped planet position.
const circlePositions = ops
.slice(9)
.map((op) =>
op.kind === "fillCircle" ? `${op.x},${op.y}` : "non-circle",
)
.sort();
const expectedCirclePositions: string[] = [];
for (const dx of [-1, 0, 1]) {
for (const dy of [-1, 0, 1]) {
expectedCirclePositions.push(
`${100 + dx * 1000},${200 + dy * 800}`,
);
}
}
expectedCirclePositions.sort();
expect(circlePositions).toEqual(expectedCirclePositions);
});
test("multiple circles produce 9 fog rects + 9N bg circles", () => {
const ops = fogPaintOps(
WORLD,
[
{ x: 100, y: 100, radius: 50 },
{ x: 700, y: 600, radius: 30 },
],
FOG_COLOR,
BG_COLOR,
"torus",
);
// 9 fog rects + (9 wraps × 2 circles) = 27 ops.
expect(ops.length).toBe(27);
expect(
ops.slice(0, 9).every((op) => op.kind === "fillRect"),
).toBe(true);
expect(
ops.slice(9).every((op) => op.kind === "fillCircle"),
).toBe(true);
const radii = ops
.slice(9)
.map((op) => (op.kind === "fillCircle" ? op.radius : 0));
expect(radii.filter((r) => r === 50).length).toBe(9);
expect(radii.filter((r) => r === 30).length).toBe(9);
});
test("a circle near the right edge produces a wrapped copy past the seam", () => {
// Planet at (950, 400) with radius 300 — the painted area
// extends to x = 1250 in the central tile. In torus mode the
// renderer also draws wrapped circles at (-50, 400) and
// (1950, 400) so the circle stays continuous across the seam
// instead of appearing as a sector clipped by the neighbour
// tile's fog rectangle.
const ops = fogPaintOps(
WORLD,
[{ x: 950, y: 400, radius: 300 }],
FOG_COLOR,
BG_COLOR,
"torus",
);
const circleXs = ops
.filter((op) => op.kind === "fillCircle")
.map((op) => (op.kind === "fillCircle" ? op.x : 0));
expect(circleXs).toContain(-50);
expect(circleXs).toContain(950);
expect(circleXs).toContain(1950);
});
test("empty input still returns no ops in torus mode", () => {
expect(fogPaintOps(WORLD, [], FOG_COLOR, BG_COLOR, "torus")).toEqual([]);
});
});