240 lines
8.6 KiB
JavaScript
240 lines
8.6 KiB
JavaScript
/**
|
||
* 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);
|