// Pure-state coverage for the pick-mode overlay helper. The // renderer owns the Pixi side (`render.ts.openPickMode`); this file // asserts that `computePickOverlay` produces the correct draw spec // for every meaningful input combination — Pixi-free, so it stays // fast and stable against renderer plumbing changes. import { describe, expect, test } from "vitest"; import { ANCHOR_PADDING_WORLD, HOVER_PADDING_PX, computePickOverlay, type PickModeOptions, } from "../src/map/pick-mode"; import { DEFAULT_POINT_RADIUS_PX, type PointPrim, type PrimitiveID, } from "../src/map/world"; function makePoint( id: PrimitiveID, x: number, y: number, pointRadiusPx?: number, ): PointPrim { return { kind: "point", id, priority: 0, hitSlopPx: 0, x, y, style: pointRadiusPx === undefined ? {} : { pointRadiusPx }, }; } function makeOptions( overrides: Partial = {}, ): PickModeOptions { return { sourcePrimitiveId: 1, sourceX: 100, sourceY: 100, reachableIds: new Set([2, 3]), onPick: () => {}, ...overrides, }; } describe("computePickOverlay", () => { const points = new Map([ [1, makePoint(1, 100, 100, 6)], [2, makePoint(2, 200, 100, 5)], [3, makePoint(3, 100, 200)], [4, makePoint(4, 300, 300, 4)], ]); const allIds: PrimitiveID[] = [1, 2, 3, 4]; test("anchor radius equals source pointRadiusPx + ANCHOR_PADDING_WORLD", () => { const spec = computePickOverlay(makeOptions(), null, null, points, allIds); expect(spec.anchor.x).toBe(100); expect(spec.anchor.y).toBe(100); expect(spec.anchor.radius).toBe(6 + ANCHOR_PADDING_WORLD); }); test("anchor radius falls back to default when source has no pointRadiusPx", () => { const sourceless = new Map(points); sourceless.set(1, makePoint(1, 100, 100)); const spec = computePickOverlay( makeOptions(), null, null, sourceless, allIds, ); expect(spec.anchor.radius).toBe( DEFAULT_POINT_RADIUS_PX + ANCHOR_PADDING_WORLD, ); }); test("dimmedIds covers everything outside source + reachable", () => { const spec = computePickOverlay(makeOptions(), null, null, points, allIds); expect(Array.from(spec.dimmedIds).sort()).toEqual([4]); }); test("dimmedIds is empty when every primitive is either source or reachable", () => { const spec = computePickOverlay( makeOptions({ reachableIds: new Set([2, 3, 4]) }), null, null, points, allIds, ); expect(spec.dimmedIds.size).toBe(0); }); test("line is null while the cursor is off-canvas", () => { const spec = computePickOverlay(makeOptions(), null, null, points, allIds); expect(spec.line).toBeNull(); }); test("line endpoints follow the cursor when present (no-wrap)", () => { const spec = computePickOverlay( makeOptions(), { x: 250, y: 320 }, null, points, allIds, ); expect(spec.line).toEqual({ x1: 100, y1: 100, x2: 250, y2: 320, }); }); test("torus line endpoint uses torusShortestDelta — short path matches direct cursor", () => { // Source at (100,100), cursor at (150,120). Wrap world 201×201 // — the direct delta is already the shortest, so the endpoint // equals the cursor. const spec = computePickOverlay( makeOptions(), { x: 150, y: 120 }, null, points, allIds, { width: 201, height: 201 }, ); expect(spec.line).toEqual({ x1: 100, y1: 100, x2: 150, y2: 120 }); }); test("torus line endpoint wraps when the short path crosses the seam (x axis)", () => { // Issue #50 repro: source A3 near the left edge (x=1.44) of a // 201-wide world, cursor over a wrap copy of an A1-like planet // at world x ≈ -15 (the user is panned to the left side, seeing // the wrap copy that A1's canonical x=184 produces at x=184-201 // = -17 in the screen-space the renderer paints in the -W tile). // torusShortestDelta(1.44, -15, 201) is -16.44 — the line endpoint // is the source + that delta, extending left past x=0. const spec = computePickOverlay( makeOptions({ sourceX: 1.44, sourceY: 146 }), { x: -15, y: 137 }, null, points, allIds, { width: 201, height: 201 }, ); expect(spec.line).not.toBeNull(); expect(spec.line!.x1).toBe(1.44); expect(spec.line!.y1).toBe(146); expect(spec.line!.x2).toBeCloseTo(-15); expect(spec.line!.y2).toBe(137); }); test("torus line endpoint wraps the long-way cursor back through the seam (x axis)", () => { // Source at x=184 (canonical A1), cursor at x=200 (just inside // the [0, W) tile near the right edge). Direct delta is +16 // (no wrap), but if the cursor sits in a wrap copy of an // A3-like planet at world x ≈ 202.44, the torus-shortest delta // from 184 is +18.44 and the endpoint extends past the seam. const spec = computePickOverlay( makeOptions({ sourceX: 184, sourceY: 137 }), { x: 202.44, y: 146 }, null, points, allIds, { width: 201, height: 201 }, ); expect(spec.line!.x1).toBe(184); expect(spec.line!.x2).toBeCloseTo(202.44); }); test("torus line endpoint wraps when the short path crosses the seam (y axis)", () => { // Symmetric coverage on the y axis — source near the bottom, // cursor pulled across the seam via the negative-y wrap. const spec = computePickOverlay( makeOptions({ sourceX: 50, sourceY: 3 }), { x: 50, y: -10 }, null, points, allIds, { width: 100, height: 50 }, ); expect(spec.line!.y1).toBe(3); expect(spec.line!.y2).toBeCloseTo(-10); expect(spec.line!.x1).toBe(50); expect(spec.line!.x2).toBe(50); }); test("torus mode preserves canonical anchor and hover-outline coords", () => { // The renderer paints the overlay into every torus copy with // its own offset, so the spec stays in canonical coords; the // torus argument must only affect the cursor line. This test // guards the contract for the future-careless reader. const spec = computePickOverlay( makeOptions(), { x: 250, y: 320 }, 2, points, allIds, { width: 400, height: 400 }, ); expect(spec.anchor).toEqual({ x: 100, y: 100, radius: 6 + ANCHOR_PADDING_WORLD }); expect(spec.hoverOutline).toEqual({ x: 200, y: 100, radius: 5 + HOVER_PADDING_PX, }); }); test("hoverOutline is null when nothing is hovered", () => { const spec = computePickOverlay( makeOptions(), { x: 1, y: 1 }, null, points, allIds, ); expect(spec.hoverOutline).toBeNull(); }); test("hoverOutline is null when the hover targets a non-reachable primitive", () => { const spec = computePickOverlay( makeOptions(), { x: 1, y: 1 }, 4, points, allIds, ); expect(spec.hoverOutline).toBeNull(); }); test("hoverOutline is null when the hover targets the source planet", () => { const spec = computePickOverlay( makeOptions(), { x: 1, y: 1 }, 1, points, allIds, ); expect(spec.hoverOutline).toBeNull(); }); test("hoverOutline reflects the reachable target with HOVER_PADDING_PX", () => { const spec = computePickOverlay( makeOptions(), { x: 1, y: 1 }, 2, points, allIds, ); expect(spec.hoverOutline).toEqual({ x: 200, y: 100, radius: 5 + HOVER_PADDING_PX, }); }); test("hoverOutline radius falls back to default radius for default-style points", () => { const spec = computePickOverlay( makeOptions(), { x: 1, y: 1 }, 3, points, allIds, ); expect(spec.hoverOutline?.radius).toBe( DEFAULT_POINT_RADIUS_PX + HOVER_PADDING_PX, ); }); });