orbit/dev/perimeter-shimmer.test.mjs

297 lines
13 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.

/**
* PerimeterShimmer test (dev tool, run with Node — no browser):
*
* node dev/perimeter-shimmer.test.mjs
*
* Runs the REAL shimmer (js/ui/PerimeterShimmer.js) — its pure arc math
* (perimeterPath / arcPoint / arcPolyline, no Phaser) and the class
* against a stubbed scene (recording FakeGraphics — the
* quest-tracker.test.mjs pattern) — and pins the READY row's
* dual-shimmer rim contract:
*
* - the perimeter is the CUT-CORNER polygon (6 segments) when notched,
* a plain rectangle (4) when not — and the notch is clamped (it can
* never eat the whole plate);
* - arcPoint / arcPolyline sample the perimeter in arc order,
* wrap-safe, with consecutive duplicates removed;
* - the base rim is drawn ONCE (a glow pass under a core pass);
* - two comets in counter-streams (opposite dirs, half a lap apart —
* the data/quests.json → tracker.shimmer defaults survive the map);
* - update(t) re-streams the comets every frame, on the ABSOLUTE
* clock (a repaint mid-flight never resets the phase — phase0 +
* t/period give the same head at the same absolute time);
* - a zero-alpha comet draws nothing; destroy() is idempotent.
*/
import { pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import fs from 'node:fs';
const __dirname = dirname(fileURLToPath(import.meta.url));
// --- Stub just enough of Phaser for the Color.js import chain ----------
const PhaserStub = {
Display: { Color: { ValueToColor: (v) => ({ color: parseInt(v.slice(1), 16) }) } },
};
globalThis.window = { Phaser: PhaserStub }; // js/vendor/phaser.js reads this
const {
PerimeterShimmer,
perimeterPath,
arcPoint,
arcPolyline,
} = await import(pathToFileURL(join(__dirname, '../js/ui/PerimeterShimmer.js')).href);
let failures = 0;
const check = (label, cond) => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
if (!cond) failures++;
};
const approx = (a, b, eps = 1e-6) => Math.abs(a - b) < eps;
const near = (p, x, y, eps = 1e-6) => approx(p.x, x, eps) && approx(p.y, y, eps);
// ===========================================================================
// 1) Pure: the perimeter — cut-corner vs plain, notch clamped
// ===========================================================================
{
const rect = perimeterPath(0, 0, 100, 20, 0);
check('plain rect: 4 perimeter segments, P = 2*(w+h)',
rect.segs.length === 4 && approx(rect.P, 240));
const cut = perimeterPath(0, 0, 100, 20, 6);
const diag = Math.hypot(6, 6);
check('cut-corner: 6 perimeter segments (4 edges + 2 diagonals)', cut.segs.length === 6);
check('…and P = 2*(w-n) + 2*(h-n) + 2*diag',
approx(cut.P, 2 * 94 + 2 * 14 + 2 * diag, 1e-9));
check('the diagonals sit at top-left + bottom-right (the CyberShape corners)',
near({ x: cut.segs[2].ax, y: cut.segs[2].ay }, 100, 14) &&
near({ x: cut.segs[2].bx, y: cut.segs[2].by }, 94, 20) &&
near({ x: cut.segs[5].ax, y: cut.segs[5].ay }, 0, 6) &&
near({ x: cut.segs[5].bx, y: cut.segs[5].by }, 6, 0));
const big = perimeterPath(0, 0, 100, 20, 1000);
check('a runaway notch is CLAMPED (n <= h/2 — never eats the plate)',
big.segs.length === 6 && Number.isFinite(big.P) && approx(big.segs[0].ax, 10));
const off = perimeterPath(30, 40, 100, 20, 0);
check('absolute coords: the rect starts at (x, y)',
near({ x: off.segs[0].ax, y: off.segs[0].ay }, 30, 40));
}
// ===========================================================================
// 2) Pure: arcPoint — arc order, corners, wrap, negatives
// ===========================================================================
{
const { segs, P } = perimeterPath(0, 0, 100, 20, 0);
check('arcPoint: along the top edge', near(arcPoint(segs, P, 10), 10, 0));
check('arcPoint: the top-right corner (d = w)', near(arcPoint(segs, P, 100), 100, 0));
check('arcPoint: down the right edge', near(arcPoint(segs, P, 110), 100, 10));
check('arcPoint: bottom-left corner (d = 2w + h)', near(arcPoint(segs, P, 220), 0, 20));
check('arcPoint: d == P wraps to the start', near(arcPoint(segs, P, 240), 0, 0));
check('arcPoint: negative d wraps the long way (−10 ≡ 230)', near(arcPoint(segs, P, -10), 0, 10));
}
// ===========================================================================
// 3) Pure: arcPolyline — in-segment, corner-crossing, wrap-safe, deduped
// ===========================================================================
{
const { segs, P } = perimeterPath(0, 0, 100, 20, 0);
const flat = (pl) => pl.map((p) => `${p.x},${p.y}`).join(' → ');
const a = arcPolyline(segs, P, 10, 30);
check('in one segment: a straight 2-point dash',
a.length === 2 && near(a[0], 10, 0) && near(a[1], 40, 0));
const b = arcPolyline(segs, P, 95, 30);
check('crossing the top-right corner: [95,0] → [100,0] → [100,20] → [95,20]',
b.length === 4 && near(b[0], 95, 0) && near(b[1], 100, 0) && near(b[2], 100, 20) && near(b[3], 95, 20));
const c = arcPolyline(segs, P, 230, 30);
check('WRAP-SAFE (crosses d = P): [0,10] → [0,0] → [20,0] (up the left edge, over the corner, 20 units along)',
c.length === 3 && near(c[0], 0, 10) && near(c[1], 0, 0) && near(c[2], 20, 0));
const deduped = (pl) => pl.every((p, i) => i === 0 || !near(p, pl[i - 1]));
check('no consecutive duplicates (shared boundary points are removed)',
deduped(a) && deduped(b) && deduped(c));
check('zero length → an empty polyline', arcPolyline(segs, P, 50, 0).length === 0);
// The notched plate: the dash rides the diagonal cleanly.
const cut = perimeterPath(0, 0, 100, 20, 6);
const { P: Pc } = cut;
const topLen = 94;
const d = arcPolyline(cut.segs, Pc, topLen + 6, 20);
check('notched: the dash crosses the bottom-right diagonal (right edge → cut → bottom edge)',
d.length === 4 && near(d[0], 100, 6, 1e-6) && near(d[1], 100, 14) && near(d[2], 94, 20) &&
approx(d[3].y, 20) && near(d[3], ...Object.values(arcPoint(cut.segs, Pc, topLen + 26))));
console.log(` (debug) ${flat(d)}`);
}
// ===========================================================================
// 4) Class: the rim is drawn once; two counter-streaming comets
// ===========================================================================
function makeScene() {
const glist = [];
const container = {
children: [],
add(o) {
this.children.push(o);
return this;
},
};
return {
time: { now: 0 },
add: {
graphics: () => {
const g = { calls: [], destroyed: false, alpha: 1 };
for (const fn of ['clear', 'lineStyle', 'fillStyle', 'fillRect', 'fillPoints', 'strokePoints', 'lineBetween']) {
g[fn] = (...args) => {
if (fn === 'clear') g.calls.length = 0;
else g.calls.push({ fn, args });
return g;
};
}
g.setScrollFactor = () => g;
g.destroy = () => {
g.destroyed = true;
return g;
};
glist.push(g);
return g;
},
existing: (o) => o,
},
glist,
container,
};
}
const linePts = (g) => g.calls.filter((c) => c.fn === 'lineBetween').map((c) => `${c.args[0]},${c.args[1]}→${c.args[2]},${c.args[3]}`);
{
const scene = makeScene();
const sh = new PerimeterShimmer(scene, 14, 72, 250, 20, { container: scene.container, notch: 6 });
check('the rim + the comet layer join the container (they slide with the tracker)',
scene.container.children.length === 2 && scene.container.children.includes(sh.rim) && scene.container.children.includes(sh.cometLayer));
const rimLines = sh.rim.calls.filter((c) => c.fn === 'lineStyle');
check('the base rim is drawn ONCE: a glow pass + a core pass (2 lineStyle + 2 strokePoints)',
rimLines.length === 2 && sh.rim.calls.filter((c) => c.fn === 'strokePoints').length === 2);
const [glow, core] = rimLines.map((c) => c.args);
check('the glow is the WIDER, fainter pass (width+3.5, low alpha)',
glow[0] > core[0] && glow[2] < core[2] && glow[1] === 0xffc94d);
check('the rim is the AMBER claimable color (0xffc94d)', core[1] === 0xffc94d);
check('TWO comets in COUNTER-STREAMS: amber clockwise + cyan counter-clockwise',
sh.comets.length === 2 &&
sh.comets[0].dir === 1 && sh.comets[1].dir === -1 &&
sh.comets[0].color === 0xffc94d && sh.comets[1].color === 0x00e5ff);
check('…half a lap apart (the crossing lands twice per cycle)',
Math.abs(sh.comets[0].phase - sh.comets[1].phase) === 0.5);
check('the rim traces the CUT-CORNER plate (6 segments)', sh.segs.length === 6);
// Per-frame: the comets re-stream (line passes land) and MOVE.
sh.update(0);
const at0 = linePts(sh.cometLayer);
sh.update(sh.periodMs / 4);
const atQuarter = linePts(sh.cometLayer);
check('update(t) re-streams the comets (line passes on the comet layer)',
at0.length > 0 && atQuarter.length > 0);
check('…and they MOVE with the clock (a quarter lap shifts the dashes)',
JSON.stringify(at0) !== JSON.stringify(atQuarter));
// ABSOLUTE phase: phase0 + t/period is the head — a "repaint" (a fresh
// instance at the same absolute time) draws the SAME dashes.
const A = new PerimeterShimmer(makeScene(), 14, 72, 250, 20, { notch: 6, phase0: 0, periodMs: 2400 });
A.update(1200); // half a lap by the clock
const B = new PerimeterShimmer(makeScene(), 14, 72, 250, 20, { notch: 6, phase0: 0.5, periodMs: 2400 });
B.update(0); // the SAME head, reached by phase0 instead of the clock
check('the phase is ABSOLUTE: phase0 0 @ t = T/2 ≡ phase0 0.5 @ t = 0',
JSON.stringify(linePts(A.cometLayer)) === JSON.stringify(linePts(B.cometLayer)));
// destroy() — idempotent, and update() after it is a safe no-op.
sh.destroy();
sh.destroy();
check('destroy(): rim + comet layer gone, idempotent',
sh.destroyed === true && sh.rim.destroyed === true && sh.cometLayer.destroyed === true);
sh.update(100); // must not throw
check('update() after destroy(): a safe no-op', true);
}
// ===========================================================================
// 5) Config-driven: data/quests.json → tracker.shimmer survives the map
// ===========================================================================
{
const quests = JSON.parse(fs.readFileSync(join(__dirname, '../data/quests.json'), 'utf8'));
const cfg = quests.tracker.shimmer;
check('the config exists and is enabled (with the tuned defaults)',
cfg !== null && cfg.enabled === true && cfg.notch === 6 && cfg.periodMs === 2400 &&
cfg.dashFrac === 0.3 && cfg.claimFlashMs === 150);
check('the config defines the dual comets (amber CW, neon CCW, half a lap apart)',
cfg.comets.length === 2 && cfg.comets[0].dir === 1 && cfg.comets[1].dir === -1 &&
cfg.comets[0].phase === 0 && cfg.comets[1].phase === 0.5);
const scene = makeScene();
const sh = new PerimeterShimmer(scene, 14, 72, 250, 20, {
container: scene.container,
notch: cfg.notch,
periodMs: cfg.periodMs,
dashFrac: cfg.dashFrac,
base: cfg.base,
comets: cfg.comets,
});
check('the tracker.shimmer base rim lands (amber, the config alpha)',
(() => {
const lines = sh.rim.calls.filter((c) => c.fn === 'lineStyle');
const core = lines[lines.length - 1].args;
return core[1] === 0xffc94d && approx(core[2], 0.55);
})());
sh.update(100);
check('the tracker.shimmer comets stream (both colors drawn on the comet layer)',
(() => {
const colors = new Set(sh.cometLayer.calls.filter((c) => c.fn === 'lineStyle').map((c) => c.args[1]));
return colors.has(0xffc94d) && colors.has(0x00e5ff);
})());
sh.destroy();
}
// ===========================================================================
// 6) Degenerate configs stay safe
// ===========================================================================
{
const scene = makeScene();
// A zero-alpha comet draws nothing (the rim still stands).
const sh0 = new PerimeterShimmer(scene, 0, 0, 100, 20, {
comets: [
{ color: '#ffc94d', dir: 1, phase: 0, width: 2, alpha: 0, haloAlpha: 0.3 },
{ color: '#00e5ff', dir: -1, phase: 0.5, width: 2, alpha: 0, haloAlpha: 0.3 },
],
});
sh0.update(0);
check('zero-alpha comets draw NOTHING (the rim is untouched)',
sh0.cometLayer.calls.filter((c) => c.fn === 'lineBetween').length === 0 &&
sh0.rim.calls.filter((c) => c.fn === 'strokePoints').length === 2);
sh0.destroy();
// Missing comets → the built-in dual pair (amber + neon).
const sh1 = new PerimeterShimmer(scene, 0, 0, 100, 20, {});
check('no comet config → the built-in dual pair (2 comets, opposite dirs)',
sh1.comets.length === 2 && sh1.comets[0].dir === 1 && sh1.comets[1].dir === -1);
sh1.destroy();
// A degenerate rect throws loudly (never a silent NaN perimeter).
let threw = false;
try {
new PerimeterShimmer(scene, 0, 0, 0, 0, {});
} catch {
threw = true;
}
check('a degenerate rect (0x0) throws (no silent NaN perimeter)', threw === true);
}
if (failures > 0) {
console.error(`\n${failures} perimeter-shimmer test(s) FAILED`);
process.exit(1);
}
console.log('\nperimeter-shimmer: all checks passed');