orbit/dev/signal-compass.test.mjs

148 lines
7.4 KiB
JavaScript
Raw Permalink 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.

/**
* Signal-compass test (dev tool, run with Node — no browser needed):
*
* node dev/signal-compass.test.mjs
*
* Asserts:
* - the SIGNAL ENVELOPE (js/ui/SignalCompass.js → signalAlpha): full
* strength for the first `fullMs`, a LINEAR fade to 0 over `fadeMs`,
* then gone — including the edges (negative age, the full/fade
* boundary, fully expired) and the `fadeMs = 0` step case;
* - the BEARING (bearingAngle): a signal sits at the object's direction
* from the ship (screen/world y-down), for the four cardinal cases;
* - the CONFIG (data/signalCompass.json): sane radius + lifetime, valid
* hex colors, positive geometry, a boolean enable gate — and that it is
* registered in the manifest (so it actually loads).
*/
process.env.NODE_ENV = 'dev';
import { pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
// --- Load the real config (data/*.json) into the config singleton --------
const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
const fs = await import('node:fs');
const dataDir = join(__dirname, '../data');
const configData = {};
for (const f of fs.readdirSync(dataDir)) {
if (!f.endsWith('.json') || f === 'manifest.json') continue;
configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8'));
}
config.init(configData);
// Minimal Phaser stub — enough to import the UI module below.
globalThis.window = {
Phaser: {
GameObjects: {
Graphics: class {},
},
BlendModes: { ADD: 'ADD' },
Display: { Color: { ValueToColor: () => ({ color: 0x00e5ff }) } },
},
};
const { signalAlpha, bearingAngle, SignalCompass } = await import(
pathToFileURL(join(__dirname, '../js/ui/SignalCompass.js')).href
);
let pass = 0;
function check(name, cond) {
if (!cond) {
console.error(`${name}`);
process.exit(1);
}
pass++;
console.log(`${name}`);
}
const TAU = Math.PI * 2;
const FULL = 20000;
const FADE = 5000;
// ----------------------------------------------------------------------
// 1. Signal envelope — full → linear fade → gone
// ----------------------------------------------------------------------
check('signalAlpha: before full ⇒ 1', signalAlpha(0, FULL, FADE) === 1 && signalAlpha(19999, FULL, FADE) === 1);
check('signalAlpha: exactly at full ⇒ 1', signalAlpha(FULL, FULL, FADE) === 1);
check('signalAlpha: mid-fade ⇒ linear (½ way ⇒ ½)', Math.abs(signalAlpha(FULL + FADE / 2, FULL, FADE) - 0.5) < 1e-9);
check('signalAlpha: a known fade point (¼ into the fade ⇒ ¾)', Math.abs(signalAlpha(FULL + FADE * 0.25, FULL, FADE) - 0.75) < 1e-9);
check('signalAlpha: fully expired ⇒ 0', signalAlpha(FULL + FADE, FULL, FADE) === 0);
check('signalAlpha: past expiry stays 0', signalAlpha(FULL + FADE + 5000, FULL, FADE) === 0);
check('signalAlpha: negative age clamps to full', signalAlpha(-500, FULL, FADE) === 1);
check('signalAlpha: monotonic non-increasing across the life', (() => {
let prev = 2;
for (let t = -100; t <= FULL + FADE + 100; t += 500) {
const a = signalAlpha(t, FULL, FADE);
if (a > prev + 1e-9) return false;
prev = a;
}
return true;
})());
check('signalAlpha: fadeMs=0 is a hard cutoff', signalAlpha(999, 1000, 0) === 1 && signalAlpha(1000, 1000, 0) === 0 && signalAlpha(1999, 1000, 0) === 0);
check('signalAlpha: stays in [0,1]', (() => {
for (let t = -1000; t <= 40000; t += 700) {
const a = signalAlpha(t, FULL, FADE);
if (a < 0 || a > 1) return false;
}
return true;
})());
// ----------------------------------------------------------------------
// 2. Bearing — the signal sits at the object's direction from the ship
// ----------------------------------------------------------------------
const EPS = 1e-9;
check('bearingAngle: object to the +x ⇒ 0', Math.abs(bearingAngle(0, 0, 100, 0)) < EPS);
check('bearingAngle: object below (+y, y-down) ⇒ +π/2', Math.abs(bearingAngle(0, 0, 0, 100) - Math.PI / 2) < EPS);
check('bearingAngle: object above (y) ⇒ −π/2', Math.abs(bearingAngle(0, 0, 0, -100) + Math.PI / 2) < EPS);
check('bearingAngle: object to the x ⇒ ±π', Math.abs(Math.abs(bearingAngle(0, 0, -100, 0)) - Math.PI) < EPS);
check('bearingAngle: diagonal (1,1) ⇒ π/4', Math.abs(bearingAngle(0, 0, 100, 100) - Math.PI / 4) < EPS);
check('bearingAngle: relative to a non-origin ship', (() => {
// ship at (10,10), object at (110,10) → due +x from the ship ⇒ 0
return Math.abs(bearingAngle(10, 10, 110, 10)) < EPS;
})());
check('bearingAngle: both sides of the x axis land on ±π (same line)', (() => {
const a = bearingAngle(0, 0, -100, 0.001); // just below the x axis (y-down)
const b = bearingAngle(0, 0, -100, -0.001); // just above it
const nearPi = (v) => Math.abs(Math.abs(v) - Math.PI) < 0.02;
return nearPi(a) && nearPi(b);
})());
// ----------------------------------------------------------------------
// 3. Config — sane, valid, and actually registered
// ----------------------------------------------------------------------
const sc = config.section('signalCompass', {});
check('config: enabled is a boolean', typeof sc.enabled === 'boolean');
check('config: radius is a sensible px count', Number.isFinite(sc.radius) && sc.radius >= 40 && sc.radius <= 480);
check('config: lifetime.fullMs > 0', Number.isFinite(sc.lifetime?.fullMs) && sc.lifetime.fullMs > 0);
check('config: lifetime.fadeMs >= 0', Number.isFinite(sc.lifetime?.fadeMs) && sc.lifetime.fadeMs >= 0);
const hex = /^#[0-9a-fA-F]{6}$/;
check('config: colors are valid hex', ['signal', 'glow', 'ring'].every((k) => hex.test(sc.colors?.[k] ?? '')));
const sig = sc.signal ?? {};
check('config: signal arc + dot are positive', (sig.arcHalfDeg ?? 0) > 0 && (sig.coreWidth ?? 0) > 0 && (sig.dotRadius ?? 0) > 0);
check('config: signal alphas in [0,1]', [sig.coreAlpha, sig.glowAlpha, sig.dotAlpha].every((a) => a >= 0 && a <= 1));
const rp = sig.ripples ?? {};
check('config: ripples sane', (rp.count ?? 0) >= 0 && (rp.spacingPx ?? 0) > 0 && rp.alpha >= 0 && rp.alpha <= 1);
const pu = sig.pulse ?? {};
check('config: pulse gentle (amp < 0.5 so it reads as a signal, not a strobe)', pu.amp >= 0 && pu.amp < 0.5 && pu.hz > 0 && pu.hz < 8);
const rg = sc.ring ?? {};
check('config: ring alphas in [0,1]', rg.alpha >= 0 && rg.alpha <= 1 && rg.glowAlpha >= 0 && rg.glowAlpha <= 1);
check('config: the 20 s full + 5 s fade defaults match the brief', sc.lifetime.fullMs === 20000 && sc.lifetime.fadeMs === 5000);
// Registered in the manifest (so it loads) + the class exposes its API.
const manifest = JSON.parse(fs.readFileSync(join(dataDir, 'manifest.json'), 'utf8'));
check('manifest: signalCompass.json is listed', Array.isArray(manifest.files) && manifest.files.includes('signalCompass.json'));
check('api: SignalCompass has refresh + destroy', typeof SignalCompass === 'function' &&
typeof SignalCompass.prototype.refresh === 'function' && typeof SignalCompass.prototype.destroy === 'function');
// A constructed instance picks up the config (radius from data/signalCompass.json).
const fakeScene = { add: { graphics: () => ({ setDepth: () => ({ setBlendMode: () => ({ clear() {}, destroy() {} }) }) }) } };
const inst = new SignalCompass(fakeScene);
check('instance: radius came from config (128)', inst.radius === 128);
check('instance: enabled gate read', inst.enabled === true);
console.log(`\nsignal-compass: ${pass} checks passed`);