From 335b4b08a2b9816d616ed079a065babe45a3ab39 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 23 Sep 2026 15:18:21 +0100 Subject: [PATCH] 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 --- package.json | 3 +- scripts/format-scope.json | 2 + scripts/qa-panel-resize.mjs | 285 +++++++++++++++++++++++++++++ src/tooling/panelResizeQa.test.mjs | 57 ++++++ src/ui/panelRails.test.mjs | 22 +++ 5 files changed, 368 insertions(+), 1 deletion(-) create mode 100644 scripts/qa-panel-resize.mjs create mode 100644 src/tooling/panelResizeQa.test.mjs diff --git a/package.json b/package.json index 809841ba2..baeb1ba4f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/format-scope.json b/scripts/format-scope.json index cc248f38d..b0dfd39a0 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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", diff --git a/scripts/qa-panel-resize.mjs b/scripts/qa-panel-resize.mjs new file mode 100644 index 000000000..fc97614e9 --- /dev/null +++ b/scripts/qa-panel-resize.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); + }); +} diff --git a/src/tooling/panelResizeQa.test.mjs b/src/tooling/panelResizeQa.test.mjs new file mode 100644 index 000000000..586f22c08 --- /dev/null +++ b/src/tooling/panelResizeQa.test.mjs @@ -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'), []); +}); diff --git a/src/ui/panelRails.test.mjs b/src/ui/panelRails.test.mjs index 2b03c1406..448479169 100644 --- a/src/ui/panelRails.test.mjs +++ b/src/ui/panelRails.test.mjs @@ -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;