DEV-B701-01: sun-ray memo keyed by _cfgEpoch, not _cfgRev (stale wedge after local geometry edits)

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.
This commit is contained in:
Matysh
2026-08-03 13:28:57 +03:00
parent b70153769a
commit a20b73621f
5 changed files with 113 additions and 6 deletions
@@ -2206,7 +2206,7 @@ const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow
</button>
</div>
</div>
</div>`}_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`<defs>
</div>`}_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`<defs>
${l.map((t,e)=>{const i=(t.a[0]+t.b[0])/2,s=(t.a[1]+t.b[1])/2;return j`<linearGradient id="hp-sun-${e}" gradientUnits="userSpaceOnUse"
x1="${i}" y1="${s}" x2="${i+t.dir[0]*t.len}" y2="${s+t.dir[1]*t.len}">
<stop offset="0%" stop-color="${c}" stop-opacity="${r.toFixed(3)}"></stop>
+104 -2
View File
@@ -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));
+1 -1
View File
@@ -2206,7 +2206,7 @@ const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow
</button>
</div>
</div>
</div>`}_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`<defs>
</div>`}_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`<defs>
${l.map((t,e)=>{const i=(t.a[0]+t.b[0])/2,s=(t.a[1]+t.b[1])/2;return j`<linearGradient id="hp-sun-${e}" gradientUnits="userSpaceOnUse"
x1="${i}" y1="${s}" x2="${i+t.dir[0]*t.len}" y2="${s+t.dir[1]*t.len}">
<stop offset="0%" stop-color="${c}" stop-opacity="${r.toFixed(3)}"></stop>
+1 -1
View File
@@ -2206,7 +2206,7 @@ const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow
</button>
</div>
</div>
</div>`}_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`<defs>
</div>`}_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`<defs>
${l.map((t,e)=>{const i=(t.a[0]+t.b[0])/2,s=(t.a[1]+t.b[1])/2;return j`<linearGradient id="hp-sun-${e}" gradientUnits="userSpaceOnUse"
x1="${i}" y1="${s}" x2="${i+t.dir[0]*t.len}" y2="${s+t.dir[1]*t.len}">
<stop offset="0%" stop-color="${c}" stop-opacity="${r.toFixed(3)}"></stop>
+6 -1
View File
@@ -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) }))