Files
galaxy-game/ui/frontend/tests/map-hit-test.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

366 lines
12 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.
// Hand-built cases for the hit-test pass in src/map/hit-test.ts.
//
// Each describe block exercises one rule from the algorithm spec in
// ui/docs/renderer.md. Worlds are kept tiny (15 primitives) so the
// expected hit is obvious from the geometry; the camera is at scale=1
// in most cases so slop in pixels equals slop in world units.
//
// F8-12 / #28 made `pointRadiusPx` and `strokeWidthPx` honest screen-
// pixel sizes — the hit zone is `(pointRadiusPx + slopPx) / scale`
// world units, which equals `pointRadiusPx + slopPx` *pixels* on
// screen at any zoom. The default `pointRadiusPx`
// (`DEFAULT_POINT_RADIUS_PX`) is 3 and the default point slop
// (`DEFAULT_HIT_SLOP_PX.point`) is 4, so a default point is hit out
// to 7 *screen* pixels — equal to 7 world units at scale=1.
import { describe, expect, test } from "vitest";
import { hitTest } from "../src/map/hit-test";
import {
type Camera,
type Primitive,
type Viewport,
World,
type WrapMode,
} from "../src/map/world";
const VP: Viewport = { widthPx: 200, heightPx: 200 };
// Centre the camera over the world centre at scale=1 so screen px
// equals world units inside the visible region.
function camAt(centerX: number, centerY: number, scale = 1): Camera {
return { centerX, centerY, scale };
}
// Cursor at world point (wx, wy) under the given camera.
function cursorOver(wx: number, wy: number, cam: Camera, vp: Viewport = VP) {
return {
x: vp.widthPx / 2 + (wx - cam.centerX) * cam.scale,
y: vp.heightPx / 2 + (wy - cam.centerY) * cam.scale,
};
}
function point(
id: number,
x: number,
y: number,
overrides: Partial<Primitive> = {},
): Primitive {
return {
kind: "point",
id,
x,
y,
priority: 0,
style: {},
hitSlopPx: 0,
...overrides,
} as Primitive;
}
function circle(
id: number,
x: number,
y: number,
radius: number,
overrides: Partial<Primitive> = {},
): Primitive {
return {
kind: "circle",
id,
x,
y,
radius,
priority: 0,
style: {},
hitSlopPx: 0,
...overrides,
} as Primitive;
}
function line(
id: number,
x1: number,
y1: number,
x2: number,
y2: number,
overrides: Partial<Primitive> = {},
): Primitive {
return {
kind: "line",
id,
x1,
y1,
x2,
y2,
priority: 0,
style: {},
hitSlopPx: 0,
...overrides,
} as Primitive;
}
function ids(world: World, mode: WrapMode, cam: Camera, cursorPx: { x: number; y: number }) {
const h = hitTest(world, cam, VP, cursorPx, mode);
return h?.primitive.id ?? null;
}
describe("hitTest — point primitive", () => {
const cam = camAt(500, 500);
const w = new World(1000, 1000, [point(1, 500, 500)]);
test("direct hit at centre", () => {
expect(ids(w, "torus", cam, cursorOver(500, 500, cam))).toBe(1);
});
test("hit on the visible disc edge (3 world units from centre)", () => {
// Default radius 3 → cursor 3 units away lands on the disc.
expect(ids(w, "torus", cam, cursorOver(503, 500, cam))).toBe(1);
});
test("hit just inside the default slop margin (within radius+slop)", () => {
// 7 world units away at scale=1 → equals radius (3) + slop (4).
expect(ids(w, "torus", cam, cursorOver(507, 500, cam))).toBe(1);
});
test("miss just outside radius+slop", () => {
// 9 world units away at scale=1 → radius+slop is 7.
expect(ids(w, "torus", cam, cursorOver(509, 500, cam))).toBe(null);
});
test("explicit pointRadiusPx widens the visible footprint", () => {
// pointRadiusPx 10 + default slop 4 → hit out to 14 world units.
const w2 = new World(1000, 1000, [
point(1, 500, 500, { style: { pointRadiusPx: 10 } }),
]);
expect(ids(w2, "torus", cam, cursorOver(513, 500, cam))).toBe(1);
expect(ids(w2, "torus", cam, cursorOver(515, 500, cam))).toBe(null);
});
test("custom hitSlopPx widens the slop margin", () => {
// pointRadiusPx defaults to 3; slop override is 20.
// Cursor 22 world units away → within 3+20.
const w2 = new World(1000, 1000, [point(1, 500, 500, { hitSlopPx: 20 })]);
expect(ids(w2, "torus", cam, cursorOver(522, 500, cam))).toBe(1);
expect(ids(w2, "torus", cam, cursorOver(524, 500, cam))).toBe(null);
});
});
describe("hitTest — torus wrap", () => {
test("point near the right edge is hit by cursor near the left edge", () => {
// World 100×100, point at x=98. Camera at left edge (x=2).
// Cursor at x=4 is 6 units from x=98 via the wrap; default
// point radius (3) + slop (4) = 7 → hit.
const cam = camAt(2, 50);
const w = new World(100, 100, [point(1, 98, 50)]);
expect(ids(w, "torus", cam, cursorOver(4, 50, cam))).toBe(1);
});
test("no-wrap mode does not match through the torus seam", () => {
const cam = camAt(2, 50);
const w = new World(100, 100, [point(1, 98, 50)]);
expect(ids(w, "no-wrap", cam, cursorOver(4, 50, cam))).toBe(null);
});
test("line spanning the torus seam is hit at the wrapped midpoint", () => {
// World 100×100, line from (95, 50) to (5, 50).
// Torus-shortest is the wrap segment of length 10.
// Cursor at x=0,y=50 is on the wrapped segment.
const cam = camAt(0, 50);
const w = new World(100, 100, [line(1, 95, 50, 5, 50)]);
expect(ids(w, "torus", cam, cursorOver(0, 50, cam))).toBe(1);
});
});
describe("hitTest — circle primitive", () => {
const cam = camAt(500, 500);
test("filled circle: cursor inside disc hits", () => {
const w = new World(1000, 1000, [
circle(1, 500, 500, 50, { style: { fillColor: 0xffffff, fillAlpha: 1 } }),
]);
expect(ids(w, "torus", cam, cursorOver(530, 500, cam))).toBe(1);
});
test("stroked-only circle: cursor inside disc but far from ring misses", () => {
const w = new World(1000, 1000, [circle(1, 500, 500, 50)]);
expect(ids(w, "torus", cam, cursorOver(510, 500, cam))).toBe(null);
});
test("stroked-only circle: cursor on ring within slop hits", () => {
const w = new World(1000, 1000, [circle(1, 500, 500, 50)]);
// Cursor at (548, 500): distance to centre is 48; ring at 50;
// gap is 2 < default slop 6 → hit.
expect(ids(w, "torus", cam, cursorOver(548, 500, cam))).toBe(1);
});
test("stroked-only circle: cursor far outside the ring misses", () => {
const w = new World(1000, 1000, [circle(1, 500, 500, 50)]);
expect(ids(w, "torus", cam, cursorOver(580, 500, cam))).toBe(null);
});
});
describe("hitTest — line primitive", () => {
const cam = camAt(500, 500);
test("cursor on the segment hits", () => {
const w = new World(1000, 1000, [line(1, 480, 500, 520, 500)]);
expect(ids(w, "torus", cam, cursorOver(500, 500, cam))).toBe(1);
});
test("cursor near the segment within slop hits", () => {
const w = new World(1000, 1000, [line(1, 480, 500, 520, 500)]);
// 4 world units away at scale=1 → within default slop 6.
expect(ids(w, "torus", cam, cursorOver(500, 504, cam))).toBe(1);
});
test("cursor near the segment outside slop misses", () => {
const w = new World(1000, 1000, [line(1, 480, 500, 520, 500)]);
expect(ids(w, "torus", cam, cursorOver(500, 510, cam))).toBe(null);
});
test("cursor beyond endpoint clamps and slop applies", () => {
const w = new World(1000, 1000, [line(1, 480, 500, 520, 500)]);
// 4 world units beyond x=520 along x; default slop 6.
expect(ids(w, "torus", cam, cursorOver(524, 500, cam))).toBe(1);
// 8 world units beyond x=520 → outside slop.
expect(ids(w, "torus", cam, cursorOver(528, 500, cam))).toBe(null);
});
});
describe("hitTest — ordering", () => {
const cam = camAt(500, 500);
test("higher priority wins over lower priority at equal distance", () => {
const w = new World(1000, 1000, [
point(1, 500, 500, { priority: 0 }),
point(2, 500, 500, { priority: 5 }),
]);
expect(ids(w, "torus", cam, cursorOver(500, 500, cam))).toBe(2);
});
test("smaller distance wins at equal priority", () => {
const w = new World(1000, 1000, [point(1, 504, 500), point(2, 502, 500)]);
expect(ids(w, "torus", cam, cursorOver(500, 500, cam))).toBe(2);
});
test("kind tie-break: point beats circle at exact distance and priority", () => {
const w = new World(1000, 1000, [
circle(1, 500, 500, 0.0001, { style: { fillColor: 0xffffff } }),
point(2, 500, 500),
]);
expect(ids(w, "torus", cam, cursorOver(500, 500, cam))).toBe(2);
});
test("id tie-break: smaller id wins at exact tie", () => {
const w = new World(1000, 1000, [point(7, 500, 500), point(3, 500, 500)]);
expect(ids(w, "torus", cam, cursorOver(500, 500, cam))).toBe(3);
});
});
describe("hitTest — empty results and scale", () => {
const cam = camAt(500, 500);
test("returns null when nothing matches", () => {
const w = new World(1000, 1000, [point(1, 100, 100)]);
expect(ids(w, "torus", cam, cursorOver(500, 500, cam))).toBe(null);
});
test("higher zoom shrinks the world-unit footprint of the default disc", () => {
// At scale=4, pointRadiusPx 3 = 0.75 world units; slop 4 = 1
// world unit. Threshold = 1.75 world units.
const cam4 = camAt(500, 500, 4);
const w = new World(1000, 1000, [point(1, 500, 500)]);
// 1.5 world units away → within 1.75 → hit.
expect(ids(w, "torus", cam4, cursorOver(501.5, 500, cam4))).toBe(1);
// 2 world units away → beyond 1.75 → null.
expect(ids(w, "torus", cam4, cursorOver(502, 500, cam4))).toBe(null);
});
test("lower zoom inflates the world-unit footprint of the default disc", () => {
// At scale=0.5, pointRadiusPx 3 = 6 world units; slop 4 = 8
// world units. Threshold = 14 world units.
const cam05 = camAt(500, 500, 0.5);
const w = new World(1000, 1000, [point(1, 500, 500)]);
// 13 world units away → within 14 → hit.
expect(ids(w, "torus", cam05, cursorOver(513, 500, cam05))).toBe(1);
// 16 world units away → beyond 14 → null.
expect(ids(w, "torus", cam05, cursorOver(516, 500, cam05))).toBe(null);
});
test("F8-12 / #6 — clicks inside the disc hit, not just on its edge", () => {
// At scale=1 with pointRadiusBasePx=10 and scaleRef=1, the
// visible world radius is 10. Any cursor inside that disc must
// resolve to the planet — the bug owner spotted in the picker
// was the click being ignored once the cursor moved off the
// circumference toward the centre.
const camAtRef = camAt(500, 500, 1);
const w = new World(1000, 1000, [
point(1, 500, 500, { style: { pointRadiusBasePx: 10 } }),
]);
for (const dx of [0, 2, 5, 8, 9.5]) {
expect(ids(w, "torus", camAtRef, cursorOver(500 + dx, 500, camAtRef))).toBe(1);
}
});
test("pointRadiusBasePx scales softly with zoom (F8-12 / #31)", () => {
// world 1000×1000, viewport 200×200 → scaleRef = 0.2. At
// scale=0.5 the on-screen pixel size is
// basePx * (scale/scaleRef)^α
// → 6 * (0.5/0.2)^0.33 ≈ 6 * 1.354 ≈ 8.13 px. In world units
// that becomes ≈ 16.27, plus slop 4/0.5 = 8 → threshold ≈ 24.27.
const cam05 = camAt(500, 500, 0.5);
const wBase = new World(1000, 1000, [
point(1, 500, 500, { style: { pointRadiusBasePx: 6 } }),
]);
expect(ids(wBase, "torus", cam05, cursorOver(520, 500, cam05))).toBe(1);
// Cursor 26 world units away exceeds the threshold (~24.27).
expect(ids(wBase, "torus", cam05, cursorOver(526, 500, cam05))).toBe(null);
});
});
describe("hitTest — Phase 29 hiddenIds parameter", () => {
const cam = camAt(500, 500);
test("a hidden primitive is skipped entirely", () => {
const w = new World(1000, 1000, [
point(1, 500, 500),
point(2, 500, 500, { priority: -1 }),
]);
// Without filtering, primitive 1 wins (higher priority).
expect(hitTest(w, cam, VP, cursorOver(500, 500, cam), "torus")?.primitive.id)
.toBe(1);
// With 1 hidden, the cursor falls through to primitive 2.
expect(
hitTest(
w,
cam,
VP,
cursorOver(500, 500, cam),
"torus",
new Set([1]),
)?.primitive.id,
).toBe(2);
});
test("hiding every match returns null", () => {
const w = new World(1000, 1000, [point(1, 500, 500)]);
expect(
hitTest(
w,
cam,
VP,
cursorOver(500, 500, cam),
"torus",
new Set([1]),
),
).toBeNull();
});
test("an empty hidden set is equivalent to omitting the parameter", () => {
const w = new World(1000, 1000, [point(1, 500, 500)]);
const a = hitTest(w, cam, VP, cursorOver(500, 500, cam), "torus");
const b = hitTest(
w,
cam,
VP,
cursorOver(500, 500, cam),
"torus",
new Set(),
);
expect(a?.primitive.id).toBe(1);
expect(b?.primitive.id).toBe(1);
});
});