Files
galaxy-game/ui/frontend/tests/map-hit-test.test.ts
T
Ilia Denisov 680ebac919
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Failing after 5m16s
feat(ui): F8-12 — map polish (zoom invariance, labels, selection, soft radius) (#55)
* Honest pixel-space sizing for `pointRadiusPx` / `strokeWidthPx`: the
  renderer divides by the current camera scale on every
  `viewport.zoomed` so thin lines / small markers stay the same on-screen
  size at any zoom.
* Known-size planets switch to `pointRadiusWorld`, softened against the
  reference scale by `PLANET_SIZE_ZOOM_ALPHA = 0.33`; unidentified
  planets pin to a 3-px disc.
* New planet label layer renders a two-line `name / #N` legend under
  each planet (`#N` only for unidentified or when the new `planetNames`
  toggle is off). Selection now paints an inverse-fill frame around the
  selected planet's label plus an outline on the disc; the old
  selection-ring primitive is retired.
* Bombing markers swap the separate CirclePrim for a planet-outline
  overlay (damaged / wiped colour); the report deep-link moves to a
  "view bombing report" link in the planet inspector.
* Docs + tests follow: `renderer.md` reflects the new sizing contract +
  label / outline layers, vitest covers the sizing math, label
  formatting, and the new toggle, and the map-toggles e2e adds a
  persistence case for `planetNames`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 23:51:16 +02:00

352 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("pointRadiusWorld scales softly with zoom (F8-12 / #31)", () => {
// world 1000×1000, viewport 200×200 → scaleRef = 0.2 (every
// world unit becomes 0.2 px on screen at the "whole world fits"
// zoom). PLANET_SIZE_ZOOM_ALPHA is 0.33: r_display =
// r_base * (scale / scaleRef)^(α - 1).
const cam05 = camAt(500, 500, 0.5);
const wBase = new World(1000, 1000, [
point(1, 500, 500, { style: { pointRadiusWorld: 6 } }),
]);
// At scale=0.5 the softening factor is (0.5/0.2)^(0.33-1) ≈ 0.554.
// Visible radius ≈ 3.32 world units, slop 8, threshold ≈ 11.32.
expect(ids(wBase, "torus", cam05, cursorOver(510, 500, cam05))).toBe(1);
// Cursor 12 world units away exceeds the threshold.
expect(ids(wBase, "torus", cam05, cursorOver(512, 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);
});
});