// v1.49.x: zoom goes below the base fit, the editor does not shift the plan. // - the stage height follows the MEASURED header, not a hard-coded 118px, so // entering an editor keeps the plan inside the viewport; // - zoom < 1 centres the content instead of pinning it to a corner; // - the content frame (default zoom) includes devices standing outside rooms. import { launch, checkAll, finish } from './serve.mjs'; const { page, browser } = await launch(); const out = {}; // -- editor entry keeps the stage inside the viewport -------------------- const stageBox = () => page.evaluate(() => { const sr = window.__card.shadowRoot || window.__card.renderRoot; const b = sr.querySelector('.stage').getBoundingClientRect(); return { top: Math.round(b.top), bottom: Math.round(b.bottom) }; }); const vh = await page.evaluate(() => window.innerHeight); const inView = await stageBox(); await page.evaluate(() => window.__card._setMode('plan')); await page.waitForFunction(() => window.__card._modeTransitionBusy === false); const inPlan = await stageBox(); out.viewFitsViewport = inView.bottom <= vh + 2; out.editorFitsViewport = inPlan.bottom <= vh + 2; // used to overflow by ~90px out.editorStageShrinks = inPlan.top > inView.top && inPlan.bottom <= inView.bottom + 2; await page.evaluate(() => window.__card._setMode('view')); await page.waitForFunction(() => window.__card._modeTransitionBusy === false); // -- zoom out below the base fit ----------------------------------------- out.zoomOut = await page.evaluate(() => { const c = window.__card; c._resetZoom(); const fit = { ...c._viewOr(c._baseVb()) }; const stage = (c.shadowRoot || c.renderRoot).querySelector('.stage'); c._zoomAt(stage.clientWidth / 2, stage.clientHeight / 2, 0.5); const v = { ...c._view }; const base = c._baseVb(); const cx = v.x + v.w / 2, cy = v.y + v.h / 2; return { zoom: c._zoom, wider: v.w > fit.w * 1.9, // actually zoomed out centredX: Math.abs(cx - (base[0] + base[2] / 2)) < 1, // not pinned to a corner centredY: Math.abs(cy - (base[1] + base[3] / 2)) < 1, }; }); out.zoomOutWorks = out.zoomOut.zoom === 0.5 && out.zoomOut.wider && out.zoomOut.centredX && out.zoomOut.centredY; delete out.zoomOut; // docs/CANVAS.md §5: the zoom-out floor moved from a fixed 0.4 of the old // view_box to 1/3 of the CONTENT frame — three canvases of empty plane is as // far as it is useful to go when the canvas has no edges. out.floorIsThird = Math.abs(await page.evaluate(() => { window.__card._resetZoom(); const c = window.__card; c._applyView(0.01); return c._zoom; }) - 1 / 3) < 1e-9; // clamped at the floor await page.evaluate(() => window.__card._resetZoom()); // -- editor zoom is a working tool, not the viewing intent ---------------- // view 1.6 → devices editor 2.5 → back to view: the pre-editor viewport // (zoom AND center) comes back; the editor keeps its own zoom while open. out.editorZoomNotSaved = await page.evaluate(async () => { const c = window.__card; const settleMode = async () => { const started = performance.now(); do { await new Promise((resolve) => requestAnimationFrame(resolve)); } while (c._modeTransitionBusy && performance.now() - started < 1500); await c.updateComplete; }; const center = () => { const v = c._view; return [v.x + v.w / 2, v.y + v.h / 2]; }; c._setMode('view'); c._resetZoom(); c._zoomAt(10, 10, 1.6); c._saveZoom(); // off-center on purpose const want = { zoom: c._zoom, c: center() }; c._setMode('devices'); await settleMode(); c._zoomAt(10, 10, 2.5); c._saveZoom(); // off-center too: the center must not leak either const editorZoomFree = Math.abs(c._zoom - 2.5) < 0.01; // zooming inside stays c._setMode('view'); await settleMode(); const got = { zoom: c._zoom, c: center() }; const ls = JSON.parse(localStorage.getItem('houseplan_card_zoom_v1') || '{}'); return { editorZoomFree, zoomRestored: Math.abs(got.zoom - want.zoom) < 0.01, centerRestored: Math.hypot(got.c[0] - want.c[0], got.c[1] - want.c[1]) < 0.02, lsRestored: Math.abs((ls[c._space] || 1) - want.zoom) < 0.01, }; }); out.editorZoomFree = out.editorZoomNotSaved.editorZoomFree; out.viewZoomRestored = out.editorZoomNotSaved.zoomRestored; out.viewCenterRestored = out.editorZoomNotSaved.centerRestored; out.viewZoomBackInLs = out.editorZoomNotSaved.lsRestored; delete out.editorZoomNotSaved; // -- editor entered and left without touching zoom: no jump --------------- out.untouchedEditorNoJump = await page.evaluate(async () => { const c = window.__card; const settleMode = async () => { const started = performance.now(); do { await new Promise((resolve) => requestAnimationFrame(resolve)); } while (c._modeTransitionBusy && performance.now() - started < 1500); await c.updateComplete; }; c._setMode('view'); await settleMode(); c._resetZoom(); c._zoomAt(30, 30, 1.6); c._saveZoom(); const before = { ...c._view }; c._setMode('plan'); await settleMode(); c._setMode('view'); await settleMode(); const v = c._view; const beforeCenter = [before.x + before.w / 2, before.y + before.h / 2]; const afterCenter = [v.x + v.w / 2, v.y + v.h / 2]; return Math.abs(c._zoom - 1.6) < 0.01 && Math.hypot(afterCenter[0] - beforeCenter[0], afterCenter[1] - beforeCenter[1]) < 0.01 && Math.abs(v.w - before.w) < 0.005 && Math.abs(v.h - before.h) < 0.005; }); // -- the view zoom still survives a space switch, per space --------------- out.viewZoomSurvivesSpaceSwitch = await page.evaluate(async () => { const c = window.__card; const wait = (ms) => new Promise((r) => setTimeout(r, ms)); c._setMode('view'); c._resetZoom(); c._applyView(1.7); c._saveZoom(); c._slideTo('garden', 'left'); await wait(350); const gardenGotOwnZoom = Math.abs(c._zoom - (JSON.parse( localStorage.getItem('houseplan_card_zoom_v1') || '{}').garden || 1)) < 0.01; c._slideTo('f1', 'right'); await wait(350); return gardenGotOwnZoom && Math.abs(c._zoom - 1.7) < 0.01; }); await page.evaluate(() => window.__card._resetZoom()); // -- owner's dacha regression: editor 500% must never reach the per-space // view store. View zoom set on BOTH floors, editor cranked to 5.0, back to // view — and the floor tab is clicked in the SAME tick (on the tablet the // exit re-render janks, so the click runs before the restore rAF and the // old fix-up save was skipped). Two switches later the stale 5.0 came back. out.editorZoomNeverInSpaceStore = await page.evaluate(async () => { const c = window.__card; const wait = (ms) => new Promise((r) => setTimeout(r, ms)); const settleMode = async () => { const started = performance.now(); do { await new Promise((resolve) => requestAnimationFrame(resolve)); } while (c._modeTransitionBusy && performance.now() - started < 1500); await c.updateComplete; }; c._setMode('view'); c._resetZoom(); c._zoomAt(10, 10, 1.6); c._saveZoom(); // floor 1 view zoom c._slideTo('garden', 'left'); await wait(350); c._resetZoom(); c._zoomAt(10, 10, 1.4); c._saveZoom(); // floor 2 view zoom c._setMode('devices'); await settleMode(); c._zoomAt(10, 10, 5.0); c._saveZoom(); // 500% — a working tool const lsInEditor = JSON.parse(localStorage.getItem('houseplan_card_zoom_v1') || '{}'); c._setMode('view'); c._slideTo('f1', 'right'); await wait(350); // same tick as the exit — beats the rAF const firstSwitch = Math.abs(c._zoom - 1.6) < 0.01; c._slideTo('garden', 'left'); await wait(350); const secondSwitch = Math.abs(c._zoom - 1.4) < 0.01; // used to come back as 5.0 const ls = JSON.parse(localStorage.getItem('houseplan_card_zoom_v1') || '{}'); c._slideTo('f1', 'right'); await wait(350); c._resetZoom(); // leave the stage as the next tests expect return { editorNotPersisted: Math.abs((lsInEditor.garden || 1) - 1.4) < 0.01, firstSwitch, secondSwitch, lsClean: Math.abs((ls.garden || 1) - 1.4) < 0.01 && Math.abs((ls.f1 || 1) - 1.6) < 0.01, }; }); out.editorZoomStaysOutOfLs = out.editorZoomNeverInSpaceStore.editorNotPersisted; out.ownerFirstSwitchOk = out.editorZoomNeverInSpaceStore.firstSwitch; out.ownerSecondSwitchOk = out.editorZoomNeverInSpaceStore.secondSwitch; out.ownerLsClean = out.editorZoomNeverInSpaceStore.lsClean; delete out.editorZoomNeverInSpaceStore; // -- HP-1543-01: смена этажа ВНУТРИ редактора не тащит editor zoom в view -- // view-зум сохранён на обоих этажах → редактор на f1 → переключение ВКЛАДКОЙ // на garden → editor zoom 5.0 → выход в view (остаёмся на garden): вид обязан // вернуться к сохранённому view-зуму garden (1.4) с штатным центром // _restoreZoom(), а не остаться на редакторских 500% (снапшот-guard по space // отбрасывал восстановление, а текущий _zoom так и оставался редакторским). out.crossFloorEditorExit = await page.evaluate(async () => { const c = window.__card; const wait = (ms) => new Promise((r) => setTimeout(r, ms)); const settleMode = async () => { const started = performance.now(); do { await new Promise((resolve) => requestAnimationFrame(resolve)); } while (c._modeTransitionBusy && performance.now() - started < 1500); await c.updateComplete; }; const sr = c.shadowRoot || c.renderRoot; c._setMode('view'); c._resetZoom(); c._zoomAt(10, 10, 1.6); c._saveZoom(); // f1 view zoom c._slideTo('garden', 'left'); await wait(350); c._resetZoom(); c._zoomAt(10, 10, 1.4); c._saveZoom(); // garden view zoom c._slideTo('f1', 'right'); await wait(350); c._setMode('devices'); await settleMode(); // этаж меняется НАСТОЯЩЕЙ вкладкой — именно этот путь под подозрением const idx = c._model.findIndex((s) => s.id === 'garden'); sr.querySelectorAll('.tabs .tab')[idx].click(); await c.updateComplete; await settleMode(); c._zoomAt(10, 10, 5.0); c._saveZoom(); // 500% — рабочий инструмент c._setMode('view'); await settleMode(); const stillGarden = c._space === 'garden'; const gotZoom = c._zoom; const v = c._view; const vb = c._baseVb(); const centred = !!v && Math.abs(v.x + v.w / 2 - (vb[0] + vb[2] / 2)) < 1 && Math.abs(v.y + v.h / 2 - (vb[1] + vb[3] / 2)) < 1; const ls = JSON.parse(localStorage.getItem('houseplan_card_zoom_v1') || '{}'); const store = { ...c._zoomBySpace }; c._slideTo('f1', 'right'); await wait(350); c._resetZoom(); // вернуть сцену следующим тестам return { stillGarden, viewZoomRestoredCrossFloor: Math.abs(gotZoom - 1.4) < 0.01, // было 5.0 (HP-1543-01) centerRestoredCrossFloor: centred, // центр как у _restoreZoom() storeIntactCrossFloor: Math.abs((store.garden || 1) - 1.4) < 0.01, // _zoomBySpace не тронут lsIntactCrossFloor: Math.abs((ls.garden || 1) - 1.4) < 0.01 && Math.abs((ls.f1 || 1) - 1.6) < 0.01, // LS_ZOOM не тронут }; }); out.crossFloorStillGarden = out.crossFloorEditorExit.stillGarden; out.crossFloorViewZoomRestored = out.crossFloorEditorExit.viewZoomRestoredCrossFloor; out.crossFloorCenterRestored = out.crossFloorEditorExit.centerRestoredCrossFloor; out.crossFloorStoreIntact = out.crossFloorEditorExit.storeIntactCrossFloor; out.crossFloorLsIntact = out.crossFloorEditorExit.lsIntactCrossFloor; delete out.crossFloorEditorExit; // -- devices outside rooms stretch the default frame --------------------- out.devicesStretchFrame = await page.evaluate(async () => { const c = window.__card; const cfg = JSON.parse(JSON.stringify(c._serverCfg)); cfg.spaces[0].plan_url = null; cfg.spaces[0].plan_aspect = null; c._serverCfg = cfg; c._model = null; const before = c._baseVb(); // walk one lamp far outside every room c._layout = { ...c._layout, d_lamp: { s: 'f1', x: 0.99, y: 0.5 } }; c.requestUpdate(); await c.updateComplete; const after = c._baseVb(); return after[0] + after[2] > before[0] + before[2] + 20; // right edge follows the lamp }); // -- a card BELOW other dashboard content still gets a stage (HP-1500-02) -- out.stageSurvivesContentAbove = await page.evaluate(async () => { const spacer = document.createElement('div'); spacer.style.height = '900px'; document.body.insertBefore(spacer, document.body.firstChild); window.dispatchEvent(new Event('resize')); await new Promise((r) => setTimeout(r, 120)); const c = window.__card; const sr = c.shadowRoot || c.renderRoot; const h = sr.querySelector('.stage').getBoundingClientRect().height; spacer.remove(); window.dispatchEvent(new Event('resize')); await new Promise((r) => setTimeout(r, 120)); // the old code billed the 900px spacer as "header" and left a 0px stage return h > 300; }); await finish(browser, checkAll(out));