v1.59.0-beta.1: warm remount keeps your view and dialogs, sun ray rim
Validate / hacs (push) Failing after 7s
Validate / hassfest (push) Failing after 7s
Validate / frontend (push) Successful in 2m47s
Validate / backend (push) Failing after 8m13s
Validate / smoke (push) Failing after 22m41s

This commit is contained in:
Matysh
2026-08-04 18:20:47 +03:00
parent 46f20fe4e1
commit 1397a71f84
23 changed files with 2095 additions and 570 deletions
+64
View File
@@ -0,0 +1,64 @@
// Stills for the rim (docs/SUN.md, «The rim»). Two scenes — white paper and
// the dark glow canvas — and, in each, the SAME frame at several rim peaks so
// the value can be picked by looking rather than by arguing. The alphas other
// than the shipped one are applied by rewriting the `hp-sunrim-*` stops in the
// DOM, which is why a rebuild is not needed to compare them.
// node demo/shot_sun_rim.mjs <outdir> [alphas, default 0,0.3,0.42,0.5]
// `0` is the "before" frame: the wedge alone, with no rim at all.
import { launch } from './serve.mjs';
const outDir = process.argv[2] || '/tmp';
const ALPHAS = (process.argv[3] || '0,0.3,0.42,0.5').split(',').map(Number);
const { page, browser } = await launch({ width: 900, height: 900 }, 2);
await page.emulateMedia({ reducedMotion: 'reduce' }); // still shots, no fades
const setup = async (scene) => {
await page.evaluate(async (sc) => {
const c = window.__card;
const cfg = c._serverCfg;
const sp = cfg.spaces.find((s) => s.id === 'f1');
sp.openings = [
{ id: 'wN', type: 'window', x: 0.30, y: 0.14, angle: 0, length: 0.08 },
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
{ id: 'wW2', type: 'window', x: 0.04, y: 0.72, angle: 90, length: 0.08 },
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 },
];
sp.plan_url = null; // plain paper, nothing to hide behind
sp.settings = { ...(sp.settings || {}), fill_mode: sc === 'dark' ? 'glow' : 'none' };
cfg.settings = { ...(cfg.settings || {}), north_deg: 0, sun_rays: true,
bg_mode: 'static', bg_color: sc === 'dark' ? '#101720' : '#f2f2f0' };
c._cfgEpoch++;
c.hass = { ...c.hass, states: { ...c.hass.states,
'light.ceiling': { ...c.hass.states['light.ceiling'], state: sc === 'dark' ? 'on' : 'off' },
'sun.sun': { entity_id: 'sun.sun', state: 'above_horizon',
attributes: { azimuth: 250, elevation: 35 } } } };
c.requestUpdate();
await c.updateComplete;
}, scene);
await page.waitForTimeout(300);
};
// Rescale every rim gradient to `peak`, always from the stops as BUILT — a
// scale relative to the current values would collapse to zero the moment the
// 0 frame is taken and never come back.
const setAlpha = async (peak) => {
await page.evaluate((a) => {
for (const g of window.__card.shadowRoot.querySelectorAll('linearGradient[id^=hp-sunrim-]')) {
const stops = [...g.querySelectorAll('stop')];
if (!g.__base) g.__base = stops.map((s) => Number(s.getAttribute('stop-opacity')));
const p0 = g.__base[0] || 1;
stops.forEach((s, i) => s.setAttribute('stop-opacity', (a * (g.__base[i] / p0)).toFixed(4)));
}
}, peak);
await page.waitForTimeout(120);
};
for (const scene of ['light', 'dark']) {
await setup(scene);
for (const a of ALPHAS) {
await setAlpha(a);
const stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
await stage.asElement().screenshot({ path: `${outDir}/rim_${scene}_${String(a).replace('.', '')}.png` });
}
}
await browser.close();
console.log('shots written to ' + outDir);
+70 -6
View File
@@ -1,4 +1,4 @@
import { launch, checkAll, finish } from './serve.mjs';
import { launch, check, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
@@ -25,14 +25,78 @@ const res = await page.evaluate(async () => {
out.dialogOpens = !!c._spaceDialog;
out.tabNotSwitched = c._space === cur;
c._spaceDialog = null; await c.updateComplete;
// кнопка "+" по-прежнему только в Плане
out.noAddInView = !sr().querySelector('.tab.tabadd');
c._setMode('plan'); await c.updateComplete;
out.addInPlan = !!sr().querySelector('.tab.tabadd');
// «+» — навигационное действие, а не инструмент редактора плана
// (владелец 2026-08-04): кнопка есть во ВСЕХ режимах и кликабельна.
const addBtn = () => sr().querySelector('.tab.tabadd');
const hittable = () => {
const b = addBtn();
if (!b) return 'нет кнопки';
const r = b.getBoundingClientRect();
if (r.width < 8 || r.height < 8) return `кнопка ${r.width}x${r.height}px`;
const hit = sr().elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
return hit && hit.closest && hit.closest('.tab.tabadd') === b ? true : 'перекрыта';
};
out.addInView = hittable();
for (const m of ['plan', 'devices', 'decor']) {
c._setMode(m); await c.updateComplete;
out['addIn_' + m] = hittable();
}
c._setMode('view'); await c.updateComplete;
// …и открывает именно диалог СОЗДАНИЯ
const b0 = addBtn();
if (b0) { b0.click(); await c.updateComplete; }
out.addOpensCreate = !!c._spaceDialog && c._spaceDialog.mode === 'create';
c._spaceDialog = null; await c.updateComplete;
return out;
});
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
checkAll(res, {
"alignDelta": 0,
});
await finish(browser, res);
// киоск — витрина: редактирование недоступно, кнопки «+» нет ВООБЩЕ
// (display:none у .hdr не считается: скрытый в DOM узел всё ещё кликабелен
// программно и всё ещё ловится автотестами как «есть»)
const kio = await page.evaluate(async () => {
const out = {};
const c = document.createElement('houseplan-card');
c.setConfig({ type: 'custom:houseplan-card', kiosk: true, cycle: 0 });
c.hass = window.__mkHass();
c.style.cssText = 'position:fixed;left:0;top:0;width:900px;height:700px;z-index:99';
document.body.appendChild(c);
await new Promise((r) => setTimeout(r, 350));
c.hass = { ...c.hass }; await c.updateComplete;
const sr = c.shadowRoot || c.renderRoot;
out.kioskNoAddButton = !sr.querySelector('.tab.tabadd');
c.remove();
return out;
});
for (const [k, v] of Object.entries(kio)) check(k, v);
// мобильный вид: ряд вкладок переносится, а не выезжает за карточку
await page.setViewportSize({ width: 390, height: 760 });
const narrow = await page.evaluate(async () => {
const out = {};
const c = window.__card;
await new Promise((r) => setTimeout(r, 250));
c.requestUpdate(); await c.updateComplete;
const sr = c.shadowRoot || c.renderRoot;
const head = sr.querySelector('.head');
const btn = sr.querySelector('.tab.tabadd');
out.narrowAddPresent = !!btn;
out.narrowNoHOverflow = head.scrollWidth <= head.clientWidth + 1
? true : `scrollW=${head.scrollWidth} > clientW=${head.clientWidth}`;
const cr = sr.querySelector('ha-card').getBoundingClientRect();
const br = btn ? btn.getBoundingClientRect() : null;
out.narrowAddInsideCard = !br ? 'нет кнопки'
: br.right <= cr.right + 1 && br.left >= cr.left - 1 ? true
: `btn ${Math.round(br.left)}..${Math.round(br.right)} vs card ${Math.round(cr.left)}..${Math.round(cr.right)}`;
out.narrowAddHittable = !br ? 'нет кнопки' : (() => {
const hit = sr.elementFromPoint(br.left + br.width / 2, br.top + br.height / 2);
return hit && hit.closest && hit.closest('.tab.tabadd') === btn ? true : 'перекрыта';
})();
return out;
});
for (const [k, v] of Object.entries(narrow)) check(k, v);
await finish(browser, { ...res, ...kio, ...narrow });
+106
View File
@@ -0,0 +1,106 @@
// «Уже загруженные» — прокручиваемый список превью внутри диалога пространства.
// Проверяем НЕ наличие узлов в DOM, а ГЕОМЕТРИЮ: scrollable-контейнер как
// flex-item в .dialog .body (flex column) схлопывается до бордера (min-height
// auto = 0 при overflow != visible), и владелец видит тонкую полоску вместо
// сетки миниатюр. Ровно этот баг уже ловили в .candlist (v1.53.1) — смок там
// смотрел только на DOM и пропустил его.
import { launch, checkAll, finish } from './serve.mjs';
const VIEWPORTS = [
{ name: 'desktop', width: 900, height: 1000 },
{ name: 'mobile', width: 390, height: 780 },
];
const out = {};
for (const vp of VIEWPORTS) {
const { page, browser } = await launch({ width: vp.width, height: vp.height }, 1);
const res = await page.evaluate(async (mode) => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const base = c.hass.callWS;
const mk = (i) => ({
name: `plan${i}.png`,
url: `/api/houseplan/content/plans/_/plan${i}.png`,
size: 100000 + i * 1000,
modified: i,
used_by: [],
});
let serverPlans = [mk(1), mk(2), mk(3), mk(4), mk(5), mk(6)];
c.hass = { ...c.hass, callWS: async (m) => {
if (m.type === 'houseplan/plans/list') return { plans: serverPlans };
if (m.type === 'houseplan/content/sign') {
const urls = {}; for (const p of m.paths) urls[p] = p; return { urls };
}
return base(m);
} };
const openPicker = async () => {
c._spaceDialog = { ...c._spaceDialog, source: 'file' };
await c.updateComplete;
await c._toggleServerPlans();
await new Promise((r) => setTimeout(r, 80));
await c.updateComplete;
await new Promise((r) => requestAnimationFrame(() => r()));
};
const geom = (tag) => {
const box = sr().querySelector('.savedplans');
const rows = [...sr().querySelectorAll('.savedplan')];
o[tag + 'Rows'] = rows.length;
if (!box || !rows.length) { o[tag + 'BoxTall'] = false; o[tag + 'FirstVisible'] = false; return; }
const b = box.getBoundingClientRect();
const r0 = rows[0].getBoundingClientRect();
o[tag + 'BoxH'] = Math.round(b.height);
// 2-3 ряда миниатюр: ряд ~40px + отступы
o[tag + 'BoxTall'] = b.height >= 100;
// первая миниатюра целиком внутри контейнера и не нулевая
o[tag + 'FirstVisible'] = r0.height >= 32 && r0.top >= b.top - 1 && r0.bottom <= b.bottom + 1;
// при 6 планах список не влезает целиком — должна быть прокрутка
o[tag + 'Scrolls'] = box.scrollHeight - box.clientHeight > 8;
box.scrollTop = 9999;
o[tag + 'ScrollWorks'] = box.scrollTop > 8;
box.scrollTop = 0;
};
// 1. диалог создания пространства
c._openSpaceDialog('create');
await c.updateComplete;
await openPicker();
geom('create');
// 2. диалог настроек существующего пространства
c._spaceDialog = null; await c.updateComplete;
c._openSpaceDialog('edit', 'f1');
await c.updateComplete;
await openPicker();
geom('edit');
// 3. пусто: аккуратная надпись, а не схлопнутая полоска
serverPlans = [];
c._spaceDialog = { ...c._spaceDialog, pickSaved: false };
await c.updateComplete;
await openPicker();
const empty = sr().querySelector('.savedplans');
o.emptyHasText = !!empty && empty.textContent.trim().length > 0;
o.emptyTall = !!empty && empty.getBoundingClientRect().height >= 24;
o.mode = mode;
return o;
}, vp.name);
for (const [k, v] of Object.entries(res)) out[vp.name + '.' + k] = v;
await browser.close();
}
console.log(JSON.stringify(out, null, 1));
checkAll(out, {
'desktop.createRows': 6,
'desktop.editRows': 6,
'desktop.createBoxH': out['desktop.createBoxH'],
'desktop.editBoxH': out['desktop.editBoxH'],
'desktop.mode': 'desktop',
'mobile.createRows': 6,
'mobile.editRows': 6,
'mobile.createBoxH': out['mobile.createBoxH'],
'mobile.editBoxH': out['mobile.editBoxH'],
'mobile.mode': 'mobile',
});
await finish(null);
+243
View File
@@ -0,0 +1,243 @@
// THE RIM (owner 2026-08-04): «тонкая (1px) чёрная граница по бокам
// светящегося сектора, которая также плавно уходит в ноль вместе с самим
// градиентом». The owner rejected the «shade instead of light» model of
// docs/SUN-CONTRAST.md and asked for this one line instead — because a wedge
// of added luminance simply cannot read on white paper, while its BOUNDARY
// can (docs/SUN.md, «The rim»).
//
// Everything this smoke pins down:
// * exactly TWO rim lines per wedge, on the two SIDE edges — the ones that
// run from the ends of the glass along the ray. Never the glass edge,
// never the far edge; the coordinates are checked against the wedge's own
// vertices, so a rim drawn around the whole polygon fails here;
// * the stroke is `url(#hp-sunrim-N)`, a gradient with BLACK stops on the
// SAME axis and the SAME offsets as the fill's `hp-sun-N`, monotone,
// dead from 85 % on — the rim cannot outlive the light it outlines;
// * `vector-effect: non-scaling-stroke` at width 1 — one screen pixel at
// any zoom;
// * no rim below the 3° threshold (after the 2 s layer fade) and none in an
// editor;
// * and the point of the whole change: on WHITE paper the pixels along the
// wedge's side are measurably darker than the paper on either hand.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 900 }, 2);
await page.emulateMedia({ reducedMotion: 'reduce' }); // no 2 s layer fade in the way
const RAY_FADE_MS = 2000;
const res = await page.evaluate(async (fadeMs) => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const cfg = c._serverCfg;
const sp = cfg.spaces.find((s) => s.id === 'f1');
// r1 is [[0.04,0.14],[0.55,0.14],[0.55,0.58],[0.04,0.58]] — a west window
// at y = 0.30 casts a wedge that stays well inside it, so the unclipped
// side edges are the whole story; the second window is deliberately on a
// wall the same sun cannot reach, to prove no rim appears without a wedge.
sp.openings = [
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 },
];
sp.plan_url = null; // plain white paper under the wedge
cfg.settings = { ...(cfg.settings || {}), north_deg: 0, bg_mode: 'static', sun_rays: true };
c._cfgEpoch++;
const upd = async () => { c.requestUpdate(); await c.updateComplete; };
const setSun = async (az, el) => {
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
entity_id: 'sun.sun', state: 'above_horizon', attributes: { azimuth: az, elevation: el } } } };
await upd();
};
const rimsOf = () => [...sr().querySelectorAll('.sunlayer line.sunrim')];
const gradOf = (id) => sr().querySelector('linearGradient[id="' + id + '"]');
const stopsOf = (g) => [...g.querySelectorAll('stop')].map((s) => ({
off: parseFloat(s.getAttribute('offset')),
color: s.getAttribute('stop-color'),
a: Number(s.getAttribute('stop-opacity')),
}));
const near = (a, b, eps = 1e-4) => Math.abs(a - b) < eps;
await setSun(270, 60); // a western sun, square into the west window
const rays = c._sunRaysCache.rays;
out.oneWedgeDrawn = rays.length === 1;
out.wedgeIsUnclipped = rays.length === 1 && rays[0].polys.length === 1
&& rays[0].polys[0].length === 4;
// ---- 1) exactly two rim lines, on the two SIDE edges -------------------
const rims = rimsOf();
out.rimDrawn = rims.length > 0;
out.twoRimsPerWedge = rims.length === rays.length * 2;
out.rimStrokeIsAGradient = rims.length > 0
&& rims.every((l) => /^url\(#hp-sunrim-\d+\)$/.test(l.getAttribute('stroke') || ''));
out.rimIsOnePixelAtAnyZoom = rims.length > 0 && rims.every((l) =>
l.getAttribute('vector-effect') === 'non-scaling-stroke'
&& Number(l.getAttribute('stroke-width')) === 1);
// every rim line must BE a side edge: its two ends are vertices of the
// wedge polygon, it runs along `dir`, and it starts at an end of the glass
out.rimSidesMatchTheWedge = true;
out.rimIsNeverTheGlassOrTheFarEdge = true;
for (let i = 0; i < rays.length; i++) {
const r = rays[i];
const mine = rims.filter((l) => l.getAttribute('stroke') === `url(#hp-sunrim-${i})`);
if (mine.length !== 2) { out.rimSidesMatchTheWedge = false; continue; }
const verts = r.polys.flat();
const isVertex = (p) => verts.some((v) => near(v[0], p[0], 1e-3) && near(v[1], p[1], 1e-3));
const ends = mine.map((l) => [
[+l.getAttribute('x1'), +l.getAttribute('y1')],
[+l.getAttribute('x2'), +l.getAttribute('y2')],
]);
// the four expected corners: the glass ends and where their rays stop
const far = (s) => [s[0] + r.dir[0] * r.len, s[1] + r.dir[1] * r.len];
const want = [[r.a, far(r.a)], [r.b, far(r.b)]];
for (const [src, tip] of want) {
const hit = ends.some(([p, q]) =>
(near(p[0], src[0], 1e-3) && near(p[1], src[1], 1e-3)
&& near(q[0], tip[0], 1e-3) && near(q[1], tip[1], 1e-3))
|| (near(q[0], src[0], 1e-3) && near(q[1], src[1], 1e-3)
&& near(p[0], tip[0], 1e-3) && near(p[1], tip[1], 1e-3)));
if (!hit) out.rimSidesMatchTheWedge = false;
}
for (const [p, q] of ends) {
if (!isVertex(p) || !isVertex(q)) out.rimSidesMatchTheWedge = false;
// parallel to the ray (the glass edge and the far edge are not)
const dx = q[0] - p[0], dy = q[1] - p[1];
const L = Math.hypot(dx, dy) || 1;
if (Math.abs((dx / L) * r.dir[1] - (dy / L) * r.dir[0]) > 1e-6)
out.rimIsNeverTheGlassOrTheFarEdge = false;
// and it must not be the glass: both ends on the a-b line would mean it
const onGlass = (z) => Math.abs((z[0] - r.a[0]) * r.dir[0] + (z[1] - r.a[1]) * r.dir[1]) < 1e-6;
if (onGlass(p) && onGlass(q)) out.rimIsNeverTheGlassOrTheFarEdge = false;
}
}
// ---- 2) the rim gradient: same axis, same curve, black ------------------
const fills = [...sr().querySelectorAll('linearGradient[id^=hp-sun-]')];
out.rimGradientBlack = true;
out.rimGradientSharesTheAxis = true;
out.rimGradientSharesTheCurve = true;
out.rimGradientMonotone = true;
out.rimGradientDeadAt85 = true;
out.rimGradientBrightAtTheGlass = true;
for (let i = 0; i < rays.length; i++) {
const rim = gradOf(`hp-sunrim-${i}`);
const fill = fills[i];
if (!rim || !fill) { out.rimGradientBlack = false; continue; }
for (const k of ['x1', 'y1', 'x2', 'y2']) {
if (!near(+rim.getAttribute(k), +fill.getAttribute(k), 1e-9)) out.rimGradientSharesTheAxis = false;
}
const rs = stopsOf(rim);
const fs = stopsOf(fill);
if (rs.length !== fs.length) out.rimGradientSharesTheCurve = false;
const peak = rs[0].a;
rs.forEach((s, j) => {
const black = /^#0{3,8}$/i.test(s.color || '') || /rgb\(0,\s*0,\s*0\)/.test(s.color || '');
if (!black) out.rimGradientBlack = false;
if (!fs[j] || !near(s.off, fs[j].off, 1e-9)) out.rimGradientSharesTheCurve = false;
// the same normalised curve as the fill, only scaled to its own peak
if (fs[j] && !near(s.a / (peak || 1), fs[j].a / (fs[0].a || 1), 1e-3))
out.rimGradientSharesTheCurve = false;
if (j > 0 && s.a > rs[j - 1].a + 1e-9) out.rimGradientMonotone = false;
if (s.off >= 85 - 1e-9 && s.a !== 0) out.rimGradientDeadAt85 = false;
if (s.off < 85 - 1e-9 && !(s.a > 0)) out.rimGradientDeadAt85 = false;
});
if (!(peak >= 0.3 && peak <= 0.55)) out.rimGradientBrightAtTheGlass = false;
}
// ---- 3) screen coords for the pixel probe, off the real geometry --------
// sample ACROSS the a-side edge at 22 % of the reach, where both the fill
// and the rim are still strong
const svgEl = sr().querySelector('.sunlayer')?.ownerSVGElement || sr().querySelector('.stage svg');
const ctm = svgEl.getScreenCTM();
const toScr = (x, y) => {
const q = svgEl.createSVGPoint(); q.x = x; q.y = y;
const p = q.matrixTransform(ctm); return [p.x, p.y];
};
if (rays.length) {
const r = rays[0];
const u = 0.22 * r.len;
const on = toScr(r.a[0] + r.dir[0] * u, r.a[1] + r.dir[1] * u);
// one render unit INTO the wedge (toward b) → the screen-space normal
const inw = [r.b[0] - r.a[0], r.b[1] - r.a[1]];
const iL = Math.hypot(inw[0], inw[1]) || 1;
const q = toScr(r.a[0] + r.dir[0] * u + (inw[0] / iL) * 10, r.a[1] + r.dir[1] * u + (inw[1] / iL) * 10);
const vx = q[0] - on[0], vy = q[1] - on[1];
const vL = Math.hypot(vx, vy) || 1;
out.probe = { on, perp: [vx / vL, vy / vL], dpr: window.devicePixelRatio };
}
// ---- 4) below the threshold and in an editor there is no rim -----------
await setSun(270, 2); // under RAY_ELEVATION_MIN
await new Promise((rs) => setTimeout(rs, fadeMs + 300)); // let the layer leave
await upd();
out.noRimBelowThreshold = rimsOf().length === 0;
out.noWedgeBelowThreshold = sr().querySelectorAll('.sunlayer polygon').length === 0;
await setSun(270, 60);
out.rimIsBackAboveThreshold = rimsOf().length === 2;
c._mode = 'plan';
await upd();
out.noRimInTheEditor = rimsOf().length === 0;
c._mode = 'view';
await upd();
out.rimIsBackInViewMode = rimsOf().length === 2;
// ---- 5) a fully overcast sky takes the rim with the light --------------
cfg.settings = { ...cfg.settings, weather_entity: 'weather.home' };
c.hass = { ...c.hass, states: { ...c.hass.states,
'weather.home': { entity_id: 'weather.home', state: 'pouring', attributes: {} } } };
c._cfgEpoch++;
await upd();
out.noRimInTheRain = rimsOf().length === 0;
delete cfg.settings.weather_entity;
c._cfgEpoch++;
await upd();
out.rimIsBackInTheSun = rimsOf().length === 2;
return out;
}, RAY_FADE_MS);
// ---- 6) the whole point: on WHITE paper the side of the wedge is DARK ----
const probe = res.probe;
delete res.probe;
if (probe) {
const shot = (await page.screenshot()).toString('base64');
const px = await page.evaluate(async ({ shot, probe }) => {
const img = new Image();
img.src = 'data:image/png;base64,' + shot;
await img.decode();
const cv = document.createElement('canvas');
cv.width = img.width; cv.height = img.height;
const ctx = cv.getContext('2d', { willReadFrequently: true });
ctx.drawImage(img, 0, 0);
const d = probe.dpr || 1;
const lum = (t) => {
const x = Math.round((probe.on[0] + probe.perp[0] * t) * d);
const y = Math.round((probe.on[1] + probe.perp[1] * t) * d);
const [r, g, b] = ctx.getImageData(x, y, 1, 1).data;
return 0.299 * r + 0.587 * g + 0.114 * b;
};
// t > 0 goes INTO the wedge, t < 0 onto the untouched paper beside it
const band = [];
for (let t = -2.5; t <= 2.5; t += 0.25) band.push(lum(t));
const darkest = Math.min(...band);
const inside = Math.min(lum(7), lum(9), lum(11)); // lit floor, still bright
const outside = Math.min(lum(-7), lum(-9), lum(-11)); // plain white paper
return {
paperIsLight: outside > 200,
wedgeInteriorIsLight: inside > 200,
rimIsDarkOnTheEdge: darkest < Math.min(inside, outside) - 25,
rimProfile: [Math.round(darkest), Math.round(inside), Math.round(outside)],
};
}, { shot, probe });
res.rimProfile = px.rimProfile;
delete px.rimProfile;
Object.assign(res, px);
} else {
res.paperIsLight = false;
res.wedgeInteriorIsLight = false;
res.rimIsDarkOnTheEdge = false;
}
const profile = res.rimProfile;
delete res.rimProfile;
console.log('rim/inside/outside luminance:', JSON.stringify(profile));
await finish(browser, checkAll(res));
+120
View File
@@ -0,0 +1,120 @@
// DEV-B703-03: «карточка перезагружается при возврате на вкладку» — часть 2.
// Тёплый ре-маунт (v1.58.0) убрал вуаль, но не сохранял ни ПАН, ни зум
// редактора, ни открытые диалоги: Lovelace пересоздаёт элемент, состояние
// диалога живёт в экземпляре и умирает вместе с ним. Смок воспроизводит
// пересоздание (remove + create на той же странице) и требует:
// 1) вид восстановлен БИТ-В-БИТ — ни один кадр не отличается от прежнего;
// 2) открытый диалог пережил пересоздание вместе с черновиком;
// 3) осознанно закрытый (Esc) — НЕ воскресает;
// 4) подтверждение «Выровнять всё» — НЕ воскресает никогда;
// 5) воскрешение одноразовое: третий экземпляр диалога уже не видит.
// ПАДАЕТ на сборке до DEV-B703-03 (вид рецентрировался, диалоги терялись).
import { launch, check, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 820, height: 760 });
const res = await page.evaluate(async () => {
const out = {};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
customElements.get('houseplan-card')?._warmBootReset?.();
localStorage.removeItem('houseplan_card_zoom_v1');
localStorage.removeItem('houseplan_card_nav_v1');
const wrap = document.createElement('div');
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
document.body.appendChild(wrap);
const mk = () => {
const c = document.createElement('houseplan-card');
c.setConfig({ type: 'custom:houseplan-card' }); // Lovelace: setConfig ДО вставки
c.hass = window.__mkHass();
wrap.appendChild(c);
return c;
};
const settle = async (c) => {
const t0 = performance.now();
while (c._booting && performance.now() - t0 < 2500) await sleep(30);
await sleep(350);
};
const rect = (c) => (c._view ? [c._view.x, c._view.y, c._view.w, c._view.h] : null);
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
/** покадрово: ни один кадр после пересоздания не отличается от эталона */
const watchView = async (c, zoom0, v0, ms = 700) => {
const bad = [];
const t1 = performance.now();
await new Promise((done) => {
const s = () => {
if (Math.abs(c._zoom - zoom0) > 1e-6 || !same(rect(c), v0)) {
bad.push({ t: Math.round(performance.now() - t1), zoom: c._zoom, v: rect(c) });
}
if (performance.now() - t1 < ms) requestAnimationFrame(s); else done();
};
requestAnimationFrame(s);
});
return bad.length === 0 ? true : `кадр ${bad[0].t}мс: zoom=${bad[0].zoom} view=${JSON.stringify(bad[0].v)} (ждали ${zoom0} / ${JSON.stringify(v0)})`;
};
// ================= A. просмотр: пан+зум и настройки пространства ==========
let c = mk();
await settle(c);
c._applyView(2.4, 260, 720); // зум в угол — вид точно не по центру
c._saveZoom();
c.requestUpdate(); await c.updateComplete; await sleep(100);
const zoomA = c._zoom, viewA = rect(c);
out.aPanned = viewA[0] > 1 || viewA[1] > 1; // sanity: вид действительно смещён
c._openSpaceDialog('edit', c._space); await c.updateComplete;
c._spaceDialog = { ...c._spaceDialog, title: 'ЧЕРНОВИК-42' }; // недосохранённая правка
await c.updateComplete;
out.aDialogOpenBefore = !!c._spaceDialog;
c.remove(); await sleep(20); // ← Lovelace выбрасывает элемент
c = mk(); // ← и создаёт новый
out.aViewBitExact = await watchView(c, zoomA, viewA);
out.aDialogSurvived = !!c._spaceDialog;
out.aDraftSurvived = c._spaceDialog?.title === 'ЧЕРНОВИК-42';
out.aStillSameSpaceMode = c._mode === 'view';
// ---- воскрешение одноразовое: ещё одно пересоздание диалог не вернёт ----
c._spaceDialog = null; await c.updateComplete; // (осознанно закрыли)
c.remove(); await sleep(20);
c = mk(); await sleep(120); await c.updateComplete; await sleep(60);
out.aNoZombieAfterClose = !c._spaceDialog;
// ================= B. редактор устройств: зум редактора + карточка =======
c._setMode('devices'); await c.updateComplete; await sleep(120);
c._applyView(3.4, 430, 380); c.requestUpdate(); await c.updateComplete; await sleep(100);
const zoomB = c._zoom, viewB = rect(c);
const dev = c._devices.find((d) => d.space === c._space);
c._openMarkerDialog(dev); await c.updateComplete;
c._markerDialog = { ...c._markerDialog, name: 'ИМЯ-ЧЕРНОВИК' };
await c.updateComplete;
out.bDialogOpenBefore = !!c._markerDialog;
c.remove(); await sleep(20);
c = mk();
out.bModeRestored = c._mode === 'devices';
out.bViewBitExact = await watchView(c, zoomB, viewB);
out.bDialogSurvived = !!c._markerDialog;
out.bDraftSurvived = c._markerDialog?.name === 'ИМЯ-ЧЕРНОВИК';
// ================= C. Esc = осознанное закрытие ==========================
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
await c.updateComplete; await sleep(30);
out.cClosedByEsc = !c._markerDialog;
c.remove(); await sleep(20);
c = mk(); await sleep(120); await c.updateComplete; await sleep(60);
out.cNoResurrectionAfterEsc = !c._markerDialog;
// ================= D. «Выровнять всё» не воскресает НИКОГДА ==============
c._setMode('view'); await c.updateComplete; await sleep(60);
c._alignDialog = { report: { moved: 3, maxShiftCm: 2, maxSpace: c._space }, spaces: [], layout: {}, cm: 2, where: '', busy: false };
await c.updateComplete;
out.dAlignOpenBefore = !!c._alignDialog;
c.remove(); await sleep(20);
c = mk(); await sleep(120); await c.updateComplete; await sleep(60);
out.dAlignNotRevived = !c._alignDialog;
c.remove(); wrap.remove();
return out;
});
for (const [k, v] of Object.entries(res)) check(k, v);
await finish(browser, res);
File diff suppressed because one or more lines are too long