orbit/dev/scan.test.mjs

90 lines
3.5 KiB
JavaScript

// Geometry unit tests for the DEEP SCAN pulse (js/scan/ScanGeometry.js).
//
// node dev/scan.test.mjs
//
// Verifies the circle-circle window math (arcInCircle) against brute-force
// angle sampling, plus the degenerate cases: concentric circles, full
// containment, no overlap, near-tangent, and the contact/far radii the
// sweep's timeline is built from.
import assert from 'node:assert';
import { arcInCircle, contactRadius, farRadius } from '../js/scan/ScanGeometry.js';
let passed = 0;
const check = (label, cond) => {
assert.ok(cond, 'FAIL: ' + label);
passed++;
console.log(` ok - ${label}`);
};
/** Brute force: the fraction of circle `a`'s circumference that lies inside
* circle `b` (`a` centered at the origin, `b` centered at (d, 0)). */
const fracInside = (a, b, d, N = 7200) => {
let hits = 0;
for (let i = 0; i < N; i++) {
const th = (i / N) * Math.PI * 2;
const px = a * Math.cos(th);
const py = a * Math.sin(th);
if (Math.hypot(px - d, py) <= b + 1e-9) hits++;
}
return hits / N;
};
// 1) Partial overlap — the predicted window fraction must match the
// measured fraction (the window is centered on a-center → b-center).
{
const a = 100, b = 60, d = 100;
const half = arcInCircle(a, b, d);
const measured = fracInside(a, b, d);
const predicted = (2 * half) / (2 * Math.PI);
check('partial overlap: half-angle in (0, pi)', half > 0 && half < Math.PI);
check(`window fraction ${predicted.toFixed(3)} ~ sampled ${measured.toFixed(3)}`,
Math.abs(predicted - measured) < 0.02);
}
// 2) The window's BOUNDARY points sit exactly on circle b's rim.
{
const a = 100, b = 60, d = 100;
const half = arcInCircle(a, b, d);
for (const sgn of [1, -1]) {
const px = a * Math.cos(sgn * half);
const py = a * Math.sin(sgn * half);
check(`boundary point (sign ${sgn}) lies on circle b's rim`,
Math.abs(Math.hypot(px - d, py) - b) < 1e-6);
}
}
// 3) Concentric: the whole of `a` inside / outside `b`.
check('concentric: a < b → whole circle', arcInCircle(50, 60, 0) === Math.PI);
check('concentric: a > b → none', arcInCircle(70, 60, 0) === -1);
check('concentric: a = b → whole circle', arcInCircle(60, 60, 0) === Math.PI);
// 4) Full containment (offset): d + a ≤ b → whole circle.
check('offset containment (d + a < b) → whole circle', arcInCircle(20, 60, 35) === Math.PI);
check('just contained (d + a = b) → whole circle', arcInCircle(20, 60, 40) === Math.PI);
// 5) No overlap.
check('far apart → none', arcInCircle(100, 60, 300) === -1);
check('b inside a hole (a > b, close) → none', arcInCircle(120, 60, 20) === -1);
check('zero radius → none', arcInCircle(0, 60, 10) === -1);
// 6) Near-tangent: the window shrinks to ~0 as the circles nearly miss.
{
const a = 100, b = 60;
const half = arcInCircle(a, b, a + b - 0.001);
check('near-tangent (inside) → tiny window', half > 0 && half < 0.05);
const half2 = arcInCircle(a, b, a + b + 0.001);
check('near-tangent (outside) → none', half2 === -1);
}
// 7) The contact radius — where the front first touches the rim.
check('ship outside: contact = d - R', contactRadius(60, 100) === 40);
check('ship inside: contact = R - d', contactRadius(60, 20) === 40);
check('concentric: contact = R', contactRadius(60, 0) === 60);
check('ship on the rim: contact = 0', contactRadius(60, 60) === 0);
// 8) The far radius — the last point absorbed.
check('far rim = R + d', farRadius(60, 100) === 160);
check('far rim (concentric) = R', farRadius(60, 0) === 60);
console.log(`\nscan-geometry: ${passed} checks passed`);