Replaces the hand-rolled inline-SVG topology with a PixiJS 8 scene graph. Why: the old renderer rebuilt all 37 nodes / 36 edges as one innerHTML string every 10s, which tore down and recreated every DOM node. That restarted CSS animations mid-flight and made dragging fight the browser's own hit-testing. The scene graph gives per-node transforms, so pan/zoom is a single container transform instead of getScreenCTM() matrix math. Changes: - static/topo_pixi.js: new self-contained renderer. Owns its Application and tears it down on tab exit so a second WebGL context cannot leak. - static/index.html: the <svg id=topoSvg> host becomes a <div id=topoHost>; the 188-line SVG renderer is replaced by a bridge to the module. - static/vendor/pixi.mjs: PixiJS 8.21.0 self-hosted (MIT). The .mjs build is required; the .js build exports no global. See vendor/README.md. - main.py: mount /static. Pages were served as inline HTMLResponse, so the directory was never mounted and the module had no URL to load from. Two real bugs found by measuring pixels rather than trusting init(): - preserveDrawingBuffer: without it WebGL clears the back buffer after compositing, so any readback or screenshot of the canvas is a coin flip depending on which frame it lands on. The graph rendered intermittently blank. Now enabled: cheap for a 2D scene, and it makes the view capturable. - Layout was centred on the SCROLLABLE width (nodes.length * 130), not the viewport, so with 37 nodes every team and challenge node landed at x=2230-2650 on a 1310px canvas: entirely off-screen. Layout now centres on the visible width and reset() frames the whole graph to fit. test_topo_pixels.js documents three wrong test designs it replaces, all of which reported false failures against a working graph: counting scene-graph children (passes on a blank canvas), diffing against the background colour (the theme is dark by design, so a perfect render measures ~0%), and diffing two Playwright screenshots (both can be captured after the scene was mutated). The check now reads the GL back buffer via readPixels in one evaluate. Verified: 37 nodes / 36 edges drawn (7.94% of frame, max channel delta 225), graph bbox [437,46,881,476] inside the 1310x520 canvas, glGetError=0, no page errors, zoom and frame-to-fit reset working. Platform unregressed: SLA 32/32.
132 lines
5.8 KiB
JavaScript
132 lines
5.8 KiB
JavaScript
// Proves the PixiJS topology actually DRAWS, not merely that it initialises.
|
|
//
|
|
// Three earlier test designs were wrong, and every one of them reported a false
|
|
// failure against a perfectly working graph. They are documented here so nobody
|
|
// reintroduces them:
|
|
//
|
|
// 1. "canvas exists and has N scene children" — passes on a fully blank canvas.
|
|
// 2. "count pixels that differ from the background colour" with a tight
|
|
// threshold — the theme is dark by design (node fill 0x0e1526 on bg
|
|
// 0x0a0f1c, team discs at alpha 0.13), so a perfect render still measured
|
|
// ~0% and looked like a total failure.
|
|
// 3. Diffing two Playwright screenshots — both captures can be taken after the
|
|
// scene was mutated, and the WebGL back buffer is not guaranteed to be
|
|
// captured, so the diff was 0 for a graph that was plainly rendering.
|
|
//
|
|
// The reliable check reads the GL back buffer via readPixels INSIDE a single
|
|
// page.evaluate, so the frame being measured is the frame just rendered, with no
|
|
// compositor or screenshot timing involved.
|
|
const { chromium } = require('/tmp/gltest/node_modules/playwright-core');
|
|
|
|
const BASE = process.env.PANEL_URL || 'https://panel.attackdefense.imrnes.team';
|
|
|
|
(async () => {
|
|
const browser = await chromium.launch({
|
|
args: ['--no-sandbox', '--use-gl=swiftshader', '--enable-unsafe-swiftshader'],
|
|
});
|
|
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
|
|
const errors = [];
|
|
page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message));
|
|
page.on('console', m => { if (m.type() === 'error') errors.push('CONSOLE: ' + m.text()); });
|
|
|
|
const creds = require('fs').readFileSync('/opt/gemastik18-final/panel/.env', 'utf8')
|
|
.split('\n').reduce((a, l) => {
|
|
const m = l.match(/^PANEL_ADMIN_(USER|PASS)=(.*)$/);
|
|
if (m) a[m[1]] = m[2];
|
|
return a;
|
|
}, {});
|
|
|
|
await page.goto(BASE + '/login', { waitUntil: 'domcontentloaded' });
|
|
await page.fill('#u', creds.USER);
|
|
await page.fill('#p', creds.PASS);
|
|
await page.click('#f button[type=submit]');
|
|
await page.waitForSelector('#view-challs.active');
|
|
await page.click('.tab[data-view=topo]');
|
|
await page.waitForSelector('#topoHost canvas');
|
|
await page.waitForTimeout(3000);
|
|
|
|
const state = await page.evaluate(() => {
|
|
const g = window.__topoProbe();
|
|
return {
|
|
nodes: g.nodeViews.size,
|
|
edgeChildren: g.edgeLayer.children.length,
|
|
attacks: g.attackViews.size,
|
|
scale: +g.scale.toFixed(3),
|
|
zoomLabel: document.getElementById('topoZoomLabel').textContent,
|
|
engine: document.getElementById('topoEngine') ? document.getElementById('topoEngine').textContent : null,
|
|
canvasW: g.app.renderer.width,
|
|
canvasH: g.app.renderer.height,
|
|
};
|
|
});
|
|
console.log('--- scene state ---');
|
|
for (const [k, v] of Object.entries(state)) console.log(` ${k}: ${v}`);
|
|
|
|
// Single evaluate: render, then read the very same frame back.
|
|
const pixels = await page.evaluate(() => {
|
|
const g = window.__topoProbe();
|
|
const r = g.app.renderer;
|
|
r.render(g.app.stage);
|
|
const W = r.width, H = r.height;
|
|
const buf = new Uint8Array(W * H * 4);
|
|
r.gl.readPixels(0, 0, W, H, r.gl.RGBA, r.gl.UNSIGNED_BYTE, buf);
|
|
|
|
// Background is 0x0a0f1c = (10, 15, 28).
|
|
let nonBg = 0, bright = 0, maxDist = 0;
|
|
let minX = W, minY = H, maxX = -1, maxY = -1;
|
|
for (let y = 0; y < H; y++) {
|
|
for (let x = 0; x < W; x++) {
|
|
const i = (y * W + x) * 4;
|
|
const dist = Math.max(Math.abs(buf[i] - 10), Math.abs(buf[i+1] - 15), Math.abs(buf[i+2] - 28));
|
|
if (dist > 3) {
|
|
nonBg++;
|
|
if (x < minX) minX = x;
|
|
if (x > maxX) maxX = x;
|
|
if (y < minY) minY = y;
|
|
if (y > maxY) maxY = y;
|
|
}
|
|
if (dist > 60) bright++;
|
|
if (dist > maxDist) maxDist = dist;
|
|
}
|
|
}
|
|
const total = W * H;
|
|
return {
|
|
w: W, h: H, total,
|
|
nonBg, pctNonBg: +(100 * nonBg / total).toFixed(2),
|
|
bright, pctBright: +(100 * bright / total).toFixed(2),
|
|
maxDist,
|
|
// A blank frame has no bbox; a real graph occupies a contiguous region.
|
|
bbox: maxX < 0 ? null : [minX, minY, maxX, maxY],
|
|
glError: r.gl.getError(),
|
|
preserveDrawingBuffer: r.preserveDrawingBuffer,
|
|
};
|
|
});
|
|
|
|
console.log('\n--- GL back-buffer readback (authoritative) ---');
|
|
for (const [k, v] of Object.entries(pixels)) console.log(` ${k}: ${JSON.stringify(v)}`);
|
|
|
|
// Human-visible proof for the report, plus a coverage cross-check.
|
|
const box = await page.locator('#topoHost canvas').boundingBox();
|
|
const shot = await page.screenshot({ clip: box });
|
|
require('fs').writeFileSync('/tmp/topo_proof.png', shot);
|
|
|
|
// The graph must ALSO be framed inside the viewport, not parked off-screen.
|
|
const framed = pixels.bbox !== null &&
|
|
pixels.bbox[0] >= 0 && pixels.bbox[1] >= 0 &&
|
|
pixels.bbox[2] <= pixels.w && pixels.bbox[3] <= pixels.h;
|
|
const drew = pixels.pctNonBg > 1 && pixels.maxDist > 40;
|
|
const noGlError = pixels.glError === 0;
|
|
|
|
console.log(`\n graph drew pixels ............. ${drew ? 'PASS' : 'FAIL'} (${pixels.pctNonBg}%, max channel delta ${pixels.maxDist})`);
|
|
console.log(` graph framed in viewport ...... ${framed ? 'PASS' : 'FAIL'} (bbox ${JSON.stringify(pixels.bbox)} in ${pixels.w}x${pixels.h})`);
|
|
console.log(` no GL errors ................. ${noGlError ? 'PASS' : 'FAIL'} (glGetError=${pixels.glError})`);
|
|
if (state.nodes < 1) console.log(' nodes rendered ................ FAIL (0 nodes)');
|
|
else console.log(` nodes rendered ................ PASS (${state.nodes} nodes, ${state.edgeChildren} edges)`);
|
|
if (errors.length) { console.log('\n page errors:'); errors.forEach(e => console.log(' ' + e)); }
|
|
console.log(` screenshot -> /tmp/topo_proof.png`);
|
|
|
|
const ok = drew && framed && noGlError && state.nodes > 0 && errors.length === 0;
|
|
console.log(`\n ${ok ? 'PASS' : 'FAIL'}`);
|
|
await browser.close();
|
|
process.exit(ok ? 0 : 1);
|
|
})();
|