148 lines
7.4 KiB
JavaScript
148 lines
7.4 KiB
JavaScript
/**
|
||
* 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`);
|