Files
galaxy-game/ui/frontend/tests/map-pick-mode.test.ts
T
Ilia Denisov eb5018342e
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Successful in 3m18s
feat(ui): F8-12 — owner feedback round 2 (#55)
* Bug fix: theme flip no longer leaves planets oversized. The
  camera-preserving remount now calls a new
  `RendererHandle.refreshCameraDerivedDraws` explicitly after the
  manual moveCenter/setZoom pair so the post-mount geometry tracks
  `viewport.scaled` even if pixi-viewport's `'zoomed'` listener
  races the next Ticker tick.
* Doc #3: clicks on a planet label route through the same hit-test
  path as a click on the disc. The label `Container` now has a
  pointer hit area sized to the text + frame padding; pointertap
  simulates a click at the planet centre, so selection and
  pick-mode resolution behave identically.
* Doc #4: battle X-crosses + cargo arrowhead wings grow
  sub-linearly with zoom (PLANET_SIZE_ZOOM_ALPHA). New
  `Style.softLengthAnchor` ('center' / 'start') makes the renderer
  treat the recorded endpoints as the geometry "at the reference
  scale" and rescale around the midpoint (X-cross) or the start
  endpoint (arrow wings). Arrowhead base length is halved from 6
  to 3 world units to match the owner's "in half" request.
* Doc #5: picker overlay loses the anchor ring at the source, the
  cursor line drops to a cargo-route-thin 0.6 px stroke, and the
  hover ring around the destination is replaced by a planet-style
  outline (visible disc + 1 px padding) in the `pickHighlight`
  accent — so candidate destinations read like selection in warm
  yellow.
* Doc #6: regression test pins the in-disc hit zone.
* Perf #1: camera-driven redraws are throttled onto the next
  Ticker tick. A rapid wheel / pinch burst now coalesces into at
  most one `clear() + redraw` pass per painted frame, which keeps
  the 500-planet map responsive on zoom and toggle flips.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 09:40:20 +02:00

274 lines
7.3 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.
// 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> = {},
): PickModeOptions {
return {
sourcePrimitiveId: 1,
sourceX: 100,
sourceY: 100,
reachableIds: new Set([2, 3]),
onPick: () => {},
...overrides,
};
}
describe("computePickOverlay", () => {
const points = new Map<PrimitiveID, PointPrim>([
[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,
);
});
});