Files
galaxy-game/ui/frontend/tests/map-math.test.ts
Ilia Denisov db415f8aa4 ui/phase-9: PixiJS map renderer with torus and no-wrap modes
Stand up the vector map renderer in ui/frontend/src/map/ on top of
PixiJS v8 + pixi-viewport@^6. Torus mode renders nine container
copies for seamless wrap; no-wrap mode pins the camera at world
bounds and centres on an axis when the viewport exceeds the world
along that axis. Hit-test is a brute-force pass with deterministic
[-priority, distSq, kindOrder, id] ordering and torus-shortest
distance, validated by hand-built unit cases.

The development playground at /__debug/map exposes a window
debug surface for the Playwright spec, which forces WebGPU on
chromium-desktop, WebGL on webkit-desktop, and accepts the
auto-picked backend on mobile projects.

Algorithm spec lives in ui/docs/renderer.md, which also pins the
new deprecation status of galaxy/client (the entire Fyne client
module, including client/world). client/world/README.md and the
Phase 9 stub in ui/PLAN.md gain matching deprecation banners.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:06:23 +02:00

107 lines
3.9 KiB
TypeScript

// Unit tests for the geometry primitives in src/map/math.ts.
//
// These functions are the foundation for hit-test and the no-wrap
// camera helpers; they run far more often than their callers and any
// regression here ripples everywhere. Each test asserts a single
// algebraic property; the cases together cover the contract of the
// functions described in ui/docs/renderer.md.
import { describe, expect, test } from "vitest";
import {
clamp,
distSqPointToSegment,
screenToWorld,
torusShortestDelta,
worldToScreen,
} from "../src/map/math";
describe("clamp", () => {
test("returns the value when inside the bounds", () => {
expect(clamp(5, 0, 10)).toBe(5);
expect(clamp(0, 0, 10)).toBe(0);
expect(clamp(10, 0, 10)).toBe(10);
});
test("clamps to the lower bound", () => {
expect(clamp(-3, 0, 10)).toBe(0);
});
test("clamps to the upper bound", () => {
expect(clamp(13, 0, 10)).toBe(10);
});
});
describe("torusShortestDelta", () => {
test("returns zero for equal inputs", () => {
expect(torusShortestDelta(50, 50, 100)).toBe(0);
});
test("returns the direct delta when no wrap is shorter", () => {
expect(torusShortestDelta(10, 30, 100)).toBe(20);
expect(torusShortestDelta(30, 10, 100)).toBe(-20);
});
test("wraps to the shorter direction near the seam", () => {
// from=10, to=90: direct=+80, wrap=-20 — wrap wins.
expect(torusShortestDelta(10, 90, 100)).toBe(-20);
// from=90, to=10: direct=-80, wrap=+20 — wrap wins.
expect(torusShortestDelta(90, 10, 100)).toBe(20);
});
test("normalises inputs outside [0, size)", () => {
expect(torusShortestDelta(-10, 10, 100)).toBe(20);
expect(torusShortestDelta(110, 10, 100)).toBe(-100 + 100); // wraps to 0
});
test("at exactly size/2 picks the positive direction deterministically", () => {
// from=0, to=50, size=100 — both directions are equal.
// The contract: returns +size/2.
expect(torusShortestDelta(0, 50, 100)).toBe(50);
});
test("rejects non-positive size", () => {
expect(() => torusShortestDelta(0, 0, 0)).toThrow();
expect(() => torusShortestDelta(0, 0, -1)).toThrow();
});
});
describe("distSqPointToSegment", () => {
test("zero distance when the point is on the segment", () => {
expect(distSqPointToSegment(5, 0, 0, 0, 10, 0)).toBe(0);
expect(distSqPointToSegment(0, 0, 0, 0, 10, 0)).toBe(0);
expect(distSqPointToSegment(10, 0, 0, 0, 10, 0)).toBe(0);
});
test("perpendicular foot inside the segment", () => {
// segment along the x-axis from (0,0) to (10,0); point at (5,3).
// foot is (5,0), distance is 3, distSq is 9.
expect(distSqPointToSegment(5, 3, 0, 0, 10, 0)).toBeCloseTo(9, 12);
});
test("foot beyond the start endpoint clamps to start", () => {
expect(distSqPointToSegment(-2, 0, 0, 0, 10, 0)).toBeCloseTo(4, 12);
});
test("foot beyond the end endpoint clamps to end", () => {
expect(distSqPointToSegment(15, 0, 0, 0, 10, 0)).toBeCloseTo(25, 12);
});
test("zero-length segment falls back to point distance", () => {
expect(distSqPointToSegment(3, 4, 0, 0, 0, 0)).toBeCloseTo(25, 12);
});
});
describe("screenToWorld and worldToScreen", () => {
const viewport = { widthPx: 800, heightPx: 600 };
const camera = { centerX: 1000, centerY: 500, scale: 2 };
test("centre of viewport maps to camera centre in world space", () => {
const w = screenToWorld({ x: 400, y: 300 }, camera, viewport);
expect(w.x).toBeCloseTo(1000, 12);
expect(w.y).toBeCloseTo(500, 12);
});
test("worldToScreen is the inverse of screenToWorld", () => {
const screenIn = { x: 123.5, y: 456.25 };
const world = screenToWorld(screenIn, camera, viewport);
const screenOut = worldToScreen(world, camera, viewport);
expect(screenOut.x).toBeCloseTo(screenIn.x, 9);
expect(screenOut.y).toBeCloseTo(screenIn.y, 9);
});
test("scale propagates: 2px on screen = 1 world unit at scale=2", () => {
const w0 = screenToWorld({ x: 400, y: 300 }, camera, viewport);
const w1 = screenToWorld({ x: 402, y: 300 }, camera, viewport);
expect(w1.x - w0.x).toBeCloseTo(1, 12);
});
});