mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
refactor(ui): remove floating, resizable rail panels
PR #768 bundled a portable-panel windowing system with the Street Level
layer. Remove it so CCTV and Street Level are ordinary docked right-rail
panels again, as on main before the merge.
Removed:
- header drag to float, 8-way edge/corner resize, double-press snap-back,
dockOnCollapse, command-dock clearance, float hint toast, floating z floor,
persisted floating geometry and ResizeObserver re-clamping
(panelPositionControls.js restored to its pre-#768 version)
- src/ui/panelResize.js and its tests, panelDock.test.mjs
- onPanelResized/dockPanel wiring in PanelChrome, the application shell and
StreetLevelControls (EXPAND/SHRINK no longer docks)
- :not(.panel-floating) guards and .panel-floating/.panel-portable/resize CSS
- qa:panel-resize, qa-panel-resize.mjs, qa-panelDrag.mjs, their tests, the
CI "Portable panel gate" step, QA_FAIL_ON_RETRY, and the Street Level
gate's float/resize/dock steps
- floating-panel docs in README, CHANGELOG, CURRENT-STATE, KNOWN-ISSUES
Kept: the Street Level panel as a docked rail panel (share/cockpit/rail
lists, layout controller, rail and Cyber CSS, EXPAND viewer), and the
independent right-rail alignment fix (settledLeftRailTop, 9dde7b4).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
7cbe04f515
commit
fbbc4cda5c
31 files changed
+201
-2945
No files matched your search
@@ -36,9 +36,6 @@
|
||||
"src/main.js",
|
||||
"docs/APPLICATION.md",
|
||||
"scripts/qa-application.mjs",
|
||||
"scripts/qa-panel-resize.mjs",
|
||||
"scripts/qa-panelDrag.mjs",
|
||||
"src/tooling/panelResizeQa.test.mjs",
|
||||
"scripts/qa-terrain-429.mjs",
|
||||
"src/app/application.js",
|
||||
"src/app/application.test.mjs",
|
||||
@@ -168,7 +165,6 @@
|
||||
"src/ui/panelRails.js",
|
||||
"src/ui/leftPanelRail.js",
|
||||
"src/ui/rightPanelRail.js",
|
||||
"src/ui/panelResize.test.mjs",
|
||||
"src/ui/panelMeasurement.js",
|
||||
"src/ui/panelRailGeometry.js",
|
||||
"src/ui/panelRails.test.mjs",
|
||||
@@ -917,7 +913,6 @@
|
||||
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
|
||||
"src/layers/streetLevel/cameraFollow.test.mjs",
|
||||
"src/ui/streetLevelControls.test.mjs",
|
||||
"src/ui/panelDock.test.mjs",
|
||||
"src/layers/streetLevel/marker.test.mjs",
|
||||
"src/layers/streetLevel/viewerHost.test.mjs",
|
||||
"src/layers/streetLevel/providers/mapillary/sequences.test.mjs",
|
||||
|
||||
@@ -623,7 +623,6 @@
|
||||
"src/ui/panelLayoutController.js",
|
||||
"src/ui/panelMeasurement.js",
|
||||
"src/ui/panelPositionControls.js",
|
||||
"src/ui/panelResize.js",
|
||||
"src/ui/panelRailGeometry.js",
|
||||
"src/ui/panelRails.js",
|
||||
"src/ui/radio.js",
|
||||
@@ -1912,7 +1911,6 @@
|
||||
"src/ui/panelLayoutController.js",
|
||||
"src/ui/panelMeasurement.js",
|
||||
"src/ui/panelPositionControls.js",
|
||||
"src/ui/panelResize.js",
|
||||
"src/ui/panelRailGeometry.js",
|
||||
"src/ui/panelRails.js",
|
||||
"src/ui/radio.js",
|
||||
@@ -2809,7 +2807,6 @@
|
||||
"src/ui/panelLayoutController.js",
|
||||
"src/ui/panelMeasurement.js",
|
||||
"src/ui/panelPositionControls.js",
|
||||
"src/ui/panelResize.js",
|
||||
"src/ui/panelRailGeometry.js",
|
||||
"src/ui/panelRails.js",
|
||||
"src/ui/rightPanelRail.js",
|
||||
|
||||
@@ -1,456 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Browser QA for the floating CCTV panel: lift, resize from every handle,
|
||||
* minimum size, reload, and dock again. `--fail-on-retry` (or
|
||||
* QA_FAIL_ON_RETRY=1) fails on any header-press retry.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { RESIZE_DIRECTIONS, resizeBox } from '../src/ui/panelResize.js';
|
||||
import {
|
||||
describePress,
|
||||
dockPanelByDoubleClick,
|
||||
dragPointer,
|
||||
liftPanelByHeader,
|
||||
nextFrames,
|
||||
} from './qa-panelDrag.mjs';
|
||||
import {
|
||||
hookRenderErrors,
|
||||
readRenderErrors,
|
||||
saveFailureArtifacts,
|
||||
watchPage,
|
||||
} from './qa-browserEvidence.mjs';
|
||||
|
||||
export const PANEL_ID = 'cctv-panel';
|
||||
export const STORAGE_KEY = `godsEyeView.v8.panelPos.${PANEL_ID}`;
|
||||
export const MIN_SIZE = { width: 300, height: 160 };
|
||||
export const ARTIFACT_DIR = 'qa-artifacts/panel-resize';
|
||||
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 };
|
||||
}
|
||||
|
||||
/** Pinned edges that moved during a resize in `dir`. */
|
||||
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';
|
||||
if (args.includes('--fail-on-retry')) process.env.QA_FAIL_ON_RETRY = '1';
|
||||
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'],
|
||||
});
|
||||
const monitors = [];
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport(VIEWPORT);
|
||||
const errors = [];
|
||||
monitors.push(watchPage(page, { name: 'panel', errors }));
|
||||
await hookRenderErrors(page);
|
||||
|
||||
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`);
|
||||
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 nextFrames(page);
|
||||
};
|
||||
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,
|
||||
// handlePoint() reads the far edges for s/e handles.
|
||||
right: rect.right,
|
||||
bottom: rect.bottom,
|
||||
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);
|
||||
/**
|
||||
* Best effort: wait for the box to hold still for half a second. The lift
|
||||
* is checked against the box at pointerdown, so a timeout only costs time.
|
||||
*/
|
||||
const settle = async () => {
|
||||
try {
|
||||
await page.waitForFunction(
|
||||
(id) => {
|
||||
const r = document.getElementById(id).getBoundingClientRect();
|
||||
const box = [r.left, r.top, r.width, r.height]
|
||||
.map(Math.round)
|
||||
.join(',');
|
||||
const now = performance.now();
|
||||
if (window.__qaLastBox !== box) {
|
||||
window.__qaLastBox = box;
|
||||
window.__qaSince = now;
|
||||
}
|
||||
return now - window.__qaSince >= 500;
|
||||
},
|
||||
{ polling: 100, timeout: 20_000 },
|
||||
PANEL_ID,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error?.name !== 'TimeoutError') throw error;
|
||||
console.log('note: the CCTV panel was still moving; lifting anyway');
|
||||
}
|
||||
};
|
||||
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 = (from, dx, dy) => dragPointer(page, from, dx, dy);
|
||||
|
||||
await page.goto(`${url}/`, { waitUntil: 'domcontentloaded' });
|
||||
await page.evaluate((key) => {
|
||||
localStorage.removeItem(key);
|
||||
localStorage.removeItem('godsEyeView.v8.panelFloatHintShown');
|
||||
}, STORAGE_KEY);
|
||||
await boot();
|
||||
|
||||
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,
|
||||
);
|
||||
// The rail animates panel heights, and on a slow software renderer the
|
||||
// panels above CCTV can still be moving after a fixed sleep.
|
||||
await settle();
|
||||
const docked = await readPanel();
|
||||
assert.equal(docked.floating, false);
|
||||
assert.equal(docked.inRail, true);
|
||||
|
||||
// Names whatever above CCTV in the rail shifted it, for a retry note.
|
||||
const railAbove = () =>
|
||||
page.evaluate((id) => {
|
||||
const out = [];
|
||||
for (const node of document.getElementById('right-context-rail')
|
||||
?.children || []) {
|
||||
if (node.id === id) break;
|
||||
out.push(
|
||||
`${node.id || node.className}:${Math.round(node.offsetHeight)}`,
|
||||
);
|
||||
}
|
||||
return out.join(' ');
|
||||
}, PANEL_ID);
|
||||
const aboveAtDock = await railAbove();
|
||||
// On CI a panel above CCTV grows after load and can shift the header out
|
||||
// from under the press; the helper retries only such a provable miss.
|
||||
const attempt = await liftPanelByHeader(page, PANEL_ID, {
|
||||
dx: -200,
|
||||
dy: -100,
|
||||
note: async () =>
|
||||
`rail above CCTV ${aboveAtDock} -> ${await railAbove()}`,
|
||||
});
|
||||
const { pressed } = attempt;
|
||||
const lifted = await readPanel();
|
||||
assert.equal(
|
||||
lifted.floating,
|
||||
true,
|
||||
`a header drag lifts the panel out (${describePress(attempt)}; panel docked at ${docked.top}, at press ${pressed?.top})`,
|
||||
);
|
||||
assert.equal(
|
||||
lifted.collapsed,
|
||||
false,
|
||||
'the drag-ending click must not collapse',
|
||||
);
|
||||
const moved = `docked at ${docked.left},${docked.top}; pressed at ${pressed.left},${pressed.top}`;
|
||||
near(
|
||||
lifted.left,
|
||||
Math.max(6, pressed.left - 200),
|
||||
2,
|
||||
`lifted left (${moved})`,
|
||||
);
|
||||
near(
|
||||
lifted.top,
|
||||
Math.max(6, pressed.top - 100),
|
||||
2,
|
||||
`lifted top (${moved})`,
|
||||
);
|
||||
near(lifted.width, pressed.width, 2, 'lifted width');
|
||||
// The rail lets go of the lifted panel on its next layout pass.
|
||||
await page
|
||||
.waitForFunction(
|
||||
(id) =>
|
||||
document
|
||||
.getElementById(id)
|
||||
.style.getPropertyValue('--right-panel-allocated-height') === '',
|
||||
{ timeout: 5_000 },
|
||||
PANEL_ID,
|
||||
)
|
||||
.catch(() => {});
|
||||
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');
|
||||
// Same rule as the rail: hidden panels take no room.
|
||||
const counted = [...rail.children].filter((panel) =>
|
||||
panel.matches(
|
||||
'[data-panel-id]:not(.panel-floating):not(.collapsed):not([hidden])',
|
||||
),
|
||||
);
|
||||
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');
|
||||
|
||||
// Park near the top: the voice dock would cover the south corners once
|
||||
// the loop below has grown the window.
|
||||
{
|
||||
const header = await headerPoint();
|
||||
await drag(header, 0, 120 - header.y);
|
||||
}
|
||||
|
||||
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);
|
||||
const point = handlePoint(before, dir);
|
||||
const hit = await page.evaluate(({ x, y }) => {
|
||||
const node = document.elementFromPoint(x, y);
|
||||
if (!node) return 'nothing';
|
||||
const dirAttr = node.dataset?.dir
|
||||
? `[data-dir=${node.dataset.dir}]`
|
||||
: '';
|
||||
return `${node.tagName.toLowerCase()}${node.id ? `#${node.id}` : ''}.${[...node.classList].join('.')}${dirAttr}`;
|
||||
}, point);
|
||||
await drag(point, 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} (pointer on ${hit})`);
|
||||
assert.deepEqual(driftedEdges(before, after, dir, 2), [], `${dir} pins`);
|
||||
}
|
||||
console.log('PASS: eight resize handles keep the opposite edge pinned');
|
||||
|
||||
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');
|
||||
|
||||
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');
|
||||
|
||||
// A collapsed window keeps the height chosen for it, through a drag of
|
||||
// its header strip and a reload.
|
||||
const setCollapsed = async (collapsed) => {
|
||||
await page.evaluate(
|
||||
(id, want) => {
|
||||
const panel = document.getElementById(id);
|
||||
if (panel.classList.contains('collapsed') !== want)
|
||||
document
|
||||
.querySelector(
|
||||
`.panel-collapse-btn[data-collapse-target="${id}"]`,
|
||||
)
|
||||
.click();
|
||||
},
|
||||
PANEL_ID,
|
||||
collapsed,
|
||||
);
|
||||
await page.waitForFunction(
|
||||
(id, want) =>
|
||||
document.getElementById(id).classList.contains('collapsed') === want,
|
||||
{},
|
||||
PANEL_ID,
|
||||
collapsed,
|
||||
);
|
||||
await nextFrames(page);
|
||||
};
|
||||
await drag(handlePoint(await readPanel(), 'se'), 0, 200);
|
||||
const chosen = await readPanel();
|
||||
assert.ok(
|
||||
chosen.height > MIN_SIZE.height + 150,
|
||||
`the window grew to ${chosen.height}px`,
|
||||
);
|
||||
await setCollapsed(true);
|
||||
const strip = await readPanel();
|
||||
assert.ok(strip.height < chosen.height - 100, 'collapsed to its header');
|
||||
await drag(await headerPoint(), 40, 30);
|
||||
const movedStrip = await readPanel();
|
||||
assert.equal(movedStrip.floating, true, 'the strip stays a window');
|
||||
near(
|
||||
JSON.parse(movedStrip.stored).height,
|
||||
chosen.height,
|
||||
2,
|
||||
'stored height while collapsed',
|
||||
);
|
||||
await boot();
|
||||
await setCollapsed(false);
|
||||
near(
|
||||
(await readPanel()).height,
|
||||
chosen.height,
|
||||
2,
|
||||
'height after expanding',
|
||||
);
|
||||
console.log(
|
||||
'PASS: a collapsed window keeps its height through a drag and a reload',
|
||||
);
|
||||
|
||||
const dock = await dockPanelByDoubleClick(page, PANEL_ID);
|
||||
const snapped = await readPanel();
|
||||
assert.equal(
|
||||
snapped.floating,
|
||||
false,
|
||||
`double-click snaps back (${describePress(dock)})`,
|
||||
);
|
||||
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');
|
||||
|
||||
const renderErrors = await readRenderErrors(page);
|
||||
assert.deepEqual(
|
||||
[...errors, ...renderErrors.map((text) => `scene.renderError: ${text}`)],
|
||||
[],
|
||||
'no page errors and no Cesium render-loop errors',
|
||||
);
|
||||
console.log('PASS: no page errors and no render-loop errors');
|
||||
} catch (error) {
|
||||
const dir = await saveFailureArtifacts({
|
||||
dir: ARTIFACT_DIR,
|
||||
browser,
|
||||
monitors,
|
||||
error,
|
||||
}).catch(() => null);
|
||||
if (dir) console.error(`failure evidence saved in ${dir}/`);
|
||||
throw error;
|
||||
} 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);
|
||||
});
|
||||
}
|
||||
@@ -1,319 +0,0 @@
|
||||
/**
|
||||
* Header press-and-verify for the QA gates that lift a rail panel into a
|
||||
* floating window and dock it again. The rail animates panel heights, so a
|
||||
* point read once can be under another panel by the time the pointer goes
|
||||
* down. Only a provable miss (a recorded press outside the header) is
|
||||
* retried, with a GitHub `::warning::`. With QA_FAIL_ON_RETRY=1 a miss the
|
||||
* panel's own movement cannot explain fails the run instead.
|
||||
*/
|
||||
|
||||
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/** Retries after the first attempt, and the pause before each. */
|
||||
export const PRESS_RETRIES = 2;
|
||||
const RETRY_PAUSE_MS = 1000;
|
||||
|
||||
/** Whether a retry fails the run (QA_FAIL_ON_RETRY=1 or --fail-on-retry). */
|
||||
export function failOnRetryDefault(
|
||||
env = process.env,
|
||||
argv = process.argv.slice(2),
|
||||
) {
|
||||
return env.QA_FAIL_ON_RETRY === '1' || argv.includes('--fail-on-retry');
|
||||
}
|
||||
|
||||
/**
|
||||
* A failed attempt whose press landed outside the header. No recorded press
|
||||
* is a failure, not a miss: a retry would only hide it.
|
||||
* @param {{done: boolean, pressed: {inHeader: boolean}|null}} attempt
|
||||
*/
|
||||
export function pressMissed(attempt) {
|
||||
return !attempt.done && attempt.pressed?.inHeader === false;
|
||||
}
|
||||
|
||||
/** Whether the panel's box changed by more than 2 px between two reads. */
|
||||
export function panelMoved(before, after) {
|
||||
if (!before || !after) return false;
|
||||
return ['left', 'top', 'width', 'height'].some(
|
||||
(key) => Math.abs((after[key] ?? NaN) - (before[key] ?? NaN)) > 2,
|
||||
);
|
||||
}
|
||||
|
||||
/** The panel's box, rounded, as `left,top widthxheight`. */
|
||||
export function formatBox(box) {
|
||||
if (!box) return 'unknown';
|
||||
const r = (value) => Math.round(value ?? NaN);
|
||||
return `${r(box.left)},${r(box.top)} ${r(box.width)}x${r(box.height)}`;
|
||||
}
|
||||
|
||||
function panelBox(page, panelId) {
|
||||
return page.evaluate((id) => {
|
||||
const r = document.getElementById(id).getBoundingClientRect();
|
||||
return { left: r.left, top: r.top, width: r.width, height: r.height };
|
||||
}, panelId);
|
||||
}
|
||||
|
||||
export function headerPoint(page, panelId) {
|
||||
return 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 };
|
||||
}, panelId);
|
||||
}
|
||||
|
||||
/** Name the element at a point, and whether it is the panel's header. */
|
||||
export function hitAt(page, point, panelId) {
|
||||
return page.evaluate(
|
||||
({ x, y, id }) => {
|
||||
const node = document.elementFromPoint(x, y);
|
||||
if (!node) return { target: 'nothing', inHeader: false };
|
||||
return {
|
||||
target: `${node.tagName.toLowerCase()}${node.id ? `#${node.id}` : ''}.${[...node.classList].join('.')}`,
|
||||
inHeader: Boolean(node.closest(`#${id} .panel-header`)),
|
||||
};
|
||||
},
|
||||
{ ...point, id: panelId },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-resolve the header point until it hits the header or the wait runs out
|
||||
* (the press then records the miss).
|
||||
*/
|
||||
export async function headerTarget(
|
||||
page,
|
||||
panelId,
|
||||
{ tries = 20, intervalMs = 500 } = {},
|
||||
) {
|
||||
let point = await headerPoint(page, panelId);
|
||||
let hit = await hitAt(page, point, panelId);
|
||||
for (let i = 0; i < tries && !hit.inHeader; i++) {
|
||||
await delay(intervalMs);
|
||||
point = await headerPoint(page, panelId);
|
||||
hit = await hitAt(page, point, panelId);
|
||||
}
|
||||
const box = await panelBox(page, panelId);
|
||||
return { point, hit, timedOut: !hit.inHeader, box };
|
||||
}
|
||||
|
||||
/**
|
||||
* Best effort: wait until the panel's box has held still for `stillMs`, so
|
||||
* the header does not move between the hit check and the press.
|
||||
*/
|
||||
export async function waitForStill(
|
||||
page,
|
||||
panelId,
|
||||
{ stillMs = 300, timeout = 5000 } = {},
|
||||
) {
|
||||
try {
|
||||
await page.waitForFunction(
|
||||
(id, ms) => {
|
||||
const r = document.getElementById(id).getBoundingClientRect();
|
||||
const box = [r.left, r.top, r.width, r.height].map(Math.round).join();
|
||||
const now = performance.now();
|
||||
if (window.__qaStillBox !== box) {
|
||||
window.__qaStillBox = box;
|
||||
window.__qaStillSince = now;
|
||||
}
|
||||
return now - window.__qaStillSince >= ms;
|
||||
},
|
||||
{ polling: 100, timeout },
|
||||
panelId,
|
||||
stillMs,
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error?.name !== 'TimeoutError') throw error;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Record the next pointerdown's target and the panel's box at that moment. */
|
||||
export function recordNextPress(page, panelId) {
|
||||
return page.evaluate((id) => {
|
||||
window.__qaPressBox = null;
|
||||
window.addEventListener(
|
||||
'pointerdown',
|
||||
(event) => {
|
||||
const r = document.getElementById(id).getBoundingClientRect();
|
||||
const target = event.target;
|
||||
window.__qaPressBox = {
|
||||
left: r.left,
|
||||
top: r.top,
|
||||
width: r.width,
|
||||
height: r.height,
|
||||
target: `${target.tagName?.toLowerCase()}${target.id ? `#${target.id}` : ''}.${[...(target.classList || [])].join('.')}`,
|
||||
inHeader: Boolean(target.closest?.(`#${id} .panel-header`)),
|
||||
};
|
||||
},
|
||||
{ capture: true, once: true },
|
||||
);
|
||||
}, panelId);
|
||||
}
|
||||
|
||||
function readPress(page) {
|
||||
return page.evaluate(() => window.__qaPressBox ?? null);
|
||||
}
|
||||
|
||||
export async function waitForFloating(
|
||||
page,
|
||||
panelId,
|
||||
floating,
|
||||
{ timeout = 3000 } = {},
|
||||
) {
|
||||
try {
|
||||
await page.waitForFunction(
|
||||
(id, want) =>
|
||||
document.getElementById(id).classList.contains('panel-floating') ===
|
||||
want,
|
||||
{ polling: 50, timeout },
|
||||
panelId,
|
||||
floating,
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error?.name !== 'TimeoutError') throw error;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Two paints: by then every layout change an input caused is on screen. */
|
||||
export function nextFrames(page) {
|
||||
return page.evaluate(
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function dragPointer(page, from, dx, dy, { steps = 6 } = {}) {
|
||||
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 });
|
||||
await page.mouse.move(from.x + dx, from.y + dy, { steps });
|
||||
await page.mouse.up();
|
||||
await nextFrames(page);
|
||||
}
|
||||
|
||||
export function describeRetry(
|
||||
panelId,
|
||||
attempt,
|
||||
{ round, retries, extra = '' },
|
||||
) {
|
||||
const { hit, pressed, timedOut, still, box } = attempt;
|
||||
return [
|
||||
`retry ${round + 1}/${retries}: the press missed the ${panelId} header (on ${pressed?.target ?? hit.target})`,
|
||||
`pre-press hit check ${timedOut ? `timed out (on ${hit.target})` : 'passed'}`,
|
||||
`stillness ${still === false ? 'timed out' : 'held'}`,
|
||||
`panel box at hit check ${formatBox(box)} -> at press ${formatBox(pressed)}`,
|
||||
]
|
||||
.concat(extra ? [extra] : [])
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
/** Retry `press` only while it provably misses the header. */
|
||||
async function pressUntilDecided(
|
||||
page,
|
||||
panelId,
|
||||
press,
|
||||
{
|
||||
retries,
|
||||
log,
|
||||
note,
|
||||
pauseMs = RETRY_PAUSE_MS,
|
||||
failOnRetry = failOnRetryDefault(),
|
||||
},
|
||||
) {
|
||||
for (let round = 0; ; round++) {
|
||||
const still = await waitForStill(page, panelId);
|
||||
await recordNextPress(page, panelId);
|
||||
const { point, hit, timedOut, box } = await headerTarget(page, panelId);
|
||||
const done = await press(point);
|
||||
const attempt = {
|
||||
point,
|
||||
hit,
|
||||
timedOut,
|
||||
still,
|
||||
box,
|
||||
done,
|
||||
pressed: await readPress(page),
|
||||
retries: round,
|
||||
};
|
||||
if (round >= retries || !pressMissed(attempt)) return attempt;
|
||||
const why = describeRetry(panelId, attempt, {
|
||||
round,
|
||||
retries,
|
||||
extra: note ? await note() : '',
|
||||
});
|
||||
if (failOnRetry && !panelMoved(box, attempt.pressed))
|
||||
throw new Error(
|
||||
`QA_FAIL_ON_RETRY=1 turns this retry into a failure: ${why}`,
|
||||
);
|
||||
log(`note: ${why}; retrying`);
|
||||
log(`::warning title=Panel press retried::${why}`);
|
||||
await delay(pauseMs);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lift a docked panel into a floating window with a header drag.
|
||||
* @returns {Promise<{point: {x: number, y: number}, hit: {target: string, inHeader: boolean}, done: boolean, pressed: object|null}>}
|
||||
*/
|
||||
export function liftPanelByHeader(
|
||||
page,
|
||||
panelId,
|
||||
{
|
||||
dx,
|
||||
dy,
|
||||
retries = PRESS_RETRIES,
|
||||
log = console.log,
|
||||
note,
|
||||
pauseMs,
|
||||
failOnRetry,
|
||||
} = {},
|
||||
) {
|
||||
return pressUntilDecided(
|
||||
page,
|
||||
panelId,
|
||||
async (point) => {
|
||||
await dragPointer(page, point, dx, dy);
|
||||
return waitForFloating(page, panelId, true);
|
||||
},
|
||||
{ retries, log, note, pauseMs, failOnRetry },
|
||||
);
|
||||
}
|
||||
|
||||
/** Dock a floating panel with a header double-click; same result as lifting. */
|
||||
export function dockPanelByDoubleClick(
|
||||
page,
|
||||
panelId,
|
||||
{
|
||||
retries = PRESS_RETRIES,
|
||||
log = console.log,
|
||||
note,
|
||||
pauseMs,
|
||||
failOnRetry,
|
||||
} = {},
|
||||
) {
|
||||
return pressUntilDecided(
|
||||
page,
|
||||
panelId,
|
||||
async (point) => {
|
||||
await page.mouse.click(point.x, point.y, { clickCount: 1 });
|
||||
await page.mouse.click(point.x, point.y, { clickCount: 2 });
|
||||
return waitForFloating(page, panelId, false);
|
||||
},
|
||||
{ retries, log, note, pauseMs, failOnRetry },
|
||||
);
|
||||
}
|
||||
|
||||
/** Where an attempt pressed, for an assertion message. */
|
||||
export function describePress(attempt) {
|
||||
const { point, hit, pressed } = attempt;
|
||||
const where = `pressed ${Math.round(point.x)},${Math.round(point.y)}`;
|
||||
if (!pressed)
|
||||
return `${where}: no pointerdown reached the page (hit check on ${hit.target}, in header: ${hit.inHeader})`;
|
||||
const n = attempt.retries || 0;
|
||||
const retried = n ? `, after ${n} ${n === 1 ? 'retry' : 'retries'}` : '';
|
||||
return `${where} on ${pressed.target}, in header: ${pressed.inHeader}${retried}`;
|
||||
}
|
||||
+75
-210
@@ -8,9 +8,8 @@
|
||||
* because the bundle carries it; CI uses a dummy one. Fixture runs also stage
|
||||
* a rejected key and a keyless second server.
|
||||
*
|
||||
* `--strict` fails on any skip not in STRICT_ALLOWED_SKIPS. `--fail-on-retry`
|
||||
* (or QA_FAIL_ON_RETRY=1) fails a missed header press unless the panel moved.
|
||||
* A failed run saves evidence under qa-artifacts/street-level/.
|
||||
* `--strict` fails on any skip not in STRICT_ALLOWED_SKIPS. A failed run saves
|
||||
* evidence under qa-artifacts/street-level/.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
@@ -18,12 +17,6 @@ import { LAYER_STATE_REGISTRY } from '../src/data/layerState.js';
|
||||
import { tileBounds } from '../src/layers/streetLevel/tileMath.js';
|
||||
import { encodeCoverageTile } from '../src/layers/streetLevel/providers/mapillary/coverageFixture.mjs';
|
||||
import { COVERAGE_MOVE_DEBOUNCE_MS } from '../src/layers/streetLevel/providers/mapillary/policy.js';
|
||||
import {
|
||||
describePress,
|
||||
dockPanelByDoubleClick,
|
||||
liftPanelByHeader,
|
||||
waitForStill,
|
||||
} from './qa-panelDrag.mjs';
|
||||
import {
|
||||
hookRenderErrors,
|
||||
readRenderErrors,
|
||||
@@ -339,6 +332,38 @@ async function clearFirstRun(page) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Best effort: wait until the panel's box has held still for `stillMs`, so a
|
||||
* point read from it does not move before the press.
|
||||
*/
|
||||
async function waitForStill(
|
||||
page,
|
||||
panelId,
|
||||
{ stillMs = 300, timeout = 5000 } = {},
|
||||
) {
|
||||
try {
|
||||
await page.waitForFunction(
|
||||
(id, ms) => {
|
||||
const r = document.getElementById(id).getBoundingClientRect();
|
||||
const box = [r.left, r.top, r.width, r.height].map(Math.round).join();
|
||||
const now = performance.now();
|
||||
if (window.__qaStillBox !== box) {
|
||||
window.__qaStillBox = box;
|
||||
window.__qaStillSince = now;
|
||||
}
|
||||
return now - window.__qaStillSince >= ms;
|
||||
},
|
||||
{ polling: 100, timeout },
|
||||
panelId,
|
||||
stillMs,
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error?.name !== 'TimeoutError') throw error;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Click a panel control once the panel holds still and the control is what
|
||||
* lies under its centre: the rail animates panel heights, so a point read
|
||||
@@ -528,7 +553,6 @@ async function main() {
|
||||
const url = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173';
|
||||
const fixtures = args.includes('--fixtures');
|
||||
const strict = args.includes('--strict');
|
||||
if (args.includes('--fail-on-retry')) process.env.QA_FAIL_ON_RETRY = '1';
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
executablePath:
|
||||
@@ -1425,12 +1449,6 @@ async function main() {
|
||||
await stacks.set(original);
|
||||
},
|
||||
);
|
||||
const expanded = () =>
|
||||
page.evaluate(() =>
|
||||
document
|
||||
.getElementById('sl-viewer-wrap')
|
||||
.classList.contains('sl-viewer-wrap-expanded'),
|
||||
);
|
||||
await step(
|
||||
'EXPAND opens a modal dialog (the rest of <body> inert) and Esc returns focus to the button',
|
||||
async () => {
|
||||
@@ -1512,209 +1530,56 @@ async function main() {
|
||||
assert.equal(after.sequence.selectedId, null);
|
||||
},
|
||||
);
|
||||
const floating = () =>
|
||||
page.$eval('#street-level-panel', (node) =>
|
||||
node.classList.contains('panel-floating'),
|
||||
);
|
||||
const center = (selector) =>
|
||||
page.$eval(selector, (node) => {
|
||||
const r = node.getBoundingClientRect();
|
||||
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
|
||||
});
|
||||
// Right after a dock the rail is still animating, so the shared helpers
|
||||
// re-resolve the header and retry only a press that provably missed it.
|
||||
const floatPanel = async () => {
|
||||
const attempt = await liftPanelByHeader(page, PANEL_ID, {
|
||||
dx: -400,
|
||||
dy: 80,
|
||||
});
|
||||
assert.equal(
|
||||
attempt.done,
|
||||
true,
|
||||
`a header drag lifts the panel out of the rail (${describePress(attempt)})`,
|
||||
);
|
||||
assert.equal(await floating(), true);
|
||||
};
|
||||
await step(
|
||||
'the panel floats on a header drag, resizes, and the viewer takes the room',
|
||||
async () => {
|
||||
await openById(firstImageId);
|
||||
await floatPanel();
|
||||
const viewerHeight = () =>
|
||||
page.$eval('#sl-viewer', (node) =>
|
||||
Math.round(node.getBoundingClientRect().height),
|
||||
);
|
||||
const before = await viewerHeight();
|
||||
const grip = await center('#street-level-panel .panel-resize-grip');
|
||||
await page.mouse.move(grip.x, grip.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(grip.x + 120, grip.y + 160, { steps: 10 });
|
||||
await page.mouse.up();
|
||||
await settle(
|
||||
page,
|
||||
(height) =>
|
||||
document.getElementById('sl-viewer').getBoundingClientRect()
|
||||
.height >
|
||||
height + 60,
|
||||
before,
|
||||
);
|
||||
const after = await viewerHeight();
|
||||
assert.ok(
|
||||
after > before + 60,
|
||||
`viewer grew with the window (${before} → ${after}px)`,
|
||||
);
|
||||
assert.equal(
|
||||
await page.$eval(
|
||||
'.sl-settings',
|
||||
(node) => getComputedStyle(node).overflowY,
|
||||
),
|
||||
'auto',
|
||||
'only the settings block scrolls',
|
||||
);
|
||||
},
|
||||
);
|
||||
await step(
|
||||
'SHRINK after EXPAND docks the window back in the rail at its default size, viewer inside the panel',
|
||||
async () => {
|
||||
await clickPanelControl(page, '#sl-viewer-expand');
|
||||
await settle(page, () =>
|
||||
document
|
||||
.getElementById('sl-viewer-wrap')
|
||||
.classList.contains('sl-viewer-wrap-expanded'),
|
||||
);
|
||||
assert.equal(await expanded(), true, 'expanded');
|
||||
await clickPanelControl(page, '#sl-viewer-expand');
|
||||
await settle(
|
||||
page,
|
||||
() =>
|
||||
!document
|
||||
.getElementById('street-level-panel')
|
||||
.classList.contains('panel-floating') &&
|
||||
!document
|
||||
.getElementById('sl-viewer-wrap')
|
||||
.classList.contains('sl-viewer-wrap-expanded'),
|
||||
);
|
||||
assert.equal(await floating(), false, 'docked again');
|
||||
const style = await page.$eval('#street-level-panel', (node) => ({
|
||||
width: node.style.width,
|
||||
height: node.style.height,
|
||||
parent: node.parentElement.id,
|
||||
}));
|
||||
assert.deepEqual(style, {
|
||||
width: '',
|
||||
height: '',
|
||||
parent: 'right-context-rail',
|
||||
});
|
||||
const home = await page.evaluate(() => {
|
||||
const wrap = document.getElementById('sl-viewer-wrap');
|
||||
return {
|
||||
inPanel: Boolean(wrap.closest('#street-level-panel')),
|
||||
onBody: wrap.parentElement === document.body,
|
||||
};
|
||||
});
|
||||
assert.deepEqual(
|
||||
home,
|
||||
{ inPanel: true, onBody: false },
|
||||
'the viewer returned into the panel',
|
||||
);
|
||||
},
|
||||
);
|
||||
await step('a header double-click docks a floating window', async () => {
|
||||
await floatPanel();
|
||||
const attempt = await dockPanelByDoubleClick(page, PANEL_ID);
|
||||
assert.equal(
|
||||
attempt.done,
|
||||
true,
|
||||
`docked again (${describePress(attempt)})`,
|
||||
);
|
||||
});
|
||||
await step(
|
||||
'collapsing a floating window docks it as the rail strip',
|
||||
async () => {
|
||||
// The double-click just docked a panel showing a photo: let the photo
|
||||
// and its sequence finish loading before pressing the header again.
|
||||
await uiUntil((u) => !u.street.loading && !u.sequence.loading);
|
||||
await floatPanel();
|
||||
await clickPanelControl(
|
||||
page,
|
||||
'.panel-collapse-btn[data-collapse-target="street-level-panel"]',
|
||||
);
|
||||
await settle(page, () => {
|
||||
const node = document.getElementById('street-level-panel');
|
||||
return (
|
||||
node.classList.contains('collapsed') &&
|
||||
!node.classList.contains('panel-floating')
|
||||
);
|
||||
});
|
||||
const state = await page.$eval('#street-level-panel', (node) => ({
|
||||
floating: node.classList.contains('panel-floating'),
|
||||
collapsed: node.classList.contains('collapsed'),
|
||||
height: node.style.height,
|
||||
}));
|
||||
assert.deepEqual(state, {
|
||||
floating: false,
|
||||
collapsed: true,
|
||||
height: '',
|
||||
});
|
||||
},
|
||||
);
|
||||
await step(
|
||||
'on a phone the whole photo fits in the docked panel',
|
||||
async () => {
|
||||
// Width alone drives the phone layout; toggling isMobile would reload.
|
||||
await page.setViewport({ width: 390, height: 844 });
|
||||
await settle(page, () => innerWidth === 390);
|
||||
if (
|
||||
await page.$eval('#street-level-panel', (node) =>
|
||||
node.classList.contains('collapsed'),
|
||||
)
|
||||
await step('on a phone the whole photo fits in the panel', async () => {
|
||||
// Width alone drives the phone layout; toggling isMobile would reload.
|
||||
await page.setViewport({ width: 390, height: 844 });
|
||||
await settle(page, () => innerWidth === 390);
|
||||
if (
|
||||
await page.$eval('#street-level-panel', (node) =>
|
||||
node.classList.contains('collapsed'),
|
||||
)
|
||||
await expandStrip();
|
||||
await openById(firstImageId);
|
||||
const measure = () =>
|
||||
page.evaluate(() => {
|
||||
const inner = document
|
||||
.querySelector('.street-level-panel-inner')
|
||||
.getBoundingClientRect();
|
||||
const meta = document
|
||||
.getElementById('sl-image-meta')
|
||||
.getBoundingClientRect();
|
||||
const viewer = document
|
||||
.getElementById('sl-viewer')
|
||||
.getBoundingClientRect();
|
||||
return {
|
||||
cut: Math.round(meta.bottom - inner.bottom),
|
||||
top: Math.round(viewer.top - inner.top),
|
||||
height: Math.round(viewer.height),
|
||||
overflowX: document.documentElement.scrollWidth > innerWidth,
|
||||
};
|
||||
});
|
||||
// The panel settles its phone layout over a frame or two.
|
||||
await settle(page, () => {
|
||||
)
|
||||
await expandStrip();
|
||||
await openById(firstImageId);
|
||||
const measure = () =>
|
||||
page.evaluate(() => {
|
||||
const inner = document
|
||||
.querySelector('.street-level-panel-inner')
|
||||
.getBoundingClientRect();
|
||||
const meta = document
|
||||
.getElementById('sl-image-meta')
|
||||
.getBoundingClientRect();
|
||||
return (
|
||||
meta.bottom - inner.bottom <= 1 &&
|
||||
document.getElementById('sl-viewer').getBoundingClientRect()
|
||||
.height >= 100
|
||||
);
|
||||
const viewer = document
|
||||
.getElementById('sl-viewer')
|
||||
.getBoundingClientRect();
|
||||
return {
|
||||
cut: Math.round(meta.bottom - inner.bottom),
|
||||
top: Math.round(viewer.top - inner.top),
|
||||
height: Math.round(viewer.height),
|
||||
overflowX: document.documentElement.scrollWidth > innerWidth,
|
||||
};
|
||||
});
|
||||
const fit = await measure();
|
||||
assert.ok(fit.cut <= 1, `photo and caption fit (${fit.cut}px cut)`);
|
||||
assert.ok(fit.height >= 100, `viewer stays usable (${fit.height}px)`);
|
||||
assert.equal(fit.overflowX, false, 'no sideways scroll');
|
||||
await page.setViewport(VIEWPORTS[0]);
|
||||
await settle(
|
||||
page,
|
||||
(width) => innerWidth === width,
|
||||
VIEWPORTS[0].width,
|
||||
// The panel settles its phone layout over a frame or two.
|
||||
await settle(page, () => {
|
||||
const inner = document
|
||||
.querySelector('.street-level-panel-inner')
|
||||
.getBoundingClientRect();
|
||||
const meta = document
|
||||
.getElementById('sl-image-meta')
|
||||
.getBoundingClientRect();
|
||||
return (
|
||||
meta.bottom - inner.bottom <= 1 &&
|
||||
document.getElementById('sl-viewer').getBoundingClientRect()
|
||||
.height >= 100
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
const fit = await measure();
|
||||
assert.ok(fit.cut <= 1, `photo and caption fit (${fit.cut}px cut)`);
|
||||
assert.ok(fit.height >= 100, `viewer stays usable (${fit.height}px)`);
|
||||
assert.equal(fit.overflowX, false, 'no sideways scroll');
|
||||
await page.setViewport(VIEWPORTS[0]);
|
||||
await settle(page, (width) => innerWidth === width, VIEWPORTS[0].width);
|
||||
});
|
||||
await step(
|
||||
'a reload with the layer on keeps a collapsed panel collapsed, and the share link carries its ui token',
|
||||
async () => {
|
||||
|
||||
Reference in new issue
Block a user