// 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 { FOG_COLOR, fogPaintOps } from "../src/map/render"; const BG_COLOR = 0x0a0e1a; 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([]); }); });