orbit/dev/galaxy-labels.test.mjs

240 lines
8.6 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.

/**
* GalaxyView star-name label test (dev tool, run with Node — no browser):
*
* node dev/galaxy-labels.test.mjs
*
* Regression for the "star names blink on and off" bug: MapWindow.update
* polls the galaxy every 500 ms (js/ui/MapWindow.js) and calls
* `gv.setVisible(true)` + `gv.applySnapshot(snap)` while open. That used to
* force EVERY in-plate name label visible for one frame (setVisible(true)
* shows all labels; _applyCull only hid OFF-plate ones), and the next
* frame's GalaxyView.update() hid the sub-threshold ones again → every star
* name flashed on at ~8 Hz.
*
* Asserts:
* - at 1× zoom only HOME/SHIP carry a name; visited/unvisited are quiet;
* - the 500 ms poll (setVisible(true) + applySnapshot, end-of-frame
* state, no update() after) NEVER flashes sub-threshold labels on;
* - a hidden view stays hidden through a poll;
* - labels earn in with zoom: visited at visitedZoom×, uncharted at
* anyZoom× (data/map.json → galaxy.labels).
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
// ── browser stubs (canvas, Phaser) BEFORE importing the game modules ─────
const gradStub = { addColorStop() {} };
const ctx2d = {
createRadialGradient: () => gradStub,
createLinearGradient: () => gradStub,
fillRect() {},
beginPath() {},
arc() {},
fill() {},
fillStyle: null,
};
globalThis.document = {
createElement: () => ({ width: 0, height: 0, getContext: () => ctx2d }),
};
/** A chainable stand-in for a Phaser game object (records visibility). */
function makeObj(props = {}) {
const o = {
x: 0,
y: 0,
alpha: 1,
visible: true,
rotation: 0,
text: '',
width: 40,
height: 10,
frame: { width: 2, height: 2 },
...props,
};
o.setScrollFactor = () => o;
o.setDepth = () => o;
o.setAlpha = (a) => ((o.alpha = a), o);
o.setVisible = (v) => ((o.visible = !!v), o);
o.setOrigin = () => o;
o.setPosition = (x, y) => ((o.x = x), (o.y = y), o);
o.setDisplaySize = (w, h) => ((o.width = w), (o.height = h), o);
o.setText = (t) => ((o.text = String(t)), o);
o.setColor = () => o;
o.setBlendMode = () => o;
o.setInteractive = () => ((o.input = { enabled: true }), o);
o.on = () => o;
o.removeAllListeners = () => o;
// graphics API
o.clear = () => o;
o.fillStyle = () => o;
o.lineStyle = () => o;
o.fillCircle = () => o;
o.strokeCircle = () => o;
o.fillPoints = () => o;
o.strokePoints = () => o;
o.lineBetween = () => o;
o.fillRoundedRect = () => o;
o.strokeRoundedRect = () => o;
return o;
}
globalThis.window = {
Phaser: {
GameObjects: { Container: class { add() {} setVisible() { return this; } setDepth() { return this; } } },
Math: { Clamp: (v, a, b) => Math.min(b, Math.max(a, v)) },
BlendModes: { ADD: 'ADD' },
Display: {
Color: {
ValueToColor: (v) => {
let s = String(v).trim().replace(/^#/, '');
if (s.length === 3) s = s.split('').map((c) => c + c).join('');
return { color: parseInt(s.slice(0, 6), 16) || 0 };
},
},
},
Geom: {
Rectangle: class {
constructor(x = 0, y = 0, w = 0, h = 0) {
Object.assign(this, { x, y, w, h });
}
contains(px, py) {
return px >= this.x && px <= this.x + this.w && py >= this.y && py <= this.y + this.h;
}
},
},
},
};
const { config } = await import('../js/config/Config.js');
const mapJson = JSON.parse(fs.readFileSync(path.join(root, 'data/map.json'), 'utf8'));
config.init({ map: mapJson });
const scene = {
textures: { exists: () => false, addCanvas() {} },
add: {
image: (x, y, key) => makeObj({ key }),
text: (x, y, str) => makeObj({ text: String(str) }),
graphics: () => makeObj(),
},
time: { now: 1000 },
input: { setDefaultCursor() {} },
};
const win = {
scene,
geo: { mapX: 100, mapY: 100, mapW: 400, mapH: 300 },
add() {},
dialog: null,
sfx() {},
isOpen: true,
};
const { GalaxyView } = await import('../js/ui/GalaxyView.js');
// ── helpers ────────────────────────────────────────────────────────────────
let passed = 0;
let failed = 0;
function ok(cond, label) {
if (cond) {
passed++;
console.log(` ok ${label}`);
} else {
failed++;
console.error(`FAIL ${label}`);
}
}
/** A fresh copy of the snapshot (MapWindow polls for a FRESH object). */
function freshSnap() {
return {
seed: 'labeltest',
homeSystemId: 'home',
currentSystemId: 'home',
stats: { visited: 1, total: 3 },
systems: [
{ id: 'home', name: 'Home', type: 'habitable', x: 0, y: 0, visited: true, isHome: true, isCurrent: true, gates: 3 },
{ id: 'a', name: 'Alpha', type: 'main', x: 300, y: 100, visited: true, gates: 2 },
{ id: 'b', name: 'Beta', type: 'redDwarf', x: -200, y: -150, visited: false, gates: 1 },
],
edges: [
{ key: 'home-a', a: 'home', b: 'a', used: true, live: true, frontier: false },
{ key: 'home-b', a: 'home', b: 'b', used: false, live: false, frontier: false },
],
};
}
const LABELS = mapJson.galaxy.labels;
const visitedZoom = LABELS.visitedZoom;
const anyZoom = LABELS.anyZoom;
const gv = new GalaxyView(win, { getSnapshot: freshSnap });
gv.applySnapshot(freshSnap());
gv.reveal(0);
const byId = Object.fromEntries(gv._stars.map((s) => [s.id, s]));
// ── 1× zoom: only HOME/SHIP label ─────────────────────────────────────────
console.log('at 1× zoom');
gv.setVisible(true);
gv.update(2000);
ok(byId.home.label.visible === true, 'HOME star keeps its name at 1×');
ok(byId.a.label.visible === false, 'visited star stays quiet at 1×');
ok(byId.b.label.visible === false, 'unvisited star stays quiet at 1×');
// ── THE BUG: the 500 ms poll must not flash sub-threshold labels on ───────
console.log('the 500 ms snapshot poll (end-of-frame state)');
gv.setVisible(true); // MapWindow.update → gv.setVisible(true)
gv.applySnapshot(freshSnap()); // …then gv.applySnapshot(snap) — no update() after
ok(byId.a.label.visible === false, 'poll does NOT flash a visited label on at 1× (was the blink)');
ok(byId.b.label.visible === false, 'poll does NOT flash an unvisited label on at 1× (was the blink)');
ok(byId.home.label.visible === true, 'HOME name survives the poll at 1×');
// a hidden view stays hidden even through a poll
gv.setVisible(false);
gv.setVisible(true);
gv.applySnapshot(freshSnap());
ok(byId.a.label.visible === false, 'poll keeps sub-threshold labels off (view visible)');
gv.setVisible(false);
ok(byId.home.label.visible === false, 'setVisible(false) hides even the HOME name');
gv.setVisible(true);
gv.update(2500);
// ── labels earn in with zoom (on-plate + threshold, like a real cursor zoom) ──
console.log('zoom thresholds');
ok(Number.isFinite(visitedZoom) && visitedZoom >= 2, `visitedZoom is a fairly close zoom (${visitedZoom}×)`);
ok(Number.isFinite(anyZoom) && anyZoom > visitedZoom, `anyZoom > visitedZoom (${anyZoom}× > ${visitedZoom}×)`);
// the threshold rule itself (culling-agnostic)
function setZoom(z) {
gv._view = { z, cx: gv._bounds.cx, cy: gv._bounds.cy };
}
setZoom(1);
ok(gv._labelVisible(byId.a) === false, 'visited rule: off below visitedZoom');
setZoom(visitedZoom);
ok(gv._labelVisible(byId.a) === true, 'visited rule: on at visitedZoom');
ok(gv._labelVisible(byId.b) === false, 'unvisited rule: off below anyZoom');
setZoom(anyZoom);
ok(gv._labelVisible(byId.b) === true, 'unvisited rule: on at anyZoom');
// end-to-end: zoom ONTO a star (the cursor-zoom idiom) and its label appears
function viewTo(z, wx, wy) {
gv._view = { z, cx: wx, cy: wy };
gv._relayout();
gv.update(2500);
}
viewTo(1, 0, 0);
ok(byId.a.label.visible === false && byId.b.label.visible === false, 'at 1× only HOME/SHIP carry names');
viewTo(visitedZoom, 300, 100); // zoom onto the visited star
ok(byId.a.label.visible === true, `visited label shows at ${visitedZoom}× when zoomed onto it`);
viewTo(1, 300, 100);
ok(byId.a.label.visible === false, 'zoom back out → the label drops again');
viewTo(anyZoom, -200, -150); // zoom onto the unvisited star
ok(byId.b.label.visible === true, `unvisited label shows at ${anyZoom}× when zoomed onto it`);
viewTo(1, 0, 0);
ok(byId.home.label.visible === true, 'HOME stays labeled at every zoom');
console.log(failed ? `\n${failed} label check(s) FAILED` : '\nAll galaxy label checks passed ✓');
process.exit(failed ? 1 : 0);