diff --git a/demo/smoke_audit_1490.mjs b/demo/smoke_audit_1490.mjs index 62f3cdab..5a0c92e9 100644 --- a/demo/smoke_audit_1490.mjs +++ b/demo/smoke_audit_1490.mjs @@ -21,14 +21,27 @@ Object.assign(out, await page.evaluate(async () => { c._setMode('plan'); await c.updateComplete; await new Promise((r) => requestAnimationFrame(r)); const inPlan = c._baseVb(); - o.editorSeesWholeCanvas = inPlan[2] === 1000 && inPlan[3] === 1000; + // docs/CANVAS.md: холста-квадрата больше нет, поэтому «редактор видит весь + // холст» переписано в исходное НАМЕРЕНИЕ HP-1490-03 — «в редакторе есть куда + // рисовать наружу». Рамка теперь по содержимому и в редакторе тоже, а место + // даёт запас панорамирования (§5) и зум-аут до 3x. + o.editorFrameIsContent = inPlan[2] < 999 && inPlan[2] > 0; + o.editorCanPanPastContent = (() => { + const fit = c._viewOr(c._baseVb()); + const moved = c._clampView({ x: fit.x + fit.w * 0.9, y: fit.y, w: fit.w, h: fit.h }, fit); + return moved.x > fit.x + fit.w * 0.5; // ушли почти на экран вправо, зажима нет + })(); + o.editorZoomOutGivesRoom = (() => { + c._resetZoom(); c._applyView(1 / 3); + const wide = c._view.w; c._resetZoom(); + return wide > inPlan[2] * 2.5; // видно втрое больше, чем нарисовано + })(); const v = c._viewOr(c._baseVb()); - o.editorViewCoversCanvas = v.w >= 999; // старый cropped view не пережил смену // в редакторе можно ткнуть в дальний угол холста o.canReachFarCorner = (() => { const stage = (c.shadowRoot || c.renderRoot).querySelector('.stage'); const pt = c._screenToVb(stage.clientWidth - 1, stage.clientHeight - 1); - return pt[0] > 900 || pt[1] > 900; + return pt[0] > inPlan[0] + inPlan[2] * 0.9 || pt[1] > inPlan[1] + inPlan[3] * 0.9; })(); c._setMode('view'); await c.updateComplete; await new Promise((r) => requestAnimationFrame(r)); diff --git a/demo/smoke_hidden_flag.mjs b/demo/smoke_hidden_flag.mjs index faa7957a..94193820 100644 --- a/demo/smoke_hidden_flag.mjs +++ b/demo/smoke_hidden_flag.mjs @@ -175,8 +175,14 @@ Object.assign(out, await page.evaluate(async () => { await card.updateComplete; const st = [...card.renderRoot.querySelectorAll('[style*="left"]')] .find((el) => /%$/.test(el.style.left || '') && el.style.top); - // статичная карточка рендерит от полного квадрата 0..1000 - const stVb = st && [parseFloat(st.style.left) * 10, parseFloat(st.style.top) * 10]; + // docs/CANVAS.md §4: статичная карточка теперь кадрирует СОДЕРЖИМОЕ, как и + // полная, а не квадрат 0..1000 — координаты берём из её же viewBox + const svb = (card.renderRoot.querySelector('svg')?.getAttribute('viewBox') || '0 0 1000 1000') + .trim().split(/\s+/).map(Number); + const stVb = st && [ + svb[0] + (parseFloat(st.style.left) / 100) * svb[2], + svb[1] + (parseFloat(st.style.top) / 100) * svb[3], + ]; o.autoGridParity = !!fullVb && !!stVb && Math.abs(fullVb[0] - stVb[0]) < 6 && Math.abs(fullVb[1] - stVb[1]) < 6; if (!o.autoGridParity) console.log('full', fullVb, 'static', stVb); diff --git a/demo/smoke_infinite_canvas.mjs b/demo/smoke_infinite_canvas.mjs new file mode 100644 index 00000000..f0827b3f --- /dev/null +++ b/demo/smoke_infinite_canvas.mjs @@ -0,0 +1,288 @@ +// Infinite canvas (docs/CANVAS.md): a plan drawn far past the old unit square +// renders whole, devices can be placed and saved out there, the opening view +// follows the content, one far stray neither breaks the view nor hides itself, +// zoom-out stops at 3x the content and icons keep their pixel size. +import { writeFileSync, mkdirSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { launch, check, checkAll, finish } from './serve.mjs'; + +const SHOTS = process.env.HP_SHOTS || ''; +const { page, browser } = await launch({ width: 900, height: 820 }, 1); +const out = {}; +const raf = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))); + +// A hand-drawn plan whose rooms live at 1.5..3.0 — entirely outside the old +// canvas. Before the infinite canvas the -25%..125% envelope threw every one +// of these vertices away and the card framed empty space. +const FAR_CFG = { + spaces: [{ + id: 'f1', title: 'Ground floor', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null, + rooms: [ + { id: 'r1', name: 'Living room', area: 'living_room', + poly: [[1.5, 1.5], [2.2, 1.5], [2.2, 2.2], [1.5, 2.2]] }, + { id: 'r2', name: 'Kitchen', area: 'kitchen', + poly: [[2.2, 1.5], [3.0, 1.5], [3.0, 2.0], [2.2, 2.0]] }, + { id: 'r3', name: 'Bedroom', area: 'bedroom', + poly: [[2.2, 2.0], [3.0, 2.0], [3.0, 2.6], [2.2, 2.6]] }, + { id: 'r4', name: 'Hallway', area: 'hallway', + poly: [[1.5, 2.2], [2.2, 2.2], [2.2, 2.6], [1.5, 2.6]] }, + ], + openings: [{ id: 'o1', type: 'window', x: 1.8, y: 1.5, angle: 0, length: 0.15 }], + decor: [{ id: 'd1', kind: 'line', x1: 1.4, y1: 2.7, x2: 3.1, y2: 2.7, color: '#8899aa' }], + }, { + id: 'garden', title: 'Garden', view_box: [0, 0, 1, 1], + rooms: [{ id: 'g1', name: 'Garden', area: 'garden', + poly: [[0.03, 0.178], [0.97, 0.178], [0.97, 0.822], [0.03, 0.822]] }], + }], + markers: [], settings: {}, +}; +const FAR_LAYOUT = { + d_light1: { s: 'f1', x: 1.7, y: 1.7 }, d_lamp: { s: 'f1', x: 2.0, y: 1.9 }, + d_tv: { s: 'f1', x: 1.6, y: 2.1 }, d_temp: { s: 'f1', x: 1.9, y: 1.6 }, + d_kettle: { s: 'f1', x: 2.5, y: 1.7 }, d_leak: { s: 'f1', x: 2.9, y: 1.8 }, + d_bedlight: { s: 'f1', x: 2.6, y: 2.3 }, d_window: { s: 'f1', x: 2.9, y: 2.5 }, + d_lock: { s: 'f1', x: 1.6, y: 2.5 }, d_motion: { s: 'f1', x: 2.0, y: 2.4 }, +}; + +const loadFar = (cfg, layout) => page.evaluate(async ([c2, l2]) => { + const c = window.__card; + c._serverCfg = JSON.parse(JSON.stringify(c2)); + c._layout = JSON.parse(JSON.stringify(l2)); + c._modelCache = null; + c._frame = null; + c._showFar = false; + c._space = 'f1'; + c._maybeRebuildDevices?.(); + c._defPos = c._defaultPositions(); + c._view = null; + c._zoom = 1; + c.requestUpdate(); + await c.updateComplete; + c._resetZoom(); + await c.updateComplete; +}, [cfg, layout]); + +await loadFar(FAR_CFG, FAR_LAYOUT); +await raf(); + +// ---- (b) the plan past the old square renders WHOLE ---------------------- +Object.assign(out, await page.evaluate(() => { + const c = window.__card; + const o = {}; + const b = c._baseVb(); + // the content is 1500..3000 x 1500..2700 render units, plus decor/openings + o.frameHoldsFarPlan = b[0] < 1450 && b[0] + b[2] > 3050 && b[1] < 1450 && b[1] + b[3] > 2750; + o.frameIsNotTheOldSquare = b[0] > 1000; // nothing is framed at the origin any more + const v = c._viewOr(b); + o.viewShowsFarPlan = v.x <= 1500 && v.x + v.w >= 3000; + // every room polygon is inside the painted viewBox + const sr = c.shadowRoot || c.renderRoot; + const polys = [...sr.querySelectorAll('polygon.room')]; + o.allRoomsDrawn = polys.length === 4; + const st = sr.querySelector('.stage').getBoundingClientRect(); + o.roomsOnScreen = polys.every((p) => { + const r = p.getBoundingClientRect(); + return r.width > 4 && r.height > 4 && r.left >= st.left - 2 && r.right <= st.right + 2 + && r.top >= st.top - 2 && r.bottom <= st.bottom + 2; + }); + // and the markers landed on the plan, not in a corner of a phantom canvas + const devs = [...sr.querySelectorAll('.devlayer .dev')]; + o.markersOnPlan = devs.length >= 10 && devs.every((d) => { + const r = d.getBoundingClientRect(); + return r.left >= st.left - 2 && r.right <= st.right + 2 && r.top >= st.top - 2 && r.bottom <= st.bottom + 2; + }); + return o; +})); + +// ---- a device can be PLACED past the old square, and it saves ------------- +const ws = await page.evaluate(async () => { + const c = window.__card; + const sent = []; + const base = c.hass.callWS; + c.hass = { ...c.hass, callWS: async (m) => { sent.push(m); return base(m); } }; + // drop a virtual marker at 3.4 / 2.9 — a long way outside the old canvas + const far = { s: 'f1', x: 3.4, y: 2.9 }; + c._layout = { ...c._layout, d_gate: far }; + c._dirtyPos.add('d_gate'); + await c._persistLayout?.(); + // and a room extended even further out, saved through the config writer + const cfg = JSON.parse(JSON.stringify(c._serverCfg)); + cfg.spaces[0].rooms.push({ id: 'r5', name: 'Workshop', + poly: [[3.1, 2.0], [3.8, 2.0], [3.8, 2.6], [3.1, 2.6]] }); + c._serverCfg = cfg; c._modelCache = null; c._frame = null; + await c._saveConfig?.(); + await new Promise((r) => setTimeout(r, 700)); + return { sent: JSON.parse(JSON.stringify(sent)), layout: JSON.parse(JSON.stringify(c._layout)) }; +}); +out.farDeviceSaved = ws.layout.d_gate.x === 3.4 && ws.layout.d_gate.y === 2.9; +const cfgMsg = ws.sent.filter((m) => m.type === 'houseplan/config/set').pop(); +const layMsg = ws.sent.filter((m) => m.type === 'houseplan/layout/update').pop(); +out.wsCarriedFarGeometry = !!cfgMsg + && JSON.stringify(cfgMsg.config).includes('3.8'); +out.wsCarriedFarPosition = !!layMsg && JSON.stringify(layMsg).includes('3.4'); + +// the payload the card sends must pass the REAL backend schema (docs/CANVAS.md +// §3: ±5000). Skipped, not failed, where voluptuous is not installed. +out.backendAcceptsFarPayload = (() => { + if (!cfgMsg) return false; + const f = '/tmp/hp_infinite_payload.json'; + writeFileSync(f, JSON.stringify({ config: cfgMsg.config, layout: (layMsg || {}).layout || ws.layout })); + try { + execFileSync('python3', ['-c', 'import voluptuous'], { stdio: 'ignore' }); + } catch { console.log('(voluptuous absent — backend schema check skipped)'); return true; } + const code = [ + 'import json,sys', + 'sys.path.insert(0, "custom_components/houseplan")', + 'import validation as v', + `d=json.load(open(${JSON.stringify(f)}))`, + 'v.CONFIG_SCHEMA(d["config"]); v.LAYOUT_SCHEMA(d["layout"])', + 'print("VALID")', + ].join('\n'); + try { + return execFileSync('python3', ['-c', code], { encoding: 'utf8' }).includes('VALID'); + } catch (e) { console.log('backend refused the payload:', String(e.stdout || e.message).slice(-400)); return false; } +})(); + +// ---- an OUTLIER: the view ignores it, the hint offers it ------------------ +await loadFar({ + ...FAR_CFG, + spaces: FAR_CFG.spaces.map((s) => (s.id === 'f1' ? { ...s } : s)), +}, { ...FAR_LAYOUT, d_motion: { s: 'f1', x: 90, y: 90 } }); +await raf(); +Object.assign(out, await page.evaluate(() => { + const c = window.__card; + const o = {}; + o.outlierReported = c._outliers === 1; + const b = c._baseVb(); + o.outlierDoesNotCommandView = b[2] < 4000 && b[0] + b[2] < 5000; // still the house, not 90 canvases + o.outlierViewStillHoldsPlan = b[0] < 1500 && b[0] + b[2] > 3000; + const sr = c.shadowRoot || c.renderRoot; + const hint = sr.querySelector('.farhint'); + o.hintShown = !!hint && !!hint.querySelector('button'); + o.hintIsNotAModal = !sr.querySelector('.dialogwrap'); + return o; +})); + +// «Показать» / «Вписать всё» takes the stray in +Object.assign(out, await page.evaluate(async () => { + const c = window.__card; + const sr = c.shadowRoot || c.renderRoot; + sr.querySelector('.farhint button').click(); + await c.updateComplete; + const o = {}; + const v = c._viewOr(c._baseVb()); + o.fitAllReachesTheStray = v.x + v.w > 90000 && v.y + v.h > 90000; + o.fitAllStillHoldsPlan = v.x <= 1500 && v.y <= 1500; + await c.updateComplete; + o.hintGoneAfterShow = !sr.querySelector('.farhint'); + return o; +})); + +// ---- zoom-out stops at 3x, zoom-in unchanged ----------------------------- +await loadFar(FAR_CFG, FAR_LAYOUT); +await raf(); +Object.assign(out, await page.evaluate(() => { + const c = window.__card; + const o = {}; + c._resetZoom(); + const fit = { ...c._viewOr(c._baseVb()) }; + c._applyView(0.001); + o.zoomOutStopsAtThree = Math.abs(c._zoom - 1 / 3) < 1e-9 + && Math.abs(c._view.w - fit.w * 3) < fit.w * 0.02; + c._applyView(50); + o.zoomInUnchanged = c._zoom === 8; + c._resetZoom(); + return o; +})); + +// ---- icons keep their PIXEL size across zooms (docs/CANVAS.md §6) -------- +out.iconSizeIsViewportRelative = await page.evaluate(async () => { + const c = window.__card; + const sr = c.shadowRoot || c.renderRoot; + const size = () => { + const el = sr.querySelector('.devlayer .dev'); + const r = el.getBoundingClientRect(); + return Math.round(r.width * 10) / 10; + }; + c._resetZoom(); await c.updateComplete; + const at1 = size(); + c._applyView(4); await c.updateComplete; + const at4 = size(); + c._applyView(1 / 3); await c.updateComplete; + const atOut = size(); + c._resetZoom(); await c.updateComplete; + return at1 > 4 && Math.abs(at4 - at1) <= 1 && Math.abs(atOut - at1) <= 1; +}); + +// ---- an OLD plan (small, with a view_box) opens exactly as before --------- +Object.assign(out, await page.evaluate(async () => { + const c = window.__card; + const o = {}; + const cfg = { + spaces: [{ id: 'f1', title: 'Old', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null, + rooms: [{ id: 'r1', name: 'Living', area: 'living_room', + poly: [[0.04, 0.14], [0.55, 0.14], [0.55, 0.58], [0.04, 0.58]] }, + { id: 'r2', name: 'Kitchen', area: 'kitchen', + poly: [[0.55, 0.14], [0.96, 0.14], [0.96, 0.46], [0.55, 0.46]] }] }], + markers: [], settings: {}, + }; + c._serverCfg = cfg; c._layout = { d_light1: { s: 'f1', x: 0.22, y: 0.22 } }; + c._modelCache = null; c._frame = null; c._showFar = false; c._view = null; + c._maybeRebuildDevices?.(); + c._defPos = c._defaultPositions(); // the auto grid follows the new rooms + c.requestUpdate(); await c.updateComplete; + c._resetZoom(); await c.updateComplete; + const b = c._baseVb(); + // identical to what contentBounds produced before: the content bbox + // (40..960 x 140..580) plus 5% of the longer side (920 -> 46) + o.legacyFrameUnchanged = Math.abs(b[0] - -6) < 1 && Math.abs(b[1] - 94) < 1 + && Math.abs(b[2] - 1012) < 1 && Math.abs(b[3] - 532) < 1; + o.legacyNoHint = c._outliers === 0; + // an EMPTY space falls back to the stored view_box hint (§4) + c._serverCfg = { spaces: [{ id: 'f1', title: 'E', view_box: [0.1, 0.2, 0.5, 0.4], rooms: [] }], + markers: [], settings: {} }; + // a space with NOTHING in it at all — not even a marker parked in it + c._layout = {}; c._modelCache = null; c._frame = null; + c._devices = []; + c.requestUpdate(); await c.updateComplete; + const e = c._baseVb(); + o.emptySpaceUsesViewBoxHint = JSON.stringify(e.map(Math.round)) === '[100,200,500,400]'; + return o; +})); + +// ---- screenshots --------------------------------------------------------- +if (SHOTS) { + mkdirSync(SHOTS, { recursive: true }); + await loadFar(FAR_CFG, FAR_LAYOUT); + await raf(); + await page.screenshot({ path: `${SHOTS}/canvas_far_plan.png` }); + // fit-all with a stray far away + await loadFar(FAR_CFG, { ...FAR_LAYOUT, d_motion: { s: 'f1', x: 90, y: 90 } }); + await raf(); + await page.screenshot({ path: `${SHOTS}/canvas_far_hint.png` }); + await page.evaluate(async () => { + const c = window.__card; + (c.shadowRoot || c.renderRoot).querySelector('.farhint button').click(); + await c.updateComplete; + }); + await raf(); + await page.screenshot({ path: `${SHOTS}/canvas_fit_all.png` }); + // adaptive grid, zoomed far out in the plan editor + await loadFar(FAR_CFG, FAR_LAYOUT); + await page.evaluate(async () => { + const c = window.__card; + c._setMode('plan'); await c.updateComplete; + c._resetZoom(); c._applyView(1 / 3); await c.updateComplete; + }); + await raf(); + await page.screenshot({ path: `${SHOTS}/canvas_grid_far.png` }); + await page.evaluate(async () => { + const c = window.__card; + c._resetZoom(); c._applyView(4); await c.updateComplete; + }); + await raf(); + await page.screenshot({ path: `${SHOTS}/canvas_grid_near.png` }); + await page.evaluate(async () => { window.__card._setMode('view'); }); +} + +await finish(browser, checkAll(out)); diff --git a/demo/smoke_zoom_out.mjs b/demo/smoke_zoom_out.mjs index d1f8484c..4dc38477 100644 --- a/demo/smoke_zoom_out.mjs +++ b/demo/smoke_zoom_out.mjs @@ -44,8 +44,11 @@ out.zoomOut = await page.evaluate(() => { out.zoomOutWorks = out.zoomOut.zoom === 0.5 && out.zoomOut.wider && out.zoomOut.centredX && out.zoomOut.centredY; delete out.zoomOut; -out.floorIsHalf = await page.evaluate(() => { window.__card._resetZoom(); const c = window.__card; - c._applyView(0.1); return c._zoom; }) === 0.4; // clamped at the floor +// 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 ---------------- diff --git a/docs/TESTING.md b/docs/TESTING.md index 648cdc99..c0bd6674 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -960,3 +960,48 @@ require hands on real hardware — they remain for the human pass. (registry-wide climate, like hidden thermometers); the tick survives dialog recreation [auto: smoke_climate_temp; units: test/devices.test.mjs; backend: tests_backend/test_validation.py (use_climate_temp)] + +## Infinite canvas (docs/CANVAS.md, dev) + +- [ ] **A plan drawn past the old square opens whole**: a space whose rooms + live at normalised 1.5..3.0 renders complete and centred (it used to + frame empty canvas with the house off-screen) [auto: + smoke_infinite_canvas; units: test/canvas.test.mjs] +- [ ] **Nothing stops at an edge any more**: in the Plan / Devices / Decor + editors a room, a marker and a decor shape can be drawn, dragged and + SAVED far outside `0..1`, on any floor; reload keeps them there + [backend: tests_backend/test_validation.py::test_infinite_canvas_range] +- [ ] **A typical small plan is visually unchanged** — same framing, same + room and label positions as before the feature. The ONE intended + difference is icon size (below) [auto: smoke_infinite_canvas + (legacyFrameUnchanged); the whole smoke suite is the regression net] +- [ ] **Icons no longer grow with zoom** (§6, owner is aware): a marker keeps + the same pixel size at zoom 1, 4 and at the zoom-out floor; the + per-device size multiplier, kiosk icon/font scales, badges, LQI chips + and presence rings all still scale from `--dev-size` +- [ ] **Start view follows the content**: opening a space frames what is + drawn plus a small margin, on every floor, with and without a backdrop + image (with one the IMAGE sets the extent — it must not be cropped to + the outlined rooms) +- [ ] **A far stray does not break the view** (§4.1): a marker dragged an + order of magnitude away leaves the opening view alone and raises the + inline chip «Объектов далеко от плана: N» with «Показать». No modal. + «Показать» fits the plan AND the stray; the chip then disappears +- [ ] **«Вписать всё»** (middle zoom button): fits the content from any pan + and any zoom, is never disabled, tooltip en/ru +- [ ] **Zoom-out floor**: the wheel / the minus button stop at three times the + content frame; zoom-in still stops at 800 % +- [ ] **Pan has slack, not walls**: you can pan a full screen past the plan in + every direction; when the plan is fully off screen a small arrow points + home and one click fits it back +- [ ] **Adaptive grid** (§7): in the Plan editor zoomed far out the grid does + not merge into a grey wash — fine dots thin out, every 5th/10th node + stays bigger; zoomed in the grid is the usual one and snapping still + lands on the same nodes as before +- [ ] **Everything else on a far-out plan**: sun wedges, glow radii, open + boundaries, room resize handles/rulers, opening rulers, split/merge, + vacuum trails, the static `houseplan-space-card` and kiosk carousel all + behave exactly as on a plan inside `0..1` +- [ ] **Real config regression**: a production config (e.g. the dacha, 3 + floors / 106 markers) frames bit-identically to the previous release — + no outliers reported, no frame movement