From f33cd54e2bc15516d26fb6e41cc21028eb723197 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Tue, 6 Oct 2026 23:27:04 +0100 Subject: [PATCH] fix(panels): a collapsed window keeps its chosen height; handle early tile failures - A floating portable panel saved its measured box, so dragging a collapsed window stored its 50 px header strip and a reload clamped it to the minimum. It now saves the inline size the user chose. A unit test and a collapse -> drag -> reload -> expand step in the panel gate pin it. - Coverage awaits the tile fetch and the one-time terrain table load together, so a tile that fails before the table loads is reported at once instead of surfacing as an unhandled rejection. Co-Authored-By: Claude Opus 5.5 --- scripts/qa-panel-resize.mjs | 55 +++++++++++++++++++ .../providers/mapillary/coverage.js | 14 +++-- .../providers/mapillary/coverage.test.mjs | 24 ++++++++ src/ui/panelDock.test.mjs | 38 +++++++++++++ src/ui/panelPositionControls.js | 15 +++-- 5 files changed, 136 insertions(+), 10 deletions(-) diff --git a/scripts/qa-panel-resize.mjs b/scripts/qa-panel-resize.mjs index 46b11d3c8..026b6f2fb 100644 --- a/scripts/qa-panel-resize.mjs +++ b/scripts/qa-panel-resize.mjs @@ -356,6 +356,61 @@ async function main() { 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( diff --git a/src/layers/streetLevel/providers/mapillary/coverage.js b/src/layers/streetLevel/providers/mapillary/coverage.js index fa20f2c20..c78d25789 100644 --- a/src/layers/streetLevel/providers/mapillary/coverage.js +++ b/src/layers/streetLevel/providers/mapillary/coverage.js @@ -510,12 +510,14 @@ export function createCoverage({ state, source }) { state.coverage.pending.set(key, controller); notify(); try { - // Fetch and the one-time terrain-height table load run side by side. - const fetching = source.getTile('coverage', tile.z, tile.x, tile.y, { - signal: controller.signal, - }); - if (kind === 'sequence') await ensureTerrainReady(); - const bytes = await fetching; + // Fetch and the one-time terrain-height table load run side by side, + // both handled from the start so an early tile failure is not unhandled. + const [bytes] = await Promise.all([ + source.getTile('coverage', tile.z, tile.x, tile.y, { + signal: controller.signal, + }), + kind === 'sequence' ? ensureTerrainReady() : null, + ]); // Only an abort, a retire, a clear or a newer request for this tile // discards the bytes; a refresh that still wants the tile keeps them. const current = () => diff --git a/src/layers/streetLevel/providers/mapillary/coverage.test.mjs b/src/layers/streetLevel/providers/mapillary/coverage.test.mjs index 4fc304c5a..1dc96d14d 100644 --- a/src/layers/streetLevel/providers/mapillary/coverage.test.mjs +++ b/src/layers/streetLevel/providers/mapillary/coverage.test.mjs @@ -906,3 +906,27 @@ test('a line across the date line from the camera is in mesh range', () => { const far = { west: -179.99, east: -179.97, south: -0.001, north: 0.001 }; assert.equal(meshBoxInRange(far, camera), false); }); + +test('a tile that fails before the terrain table loads is reported at once, never unhandled', async () => { + const { source, state, coverage, tileKey } = setup(); + let terrainLoaded; + state.coverage.terrainReady = new Promise((resolve) => { + terrainLoaded = resolve; + }); + const unhandled = []; + const onUnhandled = (reason) => unhandled.push(reason); + process.on('unhandledRejection', onUnhandled); + try { + coverage.refresh(); + source.calls + .find((call) => call.key === tileKey) + .reject(new Error('Mapillary tiles HTTP 500')); + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.equal(state.coverage.lastError, 'Mapillary tiles HTTP 500'); + assert.deepEqual(unhandled, []); + } finally { + terrainLoaded(); + await settle(); + process.off('unhandledRejection', onUnhandled); + } +}); diff --git a/src/ui/panelDock.test.mjs b/src/ui/panelDock.test.mjs index 9f3ef47ee..52716a54b 100644 --- a/src/ui/panelDock.test.mjs +++ b/src/ui/panelDock.test.mjs @@ -540,3 +540,41 @@ test('a floating header never lands under the command dock or voice pill', () => f.restore(); } }); + +test('a collapsed floating window keeps its chosen height through a drag and a reload', () => { + const f = fixture(); + try { + const { panel, handle } = floatingPanel(f, 'cctv-panel'); + panel.style.height = '500px'; + panel.classList.add('collapsed'); + // Collapsed, the window measures only its header strip. + panel.getBoundingClientRect = () => { + const left = parseFloat(panel.style.left); + const top = parseFloat(panel.style.top); + return { + left, + top, + width: 360, + height: 50, + right: left + 360, + bottom: top + 50, + }; + }; + pointer(handle, 'pointerdown', { x: 200, y: 90 }); + pointer(window, 'pointermove', { x: 260, y: 140 }); + pointer(window, 'pointerup', { x: 260, y: 140 }); + const record = JSON.parse( + f.store.get('godsEyeView.v8.panelPos.cctv-panel'), + ); + assert.equal(record.height, 500, 'the chosen height, not the strip'); + assert.equal(record.width, 360); + + // Reload: a fresh panel restores the size it expands to. + const reloaded = f.element(); + reloaded.id = 'cctv-panel'; + f.owner._restorePanelPosition('cctv-panel', reloaded); + assert.equal(reloaded.style.height, '500px'); + } finally { + f.restore(); + } +}); diff --git a/src/ui/panelPositionControls.js b/src/ui/panelPositionControls.js index d8b23af70..b40a2d378 100644 --- a/src/ui/panelPositionControls.js +++ b/src/ui/panelPositionControls.js @@ -429,10 +429,17 @@ export class PanelPositionControls { top: Math.round(rect.top), }; if (this._portablePanels.has(panelId)) { - // Only an explicitly set size is remembered: freezing a collapsed - // panel's measured height would stop it from ever expanding again. - if (panelEl.style.width) record.width = Math.round(rect.width); - if (panelEl.style.height) record.height = Math.round(rect.height); + // Only an explicitly set size is remembered, and the inline value + // rather than the measured box: a collapsed window measures only its + // header but keeps the size the user chose for when it expands. + const chosen = (inline, measured) => { + const px = parseFloat(inline); + return Math.round(Number.isFinite(px) ? px : measured); + }; + if (panelEl.style.width) + record.width = chosen(panelEl.style.width, rect.width); + if (panelEl.style.height) + record.height = chosen(panelEl.style.height, rect.height); record.floating = true; } try {