Files
houseplan-card/demo/smoke_sun.mjs
T
Claude de71bd7967 feat(general): «Общие настройки» по референсу — серия 2 из четырёх (#600)
§5 SPEC.md референса. Семь карточек #598 остались, менялось содержимое:

- Display — две полные строки с иконкой, подписью и тумблером справа; пояснение
  радара по §5.2 стало подписью строки (текст прежний, ключ переехал в ленивый
  словарь как gs.radar_show_live_hint, «?» у строки больше нет);
- Zigbee links — заголовок и «?» функции (topology.help) рисует карточка, а
  блок hp-zigbee-topology-settings получил режим embedded: строка-тумблер,
  callout «сохраните настройку», подзаголовки ZHA / Zigbee2MQTT, поле тем и
  строки действий контролами набора. Заголовок больше не удваивается
  (дефект 1). Лист набора вносится в теневой корень компонента; токены
  --hpf-* наследуются с хоста. Прежний render без embedded не тронут — его
  держит контракт #459;
- Room fill colors — три подзаголовка (Lights / Temperature / Zigbee signal,
  Q6) и плитки цвета по три в ряд: название на свотче, hex, число
  прозрачности; свотч — trigger прежнего hp-color-opacity (Q5);
- Light-source glow — две плитки и поле радиуса с единицей внутри рамки;
- Plan — плашка цвета стен, сегмент фона, при Static — плашка фона с Reset и
  подсказкой (тема / свой цвет);
- Sun — gs.sun_missing остаётся callout'ом (К5); север: поле градусов с «°»,
  ссылка Clear, «N» и компас 44 px справа — индикатор набора, но тянуть его
  по-прежнему можно (smoke_sun гоняет настоящий drag; старый 120-px SVG и его
  CSS в plan.styles.ts удалены); лучи — строка-тумблер с подписью; источник
  лучей — сегмент (sun-settings-view.ts переписан с <select> на segmented,
  логика та же);
- Data — подзаголовки с «?» и строки действий набора.

К10 (Q1): Save только при изменениях и без ошибок (радиус > 0, север 0–359),
статус в футере, вопрос при закрытии с изменениями; снимок при открытии —
editors/general-form-state.ts поверх общего editors/dialog-baseline.ts (на него
же переведён space-form-state.ts). _settingsDialog не получил новых ключей.

Удалено мёртвое: _renderColorRow (рантайм и карточка), .sunrow/.suncol,
ключи gs.wall_group, gs.bg_group, gs.bg_theme, gs.radar_show_live.help(.aria)
в четырёх локалях. Девять новых ключей — в i18n/settings.

Смоки переписаны осознанно, с пометкой #600: smoke_general_settings (инвентарь
по классам набора, подзаголовки .hpf-sub h4, пояснения под «?» и подписью,
ни одного .rhint), smoke_sun (сегмент источника лучей вместо select, поле
#gs-north, radius drag 18 px), smoke_backup_transfer (подзаголовок и строка
действий), smoke_settings_dialog_cards (callout вместо .rhint). Новый фокусный
свидетель demo/smoke_general_settings_form.mjs: семь карточек и отсутствие
старой разметки, один заголовок у Zigbee, десять плиток, компас 44, цели 44,
запись плиток/плашки/радиуса/севера/сегментов в свои ключи, Save/dirty/вопрос
при закрытии.

Мутанты: general-save-enabled-without-changes,
general-tile-opacity-writes-a-neighbour-key, general-north-clear-writes-zero,
topology-embedded-draws-its-own-heading; якоря state-callout-hidden-under-help
и dialog-card-loses-its-heading переставлены на новую разметку.

Контрактные тесты: houseplan-source.mjs приводит `host.` к `this.` и в телах
вынесенных диалогов; color-picker — три явных showOpacity=true (плитки, плашка
стены, цвет комнаты); счётчик производных .aria 35 → 34.

Бюджет: стартовый граф 290 711 — потолок ОПУЩЕН 292 500 → 292 200 (ушёл
120-px компас из eager-листа и мёртвые ключи); ленивый редакторский 237 929 —
потолок 236 300 → 238 600 с разбором в bundle-budget.mjs.

Гейты: typecheck, build, bundle:sync, npm test 2792 pass, mutation-gate
--check, семь смоков «Общих», smoke_color_picker_consumers без правок.

Issue: #600
User-Visible: no
2026-09-20 11:04:39 +03:00

485 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Sun on the plan (docs/SUN.md): compass gating, window wedges on all four
// walls, direction follows north_deg, night is empty, wedges clip to rooms,
// four-phase background vs static, per-space inheritance, weather independence,
// memoisation across hass ticks, editors stay clean.
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 = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const upd = async () => { c.requestUpdate(); await c.updateComplete; };
const settleMode = async () => {
const started = performance.now();
while (c._modeTransitionBusy && performance.now() - started < 1800) {
await new Promise((resolve) => setTimeout(resolve, 20));
await c.updateComplete;
}
};
const setSun = async (azimuth, elevation) => {
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
entity_id: 'sun.sun', state: elevation > 0 ? 'above_horizon' : 'below_horizon',
attributes: { azimuth, elevation, rising: azimuth <= 180 },
} } };
await upd();
};
const setWeather = async (state) => {
c.hass = { ...c.hass, states: { ...c.hass.states, 'weather.home': {
entity_id: 'weather.home', state, attributes: {},
} } };
await upd();
};
const cfg = () => c._serverCfg;
// 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') || '';
// ---- the test plan: windows on all four OUTER walls + one interior ----
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 }, // top of r1
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 }, // right of r3
{ id: 'wS', type: 'window', x: 0.30, y: 0.86, angle: 0, length: 0.08 }, // bottom of r4
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 }, // left of r1
{ id: 'wI', type: 'window', x: 0.55, y: 0.30, angle: 90, length: 0.08 }, // r1|r2 wall
{ id: 'dS', type: 'door', x: 0.20, y: 0.86, angle: 0, length: 0.1 }, // doors never glow
];
touchCfg();
// 1) sun present + rays enabled, but NO north_deg anywhere → no window rays
cfg().settings = { ...(cfg().settings || {}), sun_rays: true };
touchCfg();
await setSun(90, 5);
out.silentWithoutNorth = domPolys().length === 0;
out.noBackgroundWhenStatic = !sr().querySelector('.stage.daycycle');
// 2) compass set → morning east sun lights ONLY the east window
cfg().settings.north_deg = 0;
touchCfg();
await setSun(90, 5);
out.morningEast = JSON.stringify(litIds()) === '["wE"]';
out.morningDomDrawn = domPolys().length > 0;
// 3) noon south → only the south window, wedge shorter than the morning one
const eastLen = c._sunRaysCache?.rays?.[0]?.len ?? 0;
await setSun(180, 60);
out.noonSouth = JSON.stringify(litIds()) === '["wS"]';
out.noonShorter = (c._sunRaysCache?.rays?.[0]?.len ?? 1e9) < eastLen;
// 4) evening west → only the west window; wedge stays inside its room r1
await setSun(270, 4);
out.eveningWest = JSON.stringify(litIds()) === '["wW"]';
const ray = c._sunRaysCache?.rays?.[0] || { polys: [] };
out.wedgeClippedToRoom = ray.polys.length > 0 && ray.polys.every((poly) => poly.every(([x, y]) =>
x >= 40 - 0.5 && x <= 550 + 0.5 && y >= 140 - 0.5 && y <= 580 + 0.5));
// 5) night → nothing
await setSun(90, -10);
out.nightEmpty = domPolys().length === 0;
// 6) N points right: the same morning east sun is down on the canvas and
// therefore lights the SOUTH window
cfg().settings.north_deg = 90;
touchCfg();
await setSun(90, 5);
out.rotatedCompass = JSON.stringify(litIds()) === '["wS"]';
cfg().settings.north_deg = 0;
touchCfg();
// 7) memoisation: an unrelated hass tick must NOT recompute the wedges
await setSun(90, 5);
const cacheBefore = c._sunRaysCache;
c.hass = { ...c.hass, states: { ...c.hass.states } }; // plain tick, sun unchanged
await upd();
out.memoSurvivesTick = c._sunRaysCache === cacheBefore;
await setSun(91, 5);
out.memoFollowsSun = c._sunRaysCache !== cacheBefore;
// 8) four-phase background: daynight paints environment, static does not
await setSun(180, 60);
out.staticBgAtNoon = !stageStyle().includes('background:#ffffff');
cfg().settings.bg_mode = 'daynight';
touchCfg();
await setSun(180, 60);
out.daynightNoonBg = sr().querySelector('.hp-day-cycle-bg.active')?.dataset.dayCycleLayer === 'day';
out.daynightClass = !!sr().querySelector('.stage.daycycle');
await setSun(180, -20);
out.daynightNightBg = sr().querySelector('.hp-day-cycle-bg.active')?.dataset.dayCycleLayer === 'night';
out.nightPlanUnchanged = !(sr().querySelector('.zoomwrap').getAttribute('style') || '').includes('brightness(0.');
cfg().settings.bg_mode = 'static';
touchCfg();
await setSun(180, -20);
out.staticNightKeepsTheme = !sr().querySelector('.hp-day-cycle-env');
delete cfg().settings.bg_mode;
touchCfg();
// 9) per-space inheritance: the space override wins over the global flag
sp.settings = { ...(sp.settings || {}), sun_rays: false };
touchCfg();
await setSun(90, 5);
out.spaceOverridesOff = domPolys().length === 0;
delete sp.settings.sun_rays;
// and a per-space compass override redirects the light
sp.settings.north_deg = 90;
touchCfg();
await setSun(90, 5);
out.spaceNorthOverride = JSON.stringify(litIds()) === '["wS"]';
delete sp.settings.north_deg;
touchCfg();
// 10) legacy weather settings and live weather states no longer participate
cfg().settings.weather_entity = 'weather.home';
touchCfg();
await setWeather('pouring');
await setSun(90, 5);
const stopRain = Number(sr().querySelector('stop')?.getAttribute('stop-opacity'));
out.rainDoesNotHideRays = domPolys().length > 0 && Math.abs(stopRain - 0.3) < 1e-6;
await setWeather('cloudy');
const stopCloudy = Number(sr().querySelector('stop')?.getAttribute('stop-opacity'));
out.cloudsDoNotDimRays = domPolys().length > 0 && Math.abs(stopCloudy - 0.3) < 1e-6;
delete cfg().settings.weather_entity;
touchCfg();
// 11) feature off: without the flag nothing is drawn even with sun+north
delete cfg().settings.sun_rays;
touchCfg();
await setSun(90, 5);
out.offDrawsNothing = domPolys().length === 0;
cfg().settings.sun_rays = true;
cfg().settings.bg_mode = 'daynight';
touchCfg();
// 12) editors stay clean: no wedges, no day-cycle environment
await setSun(90, 5);
c._setMode('plan');
await upd();
await settleMode();
out.editorNoRays = domPolys().length === 0;
out.editorNoDaynight = !sr().querySelector('.stage.daycycle');
c._setMode('view');
await upd();
await settleMode();
out.viewRaysBack = domPolys().length > 0;
return out;
});
// 13) the general-settings dialog: a REAL drag on the compass dial sets degrees
await page.evaluate(async () => {
const c = window.__card;
c._openSettingsDialog();
c.requestUpdate();
await c.updateComplete;
c.shadowRoot.querySelector('.compass').scrollIntoView({ block: 'center' });
});
const box = await page.evaluate(() => {
const r = window.__card.shadowRoot.querySelector('.compass').getBoundingClientRect();
return { cx: r.left + r.width / 2, cy: r.top + r.height / 2 };
});
// #600 §3.1: компас — индикатор 44 px; тянуть по-прежнему можно, радиус кольца ~18.
await page.mouse.move(box.cx + 18, box.cy); // on the ring, due right = 90°
await page.mouse.down();
await page.mouse.move(box.cx, box.cy + 18); // drag on to due down = 180°
await page.mouse.up();
const dlg = await page.evaluate(async () => {
const c = window.__card;
const sr = c.shadowRoot;
// #600 §5.1: источник лучей — сегмент (радиогруппа `gs-sun-ray-origin`),
// а не `<select>`; лучи — строка-тумблер набора; север — поле `#gs-north`.
const originRadios = () => [...sr.querySelectorAll('hp-dialog input[name="gs-sun-ray-origin"]')];
const originValue = () => originRadios().find((r) => r.checked)?.closest('label')?.textContent.trim();
const pick = (labelKey) => originRadios().find((r) => r.closest('label').textContent.trim() === c._t(labelKey));
const out = {
compassDragSets180: c._settingsDialog?.northDeg === 180,
dialogHasBgSegment: sr.querySelectorAll('hp-dialog input[name="gs-bg-mode"]').length === 2,
dialogHasRaysToggle: !!sr.querySelector('hp-dialog #gs-sun-rays'),
dialogHasOriginSegment: originRadios().length === 2,
originDefaultsInner: originValue() === c._t('gs.sun_ray_origin.inner') && c._settingsDialog?.sunRayOrigin === 'inner',
originAvailableWhenRaysOff: false,
dialogHasNoWeatherList: sr.querySelectorAll('#hp-weather-list option').length === 0,
dialogNumberMatches: sr.querySelector('hp-dialog #gs-north')?.value === '180',
};
c._settingsDialog = { ...c._settingsDialog, northDeg: 0, sunRays: false };
await c.updateComplete;
out.originAvailableWhenRaysOff = originRadios().every((r) => !r.disabled);
c._settingsDialog = { ...c._settingsDialog, sunRays: true };
await c.updateComplete;
const cacheBefore = c._sunRaysCache;
pick('gs.sun_ray_origin.outer').click();
await c.updateComplete;
out.originPendingRekeysGeometry = c._settingsDialog?.sunRayOrigin === 'outer'
&& c._sunRaysCache !== cacheBefore;
await c._saveSettingsDialog();
await c.updateComplete;
out.originSavedOuter = c._serverCfg.settings.sun_ray_origin === 'outer';
c._openSettingsDialog();
c.requestUpdate();
await c.updateComplete;
out.originRoundTripsToDialog = originValue() === c._t('gs.sun_ray_origin.outer')
&& c._settingsDialog?.sunRayOrigin === 'outer';
pick('gs.sun_ray_origin.inner').click();
await c.updateComplete;
await c._saveSettingsDialog();
await c.updateComplete;
out.originSavedInner = c._serverCfg.settings.sun_ray_origin === 'inner';
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');
const space = c._serverCfg.spaces.find((s) => s.id === 'f1');
const opening = space.openings.find((o) => o.id === 'wE');
const pointInRing = ([x, y], ring) => {
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const [xi, yi] = ring[i];
const [xj, yj] = ring[j];
if (((yi > y) !== (yj > y))
&& x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
}
return inside;
};
const wallCenterX = opening.x * 1000;
const innerFaceX = r ? wallCenterX * 2 - r.a[0] : NaN;
const innerSeamYs = r?.polys?.flat()
.filter(([x]) => Math.abs(x - innerFaceX) < 1e-4).map(([, y]) => y) || [];
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,
sourceX: r?.a?.[0] ?? NaN,
polyCount: r?.polys?.length ?? 0,
hasExteriorTunnel: !!r?.polys?.some((poly) => poly.some(([x]) => x > 960 + 0.5)),
hasRoomLight: !!r?.polys?.some((poly) => poly.some(([x]) => x < 960 - 0.5)),
innerSeamMinY: innerSeamYs.length ? Math.min(...innerSeamYs) : NaN,
innerSeamMaxY: innerSeamYs.length ? Math.max(...innerSeamYs) : NaN,
openPointLit: !!r?.polys?.some((poly) => pointInRing([950, 620], poly)),
blockedPointLit: !!r?.polys?.some((poly) => pointInRing([950, 646], poly)),
domPolyCount: [...(c.renderRoot || c.shadowRoot).querySelectorAll('.sunlayer polygon')].length,
domPointsFinite: [...(c.renderRoot || c.shadowRoot).querySelectorAll('.sunlayer polygon')]
.every((polygon) => polygon.getAttribute('points')?.split(/[ ,]+/)
.filter(Boolean).every((value) => Number.isFinite(Number(value)))),
rev: c._cfgRev,
wEy: opening.y,
};
});
// baseline: morning east sun, only wE glows, window sits at y = 600
await page.evaluate(async () => {
const c = window.__card;
// This section exercises a production structural write. Give it a compact
// canonical v9 fixture: the broad legacy fixture above intentionally omits
// wall identity and is valid for read/render compatibility only (#282).
const sp = c._serverCfg.spaces.find((space) => space.id === 'f1');
const edges = [
{ id: 'sun-top', a: [0.55, 0.46], b: [0.96, 0.46] },
{ id: 'sun-east', a: [0.96, 0.46], b: [0.96, 0.86] },
{ id: 'sun-bottom', a: [0.96, 0.86], b: [0.55, 0.86] },
{ id: 'sun-west', a: [0.55, 0.86], b: [0.55, 0.46] },
];
sp.rooms = [{
id: 'r3', name: 'Bedroom', area: 'bedroom',
poly: edges.map((edge) => [...edge.a]),
wall_ids: edges.map((edge) => edge.id),
}];
sp.wall_segments = edges.map((edge) => ({ ...edge, cm: 15 }));
sp.walls = edges.map((edge) => ({
key: edge.id, a: [...edge.a], b: [...edge.b], cm: 15,
}));
sp.openings = [{
id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08,
host: { kind: 'wall', id: 'sun-east', t: 0.35 },
}];
c._serverCfg.spaces = [sp];
c._serverCfg.model_version = 9;
c._modelCache = null;
c._cfgEpoch++;
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);
// #577 review regression: exercise the complete browser render path on a real
// physical 15 cm exterior wall. Outer starts beyond the wall centreline and
// paints both the opening tunnel and the clean-floor part of the ray. The
// golden scene pins their shared raster boundary so a one-pixel seam cannot
// hide behind these geometry assertions.
await page.evaluate(async () => {
const c = window.__card;
c._serverCfg.settings.sun_ray_origin = 'outer';
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
entity_id: 'sun.sun', state: 'above_horizon', attributes: { azimuth: 60, elevation: 5 },
} } };
c._cfgEpoch++;
c.requestUpdate(); await c.updateComplete;
});
const outer = await rayInfo();
check('outer_real_wall_source_is_exterior', outer.sourceX > 960, true);
check('outer_real_wall_has_tunnel_and_room_polys', outer.polyCount >= 2, true);
check('outer_real_wall_tunnel_is_painted', outer.hasExteriorTunnel, true);
check('outer_real_wall_room_is_painted', outer.hasRoomLight, true);
check('outer_oblique_inner_seam_starts_after_jamb_shadow',
outer.innerSeamMinY > 560 && outer.innerSeamMinY < 580, true);
check('outer_oblique_inner_seam_stops_at_aperture',
Math.abs(outer.innerSeamMaxY - 640) < 0.01, true);
check('outer_oblique_open_path_is_painted', outer.openPointLit, true);
check('outer_oblique_wall_shadow_is_not_painted', outer.blockedPointLit, false);
check('outer_real_wall_dom_polys_are_complete', outer.domPolyCount >= 2, true);
check('outer_real_wall_dom_points_are_finite', outer.domPointsFinite, true);
await page.evaluate(async () => {
const c = window.__card;
c._serverCfg.settings.sun_ray_origin = 'inner';
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
entity_id: 'sun.sun', state: 'above_horizon', attributes: { azimuth: 90, elevation: 5 },
} } };
c._cfgEpoch++;
c.requestUpdate(); await c.updateComplete;
});
// 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;
});
// The mode/chrome transition owns the stage geometry. Wait for its public
// completion signal before converting plan coordinates to screen pixels;
// a fixed delay races slower CI and drags empty canvas instead of the window.
await page.waitForFunction(() => !window.__card._modeTransitionBusy, { timeout: 2000 });
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');
const before = c._geometrySnapshot();
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._commitPhysicalGeometry('sun smoke room edit', before);
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);
// 15) the 3° threshold + the 2 s dissolve (owner 2026-08-03). Runs LAST and in
// its own evaluate: it deliberately waits out a two-second fade, and the b701
// regression above measures a debounced config write against wall-clock time.
const thr = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const upd = async () => { c.requestUpdate(); await c.updateComplete; };
const setSun = async (azimuth, elevation) => {
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
entity_id: 'sun.sun', state: elevation > 0 ? 'above_horizon' : 'below_horizon',
attributes: { azimuth, elevation },
} } };
await upd();
};
const domPolys = () => [...sr().querySelectorAll('.sunlayer polygon')];
const layer = () => sr().querySelector('.sunlayer');
const stopAlpha = () => Number(sr().querySelector('stop')?.getAttribute('stop-opacity'));
// the old build ramped the alpha in over the first ~2°; now the layer is
// either absent or at FULL strength, and crossing 3° fades the whole LAYER
// (never the geometry) in or out over exactly two seconds
await setSun(90, 10);
out.aboveThresholdDrawn = domPolys().length > 0 && !layer()?.classList.contains('out');
{
const current = layer();
const cs = current && getComputedStyle(current);
out.fadeInTakesTwoSeconds = !!cs
&& cs.animationName === 'hp-sunfade-in' && cs.animationDuration === '2s';
}
out.fullAlphaWellAboveThreshold = Math.abs(stopAlpha() - 0.3) < 1e-6;
await setSun(90, 3.2);
out.fullAlphaJustAboveThreshold = Math.abs(stopAlpha() - 0.3) < 1e-6;
// below 3°: the layer stays for exactly the dissolve, then leaves the DOM
await setSun(90, 2);
{
const dying = layer();
const cs = dying && getComputedStyle(dying);
out.belowThresholdDissolves = !!dying && dying.classList.contains('out')
&& cs.animationName === 'hp-sunfade-out' && cs.animationDuration === '2s';
}
await new Promise((r) => setTimeout(r, 2300));
await upd();
out.belowThresholdGoneAfterFade = !layer() && domPolys().length === 0;
// a sun that never crossed the threshold draws nothing at all — no ghost
await setSun(90, 1);
out.coldStartBelowThresholdEmpty = domPolys().length === 0 && !layer();
await setSun(90, 5);
out.backAboveThreshold = domPolys().length > 0 && !layer()?.classList.contains('out');
return out;
});
Object.assign(res, thr);
await finish(browser, checkAll(res));