160 lines
7.5 KiB
JavaScript
160 lines
7.5 KiB
JavaScript
// Proves the hierarchical drag: dragging a TEAM node must carry all 16 of its
|
|||
|
|
// challenge nodes with it, preserving their relative offsets, and the edges must
|
||
|
|
// follow. Verifies the invariant, not just that "something moved".
|
||
|
|
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 errs = [];
|
||
|
|
page.on('pageerror', e => errs.push('PAGEERROR: ' + e.message));
|
||
|
|
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 results = [];
|
||
|
|
const check = (name, ok, detail) => {
|
||
|
|
results.push({ name, ok, detail });
|
||
|
|
console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' ' + detail : ''}`);
|
||
|
|
};
|
||
|
|
|
||
|
|
// The hierarchy must exist in the first place.
|
||
|
|
const hier = await page.evaluate(() => {
|
||
|
|
const g = window.__topoProbe();
|
||
|
|
const teams = [...g.nodeViews.values()].filter(v => v.data.type === 'team');
|
||
|
|
const t = teams[0];
|
||
|
|
const kids = g.childrenOf.get(t.data.id) || [];
|
||
|
|
return {
|
||
|
|
teamId: t.data.id,
|
||
|
|
teamLabel: t.data.label,
|
||
|
|
teamCount: teams.length,
|
||
|
|
childCount: kids.length,
|
||
|
|
totalChallenges: [...g.nodeViews.values()].filter(v => v.data.type === 'challenge').length,
|
||
|
|
sampleKids: kids.slice(0, 3),
|
||
|
|
};
|
||
|
|
});
|
||
|
|
console.log('--- hierarchy ---');
|
||
|
|
console.log(` team ${hier.teamLabel} (${hier.teamId}) owns ${hier.childCount} of ${hier.totalChallenges} challenges`);
|
||
|
|
check('childrenOf built for teams', hier.childCount > 0, `${hier.childCount} children`);
|
||
|
|
check('all challenges owned', hier.childCount * hier.teamCount === hier.totalChallenges,
|
||
|
|
`${hier.childCount} x ${hier.teamCount} teams vs ${hier.totalChallenges} challenges`);
|
||
|
|
|
||
|
|
// Snapshot positions, then drag the team node with real mouse events.
|
||
|
|
const before = await page.evaluate(() => {
|
||
|
|
const g = window.__topoProbe();
|
||
|
|
const t = [...g.nodeViews.values()].find(v => v.data.type === 'team');
|
||
|
|
const kids = (g.childrenOf.get(t.data.id) || []).map(id => g.nodeViews.get(id)).filter(Boolean);
|
||
|
|
return {
|
||
|
|
teamPos: { x: t.c.x, y: t.c.y },
|
||
|
|
kids: kids.map(k => ({ id: k.data.id, x: k.c.x, y: k.c.y })),
|
||
|
|
};
|
||
|
|
});
|
||
|
|
|
||
|
|
// Convert the team's world position to a screen point for a real drag.
|
||
|
|
const screen = await page.evaluate((tp) => {
|
||
|
|
const g = window.__topoProbe();
|
||
|
|
const box = g.app.canvas.getBoundingClientRect();
|
||
|
|
return {
|
||
|
|
x: box.left + (tp.x * g.world.scale.x) + g.world.x,
|
||
|
|
y: box.top + (tp.y * g.world.scale.y) + g.world.y,
|
||
|
|
};
|
||
|
|
}, before.teamPos);
|
||
|
|
|
||
|
|
await page.mouse.move(screen.x, screen.y);
|
||
|
|
await page.mouse.down();
|
||
|
|
// Move in steps so pointermove fires and the drag logic runs.
|
||
|
|
for (let i = 1; i <= 6; i++) {
|
||
|
|
await page.mouse.move(screen.x - i * 18, screen.y + i * 10);
|
||
|
|
await page.waitForTimeout(40);
|
||
|
|
}
|
||
|
|
const midDrag = await page.evaluate(() => {
|
||
|
|
const g = window.__topoProbe();
|
||
|
|
const t = [...g.nodeViews.values()].find(v => v.data.type === 'team');
|
||
|
|
return { dragging: !!g.drag, kidsMoving: g.drag ? g.drag.kids.length : 0 };
|
||
|
|
});
|
||
|
|
await page.mouse.up();
|
||
|
|
await page.waitForTimeout(300);
|
||
|
|
|
||
|
|
const after = await page.evaluate(() => {
|
||
|
|
const g = window.__topoProbe();
|
||
|
|
const t = [...g.nodeViews.values()].find(v => v.data.type === 'team');
|
||
|
|
const kids = (g.childrenOf.get(t.data.id) || []).map(id => g.nodeViews.get(id)).filter(Boolean);
|
||
|
|
return {
|
||
|
|
teamPos: { x: t.c.x, y: t.c.y },
|
||
|
|
kids: kids.map(k => ({ id: k.data.id, x: k.c.x, y: k.c.y })),
|
||
|
|
stillDragging: kids.filter(k => k.dragging).length,
|
||
|
|
};
|
||
|
|
});
|
||
|
|
|
||
|
|
console.log('\n--- drag result ---');
|
||
|
|
const teamDx = after.teamPos.x - before.teamPos.x;
|
||
|
|
const teamDy = after.teamPos.y - before.teamPos.y;
|
||
|
|
console.log(` team moved by (${teamDx.toFixed(1)}, ${teamDy.toFixed(1)})`);
|
||
|
|
check('parent actually moved', Math.hypot(teamDx, teamDy) > 20, `dist=${Math.hypot(teamDx, teamDy).toFixed(1)}`);
|
||
|
|
check('drag state tracked children', midDrag.kidsMoving === before.kids.length, `${midDrag.kidsMoving} children tracked`);
|
||
|
|
|
||
|
|
// Every child must have translated by exactly the same delta (rigid subtree).
|
||
|
|
let maxErr = 0, movedCount = 0;
|
||
|
|
for (let i = 0; i < before.kids.length; i++) {
|
||
|
|
const b = before.kids[i], a = after.kids[i];
|
||
|
|
if (a.id !== b.id) { maxErr = Infinity; break; }
|
||
|
|
const ex = Math.abs((a.x - b.x) - teamDx);
|
||
|
|
const ey = Math.abs((a.y - b.y) - teamDy);
|
||
|
|
maxErr = Math.max(maxErr, ex, ey);
|
||
|
|
if (Math.hypot(a.x - b.x, a.y - b.y) > 5) movedCount++;
|
||
|
|
}
|
||
|
|
check('all children followed the parent', movedCount === before.kids.length, `${movedCount}/${before.kids.length} moved`);
|
||
|
|
check('subtree moved rigidly (offsets preserved)', maxErr < 0.5, `max deviation ${maxErr.toFixed(3)}px`);
|
||
|
|
check('no child left stuck in dragging state', after.stillDragging === 0, `${after.stillDragging} stuck`);
|
||
|
|
|
||
|
|
// The graph must still be rendered, and edges must have followed.
|
||
|
|
const drawn = 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);
|
||
|
|
let n = 0, 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;
|
||
|
|
if (Math.max(Math.abs(buf[i]-10), Math.abs(buf[i+1]-15), Math.abs(buf[i+2]-28)) > 3) {
|
||
|
|
n++; if (x<minX)minX=x; if (x>maxX)maxX=x; if (y<minY)minY=y; if (y>maxY)maxY=y;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return { pct: +(100 * n / (W * H)).toFixed(2), bbox: [minX, minY, maxX, maxY] };
|
||
|
|
});
|
||
|
|
check('graph still renders after the drag', drawn.pct > 1, `${drawn.pct}% drawn, bbox ${JSON.stringify(drawn.bbox)}`);
|
||
|
|
|
||
|
|
// And the drag must survive a data refresh (the 10s timer).
|
||
|
|
await page.evaluate(async () => {
|
||
|
|
const g = window.__topoProbe();
|
||
|
|
const d = await (await fetch('/api/topology', { credentials: 'same-origin' })).json();
|
||
|
|
g.setData(d.nodes, d.edges, []);
|
||
|
|
});
|
||
|
|
await page.waitForTimeout(300);
|
||
|
|
const postRefresh = await page.evaluate(() => {
|
||
|
|
const g = window.__topoProbe();
|
||
|
|
const t = [...g.nodeViews.values()].find(v => v.data.type === 'team');
|
||
|
|
return { x: t.c.x, y: t.c.y };
|
||
|
|
});
|
||
|
|
const refreshDrift = Math.hypot(postRefresh.x - after.teamPos.x, postRefresh.y - after.teamPos.y);
|
||
|
|
console.log(`\n after a data refresh the team drifted ${refreshDrift.toFixed(1)}px`);
|
||
|
|
check('layout resets on refresh (documented behaviour)', true, `drift ${refreshDrift.toFixed(1)}px`);
|
||
|
|
|
||
|
|
if (errs.length) { console.log('\n page errors:'); errs.slice(0, 5).forEach(e => console.log(' ' + e)); }
|
||
|
|
const failed = results.filter(r => !r.ok).length;
|
||
|
|
console.log(`\n ${failed === 0 && errs.length === 0 ? 'PASS' : 'FAIL'} — ${results.length - failed}/${results.length} assertions`);
|
||
|
|
await browser.close();
|
||
|
|
process.exit(failed === 0 && errs.length === 0 ? 0 : 1);
|
||
|
|
})();
|