Files
galaxy-game/ui/frontend/tests/map-math.test.ts
T
Ilia Denisov 2bd1b54936
Tests · Go / test (push) Successful in 2m31s
Tests · UI / test (push) Failing after 8m7s
feat(ui): Phase 29 map visibility toggles
Adds the gear-icon popover on the map view with per-game persistence
of every category toggle plus the wrap-mode radio. Hide-by-id and
visibility-fog facilities land on the renderer so every flip applies
within one frame without a Pixi remount; the wrap-mode toggle keeps
its existing remount + camera-preserve path. A new server-side turn
force-resets every flag to defaults so a hidden category never makes
the player miss the next turn's news.

Also fixes the FligthDistance → FlightDistance typo in pkg/calc/race.go
(plus the single Go caller); the TS side keeps duplicating the formula
until a race-level WASM bridge lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:33:53 +02:00

127 lines
4.6 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,
torusShortestDistance,
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);
});
});
describe("torusShortestDistance", () => {
test("returns plain Euclidean distance when no wrap is shorter", () => {
const d = torusShortestDistance(0, 0, 3, 4, 1000, 1000);
expect(d).toBeCloseTo(5, 12);
});
test("respects torus wrap on both axes", () => {
// Wrap on x: 50 → 950 across the seam is 100 units.
// Wrap on y: 100 → 900 across the seam is 200 units.
// Hypot(100, 200) ≈ 223.606.
const d = torusShortestDistance(50, 100, 950, 900, 1000, 1000);
expect(d).toBeCloseTo(Math.hypot(100, 200), 9);
});
test("zero when both points coincide", () => {
expect(torusShortestDistance(123, 456, 123, 456, 1000, 1000)).toBe(0);
});
});