From a20b73621fe5fe7bc578edee7864ddaa0a15d3e3 Mon Sep 17 00:00:00 2001 From: Matysh Date: Mon, 3 Aug 2026 13:28:57 +0300 Subject: [PATCH] DEV-B701-01: sun-ray memo keyed by _cfgEpoch, not _cfgRev (stale wedge after local geometry edits) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wedge cache key carried the SERVER revision, which only moves after the debounced houseplan/config/set is acked. Every local mutation path ends in _saveConfig(), which bumps _cfgEpoch synchronously — so a dragged window or an edited room kept its old wedge for the whole write window (forever on a failed write). The memo now uses the epoch, the same signal the model/ geometry caches key on (audit L1). smoke_sun.mjs no longer masks the defect: touchCfg() bumps _cfgEpoch (what production does) instead of faking a server rev, and a new regression drives the REAL path — a pointer drag of the east window, exit from the editor inside the debounce window (rev untouched), a room shrink through _saveConfig() that must re-clip the wedge, and a late-ack survival check. Fails on b701537, green with the fix. 218 unit + 79 backend + 85/85 smokes. --- .../houseplan/frontend/houseplan-card.js | 2 +- demo/smoke_sun.mjs | 106 +++++++++++++++++- demo/srv/assets/houseplan-card.js | 2 +- dist/houseplan-card.js | 2 +- src/houseplan-card.ts | 7 +- 5 files changed, 113 insertions(+), 6 deletions(-) diff --git a/custom_components/houseplan/frontend/houseplan-card.js b/custom_components/houseplan/frontend/houseplan-card.js index 76df4fad..d32dba20 100755 --- a/custom_components/houseplan/frontend/houseplan-card.js +++ b/custom_components/houseplan/frontend/houseplan-card.js @@ -2206,7 +2206,7 @@ const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow - `}_sunGlobal(){const t=this._settingsDialog;return t?{...this._settings,north_deg:t.northDeg??void 0,bg_mode:t.bgMode,sun_rays:t.sunRays,weather_entity:(t.weatherEntity||"").trim()||void 0}:this._settings}_sunSpace(){const t=this._spaceDialog,e=this._curSpaceCfg?.settings||{};return t&&"edit"===t.mode&&t.spaceId===this._space?{...e,north_deg:t.northDeg??void 0,bg_mode:t.bgMode??void 0,sun_rays:t.sunRays??void 0}:e}_effNorth(){return hs(this._sunGlobal(),this._sunSpace())}_effBgMode(){return ds(this._sunGlobal(),this._sunSpace())}_effSunRays(){return ps(this._sunGlobal(),this._sunSpace())}_sunNow(){return null!==this._effNorth()?_s(this.hass):null}_renderSunRays(t){const e=j``;if(this._editing||!this._effSunRays())return e;const i=this._effNorth(),s=null!==i?_s(this.hass):null;if(!s||s.elevation<=0)return e;const o=us(this._sunGlobal()),n=function(t){if(!t)return 1;const e=ls[String(t).toLowerCase()];return void 0===e?1:e}(o?this.hass?.states?.[o]?.state:null),r=function(t,e=1){return t>0?.18*Math.min(1,t/2)*es(e):0}(s.elevation,n);if(r<=0)return e;const a=`${t.id}|${s.azimuth}|${s.elevation}|${i}|${this._cfgRev}`;if(!this._sunRaysCache||this._sunRaysCache.key!==a){const e=t.rooms.map(t=>({id:t.id||"",poly:ke(t)})).filter(t=>!!t.id&&!!t.poly),o=this._openingsR.filter(t=>"window"===t.type).map(t=>({id:t.id,x:t.rx,y:t.ry,angle:t.angle,length:t.rlen}));this._sunRaysCache={key:a,rays:as(e,o,s.azimuth,s.elevation,i)}}const l=this._sunRaysCache.rays;if(!l.length)return e;const c=(h=is(s.elevation).warmth,ui("#ffe9c2","#ff9a45",es(h)));var h;return j` + `}_sunGlobal(){const t=this._settingsDialog;return t?{...this._settings,north_deg:t.northDeg??void 0,bg_mode:t.bgMode,sun_rays:t.sunRays,weather_entity:(t.weatherEntity||"").trim()||void 0}:this._settings}_sunSpace(){const t=this._spaceDialog,e=this._curSpaceCfg?.settings||{};return t&&"edit"===t.mode&&t.spaceId===this._space?{...e,north_deg:t.northDeg??void 0,bg_mode:t.bgMode??void 0,sun_rays:t.sunRays??void 0}:e}_effNorth(){return hs(this._sunGlobal(),this._sunSpace())}_effBgMode(){return ds(this._sunGlobal(),this._sunSpace())}_effSunRays(){return ps(this._sunGlobal(),this._sunSpace())}_sunNow(){return null!==this._effNorth()?_s(this.hass):null}_renderSunRays(t){const e=j``;if(this._editing||!this._effSunRays())return e;const i=this._effNorth(),s=null!==i?_s(this.hass):null;if(!s||s.elevation<=0)return e;const o=us(this._sunGlobal()),n=function(t){if(!t)return 1;const e=ls[String(t).toLowerCase()];return void 0===e?1:e}(o?this.hass?.states?.[o]?.state:null),r=function(t,e=1){return t>0?.18*Math.min(1,t/2)*es(e):0}(s.elevation,n);if(r<=0)return e;const a=`${t.id}|${s.azimuth}|${s.elevation}|${i}|${this._cfgEpoch}`;if(!this._sunRaysCache||this._sunRaysCache.key!==a){const e=t.rooms.map(t=>({id:t.id||"",poly:ke(t)})).filter(t=>!!t.id&&!!t.poly),o=this._openingsR.filter(t=>"window"===t.type).map(t=>({id:t.id,x:t.rx,y:t.ry,angle:t.angle,length:t.rlen}));this._sunRaysCache={key:a,rays:as(e,o,s.azimuth,s.elevation,i)}}const l=this._sunRaysCache.rays;if(!l.length)return e;const c=(h=is(s.elevation).warmth,ui("#ffe9c2","#ff9a45",es(h)));var h;return j` ${l.map((t,e)=>{const i=(t.a[0]+t.b[0])/2,s=(t.a[1]+t.b[1])/2;return j` diff --git a/demo/smoke_sun.mjs b/demo/smoke_sun.mjs index 1dde7856..5aefc434 100644 --- a/demo/smoke_sun.mjs +++ b/demo/smoke_sun.mjs @@ -2,7 +2,7 @@ // walls, direction follows north_deg, night is empty, wedges clip to rooms, // day/night background vs static, per-space inheritance, cloud fading, // memoisation across hass ticks, editors stay clean. -import { launch, checkAll, finish } from './serve.mjs'; +import { launch, check, checkAll, finish } from './serve.mjs'; const { page, browser } = await launch({ width: 900, height: 900 }, 1); const res = await page.evaluate(async () => { const out = {}; @@ -23,7 +23,11 @@ const res = await page.evaluate(async () => { await upd(); }; const cfg = () => c._serverCfg; - const touchCfg = () => { c._cfgRev = (c._cfgRev || 0) + 1; }; + // DEV-B701-01: local mutations bump _cfgEpoch (what _saveConfig() does + // synchronously). The old helper bumped _cfgRev — a server ack that the + // production save path does NOT produce until the debounced write lands — + // and thereby masked the stale sun-ray cache. + const touchCfg = () => { c._cfgEpoch++; }; const litIds = () => (c._sunRaysCache?.rays || []).map((r) => r.openingId).sort(); const domPolys = () => [...sr().querySelectorAll('.sunlayer polygon')]; const stageStyle = () => sr().querySelector('.stage').getAttribute('style') || ''; @@ -188,4 +192,102 @@ const dlg = await page.evaluate(() => { return out; }); Object.assign(res, dlg); + +// 14) DEV-B701-01 regression: the PRODUCTION local-save path (no touchCfg, no +// fake rev/epoch bump from the test) must move the wedge. A REAL pointer drag +// of the east window ends in _opPointerUp -> _saveConfig(); the WS write is +// debounced 500 ms, so at check time _cfgRev is still the boot revision — the +// ray must follow the window anyway, and again after the late server ack. +const settle = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))); +const screenPt = (x, y) => page.evaluate(([x, y]) => { + const c = window.__card; + const stage = c.renderRoot.querySelector('.stage'); + const r = stage.getBoundingClientRect(); + const svgEl = stage.querySelector('svg'); + const [vx, vy, vw, vh] = svgEl.getAttribute('viewBox').split(' ').map(Number); + return [r.left + ((x - vx) / vw) * r.width, r.top + ((y - vy) / vh) * r.height]; +}, [x, y]); +const rayInfo = () => page.evaluate(() => { + const c = window.__card; + const rays = c._sunRaysCache?.rays || []; + const r = rays.find((q) => q.openingId === 'wE'); + return { + ids: rays.map((q) => q.openingId).sort(), + midY: r ? (r.a[1] + r.b[1]) / 2 : NaN, + minX: r ? Math.min(...r.polys.flat().map((p) => p[0])) : NaN, + rev: c._cfgRev, + wEy: c._serverCfg.spaces.find((s) => s.id === 'f1').openings.find((o) => o.id === 'wE').y, + }; +}); + +// baseline: morning east sun, only wE glows, window sits at y = 600 +await page.evaluate(async () => { + const c = window.__card; + c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': { + entity_id: 'sun.sun', state: 'above_horizon', attributes: { azimuth: 90, elevation: 5 }, + } } }; + c.requestUpdate(); await c.updateComplete; +}); +const base = await rayInfo(); +check('b701_baseline_east_only', base.ids, ['wE']); +check('b701_baseline_mid_600', Math.abs(base.midY - 600) < 1, true); + +// the real drag: enter the opening editor and pull wE down the east wall +await page.evaluate(async () => { + const c = window.__card; + c._setMode('plan'); c._tool = 'opening'; + c.requestUpdate(); await c.updateComplete; +}); +await settle(); +const [dx, dy] = await screenPt(960, 600); +const [, dty] = await screenPt(960, 700); +await page.mouse.move(dx, dy); +await page.mouse.down(); +await page.mouse.move(dx, dty, { steps: 6 }); +await page.mouse.up(); +await settle(); + +// leave the editor IMMEDIATELY — well inside the 500 ms debounce window +const after = await page.evaluate(async () => { + const c = window.__card; + c._setMode('view'); + c.requestUpdate(); await c.updateComplete; + return true; +}); +check('b701_left_editor', after); +const moved = await rayInfo(); +check('b701_window_really_moved', Math.abs(moved.wEy * 1000 - 700) < 15, true); +check('b701_rev_still_boot', moved.rev, base.rev); // the write has NOT landed yet +check('b701_ray_follows_window', Math.abs(moved.midY - moved.wEy * 1000) < 1, true); +check('b701_ray_not_stale', Math.abs(moved.midY - 600) > 50, true); + +// same defect class for ROOM geometry: r3 shrinks to a thin strip along its +// east wall through the real mutation endpoint (_saveConfig, still no ack). +// The wedge reaches ~188 units into the room (minX ~772), so the new west +// boundary at x = 900 MUST cut it — a stale clip keeps points near x = 772. +const clipped = await page.evaluate(async () => { + const c = window.__card; + const sp = c._serverCfg.spaces.find((s) => s.id === 'f1'); + sp.rooms.find((r) => r.id === 'r3').poly = [[0.90, 0.46], [0.96, 0.46], [0.96, 0.86], [0.90, 0.86]]; + c._saveConfig(); // the one true local-save entry point + c.requestUpdate(); await c.updateComplete; + return true; +}); +check('b701_room_mutated', clipped); +const reclipped = await rayInfo(); +check('b701_room_rev_still_boot', reclipped.rev, base.rev); +check('b701_room_reclips_ray', reclipped.minX >= 900 - 1, true); + +// let the debounced write land: the ray must survive the late server ack +await page.waitForTimeout(800); +const acked = await page.evaluate(async () => { + const c = window.__card; + c.requestUpdate(); await c.updateComplete; + return true; +}); +check('b701_acked', acked); +const final = await rayInfo(); +check('b701_write_landed', final.rev !== base.rev, true); +check('b701_ray_survives_ack', Math.abs(final.midY - final.wEy * 1000) < 1, true); + await finish(browser, checkAll(res)); diff --git a/demo/srv/assets/houseplan-card.js b/demo/srv/assets/houseplan-card.js index 76df4fad..d32dba20 100755 --- a/demo/srv/assets/houseplan-card.js +++ b/demo/srv/assets/houseplan-card.js @@ -2206,7 +2206,7 @@ const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow - `}_sunGlobal(){const t=this._settingsDialog;return t?{...this._settings,north_deg:t.northDeg??void 0,bg_mode:t.bgMode,sun_rays:t.sunRays,weather_entity:(t.weatherEntity||"").trim()||void 0}:this._settings}_sunSpace(){const t=this._spaceDialog,e=this._curSpaceCfg?.settings||{};return t&&"edit"===t.mode&&t.spaceId===this._space?{...e,north_deg:t.northDeg??void 0,bg_mode:t.bgMode??void 0,sun_rays:t.sunRays??void 0}:e}_effNorth(){return hs(this._sunGlobal(),this._sunSpace())}_effBgMode(){return ds(this._sunGlobal(),this._sunSpace())}_effSunRays(){return ps(this._sunGlobal(),this._sunSpace())}_sunNow(){return null!==this._effNorth()?_s(this.hass):null}_renderSunRays(t){const e=j``;if(this._editing||!this._effSunRays())return e;const i=this._effNorth(),s=null!==i?_s(this.hass):null;if(!s||s.elevation<=0)return e;const o=us(this._sunGlobal()),n=function(t){if(!t)return 1;const e=ls[String(t).toLowerCase()];return void 0===e?1:e}(o?this.hass?.states?.[o]?.state:null),r=function(t,e=1){return t>0?.18*Math.min(1,t/2)*es(e):0}(s.elevation,n);if(r<=0)return e;const a=`${t.id}|${s.azimuth}|${s.elevation}|${i}|${this._cfgRev}`;if(!this._sunRaysCache||this._sunRaysCache.key!==a){const e=t.rooms.map(t=>({id:t.id||"",poly:ke(t)})).filter(t=>!!t.id&&!!t.poly),o=this._openingsR.filter(t=>"window"===t.type).map(t=>({id:t.id,x:t.rx,y:t.ry,angle:t.angle,length:t.rlen}));this._sunRaysCache={key:a,rays:as(e,o,s.azimuth,s.elevation,i)}}const l=this._sunRaysCache.rays;if(!l.length)return e;const c=(h=is(s.elevation).warmth,ui("#ffe9c2","#ff9a45",es(h)));var h;return j` + `}_sunGlobal(){const t=this._settingsDialog;return t?{...this._settings,north_deg:t.northDeg??void 0,bg_mode:t.bgMode,sun_rays:t.sunRays,weather_entity:(t.weatherEntity||"").trim()||void 0}:this._settings}_sunSpace(){const t=this._spaceDialog,e=this._curSpaceCfg?.settings||{};return t&&"edit"===t.mode&&t.spaceId===this._space?{...e,north_deg:t.northDeg??void 0,bg_mode:t.bgMode??void 0,sun_rays:t.sunRays??void 0}:e}_effNorth(){return hs(this._sunGlobal(),this._sunSpace())}_effBgMode(){return ds(this._sunGlobal(),this._sunSpace())}_effSunRays(){return ps(this._sunGlobal(),this._sunSpace())}_sunNow(){return null!==this._effNorth()?_s(this.hass):null}_renderSunRays(t){const e=j``;if(this._editing||!this._effSunRays())return e;const i=this._effNorth(),s=null!==i?_s(this.hass):null;if(!s||s.elevation<=0)return e;const o=us(this._sunGlobal()),n=function(t){if(!t)return 1;const e=ls[String(t).toLowerCase()];return void 0===e?1:e}(o?this.hass?.states?.[o]?.state:null),r=function(t,e=1){return t>0?.18*Math.min(1,t/2)*es(e):0}(s.elevation,n);if(r<=0)return e;const a=`${t.id}|${s.azimuth}|${s.elevation}|${i}|${this._cfgEpoch}`;if(!this._sunRaysCache||this._sunRaysCache.key!==a){const e=t.rooms.map(t=>({id:t.id||"",poly:ke(t)})).filter(t=>!!t.id&&!!t.poly),o=this._openingsR.filter(t=>"window"===t.type).map(t=>({id:t.id,x:t.rx,y:t.ry,angle:t.angle,length:t.rlen}));this._sunRaysCache={key:a,rays:as(e,o,s.azimuth,s.elevation,i)}}const l=this._sunRaysCache.rays;if(!l.length)return e;const c=(h=is(s.elevation).warmth,ui("#ffe9c2","#ff9a45",es(h)));var h;return j` ${l.map((t,e)=>{const i=(t.a[0]+t.b[0])/2,s=(t.a[1]+t.b[1])/2;return j` diff --git a/dist/houseplan-card.js b/dist/houseplan-card.js index 76df4fad..d32dba20 100755 --- a/dist/houseplan-card.js +++ b/dist/houseplan-card.js @@ -2206,7 +2206,7 @@ const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow - `}_sunGlobal(){const t=this._settingsDialog;return t?{...this._settings,north_deg:t.northDeg??void 0,bg_mode:t.bgMode,sun_rays:t.sunRays,weather_entity:(t.weatherEntity||"").trim()||void 0}:this._settings}_sunSpace(){const t=this._spaceDialog,e=this._curSpaceCfg?.settings||{};return t&&"edit"===t.mode&&t.spaceId===this._space?{...e,north_deg:t.northDeg??void 0,bg_mode:t.bgMode??void 0,sun_rays:t.sunRays??void 0}:e}_effNorth(){return hs(this._sunGlobal(),this._sunSpace())}_effBgMode(){return ds(this._sunGlobal(),this._sunSpace())}_effSunRays(){return ps(this._sunGlobal(),this._sunSpace())}_sunNow(){return null!==this._effNorth()?_s(this.hass):null}_renderSunRays(t){const e=j``;if(this._editing||!this._effSunRays())return e;const i=this._effNorth(),s=null!==i?_s(this.hass):null;if(!s||s.elevation<=0)return e;const o=us(this._sunGlobal()),n=function(t){if(!t)return 1;const e=ls[String(t).toLowerCase()];return void 0===e?1:e}(o?this.hass?.states?.[o]?.state:null),r=function(t,e=1){return t>0?.18*Math.min(1,t/2)*es(e):0}(s.elevation,n);if(r<=0)return e;const a=`${t.id}|${s.azimuth}|${s.elevation}|${i}|${this._cfgRev}`;if(!this._sunRaysCache||this._sunRaysCache.key!==a){const e=t.rooms.map(t=>({id:t.id||"",poly:ke(t)})).filter(t=>!!t.id&&!!t.poly),o=this._openingsR.filter(t=>"window"===t.type).map(t=>({id:t.id,x:t.rx,y:t.ry,angle:t.angle,length:t.rlen}));this._sunRaysCache={key:a,rays:as(e,o,s.azimuth,s.elevation,i)}}const l=this._sunRaysCache.rays;if(!l.length)return e;const c=(h=is(s.elevation).warmth,ui("#ffe9c2","#ff9a45",es(h)));var h;return j` + `}_sunGlobal(){const t=this._settingsDialog;return t?{...this._settings,north_deg:t.northDeg??void 0,bg_mode:t.bgMode,sun_rays:t.sunRays,weather_entity:(t.weatherEntity||"").trim()||void 0}:this._settings}_sunSpace(){const t=this._spaceDialog,e=this._curSpaceCfg?.settings||{};return t&&"edit"===t.mode&&t.spaceId===this._space?{...e,north_deg:t.northDeg??void 0,bg_mode:t.bgMode??void 0,sun_rays:t.sunRays??void 0}:e}_effNorth(){return hs(this._sunGlobal(),this._sunSpace())}_effBgMode(){return ds(this._sunGlobal(),this._sunSpace())}_effSunRays(){return ps(this._sunGlobal(),this._sunSpace())}_sunNow(){return null!==this._effNorth()?_s(this.hass):null}_renderSunRays(t){const e=j``;if(this._editing||!this._effSunRays())return e;const i=this._effNorth(),s=null!==i?_s(this.hass):null;if(!s||s.elevation<=0)return e;const o=us(this._sunGlobal()),n=function(t){if(!t)return 1;const e=ls[String(t).toLowerCase()];return void 0===e?1:e}(o?this.hass?.states?.[o]?.state:null),r=function(t,e=1){return t>0?.18*Math.min(1,t/2)*es(e):0}(s.elevation,n);if(r<=0)return e;const a=`${t.id}|${s.azimuth}|${s.elevation}|${i}|${this._cfgEpoch}`;if(!this._sunRaysCache||this._sunRaysCache.key!==a){const e=t.rooms.map(t=>({id:t.id||"",poly:ke(t)})).filter(t=>!!t.id&&!!t.poly),o=this._openingsR.filter(t=>"window"===t.type).map(t=>({id:t.id,x:t.rx,y:t.ry,angle:t.angle,length:t.rlen}));this._sunRaysCache={key:a,rays:as(e,o,s.azimuth,s.elevation,i)}}const l=this._sunRaysCache.rays;if(!l.length)return e;const c=(h=is(s.elevation).warmth,ui("#ffe9c2","#ff9a45",es(h)));var h;return j` ${l.map((t,e)=>{const i=(t.a[0]+t.b[0])/2,s=(t.a[1]+t.b[1])/2;return j` diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts index b59441b8..9bcc7f2a 100755 --- a/src/houseplan-card.ts +++ b/src/houseplan-card.ts @@ -4458,7 +4458,12 @@ class HouseplanCard extends LitElement { const cloud = cloudFactor(weather ? this.hass?.states?.[weather]?.state : null); const alpha = rayAlpha(sun.elevation, cloud); if (alpha <= 0) return empty; - const key = `${space.id}|${sun.azimuth}|${sun.elevation}|${north}|${this._cfgRev}`; + // DEV-B701-01: the geometry signal must be _cfgEpoch, not _cfgRev. + // Every local mutation ends in _saveConfig(), which bumps the epoch + // SYNCHRONOUSLY; _cfgRev only moves after the debounced WS write is + // acked, so a rev-keyed memo served wedges for the OLD window position + // during the whole write window (and forever if the write failed). + const key = `${space.id}|${sun.azimuth}|${sun.elevation}|${north}|${this._cfgEpoch}`; if (!this._sunRaysCache || this._sunRaysCache.key !== key) { const rooms = space.rooms .map((r) => ({ id: r.id || '', poly: roomPoly(r) }))