mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
test(ui): cover rail exclusion and add the panel-resize QA harness
The right-rail test now proves a .panel-floating panel is neither measured, allocated nor auto-collapsed while its docked sibling still is. scripts/qa-panel-resize.mjs drives the CCTV panel in a real browser: lift-out by header drag, all eight handles with the opposite edge pinned, minimum size, reload restore and double-click snap-back. Its pure helpers sit behind a main guard so src/tooling/panelResizeQa.test.mjs can check them without a browser. Run it with `npm run qa:panel-resize`. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
1 parent
96a1e710ea
commit
335b4b08a2
5 files changed
+368
-1
No files matched your search
+2
-1
@@ -44,7 +44,8 @@
|
||||
"test:track": "node scripts/track-regression.mjs",
|
||||
"qa:map-source-tray": "node scripts/qa-map-source-tray.mjs",
|
||||
"check:boundaries": "node scripts/check-import-directions.mjs && node scripts/check-package-boundaries.mjs",
|
||||
"qa:transit": "node scripts/qa-transit.mjs"
|
||||
"qa:transit": "node scripts/qa-transit.mjs",
|
||||
"qa:panel-resize": "node scripts/qa-panel-resize.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mapbox/vector-tile": "^3.0.0",
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
"src/main.js",
|
||||
"docs/APPLICATION.md",
|
||||
"scripts/qa-application.mjs",
|
||||
"scripts/qa-panel-resize.mjs",
|
||||
"src/tooling/panelResizeQa.test.mjs",
|
||||
"src/app/application.js",
|
||||
"src/app/application.test.mjs",
|
||||
"src/app/mapLifecycle.test.mjs",
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Prove the portable CCTV panel in a real browser: a header drag lifts it out
|
||||
* of the right rail, every resize handle moves only its own edges, minimum
|
||||
* sizes hold, the window survives a reload and a header double-click snaps
|
||||
* it back and forgets the stored position.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { RESIZE_DIRECTIONS, resizeBox } from '../src/ui/panelResize.js';
|
||||
|
||||
export const PANEL_ID = 'cctv-panel';
|
||||
export const STORAGE_KEY = `godsEyeView.v8.panelPos.${PANEL_ID}`;
|
||||
export const MIN_SIZE = { width: 300, height: 160 };
|
||||
const VIEWPORT = { width: 1400, height: 900 };
|
||||
const EDGE_BY_SIDE = { n: 'top', s: 'bottom', e: 'right', w: 'left' };
|
||||
|
||||
/** Box edges a resize in `dir` must leave exactly where they were. */
|
||||
export function pinnedEdges(dir) {
|
||||
return Object.entries(EDGE_BY_SIDE)
|
||||
.filter(([side]) => !dir.includes(side))
|
||||
.map(([, edge]) => edge);
|
||||
}
|
||||
|
||||
/** Pointer travel that grows a panel by `amount` on every edge named in `dir`. */
|
||||
export function growthDelta(dir, amount) {
|
||||
return {
|
||||
dx: dir.includes('e') ? amount : dir.includes('w') ? -amount : 0,
|
||||
dy: dir.includes('s') ? amount : dir.includes('n') ? -amount : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Viewport point inside the handle for `dir`, given the panel's client rect.
|
||||
* Edge strips straddle the border by 3px; corners and the grip sit inside.
|
||||
*/
|
||||
export function handlePoint(rect, dir) {
|
||||
const inset = dir.length === 2 ? 4 : 1;
|
||||
const x = dir.includes('w')
|
||||
? rect.left + inset
|
||||
: dir.includes('e')
|
||||
? rect.right - inset
|
||||
: rect.left + rect.width / 2;
|
||||
const y = dir.includes('n')
|
||||
? rect.top + inset
|
||||
: dir.includes('s')
|
||||
? rect.bottom - inset
|
||||
: rect.top + rect.height / 2;
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
/** Edges of `after` that moved although a resize in `dir` should have pinned them. */
|
||||
export function driftedEdges(before, after, dir, tolerance = 1.5) {
|
||||
const edges = (box) => ({
|
||||
left: box.left,
|
||||
top: box.top,
|
||||
right: box.left + box.width,
|
||||
bottom: box.top + box.height,
|
||||
});
|
||||
const a = edges(before);
|
||||
const b = edges(after);
|
||||
return pinnedEdges(dir).filter(
|
||||
(edge) => Math.abs(a[edge] - b[edge]) > tolerance,
|
||||
);
|
||||
}
|
||||
|
||||
function near(actual, expected, tolerance, label) {
|
||||
assert.ok(
|
||||
Math.abs(actual - expected) <= tolerance,
|
||||
`${label}: expected ${expected} ± ${tolerance}, got ${actual}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { default: puppeteer } = await import('puppeteer');
|
||||
const args = process.argv.slice(2);
|
||||
const urlIndex = args.indexOf('--url');
|
||||
const url = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173';
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
executablePath:
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH ||
|
||||
(await puppeteer.executablePath()),
|
||||
args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'],
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport(VIEWPORT);
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.stack || error.message));
|
||||
|
||||
const boot = async () => {
|
||||
await page.goto(`${url}/`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => window.__godsEyeView?.viewer, {
|
||||
timeout: 90_000,
|
||||
});
|
||||
await page.waitForSelector(`#${PANEL_ID} .panel-header`);
|
||||
// The first-run dialog sits over the globe until a choice is made.
|
||||
await page.evaluate(() => {
|
||||
document.querySelector('.first-run-explore')?.click();
|
||||
});
|
||||
await page.keyboard.press('Escape');
|
||||
await page.evaluate(() => {
|
||||
document.getElementById('first-run-launcher')?.remove();
|
||||
for (const node of document.querySelectorAll('[class*=first-run]'))
|
||||
node.remove();
|
||||
});
|
||||
await page.mouse.click(VIEWPORT.width / 2, VIEWPORT.height / 2);
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
};
|
||||
const readPanel = () =>
|
||||
page.evaluate((id) => {
|
||||
const panel = document.getElementById(id);
|
||||
const rect = panel.getBoundingClientRect();
|
||||
return {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
floating: panel.classList.contains('panel-floating'),
|
||||
collapsed: panel.classList.contains('collapsed'),
|
||||
inRail: panel.parentElement?.id === 'right-context-rail',
|
||||
allocated: panel.style.getPropertyValue(
|
||||
'--right-panel-allocated-height',
|
||||
),
|
||||
inlineWidth: panel.style.width,
|
||||
inlineHeight: panel.style.height,
|
||||
stored: localStorage.getItem(`godsEyeView.v8.panelPos.${id}`),
|
||||
};
|
||||
}, PANEL_ID);
|
||||
const headerPoint = async () => {
|
||||
const rect = await page.evaluate((id) => {
|
||||
const title = document.querySelector(
|
||||
`#${id} .panel-header .panel-title`,
|
||||
);
|
||||
const r = title.getBoundingClientRect();
|
||||
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
|
||||
}, PANEL_ID);
|
||||
return rect;
|
||||
};
|
||||
const drag = async (from, dx, dy) => {
|
||||
await page.mouse.move(from.x, from.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(from.x + dx / 2, from.y + dy / 2, { steps: 6 });
|
||||
await page.mouse.move(from.x + dx, from.y + dy, { steps: 6 });
|
||||
await page.mouse.up();
|
||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||
};
|
||||
|
||||
await page.goto(`${url}/`, { waitUntil: 'domcontentloaded' });
|
||||
await page.evaluate((key) => {
|
||||
localStorage.removeItem(key);
|
||||
localStorage.removeItem('godsEyeView.v8.panelFloatHintShown');
|
||||
}, STORAGE_KEY);
|
||||
await boot();
|
||||
|
||||
// Expand CCTV so the panel has a body worth resizing.
|
||||
await page.evaluate((id) => {
|
||||
const panel = document.getElementById(id);
|
||||
if (panel.classList.contains('collapsed'))
|
||||
document
|
||||
.querySelector(`.panel-collapse-btn[data-collapse-target="${id}"]`)
|
||||
.click();
|
||||
}, PANEL_ID);
|
||||
await page.waitForFunction(
|
||||
(id) => !document.getElementById(id).classList.contains('collapsed'),
|
||||
{},
|
||||
PANEL_ID,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||
const docked = await readPanel();
|
||||
assert.equal(docked.floating, false);
|
||||
assert.equal(docked.inRail, true);
|
||||
|
||||
// Lift out: drag the header 200px left and 100px up.
|
||||
await drag(await headerPoint(), -200, -100);
|
||||
const lifted = await readPanel();
|
||||
assert.equal(lifted.floating, true, 'a header drag lifts the panel out');
|
||||
assert.equal(
|
||||
lifted.collapsed,
|
||||
false,
|
||||
'the drag-ending click must not collapse',
|
||||
);
|
||||
near(lifted.left, Math.max(6, docked.left - 200), 2, 'lifted left');
|
||||
near(lifted.top, Math.max(6, docked.top - 100), 2, 'lifted top');
|
||||
near(lifted.width, docked.width, 2, 'lifted width');
|
||||
await new Promise((resolve) => setTimeout(resolve, 700));
|
||||
const afterLayout = await readPanel();
|
||||
assert.equal(afterLayout.allocated, '', 'the rail no longer allocates it');
|
||||
const railExcludes = await page.evaluate((id) => {
|
||||
const rail = document.getElementById('right-context-rail');
|
||||
const counted = [...rail.children].filter((panel) =>
|
||||
panel.matches('[data-panel-id]:not(.panel-floating):not(.collapsed)'),
|
||||
);
|
||||
return {
|
||||
expandedCount: rail.dataset.expandedCount,
|
||||
counted: counted.length,
|
||||
includesPanel: counted.some((panel) => panel.id === id),
|
||||
};
|
||||
}, PANEL_ID);
|
||||
assert.equal(railExcludes.includesPanel, false);
|
||||
if (railExcludes.expandedCount !== undefined)
|
||||
assert.equal(Number(railExcludes.expandedCount), railExcludes.counted);
|
||||
const storedAfterLift = JSON.parse(afterLayout.stored);
|
||||
assert.equal(storedAfterLift.floating, true);
|
||||
assert.equal(
|
||||
'height' in storedAfterLift,
|
||||
false,
|
||||
'a drag alone must not freeze the measured height',
|
||||
);
|
||||
console.log('PASS: header drag lifts CCTV out of the rail');
|
||||
|
||||
// Every handle moves only its own edges.
|
||||
const limits = {
|
||||
minWidth: MIN_SIZE.width,
|
||||
minHeight: MIN_SIZE.height,
|
||||
viewportWidth: VIEWPORT.width,
|
||||
viewportHeight: VIEWPORT.height,
|
||||
};
|
||||
for (const dir of RESIZE_DIRECTIONS) {
|
||||
const before = await readPanel();
|
||||
const { dx, dy } = growthDelta(dir, 40);
|
||||
await drag(handlePoint(before, dir), dx, dy);
|
||||
const after = await readPanel();
|
||||
const expected = resizeBox(before, dir, dx, dy, limits);
|
||||
for (const key of ['left', 'top', 'width', 'height'])
|
||||
near(after[key], expected[key], 2, `${dir} ${key}`);
|
||||
assert.deepEqual(driftedEdges(before, after, dir, 2), [], `${dir} pins`);
|
||||
}
|
||||
console.log('PASS: eight resize handles keep the opposite edge pinned');
|
||||
|
||||
// Minimum size stops the moving edge.
|
||||
let before = await readPanel();
|
||||
await drag(handlePoint(before, 'se'), -2000, -2000);
|
||||
let after = await readPanel();
|
||||
near(after.width, MIN_SIZE.width, 2, 'min width (se)');
|
||||
near(after.height, MIN_SIZE.height, 2, 'min height (se)');
|
||||
assert.deepEqual(driftedEdges(before, after, 'se', 2), []);
|
||||
before = after;
|
||||
await drag(handlePoint(before, 'nw'), 2000, 2000);
|
||||
after = await readPanel();
|
||||
near(after.width, MIN_SIZE.width, 2, 'min width (nw)');
|
||||
near(after.height, MIN_SIZE.height, 2, 'min height (nw)');
|
||||
assert.deepEqual(driftedEdges(before, after, 'nw', 2), []);
|
||||
console.log('PASS: minimum size holds from both corners');
|
||||
|
||||
// Reload restores the window.
|
||||
const saved = await readPanel();
|
||||
const record = JSON.parse(saved.stored);
|
||||
assert.equal(record.floating, true);
|
||||
await boot();
|
||||
const restored = await readPanel();
|
||||
assert.equal(restored.floating, true, 'floating survives a reload');
|
||||
for (const key of ['left', 'top', 'width', 'height'])
|
||||
near(restored[key], record[key], 2, `restored ${key}`);
|
||||
console.log('PASS: reload restores position and size');
|
||||
|
||||
// Double-click the header to snap back.
|
||||
const point = await headerPoint();
|
||||
await page.mouse.click(point.x, point.y, { clickCount: 1 });
|
||||
await page.mouse.click(point.x, point.y, { clickCount: 2 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
const snapped = await readPanel();
|
||||
assert.equal(snapped.floating, false, 'double-click snaps back');
|
||||
assert.equal(snapped.inRail, true);
|
||||
assert.equal(snapped.inlineWidth, '');
|
||||
assert.equal(snapped.inlineHeight, '');
|
||||
assert.equal(snapped.stored, null, 'the storage key is gone');
|
||||
console.log('PASS: header double-click returns the panel to the rail');
|
||||
|
||||
assert.deepEqual(errors, []);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
import.meta.url === pathToFileURL(process.argv[1]).href
|
||||
) {
|
||||
main().catch((error) => {
|
||||
console.error(error.stack || error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
driftedEdges,
|
||||
growthDelta,
|
||||
handlePoint,
|
||||
MIN_SIZE,
|
||||
pinnedEdges,
|
||||
STORAGE_KEY,
|
||||
} from '../../scripts/qa-panel-resize.mjs';
|
||||
import { RESIZE_DIRECTIONS } from '../ui/panelResize.js';
|
||||
|
||||
test('the harness targets the key and minimums the controls use', () => {
|
||||
assert.equal(STORAGE_KEY, 'godsEyeView.v8.panelPos.cctv-panel');
|
||||
assert.deepEqual(MIN_SIZE, { width: 300, height: 160 });
|
||||
});
|
||||
|
||||
test('pinned edges are exactly the sides a direction does not name', () => {
|
||||
assert.deepEqual(pinnedEdges('n'), ['bottom', 'right', 'left']);
|
||||
assert.deepEqual(pinnedEdges('se'), ['top', 'left']);
|
||||
assert.deepEqual(pinnedEdges('nw'), ['bottom', 'right']);
|
||||
for (const dir of RESIZE_DIRECTIONS)
|
||||
assert.equal(pinnedEdges(dir).length, 4 - dir.length);
|
||||
});
|
||||
|
||||
test('growth deltas push each named edge outward', () => {
|
||||
assert.deepEqual(growthDelta('n', 40), { dx: 0, dy: -40 });
|
||||
assert.deepEqual(growthDelta('e', 40), { dx: 40, dy: 0 });
|
||||
assert.deepEqual(growthDelta('sw', 40), { dx: -40, dy: 40 });
|
||||
assert.deepEqual(growthDelta('se', 40), { dx: 40, dy: 40 });
|
||||
});
|
||||
|
||||
test('handle points land inside the strip or corner for every direction', () => {
|
||||
const rect = { left: 100, top: 200, width: 400, height: 300 };
|
||||
rect.right = rect.left + rect.width;
|
||||
rect.bottom = rect.top + rect.height;
|
||||
assert.deepEqual(handlePoint(rect, 'n'), { x: 300, y: 201 });
|
||||
assert.deepEqual(handlePoint(rect, 'w'), { x: 101, y: 350 });
|
||||
assert.deepEqual(handlePoint(rect, 'se'), { x: 496, y: 496 });
|
||||
assert.deepEqual(handlePoint(rect, 'nw'), { x: 104, y: 204 });
|
||||
for (const dir of RESIZE_DIRECTIONS) {
|
||||
const { x, y } = handlePoint(rect, dir);
|
||||
assert.ok(x >= rect.left && x <= rect.right, `${dir} x inside`);
|
||||
assert.ok(y >= rect.top && y <= rect.bottom, `${dir} y inside`);
|
||||
}
|
||||
});
|
||||
|
||||
test('drift detection reports only pinned edges that moved', () => {
|
||||
const before = { left: 100, top: 100, width: 400, height: 300 };
|
||||
const grewEast = { left: 100, top: 100, width: 440, height: 300 };
|
||||
assert.deepEqual(driftedEdges(before, grewEast, 'e'), []);
|
||||
const slidWest = { left: 60, top: 100, width: 440, height: 300 };
|
||||
assert.deepEqual(driftedEdges(before, slidWest, 'e'), ['left']);
|
||||
assert.deepEqual(driftedEdges(before, slidWest, 'w'), []);
|
||||
const wobble = { left: 101, top: 100, width: 439, height: 300 };
|
||||
assert.deepEqual(driftedEdges(before, wobble, 'e'), []);
|
||||
});
|
||||
@@ -243,6 +243,28 @@ test('right layout retains Display allocation during measurement and caps restor
|
||||
);
|
||||
});
|
||||
|
||||
test('right rail neither measures nor allocates a panel floating out of it', () => {
|
||||
const f = fixture('right');
|
||||
f.expand(f.first, 900);
|
||||
f.expand(f.second, 300);
|
||||
f.first.classList.add('panel-floating');
|
||||
f.first.style.setProperty('--right-panel-allocated-height', '500px');
|
||||
f.first.writes.length = 0;
|
||||
f.run();
|
||||
assert.equal(f.stack.dataset.expandedCount, '1');
|
||||
assert.deepEqual(
|
||||
f.first.writes,
|
||||
[],
|
||||
'a floating panel keeps whatever inline style it left the rail with',
|
||||
);
|
||||
assert.equal(f.first.getAttribute('aria-hidden'), undefined);
|
||||
assert.equal(f.first.classList.contains('layout-auto-collapsed'), false);
|
||||
assert.ok(
|
||||
f.second.style.getPropertyValue('--right-panel-allocated-height'),
|
||||
'the docked panel still receives its allocation',
|
||||
);
|
||||
});
|
||||
|
||||
test('right rail aligns to the current left rail and excludes hidden obstacles', () => {
|
||||
const f = fixture('right');
|
||||
f.options.leftStack.rect.top = 200;
|
||||
|
||||
Reference in new issue
Block a user