eb5018342e
* 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>
274 lines
7.3 KiB
TypeScript
274 lines
7.3 KiB
TypeScript
// 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,
|
||
);
|
||
});
|
||
});
|