Compare commits

...
Author SHA1 Message Date
Matysh 01980ac3e6 Release v1.62.0-beta.7 candidate
Validate / performance_smoke (push) Failing after 9m34s
Validate / backend (push) Failing after 4m47s
Validate / smoke (push) Failing after 19m8s
Validate / hacs (push) Failing after 10s
Validate / hassfest (push) Failing after 11s
Validate / frontend (push) Successful in 4m20s
Validate / golden (push) Failing after 6m33s
2026-08-12 15:13:38 +03:00
Matysh 36e81e9fb1 Release v1.62.0-beta.6 candidate 2026-08-12 14:07:09 +03:00
Matysh 6d0f97ef82 test: accept beta.5 visual baselines 2026-08-12 13:10:46 +03:00
Matysh bd9b6a6d75 Stabilize beta.5 transition validation 2026-08-12 13:02:22 +03:00
Matysh 4b03b888ff Release v1.62.0-beta.5 candidate 2026-08-12 12:43:27 +03:00
Matysh 58d952e94e test: align beta.4 action smokes 2026-08-12 10:36:10 +03:00
Matysh 5d3f580df0 test: accept beta.4 help icon golden baselines 2026-08-12 10:26:56 +03:00
Matysh 4dc3fdef36 Release v1.62.0-beta.4 candidate 2026-08-12 10:19:51 +03:00
Matysh 8a417539e0 test: align beta.3 action smokes
Validate / hacs (push) Failing after 8s
Validate / hassfest (push) Failing after 8s
Validate / frontend (push) Successful in 3m57s
Validate / backend (push) Failing after 5m9s
Validate / performance_smoke (push) Failing after 1m40s
Validate / golden (push) Failing after 1m44s
Validate / smoke (push) Failing after 12m18s
2026-08-12 02:42:57 +03:00
Matysh c41231a7ba Release v1.62.0-beta.3 candidate 2026-08-12 02:33:54 +03:00
Matysh 2158e5d3f6 test: align beta.2 visual and badge gates
Validate / hacs (push) Failing after 44s
Validate / hassfest (push) Failing after 1m19s
Validate / frontend (push) Successful in 4m49s
Validate / golden (push) Failing after 2m9s
Validate / smoke (push) Failing after 2m15s
Validate / backend (push) Failing after 7m27s
Validate / performance_smoke (push) Failing after 6m30s
2026-08-11 22:21:09 +03:00
Matysh 554d2e6544 Release v1.62.0-beta.2 candidate 2026-08-11 22:12:08 +03:00
Matysh 2cf5c2748e test: accept v1.62.0-beta.1 golden matrix 2026-08-11 19:37:53 +03:00
Matysh 59d028caf7 fix: wrap backup import confirmation 2026-08-11 19:30:56 +03:00
Matysh 4381f65fde test: restore wall thickness in golden fixture 2026-08-11 19:23:49 +03:00
Matysh 446f33ed31 Release v1.62.0-beta.1 candidate 2026-08-11 19:15:21 +03:00
Matysh 9419842333 fix(hacs): keep exactly one *manifest.json in the tree
Validate / hassfest (push) Failing after 14s
Validate / frontend (push) Successful in 8m18s
Validate / backend (push) Failing after 10m34s
Validate / smoke (push) Failing after 24m45s
Validate / performance_smoke (push) Failing after 9m42s
Full Performance / performance (push) Failing after 54m31s
Validate / golden (push) Failing after 6m50s
Validate / hacs (push) Failing after 11s
The HACS submission check does not read hacs.json to find the integration: it
globs `*manifest.json` over the whole clone of the default branch and exits 1
unless there is exactly one (hacs/default, scripts/helpers/integration_path.py).
Three files matched — the two stand-only integrations added on 2026-07-31 and
the golden baseline index added on 2026-08-11 — so the Hassfest job of PR #9004
went red five weeks into the review queue, with a log that named no file.

The stand manifests ship as manifest.template.json and demo/stand/install.sh
renames them at install time; the golden index becomes baselines-index.json
(the exported constant keeps its name, so no consumer changes).
test/repo-hygiene.test.mjs fails if a second manifest ever appears, and the
existing golden-policy assertion — which compared against 'manifest.json' and
happily passed on 'baseline-manifest.json' — now checks the suffix.
2026-08-11 14:03:38 +03:00
Matysh 02373bb31f Release v1.61.0
Validate / performance_smoke (push) Failing after 8m49s
Validate / hacs (push) Failing after 14s
Validate / hassfest (push) Failing after 12s
Validate / frontend (push) Successful in 7m12s
Validate / golden (push) Failing after 6m12s
Validate / backend (push) Failing after 11m16s
Validate / smoke (push) Failing after 25m49s
Full Performance / performance (push) Failing after 53m37s
2026-08-11 08:32:16 +03:00
Matysh 9a4ecbf961 test: keep Linux golden baselines authoritative 2026-08-11 05:44:58 +03:00
Matysh 48eebfd8a5 Release v1.61.0-beta.8 candidate 2026-08-11 05:40:32 +03:00
Matysh 5c5833d69a fix: stabilize prerelease signing checks 2026-08-11 03:14:56 +03:00
Matysh a41a75eba5 test: accept visual continuity golden matrix v8 2026-08-11 02:57:05 +03:00
Matysh 6f34c06d74 test: repair persisted golden marker fixture 2026-08-11 02:53:47 +03:00
Matysh c237baaffd Release v1.61.0-beta.7 candidate 2026-08-11 02:49:42 +03:00
209 changed files with 26485 additions and 4883 deletions
+12
View File
@@ -53,6 +53,18 @@ jobs:
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npm ci && npm run build
- name: Verify compositor frame continuity for a stable release
if: ${{ !github.event.release.prerelease }}
run: |
npx playwright install --with-deps chromium
cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
npm run continuity:screencast
- name: Upload failed continuity frames
if: ${{ failure() && !github.event.release.prerelease }}
uses: actions/upload-artifact@v4
with:
name: continuity-screencast
path: artifacts/continuity-screencast
- run: cp dist/houseplan-card.js custom_components/houseplan/frontend/
- name: Attach card to release
uses: softprops/action-gh-release@v2
+1
View File
@@ -6,3 +6,4 @@ __pycache__/
.pytest_cache/
.venv-backend/
artifacts/
.agents/
+16
View File
@@ -8,6 +8,22 @@ House Plan is one HACS package with two parts plus a demo harness:
Standard commands live in `package.json` scripts, `CONTRIBUTING.md`, and `docs/DEVELOPMENT.md`. Read `docs/ARCHITECTURE.md` and `docs/STATUS.md` before non-trivial changes.
## Two-agent workflow
House Plan is developed by two agents: **Codex is the author** (analysis,
estimate, spec, implementation) and **Claude is the reviewer** (estimate, spec,
code). They exchange remarks through a local, git-ignored message bus —
**read `.agents/PROTOCOL.md` before acting on any task**. It defines the
message format, the estimate scales every agent must use, the three stages
(`estimate` → `spec` → `code`), the three-round convergence limit and what gets
published to GitHub.
Two rules that matter even if you read nothing else:
- write only into the *other* agent's inbox, never edit a file you did not
create, and move a processed message to `.agents/archive/`;
- "verified" without a command and its output is not evidence — from either side.
## Canonical backlog
GitHub is the only active backlog for House Plan:
+82
View File
@@ -0,0 +1,82 @@
# Code review — issue #68 (contextual help)
Date: 2026-08-12
Branch: `dev`
Scope: `hp-help`, Houseplan help factory, localization contract, dialog/overlay lifecycle,
keyboard and touch interaction, responsive placement, consumers and regression coverage.
## Outcome
The overlay, focus, Escape, outside-click, scroll, Popover/fallback and visual-viewport
paths are internally consistent. The shared dialog overlay registry is used correctly,
the trigger remains reachable in disabled fieldsets through `legend`, and all seven
current call sites have non-empty RU/EN body and ARIA strings.
Four hardening findings were accepted and fixed locally. No model, saved configuration
or user data contract changed.
## Findings and resolutions
### CR68-01 — dead trigger is rendered without help content (P1)
`hp-help` blocked `_openHelp()` when `text` was empty, but `render()` still returned a
focusable button. The result was the exact reported defect: a visible help glyph that
could not open any explanation.
Resolution: `hp-help` renders nothing unless both trimmed `text` and `ariaLabel` exist.
The same predicate guards opening and closes an already-open surface if either value is
removed dynamically.
### CR68-02 — missing translation could be displayed as a key (P1)
The card factory called `t()` directly. Its intended generic fallback returns the key
name when neither dictionary contains a value, so a future incomplete help pair could
produce a real trigger with implementation text such as `marker.foo.help`.
Resolution: the factory now checks the localized value and its derived `.aria` value
through `hasTranslation()` before it creates `hp-help`. English fallback remains valid;
a genuinely absent or whitespace-only pair produces no host and no layout gap.
### CR68-03 — accessible name had a hard-coded English fallback (P1)
Direct use without `ariaLabel` produced `aria-label="Help"`. This violated the issue
contract requiring a complete localized accessible name and made an incomplete component
look valid to keyboard and screen-reader users.
Resolution: the fallback was removed. Missing ARIA copy suppresses the affordance just
like missing visible copy.
### CR68-04 — icon did not follow the product icon system (P2)
The trigger used a font `?`, whose shape and optical alignment depended on the platform
font and did not visually mean “question in a circle”.
Resolution: the glyph is now the shared MDI `help-circle-outline` vector inside the same
32/40 px target. It remains decorative because the button already has a full ARIA label.
### CR68-05 — regression coverage missed incomplete content (P2)
The smoke covered all open/close and overlay paths but never instantiated an empty or
half-configured component, so CR68-01/03 could pass the release gate.
Resolution: the #68 smoke now asserts that empty body and empty ARIA copy create no
trigger, and that restoring a complete pair creates the circled-question SVG.
## Reviewed without changes
- Mouse hover timing, keyboard focus, touch click and the second-Escape dialog path.
- `aria-describedby` only while open; the visible bubble stays hidden from the
accessibility tree to avoid duplicate announcements.
- Exclusive transient-surface ownership with the colour/opacity picker and toast.
- Popover API path and dialog-owned fallback portal.
- Cached dialog scroll-listener cleanup and disconnect cleanup.
- Visual viewport placement, flipping and edge clamping.
- Existing call sites and RU/EN localization parity.
## Verification policy
Per project policy, no tests were run during this local edit. Static type checking,
syntax checking and whitespace validation are recorded in the handoff; the updated
targeted smoke is intended for the next prerelease gate.
+180
View File
@@ -0,0 +1,180 @@
# Код-ревью issue #94 — универсальное «Переключить состояние»
- **Дата:** 2026-08-12
- **Issue:** https://github.com/Matysh/houseplan-card/issues/94
- **Проверенная версия:** локальный `dev` после `v1.62.0-beta.3`, включая
незакоммиченные исправления #95–#97
- **Итог ревью до правок:** changes requested — 2 high, 4 medium, 1 minor
- **Итог после локальных правок:** замечания устранены; проверки отложены до
ближайшего pre-release по принятому правилу владельца
## 1. Охват
Проверены:
1. нормативный алгоритм и acceptance criteria в
`docs/specs/094-universal-state-toggle.md`;
2. pure resolver `src/device-toggle.ts`;
3. target selection через exact binding, device role и `controls`;
4. capability/security/service guards;
5. dialog projection, hint, lossless Save и preview;
6. обычный click, confirmation re-resolve и обработка ошибок;
7. общий cover target для действия и presentation;
8. backend schema и import/export round-trip;
9. unit/smoke-матрица и архитектурная документация;
10. совместимость с визуальной непрерывностью #73 и локальными правками
#95–#97.
## 2. Найденные и исправленные замечания
### CR94-01 — High: domain service ошибочно считался capability конкретной entity
**Было:** `POWER_DOMAINS` разрешал `climate`, `water_heater`, `siren` и
`camera`, если нужный service существовал на уровне domain. Но HA публикует
services для всего domain; неподдерживающая их конкретная entity всё равно
оставалась «исполняемой» в hint, а вызов затем отклонялся Home Assistant.
Это прямо противоречило §9.1 и mutation gate 6 ТЗ. Home Assistant Core
подтверждает entity-level guards:
- Climate `TURN_OFF=128`, `TURN_ON=256`:
https://github.com/home-assistant/core/blob/dev/homeassistant/components/climate/const.py
- Water heater `ON_OFF=8`:
https://github.com/home-assistant/core/blob/dev/homeassistant/components/water_heater/__init__.py
- Siren `TURN_ON=1`, `TURN_OFF=2`:
https://github.com/home-assistant/core/blob/dev/homeassistant/components/siren/const.py
- Camera `ON_OFF=1`:
https://github.com/home-assistant/core/blob/dev/homeassistant/components/camera/__init__.py
**Исправлено:** введён декларативный `POWER_ADAPTERS` с state semantics,
unknown policy и точными feature masks. Feature-gated entity теперь получает
команду только при наличии требуемых bits; service catalog остаётся вторым
guard. Media player и legacy vacuum включены в тот же реестр.
**Покрытие:** параметрические unit-матрицы для всех базовых power adapters и
для climate, media player, siren, water heater, camera, legacy vacuum — как
разрешённые, так и запрещённые/missing-feature варианты; отдельная матрица
`unknown` проверяет полный/неполный capability mask.
### CR94-02 — High: click мог использовать target из сохранённого визуального frame
**Было:** #73 намеренно может некоторое время показывать последний цельный
`_renderDevices` snapshot, но `_clickDevice(ev, d)` разрешал action прямо по
переданному `d`. Если binding/controls изменились до атомарной смены frame,
нажатие без confirmation могло вызвать прежнюю цель. Confirmation уже делал
повторное разрешение, обычный click — нет.
**Исправлено:** в View действие сначала находит текущий `DevItem` в
`this._devices` по стабильному marker id. Action, binding, controls и command
разрешаются только из него; исчезнувший marker даёт no-op. Локальная
House Plan info-card по-прежнему может использовать видимый snapshot — это
безопасная read-only поверхность и намеренный контракт исправления #96.
**Покрытие:** smoke сохраняет старый `DevItem`, меняет controls, перестраивает
live devices и проверяет, что click вызывает только новую группу.
### CR94-03 — Medium: неизвестный persisted action расходил UI и runtime
**Было:** неизвестный token на light проецировался как default `toggle`, тогда
как `toggleOriginOf()` правильно не признавал его toggle-origin. Селектор мог
показать «Переключить состояние», hint оставался пустым, а click был no-op.
**Исправлено:** light default применяется только к действительно отсутствующему
token (`null`, `undefined`, пустая legacy-строка). Неизвестное значение fail-
closed проецируется в локальную карточку; backend по-прежнему отклоняет его при
записи.
### CR94-04 — Medium: legacy cover терял identity после disable в HA
**Было:** legacy `tap_action: cover` искал cover только в active
`device.entities`, если рядом оставался хотя бы один активный sibling. После
disable cover в HA старое явное намерение превращалось в анонимный no-target и
presentation переставал знать прежнюю cover entity.
**Исправлено:** legacy-cover origin сначала сохраняет приоритет активной cover,
а при её отсутствии ищет историческую цель в `allEntities`. Общий resolver
возвращает `ha-disabled` и сохраняет тот же cover identity для hint/presentation,
но более ранняя disabled registry row не может заслонить рабочую cover. Новый
device-role toggle по-прежнему исключает disabled rows.
### CR94-05 — Medium: пустой service catalog считался поддержкой всех services
**Было:** отсутствие/пустой `hass.services` давало optimistic `true` для любого
service. Это нарушало runtime guard из ТЗ и позволяло построить команду без
доказательства её существования.
**Исправлено:** отсутствующий catalog/domain/service теперь означает
`unsupported`. После появления актуального HA snapshot resolver автоматически
пересчитывает hint и command. Синтетический HA в `demo/srv/demo.html` теперь
публикует явный service catalog, поэтому smoke-среда проверяет тот же fail-closed
контракт и не создаёт ложные no-op.
### CR94-06 — Medium: device binding не выбирал первую действительно поддерживаемую entity роли
**Было:** resolver выбирал первую entity «подходящего domain», а затем мог
остановиться на `unsupported`, хотя следующая равноправная entity той же
functional role имела требуемую capability. Это не соответствовало формулировке
§8.1 «первая поддерживаемая entity».
**Исправлено:** проверка идёт по уже выбранной shared functional role.
Capability-unsupported peer можно пропустить только внутри неё; missing,
unavailable и secure identity сохраняются без retarget. Config/diagnostic
switch более слабой роли по-прежнему никогда не подставляется.
### CR94-07 — Minor: статус ТЗ оставался «готово к реализации»
**Исправлено:** ТЗ, specs index, архитектура, STATUS, TESTING и RU/EN changelog
актуализированы под опубликованную beta.3 и этот локальный hardening pass.
## 3. Проверенные инварианты без изменений
- exact `entity:` binding не ищет sibling при unsupported/missing/unavailable;
- raw external controls владеют tap только у explicit toggle и не дают fallback
на собственную entity контроллера;
- passive forced-light marker сохраняет единственное документированное driver-
исключение и дедупликацию;
- partial group вызывает только отображённое доступное подмножество;
- any-on/all-off group semantics соответствует ТЗ;
- lock, alarm и cover classes `garage`/`door`/`gate` остаются secure no-op;
- cover/valve open/close/stop используют одновременно feature bit и service;
- confirmation сравнивает target set, а направление намеренно пересчитывается
по текущему state;
- legacy `cover` и отсутствующий default-light action сохраняются lossless до
явного изменения select;
- backend принимает текущие actions и legacy `cover`, неизвестные tokens
отклоняет; import/export сохраняет action-поля без преобразования;
- отдельного `cover` в текущем UI нет;
- right-click, long press, touch/pinch и confirmation UX этим проходом не
менялись.
## 4. Изменённые файлы
- `src/device-toggle.ts`
- `src/houseplan-card.ts`
- `test/device-toggle.test.mjs`
- `demo/smoke_controls.mjs`
- `demo/srv/demo.html`
- `docs/specs/094-universal-state-toggle.md`
- `docs/specs/README.md`
- `docs/ARCHITECTURE.md`
- `docs/STATUS.md`
- `docs/TESTING.md`
- `docs/CHANGELOG.md`
- `docs/CHANGELOG.ru.md`
## 5. Проверка
Локально выполнены только read-only/static проверки ревью:
- `git diff --check`;
- `npm run typecheck`;
- `node --check test/device-toggle.test.mjs`;
- `node --check demo/smoke_controls.mjs`;
- поиск всех consumers `resolveToggleIntent`, `projectedTapAction`,
`toggleCoverEntity`, `sameToggleCommandTargets`;
- сверка backend schema/import-export;
- сверка capability flags с официальным Home Assistant Core.
Unit, browser smoke, backend tests, build и generated bundles **не запускались**
по правилу проекта: локальные правки делаются без тестов, минимальный целевой
прогон выполняется при следующем pre-release.
+19 -6
View File
@@ -278,12 +278,15 @@ Switch to the **Devices** tab to arrange icons: drag them with the mouse, click
### Tap actions: control devices from the plan
By default a tap on an icon opens its info card. In the card settings you can switch
**Tap on a device** to *Toggle* — a tap then switches lights, sockets, fans and
humidifiers directly on the plan (wall-tablet style). For safety, a card-wide toggle
never affects locks, alarms, covers or valves; you can consciously enable toggle for a
specific device (except locks and alarms — those never toggle from the plan) in its
edit dialog. A **long press** always opens the info card.
By default a tap on an icon opens its info card. A device can instead use the
universal **Toggle state** action. Its editor shows the exact entity or configured
group, the current state and what the next tap will do; when nothing can be toggled,
it says so and the tap is a quiet no-op rather than an unexpected info-card fallback.
Lights keep their convenient toggle default. Covers and valves use open/close/stop
semantics automatically, while locks, alarm panels and secure garage/door/gate covers
remain blocked. An exact entity binding never falls through to a sibling switch, and
temporarily unavailable group members are skipped without erasing the configuration.
A **long press** still opens the info card and right-click still opens HA more-info.
### Icon rules
@@ -299,6 +302,16 @@ You can also place a **single entity** (not just a whole device): start typing i
Not everything has to be left to the automation. With the **+** button in the header you can place any device, group or a **virtual point** on the plan (for example, an "Inlet valve" that does not exist as a device). Set a name, icon, model, link, description and, if you wish, attach a **PDF manual**.
To represent a dumb physical lamp controlled by a smart relay, place a virtual
point where the lamp really is and set **Light source → Always**. Manual colour,
brightness and radius stay available even though the point has no HA entity.
Then open the relay and add that plan source under **Controls other light
sources**. The relay continues to show the aggregate working state, while Glow,
room fill and statistics belong to the lamp's position. An unlinked passive
Always source is deliberately constant-on. With several own `light.*`/`switch.*`
entities, Always also offers a leading-entity selector; a missing saved choice
is warned about and retained while a deterministic fallback is used.
The same dialog controls how the device looks on the plan. **Display** switches between the
icon badge, an animated **presence ripple** (pulsing rings while the entity is active, a faint
dot when idle — great for motion sensors) or both, with a per-device ring colour and size. The
+503
View File
@@ -0,0 +1,503 @@
# Ревью ТЗ `docs/specs/089-isometric-view-stage1.md`
Дата ревью: 2026-08-11
Issue: [#89](https://github.com/Matysh/houseplan-card/issues/89)
Проверенная версия ТЗ: локальный `dev`, после `v1.62.0-beta.1`, SHA `2cf5c27`
## Итог
Направление выбрано правильно: фиксированная SVG-проекция, отсутствие новой
модели данных, каноническая геометрия стен, плоские редакторы, скрытая поставка
через Labs и обязательные golden/performance gates хорошо соответствуют
текущей архитектуре House Plan.
Однако статус **«готово к реализации» пока преждевременен**. В ТЗ остаются
восемь блокирующих неоднозначностей. Главная из них — документ описывает
проекцию точек, но не определяет переход между тремя реально существующими
системами координат и не задаёт новый контракт viewport/frame. Если начать
реализацию буквально по текущему тексту, наиболее вероятный результат —
прыжок масштаба при переключении, рассинхронизация HTML-маркеров и SVG, неверный
warm-remount либо двойное обратное преобразование pointer events.
Рекомендация: внести блокеры B1–B8 и существенные замечания M1–M8 в ТЗ, после
чего документ можно переводить в `approved`. Переписывать продуктовую часть
или менять выбранный renderer не требуется.
## Что уже зафиксировано хорошо
1. Labs не меняет backend, schema/config/layout и не попадает в backup.
2. Плоский вид остаётся default и fallback; редакторы остаются плоскими.
3. CSS 3D, WebGL и многократное клонирование SVG явно запрещены.
4. Источник wall geometry — `wallBodiesGeometry()`, а не новый параллельный
контур.
5. Проёмы должны быть настоящими разрывами masonry geometry.
6. Glow сохраняет один регион на источник и один blur на слой по `LIGHT.md`.
7. Кэш не должен зависеть только от `_cfgEpoch`.
8. У stage 1 нет публичного обещания и пользовательской миграции.
---
## Блокирующие замечания
### B1 — Не определены системы координат, pivot и projected frame
**Где:** §4, §7, §8, AC 8.
**Критичность:** blocker.
`projectPoint(p, z, cam)` и `unprojectPoint(screen, cam)` недостаточны для
существующего renderer. Сейчас House Plan различает как минимум:
- plan/model units (`room`, `wall`, `marker`);
- координаты SVG scene/viewBox (`_view`);
- client pixels внутри `.stage` (`_screenToVb()`).
В объёмном виде plan units и scene units перестают совпадать. Кроме того,
`IsoCamera` не содержит pivot/origin и масштаба оси Z. Проекция вокруг `(0, 0)`
сместит план при смене пространства, а старый `_baseVb()` не включает поднятые
верхние грани и начнёт обрезать стены.
**Что добавить в ТЗ:**
```ts
type PlanPoint = readonly [number, number];
type ScenePoint = readonly [number, number];
interface IsoCamera {
rotDeg: number;
tiltDeg: number;
xyScale: number;
zScale: number;
origin: PlanPoint;
}
projectPlanPoint(p: PlanPoint, zUnits: number, cam: IsoCamera): ScenePoint;
unprojectFloorPoint(p: ScenePoint, cam: IsoCamera): PlanPoint; // только z=0
clientToScenePoint(client: readonly [number, number], stageRect: DOMRectReadOnly,
view: ViewRect): ScenePoint;
projectedFrame(input: IsoFrameInput, cam: IsoCamera): ViewRect;
```
Нормативно определить:
1. `projectPoint` возвращает **scene**, а не screen/client coordinates.
2. `unprojectFloorPoint` инвертирует только плоскость `z=0`; высотная грань не
имеет единственной plan-точки.
3. Pivot — одна фиксированная plan-space константа (рекомендуемо
`[NORM_W / 2, NORM_W / 2]`), а не центр viewport/content frame и не
положение курсора. Иначе появление far marker или переключение `_showFar`
сдвинет уже построенные стены без изменения их геометрии.
4. Wall height сначала переводится из общей константы в plan units, затем
применяется `zScale`; выбранные значения фиксируются ADR.
5. `fit`, pan clamp, home arrow, far-object hint и initial view используют
`projectedFrame`, включающий floor content и поднятые wall faces.
6. Projected frame не зависит от текущего zoom/pan и входит в geometry cache.
Без этого нельзя проверить «не меняет фокус плана скачком» и «не обрезает
объекты».
### B2 — Не задано преобразование viewport при flat ↔ iso и при входе в редактор
**Где:** §7, §10, AC 7–9.
**Критичность:** blocker.
Текущий `_view` хранит прямоугольник именно в координатах текущего SVG. Его
нельзя без преобразования перенести из flat scene в iso scene. Текущий
`_viewModeSnap` также хранит `cx/cy` в flat units. Требование «не менять zoom и
фокус» сейчас не имеет алгоритма.
**Добавить нормативный алгоритм:**
1. Перед сменой проекции получить логический центр пола:
- flat: центр `_view` уже является plan point;
- iso: центр `_view` пропустить через `unprojectFloorPoint`.
2. Построить target frame и target fit.
3. Сохранить тот же scalar zoom.
4. Спроецировать логический центр в target scene и вызвать `_applyView()` с
этим scene center.
5. Не переиспользовать raw `x/y/w/h` между видами.
6. Вход в editor выполняет тот же iso → flat переход; выход — flat → прежний
view kind. Смена пространства внутри editor сбрасывает старый snapshot по
существующему правилу.
Предпочтение вида (`flat|iso`) и viewport — разные сущности. В localStorage
пишется только предпочтение и существующий scalar zoom; raw viewport остаётся
runtime/warm state.
### B3 — ТЗ не совместимо с `docs/WARM-REMOUNT.md`
**Где:** §7 «Непрерывность», §10.
**Критичность:** blocker.
#73 переносит через `warmBoot` точный `_view`, `_viewModeSnap`, mode и
fingerprint кадра. После введения iso один и тот же `ViewRect` имеет два разных
смысла. Если новый экземпляр восстановит iso rectangle в flat mode (например,
флаг снят/истёк) либо наоборот, получится именно тот скачок/пустой кадр, который
#73 устраняет.
**Добавить:**
- warm viewport хранит `projection: 'flat'|'iso'` и `logicalCenter`;
- raw `_view` усыновляется только при совпадении space, projection и активного
Labs contract;
- при несовпадении восстанавливаются scalar zoom + logical center через
алгоритм B2, а не чужой rectangle;
- frame fingerprint включает effective projection и iso geometry fingerprint;
- выключение/expiry Labs никогда не может воскресить iso DOM из memo;
- отдельный smoke: iso → remount → тот же iso frame; iso → снять flag →
remount → корректный flat frame без veil/flash.
### B4 — Правило «все попадания через unproject» технически неверно
**Где:** §7 Pointer, §11 smoke Pointer.
**Критичность:** blocker.
SVG сам hit-тестирует элементы внутри трансформированного `<g>`. Room hover и
SVG opening symbols не нужно вручную unproject-ить: это даст двойное
преобразование. HTML marker также получает click как обычный DOM-элемент.
Кроме того, marker drag выполняется в Device editor, а по этому же ТЗ все
редакторы плоские; smoke «перетаскивание маркера в объёмном виде» противоречит
scope.
**Заменить правило на:**
- SVG/HTML interactive children используют нативный DOM/SVG hit-test;
- pan и zoom anchor работают в scene coordinates;
- `client → scene → unprojectFloor` применяется только там, где stage event
действительно должен получить plan coordinate;
- в stage 1 iso mode не создаёт/редактирует geometry и не перетаскивает
markers, поэтому editor `_svgPoint()` остаётся flat;
- тесты кликают реальные room/device/opening DOM targets и проверяют action;
отдельный unit проверяет `client → scene → floor` для будущего использования.
### B5 — Kiosk UX противоречит фактическому DOM
**Где:** §3, §7, AC 2/4.
**Критичность:** blocker.
ТЗ обещает кнопку «в режиме просмотра (и в киоске) рядом с шапкой». В текущем
kiosk вся `.hdr.kioskhide` имеет `display:none`; такой кнопки физически не
будет. Одновременно §7 говорит, что скрытая панель не должна лишить пользователя
возврата в flat.
Для скрытого stage 1 рекомендуется закрепить простой вариант:
1. Кнопка существует только в обычном View под активным Labs.
2. Kiosk читает последнее per-space предпочтение этого браузера.
3. `hp-labs=-iso` или `hp-labs=off` — обязательный аварийный путь: kiosk сразу
становится flat и не может восстановить iso из warm memo.
4. В kiosk нет новой панели/диалога stage 1.
5. Smoke покрывает загрузку kiosk с сохранённым `iso` и возврат в flat через
URL operation.
Если владельцу нужен переключатель прямо в kiosk, его надо отдельно поместить
в существующий long-press kiosk dialog; «рядом с шапкой» всё равно неверно.
### B6 — Грамматика Labs содержит противоречия и ломает комбинированный hash
**Где:** §2.2–2.4.
**Критичность:** blocker.
Не определено:
- кто сильнее при одновременных `?hp-labs=` и `#hp-labs=`;
- является URL полным replacement или операциями над storage;
- что делает `iso,-iso`, `off,iso`, повторный параметр;
- §2.2 требует не удалять параметр из URL, а §2.3 говорит, что `off` «очищает
и то, и другое»;
- как `#space=x&hp-labs=iso` сохраняет существующий deep link;
- что происходит при `history.back()`/`popstate`.
**Предлагаемый точный контракт:**
1. База — валидный набор из storage.
2. Query operations применяются слева направо, затем hash operations слева
направо; hash сильнее, потому что именно он реактивен внутри Lovelace.
3. `id` добавляет, `-id` удаляет, `off` очищает набор в этой позиции; следующие
токены снова могут добавлять.
4. Повторные `hp-labs` обрабатываются в порядке появления.
5. Если в URL был хотя бы один известный operation или `off`, итог пишется в
storage. Неизвестные значения сами по себе storage не переписывают.
6. URL никогда не переписывается механизмом Labs. Из §2.3 убрать слова об
очистке URL: `off` очищает **effective set и storage**, но остаётся видимым.
7. Hash разбирается общим helper вместе с `space`; оба порядка параметров и
percent-encoding тестируются. `_hashSpace()` не остаётся вторым regex parser.
8. `hashchange` реактивен; `popstate` перечитывает query/hash, если URL реально
сменился без reload.
### B7 — Не определена топология side faces и смысл «нет торцов в проёме»
**Где:** §5, AC 3/5/6.
**Критичность:** blocker.
`wallBodiesGeometry().geom` — MultiPolygon с внешними и внутренними rings, уже
после union, junction patches и opening cuts. «Граничные рёбра» недостаточно:
нужно определить winding, outward normal, holes, culling и порядок отрисовки.
Фраза «без торцов внутри проёма» двусмысленна. При полном разрыве стены
вертикальные jamb faces по краям проёма являются корректной частью объёма;
запретить их — значит получить визуально обрезанную плёнку вместо стены.
**Добавить:**
- faces строятся непосредственно из rings канонического MultiPolygon после
union/cuts; исходные room edges для extrusion не используются;
- winding нормализуется один раз, outward normal учитывает outer/hole ring;
- face видима по знаку dot product normal и фиксированного view direction;
- для фиксированной камеры задаётся детерминированный stable depth order;
- opening slot создаёт две exposed jamb faces по концам разрыва — они нужны;
- запрещены face/полоса, пересекающая сам gap, и cap на floor тоннеля;
- на stage 1 дверь, окно и ворота являются full-height gaps осознанно, так как
модель не хранит высоту подоконника;
- opening никогда не вырезает coincident partition/column — сохраняется
текущий порядок union extras после room opening cuts;
- top face использует whole geometry с `fill-rule:evenodd`;
- unit fixtures включают outer ring, hole, multipolygon, T/X join, opening у
угла и coincident independent body.
### B8 — Fallback может зациклить exception и оставить кнопку во лжи
**Где:** §9, AC 10.
**Критичность:** blocker.
«Вернуться в flat на этом кадре» не отвечает на вопросы: будет ли следующий
Lit render снова падать, что показывает `aria-pressed`, сохраняется ли `iso` в
localStorage и когда разрешён retry.
**Добавить state machine:**
- `desiredView` — сохранённое предпочтение;
- `effectiveView` — реально нарисованный `flat|iso`;
- исключение в pure geometry/iso template ловится на границе
`renderIsoScene()`, для `(space, geometryFingerprint)` ставится session latch;
- при latch effective view = flat, iso geometry больше не вызывается на каждом
HA state update;
- конфиг/layout и сохранённое предпочтение не меняются автоматически;
- кнопка отражает `effectiveView` (`aria-pressed=false`), явное повторное
нажатие или новый geometry fingerprint очищает latch и делает один retry;
- console error содержит issue, space, fingerprint и короткий reason, но без
config/entity payload; один раз на latch;
- ошибка HTML overlay projection также входит в эту границу, иначе получится
«стены flat, markers iso».
---
## Существенные замечания
### M1 — Spike ADR должен фиксировать больше, чем выбор renderer
Сейчас D6 требует ADR, но его обязательные решения не перечислены. ADR должен
закрыть до основной реализации:
- формулу и pivot проекции;
- camera constants, wall-height units и zScale;
- top/side fill, stroke, hatch и side shading в light/dark theme;
- ring normalization, face visibility и depth order;
- z-order floor → Glow/sun/decor → faces/top → screen-facing HTML overlays;
- осознанное правило stage 1: markers/room cards всегда выше wall faces и не
получают геометрическую occlusion;
- projected frame и flat↔iso viewport conversion;
- результат проверки SVG filter/clip/mix-blend на Chromium, Firefox, WebKit;
- причины отказа от проигравшего прототипа.
До ADR issue остаётся в статусе spike/implementation-prep, не renderer-ready.
### M2 — Fingerprint перечисляет не все входы iso geometry
В §8 добавить как минимум:
- `room_drafts` и их segment thickness;
- нормализованные `openCuts`/virtual intervals;
- canonical opening cuts;
- partitions и columns с shape/angle/diameter;
- `cell_cm`, grid pitch, coordinate scale/NORM;
- camera constants и wall-height constant;
- версию алгоритма projection/faces.
Массивы должны сериализоваться детерминированно, числа — нормализоваться как в
существующих geometry fingerprints. Display state (`hover`, HA states,
`show_borders`) не должен инвалидировать geometry cache. `show_borders:false`
просто не рисует cached top/sides, но physics остаётся прежней.
### M3 — `since`/`expires` требуют точной version semantics
В проекте нет зависимости `semver`; строкового сравнения допускать нельзя.
Зафиксировать parser `major.minor.patch[-prerelease]`, fail-closed для
некорректной registry entry и инвариант `since < expires`.
Рекомендуемое продуктовое правило: сравнивать numeric core, поэтому
`1.65.0-beta.1` уже достигает `expires: 1.65.0` и не тащит мёртвый флаг в новый
release cycle. Добавить тесты `1.64.9`, `1.65.0-beta.1`, `1.65.0`, malformed.
### M4 — Не определён runtime owner механизма Labs
Нужно указать, что availability flags глобальны для загруженного JS-модуля, а
effective `flat|iso` остаётся состоянием конкретной карточки/пространства.
Один module-level resolver/subscription не должен создавать по listener на
каждый render.
`window.__hpLabs` должен иметь нормативную форму, например frozen sorted array:
```ts
Object.freeze(['iso'])
```
При изменении URL property заменяется новым frozen array, все подключённые
карточки получают update. Нельзя отдавать внутренний mutable `Set`.
### M5 — Scope `houseplan-space-card` не указан
В репозитории есть второй renderer: `src/space-card.ts` + `src/space-render.ts`.
Текущий текст можно прочитать как требование объёмного вида для обеих карточек.
Рекомендация для stage 1: явно записать, что `houseplan-space-card` остаётся
flat и Labs `iso` на него не влияет. Его поддержка — отдельный будущий scope.
Иначе придётся сразу заводить вторую композицию сцены, что противоречит цели
скрытого первого этапа.
### M6 — Performance contract не совпадает с существующей инфраструктурой
`compare.mjs` использует profile-specific budgets, noise allowance,
relative+absolute thresholds и exact same runner. Просто потребовать «≤20% по
трём полям» недостаточно; `longTask.maxSingleMs` особенно нестабилен около
нуля, а `modelReadyMs` почти не измеряет переключение renderer.
Добавить отдельный профиль `large-house-isometric-v1`:
- текущий benchmark harness запускает candidate bundle с `hp-labs=iso` и
переключает view; тот же harness запускает base bundle, который игнорирует
неизвестный flag и остаётся flat;
- profile id в обоих reports одинаков, runtime/browser/fingerprint проверяются
существующим fail-closed контрактом;
- отдельный reviewed budget JSON задаёт 20% relative allowance **плюс**
абсолютный noise allowance;
- обязательные метрики: first stable iso frame, view toggle, pan/zoom,
HA-state update, space switch, long-task count/total/max, heap growth,
iso-cache entries/growth, rendered devices;
- candidate-only prerelease smoke получает абсолютные ceilings;
- перед завершением этапа выполняется exact-SHA full performance workflow, а
не локальное сравнение с другой машиной.
Фразу «flat не должен подорожать вообще» заменить на проверяемое: при
выключенном флаге iso geometry/cache/DOM отсутствуют и нет дополнительного
прохода по room/device collections; timing находится внутри noise allowance.
### M7 — Golden coverage слишком мала для новой системы координат
Две картинки не покрывают заявленный scope. Минимальная матрица stage 1:
1. desktop dark: mixed walls + openings + Glow/sun + devices;
2. desktop light: theme/shading/filter parity;
3. mobile portrait или узкий kiosk: fit, marker/label alignment, no clipping;
4. `show_borders:false`: стены не нарисованы, room fill/Glow сохраняются;
5. remount/toggle sequence проверяется smoke, а финальный кадр — golden при
необходимости.
Существующие flat baselines действительно не принимаются заново, если diff не
нулевой. Новые baselines принимаются только из полного Linux CI artifact по
действующему HP-QA-01 контракту.
### M8 — A11y toggle contract неполон
Для кнопки добавить:
- `aria-pressed="true|false"` по `effectiveView`;
- стабильный accessible name «Объёмный вид» / `Volumetric view`;
- focus остаётся на той же кнопке после переключения;
- active visual state не кодируется только цветом;
- DOM/tab order устройств и room actions совпадает с flat;
- stage 1 не добавляет projection animation: swap атомарный. Если анимация
будет добавлена через #82, `prefers-reduced-motion` делает её мгновенной.
---
## Замечания к тестам и формулировкам
### T1 — «innerHTML до и после ветки» нужно сделать воспроизводимым
Обычный тест не может сравнить текущий commit с кодом до ветки. Разделить
контракт:
- в одном candidate build сравнить no-param и unknown-param: нет iso nodes,
нет дополнительных WS/HTTP и config/layout writes;
- golden гарантирует нулевой diff существующих flat scenes между revisions;
- unit spy подтверждает, что iso geometry builder не вызывался;
- чтение собственного Labs localStorage не считать сетевым/сторным изменением,
но при отсутствии URL оно не должно переписывать ключ.
### T2 — Мутанты должны быть исполнимыми
Пункт «отдельная формула проекции HTML» нельзя надёжно поймать текстовым
поиском. Нормативный mutant: внести controlled offset только в overlay mapping;
smoke должен увидеть расхождение anchor больше 1 CSS px. Для cache mutant тест
меняет geometry in-place без `_cfgEpoch`; iso faces обязаны обновиться. Для
layer-copy mutant тест проверяет upper bound DOM face count как `O(E)`.
Для каждого из пяти mutants сохранить команду/patch id и имя краснеющего теста
в PR/issue evidence; ручной тезис «проверено» недостаточен.
### T3 — Opening wording
В AC 6 заменить «без швов и торцов внутри проёма» на:
> Проём является full-height gap. Внутри gap нет wall top/side полосы;
> вертикальные jamb faces на двух границах masonry разрыва являются ожидаемыми.
Это снимает конфликт с B7 и делает golden однозначным.
### T4 — Первый запуск и сохранённое предпочтение
В §9/§10 уточнить:
- без записи `houseplan_card_view_v1[space]` effective view всегда flat, даже
при активном Labs;
- toggle в обычном View пишет `flat|iso` per space;
- при неактивном/expired flag сохранённое `iso` игнорируется, не меняет DOM и
не попадает в warm memo;
- вход/выход editor не перезаписывает предпочтение;
- fallback B8 не перезаписывает предпочтение автоматически.
### T5 — Backlog — канонический источник
Issue #89 сейчас говорит «черновик продуктового и технического решения», тогда
как файл говорит «готово к реализации». По `AGENTS.md` Issue/Project являются
каноническими. После принятия новой редакции:
- добавить в body issue ссылку на stage 1 spec как нормативную;
- синхронизировать scope/acceptance criteria issue с утверждённой редакцией;
- оставить Project `Todo` до фактического начала, затем перевести в
`In progress`;
- не закрывать #89 после одного spike ADR: закрытие только после всех AC этапа.
---
## Рекомендуемая новая структура нормативных разделов
Чтобы не раздувать основной текст, достаточно добавить четыре подраздела:
1. **§4.4 Coordinate spaces and viewport** — B1, B2.
2. **§5.1 Wall-face topology and visual tokens** — B7, M1.
3. **§7.1 Native hit testing and warm continuity** — B3, B4, B5.
4. **§2.2.1 Labs operation precedence and version lifecycle** — B6, M3, M4.
Остальные замечания можно встроить в §8–§13.
## Definition of Ready после следующей итерации
ТЗ можно считать готовым к реализации, когда:
- [ ] определены plan/scene/client spaces, camera pivot/zScale и projected frame;
- [ ] записан алгоритм flat↔iso viewport conversion;
- [ ] обновлён warm-remount contract;
- [ ] исправлено pointer rule и убран iso marker-drag smoke;
- [ ] выбран однозначный kiosk escape contract;
- [ ] полностью определена Labs grammar и expiry semantics;
- [ ] определены ring/face/jamb/depth rules;
- [ ] определена fallback state machine;
- [ ] ADR имеет обязательный список решений;
- [ ] fingerprint содержит все входы;
- [ ] указан scope `houseplan-space-card`;
- [ ] создан исполнимый performance profile/budget plan;
- [ ] расширена golden/a11y/mutant matrix;
- [ ] issue #89 ссылается на утверждённое ТЗ и не противоречит ему.
После этого оценка stage 1 остаётся **L/XL с высоким риском**, но работа станет
декомпозируемой и проверяемой; менять выбранную продуктовую концепцию не нужно.
+64 -25
View File
@@ -1,6 +1,7 @@
"""House Plan: server-side house plan configuration + Lovelace card serving."""
from __future__ import annotations
import inspect
import logging
from datetime import timedelta
from pathlib import Path
@@ -23,7 +24,7 @@ from .const import (
from .geometry_migration import migrate_config, migrate_layout, pending_from_config
from .plans import collect_attachments, collect_plans, sweep_upload_temps
from .repairs import async_check_plan_files
from .store import HouseplanConfigEntry, create_data
from .store import HouseplanConfigEntry, async_save_layout_state, create_data
_LOGGER = logging.getLogger(__name__)
@@ -32,16 +33,27 @@ async def async_setup(hass: HomeAssistant, config) -> bool:
"""Register global handlers (survive config-entry reloads): WS commands, HTTP view."""
hass.data.setdefault(DOMAIN, {})
hp_ws.async_register(hass)
from .http_api import HouseplanContentView, HouseplanUploadView
from .http_api import HouseplanContentView, HouseplanImportPreviewView, HouseplanUploadView
hass.http.register_view(HouseplanUploadView())
hass.http.register_view(HouseplanContentView())
hass.http.register_view(HouseplanImportPreviewView())
return True
async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) -> bool:
"""Config entry: stores in runtime_data, static paths, card auto-registration."""
data = create_data(hass)
# Home Assistant's installation id never leaves the instance. Exports
# carry only a salted SHA-256 fingerprint so same-instance internal files
# can be distinguished from cross-instance references.
try:
from homeassistant.helpers import instance_id as ha_instance_id
value = ha_instance_id.async_get(hass)
data.instance_id = str(await value if inspect.isawaitable(value) else value)
except Exception: # noqa: BLE001 - old HA/test harness fallback
data.instance_id = str(entry.entry_id)
# test-before-setup: storage must be readable, otherwise retry later
try:
await data.store.async_load()
@@ -126,10 +138,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
cfg = stored.get("config")
lay_stored = await data.store.async_load() or {}
layout = lay_stored.get("layout") or {}
lay_meta = {
k: v for k, v in lay_stored.items()
if k not in ("layout", "rev", "geom_pending")
}
pending = {
str(k): v for k, v in (lay_stored.get("geom_pending") or {}).items()
}
@@ -137,15 +145,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
if merged:
lay_rev = int(lay_stored.get("rev", 0))
if merged != pending: # 1. the durable intent, before anything moves
await data.store.async_save(
{**lay_meta, "layout": layout, "rev": lay_rev, "geom_pending": merged}
await async_save_layout_state(
data, lay_stored, layout, lay_rev,
metadata={"geom_pending": merged}, remove=("geom_pending",),
)
rev = int(stored.get("rev", 0))
if cfg and migrate_config(cfg): # 2. the config half
rev += 1
await data.config_store.async_save({"config": cfg, "rev": rev})
migrate_layout(layout, merged) # 3. the layout half + intent cleared
await data.store.async_save({**lay_meta, "layout": layout, "rev": lay_rev + 1})
await async_save_layout_state(
data, lay_stored, layout, lay_rev + 1, remove=("geom_pending",)
)
_LOGGER.info(
"House Plan: migrated %s space(s) to the square canvas", len(merged)
)
@@ -157,6 +168,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
# config and layout store writes. The target was persisted before either
# visible half changed, so setup can always converge on the requested pair.
optimize_revs: tuple[int, int] | None = None
recovered_import = False
async with data.write_lock:
stored = await data.config_store.async_load() or {}
lay_stored = await data.store.async_load() or {}
@@ -167,30 +179,57 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
target_layout = pending["layout"]
config_rev = int(stored.get("rev", 0))
layout_rev = int(lay_stored.get("rev", 0))
if stored.get("config") != target_config:
config_rev += 1
target_config_rev = int(pending.get(
"config_rev", config_rev + (stored.get("config") != target_config)
))
target_layout_rev = int(pending.get(
"layout_rev", layout_rev + (lay_stored.get("layout", {}) != target_layout)
))
if stored.get("config") != target_config or config_rev < target_config_rev:
config_rev = max(config_rev, target_config_rev)
await data.config_store.async_save({
"config": target_config,
"rev": config_rev,
})
if lay_stored.get("layout", {}) != target_layout:
layout_rev += 1
layout_meta = {
k: v for k, v in lay_stored.items()
if k not in ("layout", "rev", "optimize_pending", "optimize_backup")
}
if not pending.get("clear_backup") and "optimize_backup" in lay_stored:
layout_meta["optimize_backup"] = lay_stored["optimize_backup"]
await data.store.async_save({
**layout_meta,
"layout": target_layout,
"rev": layout_rev,
})
if lay_stored.get("layout", {}) != target_layout or layout_rev < target_layout_rev:
layout_rev = max(layout_rev, target_layout_rev)
exact_metadata = pending.get("final_metadata")
replace_metadata = isinstance(exact_metadata, dict)
metadata = dict(exact_metadata) if replace_metadata else None
if not replace_metadata and not pending.get("clear_backup") \
and "optimize_backup" in lay_stored:
metadata = {"optimize_backup": lay_stored["optimize_backup"]}
remove_metadata = ["optimize_pending", "optimize_backup"]
if pending.get("clear_backup"):
# A recovered whole-plan undo replaces the complete layout;
# a point-wise repair snapshot from the replaced layout must
# not survive and later restore coordinates into the new pair.
remove_metadata.append("repair_backup")
await async_save_layout_state(
data,
lay_stored,
target_layout,
layout_rev,
metadata=metadata,
remove=tuple(remove_metadata),
replace_metadata=replace_metadata,
)
optimize_revs = (config_rev, layout_rev)
_LOGGER.warning("House Plan: completed an interrupted plan optimization")
recovered_import = str(pending.get("kind") or "").startswith("import")
_LOGGER.warning(
"House Plan: completed an interrupted %s",
str(pending.get("kind") or "plan optimization").replace("_", " "),
)
if optimize_revs is not None:
hass.bus.async_fire("houseplan_config_updated", {"rev": optimize_revs[0]})
hass.bus.async_fire("houseplan_layout_updated", {"rev": optimize_revs[1]})
if recovered_import:
await recorder.async_refresh()
current = (await data.config_store.async_load() or {}).get("config") or {}
live_ids = {str(marker.get("id")) for marker in current.get("markers") or []}
for marker_id in list(recorder.book.data):
if marker_id not in live_ids:
await recorder.async_delete(marker_id)
await async_check_plan_files(hass, entry)
+15 -1
View File
@@ -45,7 +45,21 @@ PLAN_ORPHAN_TTL_S = 3600
SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.61.0-beta.6"
VERSION = "1.62.0-beta.7"
# Portable backup format. This is deliberately independent from the Home
# Assistant Store version above: storage migrations and files exported by a
# user have different compatibility lifecycles.
PLAN_MODEL_VERSION = 6
EXPORT_VERSION = 1
MAX_EXPORT_BYTES = 8 * 1024 * 1024
IMPORT_PREVIEW_TTL_S = 10 * 60
MAX_IMPORT_PREVIEWS_PER_USER = 3
# Parsed documents are larger than their wire representation. Keep the
# original three-preview memory ceiling global as well as per user so turning
# off the admin-only policy cannot multiply it by the number of household
# accounts.
MAX_IMPORT_PREVIEWS_TOTAL = 3
DEFAULT_CONFIG: dict = {
"spaces": [],
File diff suppressed because one or more lines are too long
+65 -1
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
import logging
import os
import tempfile
from functools import partial
from pathlib import Path
from aiohttp import web
@@ -22,10 +23,13 @@ from homeassistant.core import HomeAssistant
from .const import (
CONF_ADMIN_ONLY, CONTENT_URL, FILES_DIR, FILES_URL, MAX_FILES_BYTES,
MAX_FILES_COUNT, PLANS_DIR,
MAX_FILES_COUNT, MAX_EXPORT_BYTES, PLANS_DIR,
)
from .auth import may_write
from .import_export import ImportFailure, create_preview
from .plans import TMP_PREFIX, QuotaError, check_quota, reserve_filename
from .registry_snapshot import import_registry_snapshot
from .store import get_data
from .validation import (
FILE_EXTENSIONS,
MAX_FILE_BYTES,
@@ -52,6 +56,66 @@ _MIME = {
}
class HouseplanImportPreviewView(HomeAssistantView):
"""Upload a bounded JSON backup and return a server-side preview token."""
url = "/api/houseplan/import/preview"
name = "api:houseplan:import-preview"
requires_auth = True
async def post(self, request: web.Request) -> web.Response:
hass: HomeAssistant = request.app[KEY_HASS]
user = request.get("hass_user")
if not may_write(hass, user):
return web.json_response({"error": "unauthorized"}, status=403)
runtime = get_data(hass)
if runtime is None:
return web.json_response({"error": "not_ready"}, status=503)
policy = request.query.get("duplicate_policy", "skip")
if policy not in ("skip", "virtual"):
return web.json_response({"error": "invalid_format"}, status=400)
declared = request.content_length
if declared is not None and declared > MAX_EXPORT_BYTES:
return web.json_response({"error": "too_large"}, status=413)
blocks: list[bytes] = []
size = 0
async for block in request.content.iter_chunked(_CHUNK):
size += len(block)
if size > MAX_EXPORT_BYTES:
return web.json_response({"error": "too_large"}, status=413)
blocks.append(block)
owner_id = str(getattr(user, "id", ""))
try:
async with runtime.write_lock:
config_data = await runtime.config_store.async_load() or {}
layout_data = await runtime.store.async_load() or {}
try:
registry_snapshot = import_registry_snapshot(hass)
except Exception: # noqa: BLE001 - summary must not block a valid backup
_LOGGER.debug("House Plan import registry summary unavailable", exc_info=True)
registry_snapshot = None
result = await hass.async_add_executor_job(
partial(
create_preview,
runtime,
b"".join(blocks),
owner_id=owner_id,
duplicate_policy=policy,
current_config_data=config_data,
current_layout_data=layout_data,
config_root=Path(hass.config.path("")),
registry_snapshot=registry_snapshot,
)
)
except ImportFailure as err:
status = 413 if err.code == "too_large" else 400
return web.json_response({"error": err.code, "message": err.message}, status=status)
except Exception: # noqa: BLE001
_LOGGER.exception("House Plan import preview failed")
return web.json_response({"error": "invalid_format"}, status=400)
return web.json_response(result)
class HouseplanContentView(HomeAssistantView):
"""Authenticated read access to plans and marker files (audit B1).
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.61.0-beta.6"
"version": "1.62.0-beta.7"
}
@@ -0,0 +1,47 @@
"""Small registry projection shared by import HTTP and WebSocket previews."""
from __future__ import annotations
from typing import Any
from homeassistant.core import HomeAssistant
def import_registry_snapshot(hass: HomeAssistant) -> dict[str, set[str]]:
"""Return non-sensitive target inventory used only for preview counts."""
from homeassistant.helpers import area_registry as ar
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import entity_registry as er
entities = list(er.async_get(hass).entities.values())
active_entity: set[str] = set()
disabled_entity: set[str] = set()
entities_by_device: dict[str, list[Any]] = {}
for entry in entities:
entity_id = str(entry.entity_id)
if getattr(entry, "disabled_by", None) is None:
active_entity.add(entity_id)
else:
disabled_entity.add(entity_id)
if entry.device_id:
entities_by_device.setdefault(str(entry.device_id), []).append(entry)
# Synthetic/runtime entities may legitimately have no registry row.
active_entity.update(str(state.entity_id) for state in hass.states.async_all())
active_device: set[str] = set()
disabled_device: set[str] = set()
for entry in dr.async_get(hass).devices.values():
device_id = str(entry.id)
children = entities_by_device.get(device_id, [])
disabled = getattr(entry, "disabled_by", None) is not None or (
bool(children)
and all(getattr(child, "disabled_by", None) is not None for child in children)
)
(disabled_device if disabled else active_device).add(device_id)
return {
"active_device": active_device,
"disabled_device": disabled_device,
"active_entity": active_entity,
"disabled_entity": disabled_entity - active_entity,
"areas": {str(entry.id) for entry in ar.async_get(hass).areas.values()},
}
+59
View File
@@ -54,6 +54,11 @@ class HouseplanData:
# directly — a test that fakes a 24 h jump proves the timer fires, not that
# the work happens, and those are different claims.
sweep: Callable[[], Awaitable[None]] | None = None
# Stable HA instance id used only through a one-way export fingerprint.
instance_id: str = ""
# Parsed import candidates are short-lived, user-bound and memory-only.
# dict keeps insertion order, which lets the preview service evict oldest.
import_previews: dict[str, dict[str, Any]] = field(default_factory=dict)
HouseplanConfigEntry = ConfigEntry[HouseplanData]
@@ -79,3 +84,57 @@ def get_entry(hass: HomeAssistant) -> ConfigEntry | None:
"""The loaded config entry, or None."""
entries = hass.config_entries.async_loaded_entries(DOMAIN)
return entries[0] if entries else None
OPTIMIZE_BACKUP = "optimize_backup"
OPTIMIZE_PENDING = "optimize_pending"
def layout_store_payload(
stored: dict[str, Any],
layout: dict[str, Any],
rev: int,
*,
metadata: dict[str, Any] | None = None,
remove: tuple[str, ...] = (),
replace_metadata: bool = False,
) -> dict[str, Any]:
"""Build one layout-store write without silently dropping metadata.
Layout used to be saved by several independent dict comprehensions. Every
new metadata key therefore had to be added to every caller or was lost on
the next drag. All writers now express only the metadata they intentionally
add/remove and this helper preserves the rest.
"""
excluded = {"layout", "rev", *remove}
out = {} if replace_metadata else {
key: value for key, value in stored.items() if key not in excluded
}
if metadata:
out.update(metadata)
out["layout"] = layout
out["rev"] = rev
return out
async def async_save_layout_state(
runtime: HouseplanData,
stored: dict[str, Any],
layout: dict[str, Any],
rev: int,
*,
metadata: dict[str, Any] | None = None,
remove: tuple[str, ...] = (),
replace_metadata: bool = False,
) -> dict[str, Any]:
"""Persist layout and return the exact store document written."""
payload = layout_store_payload(
stored,
layout,
rev,
metadata=metadata,
remove=remove,
replace_metadata=replace_metadata,
)
await runtime.store.async_save(payload)
return payload
+213
View File
@@ -4,6 +4,7 @@ Kept separate so it can be covered by unit tests (only voluptuous is needed).
"""
from __future__ import annotations
from collections import Counter
import re
import voluptuous as vol
@@ -19,6 +20,195 @@ _SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
# The name length the content view will accept back in a request. Anything a
# generated name must fit inside, collision tag included (HP-1460-01).
MAX_FILENAME = 120
MARKER_CONTROL_PREFIX = "marker:"
_CONTROL_ENTITY_ID_RE = re.compile(r"^[a-z0-9_]+\.[a-z0-9_]+\Z")
class MarkerControlError(ValueError):
"""Semantic marker-link error with a stable public code."""
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
VALUE_BADGE_ATTRIBUTES = {
"current_temperature", "temperature", "current_humidity", "humidity",
"current_position", "percentage", "brightness", "volume_level",
"battery_level", "fan_speed",
}
VALUE_BADGE_POSITIONS = {"right", "bottom", "left", "top"}
VALUE_BADGE_SOURCE_KINDS = {
"entity_state", "entity_attribute", "derived_lqi", "derived_marker_state",
}
def _matching_previous_marker(
marker: dict, marker_id: str, old_by_id: dict[str, dict], old_markers: list[dict],
new_ids: set[str], consumed_old_ids: set[str], validate_all: bool,
) -> dict | None:
"""Match the previous marker across the binding-stable id rename path."""
old_marker = old_by_id.get(marker_id)
if old_marker is not None:
consumed_old_ids.add(marker_id)
return old_marker
if validate_all:
return None
binding = marker.get("binding")
matches = [
old for old in old_markers
if binding not in (None, "virtual")
and old.get("binding") == binding
and str(old.get("id")) not in new_ids
and str(old.get("id")) not in consumed_old_ids
]
if len(matches) == 1:
consumed_old_ids.add(str(matches[0].get("id")))
return matches[0]
return None
def validate_marker_value_badges(
config: dict, previous: dict | None = None, *, validate_all: bool = False
) -> None:
"""Validate only new/changed badge data; dormant future/legacy data round-trips."""
markers = config.get("markers") or []
by_id = {str(marker.get("id")): marker for marker in markers}
old_markers = (previous or {}).get("markers") or []
old_by_id = {str(marker.get("id")): marker for marker in old_markers}
new_ids = set(by_id)
consumed_old_ids: set[str] = set()
known_source_fields = {"kind", "entity_id", "attribute", "ref"}
for marker_id, marker in by_id.items():
old_marker = _matching_previous_marker(
marker, marker_id, old_by_id, old_markers, new_ids,
consumed_old_ids, validate_all,
)
badge = marker.get("value_badge")
old_badge = None if validate_all else (old_marker or {}).get("value_badge")
if not validate_all and badge == old_badge:
continue
if badge is None:
continue
if not isinstance(badge, dict):
raise MarkerControlError("invalid_value_badge", "Value badge must be an object")
if not isinstance(badge.get("enabled"), bool):
raise MarkerControlError("invalid_value_badge", "Value badge enabled must be boolean")
if badge.get("position") not in VALUE_BADGE_POSITIONS:
raise MarkerControlError("invalid_value_badge_position", "Invalid value badge position")
source = badge.get("source")
if badge["enabled"] and not isinstance(source, dict):
raise MarkerControlError("value_badge_source_required", "Enabled value badge needs a source")
if source is None:
continue
if not isinstance(source, dict) or source.get("kind") not in VALUE_BADGE_SOURCE_KINDS:
raise MarkerControlError("invalid_value_badge_source", "Invalid value badge source")
kind = source["kind"]
allowed_fields = {
"entity_state": {"kind", "entity_id"},
"entity_attribute": {"kind", "entity_id", "attribute"},
"derived_lqi": {"kind"},
"derived_marker_state": {"kind", "ref"},
}[kind]
if (known_source_fields & set(source)) - allowed_fields:
raise MarkerControlError("invalid_value_badge_source", "Inconsistent value badge source")
if kind in {"entity_state", "entity_attribute"}:
entity_id = source.get("entity_id")
if not isinstance(entity_id, str) or not _CONTROL_ENTITY_ID_RE.fullmatch(entity_id):
raise MarkerControlError("invalid_value_badge_source", "Invalid value badge entity id")
if kind == "entity_attribute":
if source.get("attribute") not in VALUE_BADGE_ATTRIBUTES:
raise MarkerControlError("invalid_value_badge_attribute", "Invalid value badge attribute")
if kind == "derived_marker_state":
ref = source.get("ref")
if not isinstance(ref, str) or not ref.startswith(MARKER_CONTROL_PREFIX) or not ref[len(MARKER_CONTROL_PREFIX):]:
raise MarkerControlError("invalid_value_badge_source", "Invalid marker value badge target")
target = by_id.get(ref[len(MARKER_CONTROL_PREFIX):])
if target is None or target.get("removed") is True:
raise MarkerControlError("value_badge_marker_missing", "Marker value badge target does not exist")
if target.get("is_light") is not True:
raise MarkerControlError("value_badge_marker_not_light", "Marker value badge target is not a forced light")
def validate_marker_controls(
config: dict, previous: dict | None = None, *, validate_all: bool = False
) -> None:
"""Validate newly introduced marker:* edges without rewriting old data.
Existing broken refs remain editable and round-trip losslessly. Imports use
validate_all because their complete candidate graph is new to this plan.
"""
markers = config.get("markers") or []
by_id = {str(marker.get("id")): marker for marker in markers}
old_markers = (previous or {}).get("markers") or []
old_by_id = {
str(marker.get("id")): marker for marker in (previous or {}).get("markers") or []
}
new_ids = set(by_id)
consumed_old_ids: set[str] = set()
graph: dict[str, list[str]] = {}
added: list[tuple[str, str]] = []
for marker_id, marker in by_id.items():
old_marker = _matching_previous_marker(
marker, marker_id, old_by_id, old_markers, new_ids,
consumed_old_ids, validate_all,
)
raw_controls = [
ref for ref in marker.get("controls") or [] if isinstance(ref, str)
]
old_controls = [] if validate_all else [
ref for ref in (old_marker or {}).get("controls") or [] if isinstance(ref, str)
]
refs = [
ref for ref in raw_controls
if isinstance(ref, str) and ref.startswith(MARKER_CONTROL_PREFIX)
]
graph[marker_id] = [ref[len(MARKER_CONTROL_PREFIX):] for ref in refs]
old_refs = [
ref for ref in old_controls
if isinstance(ref, str) and ref.startswith(MARKER_CONTROL_PREFIX)
]
remaining = list(old_refs)
for ref in refs:
if ref in remaining:
remaining.remove(ref)
else:
added.append((marker_id, ref[len(MARKER_CONTROL_PREFIX):]))
new_counts, old_counts = Counter(refs), Counter(old_refs)
if any(count > 1 and count > old_counts[ref] for ref, count in new_counts.items()):
raise MarkerControlError("duplicate_marker_control", "Duplicate marker light target")
remaining_controls = list(old_controls)
for ref in raw_controls:
if ref in remaining_controls:
remaining_controls.remove(ref)
elif not ref.startswith(MARKER_CONTROL_PREFIX) and not _CONTROL_ENTITY_ID_RE.fullmatch(ref):
raise MarkerControlError("invalid_marker_control", f"Invalid entity target: {ref}")
def reaches(start: str, wanted: str) -> bool:
stack, seen = [start], set()
while stack:
node = stack.pop()
if node == wanted:
return True
if node in seen:
continue
seen.add(node)
stack.extend(graph.get(node, []))
return False
for controller, target in added:
if not target:
raise MarkerControlError("invalid_marker_control", "Marker target id is empty")
if target == controller:
raise MarkerControlError("marker_control_self", "A marker cannot control itself")
target_marker = by_id.get(target)
if target_marker is None or target_marker.get("removed") is True:
raise MarkerControlError("marker_control_missing", f"Marker target does not exist: {target}")
if target_marker.get("is_light") is not True:
raise MarkerControlError("marker_control_not_light", f"Marker target is not a forced light: {target}")
if reaches(target, controller):
raise MarkerControlError("marker_control_cycle", "Marker light controls contain a cycle")
# ---------- sanitizers ----------
@@ -656,6 +846,29 @@ MARKER_SCHEMA = vol.Schema(
),
),
vol.Optional("is_light"): vol.Any(bool, None),
# Explicit leading entity for composite Always sources. It is kept
# literally when temporarily absent; runtime falls back without
# deleting the user's choice.
vol.Optional("light_entity"): vol.Any(
None,
vol.All(str, vol.Length(max=MAX_TEXT), vol.Match(r"^(?:light|switch)\.[a-z0-9_]+\Z")),
),
vol.Optional("value_badge"): vol.Any(
None,
vol.Schema(
{
# Required semantically for changed/new data. Optional here
# keeps old/future configs readable until the user edits it.
vol.Optional("enabled"): object,
vol.Optional("position"): object,
vol.Optional("source"): vol.Any(
None,
vol.Schema({}, extra=vol.ALLOW_EXTRA),
),
},
extra=vol.ALLOW_EXTRA,
),
),
# climate current_temperature: badge + room-average vote (off unless True)
vol.Optional("use_climate_temp"): vol.Any(bool, None),
vol.Optional("room_id"): vol.Any(str, None),
+470 -80
View File
@@ -8,6 +8,8 @@ import binascii
import json
import secrets
import time
from datetime import UTC, datetime
from functools import partial
from pathlib import Path
from typing import Any
@@ -24,25 +26,38 @@ from .const import (
PLANS_DIR, PLANS_URL,
)
from .auth import may_write
from .import_export import (
ImportFailure,
create_export,
get_candidate,
live_layout,
prepare_apply,
revalidate_candidate,
)
from .plans import (
QuotaError, check_quota, collect_attachments, collect_plans, is_plan_file,
plan_basename, plan_refs, reserve_filename,
)
from .store import HouseplanData, get_data, get_entry
from .store import (
OPTIMIZE_BACKUP as _OPTIMIZE_BACKUP,
OPTIMIZE_PENDING as _OPTIMIZE_PENDING,
HouseplanData,
async_save_layout_state,
get_data,
get_entry,
)
from .registry_snapshot import import_registry_snapshot
from .validation import (
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES,
PLAN_EXTENSIONS, POS_SCHEMA, sanitize_filename, valid_space_id,
PLAN_EXTENSIONS, POS_SCHEMA, MarkerControlError, sanitize_filename,
validate_marker_controls, validate_marker_value_badges, valid_space_id,
)
_LOGGER = logging.getLogger(__name__)
_OPTIMIZE_BACKUP = "optimize_backup"
_OPTIMIZE_PENDING = "optimize_pending"
def _optimizer_backup_is_current(config_data: dict[str, Any], layout_data: dict[str, Any]) -> bool:
"""An optimization can be undone only before any later plan edit."""
"""An optimization can be undone before any later ordinary plan edit."""
backup = layout_data.get(_OPTIMIZE_BACKUP)
if not isinstance(backup, dict):
return False
@@ -55,17 +70,46 @@ def _optimizer_backup_is_current(config_data: dict[str, Any], layout_data: dict[
return False
def _optimizer_backup_after_layout_maintenance(
layout_data: dict[str, Any], new_layout_rev: int,
) -> dict[str, Any]:
"""Carry a one-deep plan snapshot across explicit layout maintenance.
Geometry repair is part of plan maintenance, not an ordinary user edit:
losing the Optimize/Import undo there makes the advertised safety net
disappear. The snapshot must also follow the new layout revision or the
freshness guard will correctly, but unhelpfully, classify it as stale.
"""
backup = layout_data.get(_OPTIMIZE_BACKUP)
if not isinstance(backup, dict):
return {}
return {
_OPTIMIZE_BACKUP: {
**backup,
"after_layout_rev": new_layout_rev,
}
}
def _undo_kind(config_data: dict[str, Any], layout_data: dict[str, Any]) -> str | None:
if not _optimizer_backup_is_current(config_data, layout_data):
return None
backup = layout_data.get(_OPTIMIZE_BACKUP)
return str(backup.get("kind") or "optimize") if isinstance(backup, dict) else None
async def _discard_optimizer_snapshot(rt: HouseplanData) -> None:
"""Free a snapshot made stale by a later ordinary config edit."""
data = await rt.store.async_load() or {}
if _OPTIMIZE_BACKUP not in data and _OPTIMIZE_PENDING not in data:
return
await rt.store.async_save({
**{
k: v for k, v in data.items()
if k not in (_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)
}
})
await async_save_layout_state(
rt,
data,
data.get("layout") or {},
int(data.get("rev", 0)),
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING),
)
@callback
@@ -88,6 +132,9 @@ def async_register(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_files_migrate)
websocket_api.async_register_command(hass, ws_files_cleanup)
websocket_api.async_register_command(hass, ws_content_sign)
websocket_api.async_register_command(hass, ws_export_create)
websocket_api.async_register_command(hass, ws_import_revalidate)
websocket_api.async_register_command(hass, ws_import_apply)
def _runtime(hass: HomeAssistant, connection, msg_id: int) -> HouseplanData | None:
@@ -108,6 +155,325 @@ def _check_write(hass: HomeAssistant, connection) -> bool:
return may_write(hass, getattr(connection, "user", None))
def _connection_user_id(connection) -> str:
return str(getattr(getattr(connection, "user", None), "id", ""))
def _send_import_error(connection, msg_id: int, err: ImportFailure) -> None:
connection.send_error(msg_id, err.code, err.message)
def _layout_metadata(stored: dict[str, Any]) -> dict[str, Any]:
"""Return the exact non-layout portion of a layout-store document."""
return {
key: value for key, value in stored.items()
if key not in {"layout", "rev"}
}
async def _persist_pair_intent(
rt: HouseplanData,
pending: dict[str, Any],
) -> None:
"""Make a target pair recoverable before either visible half moves."""
stored = await rt.store.async_load() or {}
metadata = dict(pending.get("final_metadata") or {})
metadata[_OPTIMIZE_PENDING] = pending
await async_save_layout_state(
rt,
stored,
stored.get("layout") or {},
int(stored.get("rev", 0)),
metadata=metadata,
replace_metadata=True,
)
async def _converge_pair(rt: HouseplanData, pending: dict[str, Any]) -> None:
"""Write both target halves and remove the durable intent last."""
await rt.config_store.async_save({
"config": pending["config"],
"rev": int(pending["config_rev"]),
})
stored = await rt.store.async_load() or {}
await async_save_layout_state(
rt,
stored,
pending["layout"],
int(pending["layout_rev"]),
metadata=dict(pending.get("final_metadata") or {}),
replace_metadata=True,
)
async def _commit_import_pair(
rt: HouseplanData,
pending: dict[str, Any],
rollback: dict[str, Any],
) -> None:
"""Commit, retry once, or restore the before-pair before returning.
A Store write may raise after the bytes reached disk, so recovery always
reloads and converges an explicit pair instead of guessing which half won.
If the target cannot be completed, a rollback intent replaces it before we
restore the old pair; setup will therefore restore, never unexpectedly
finish an import that was reported as failed.
"""
try:
await _persist_pair_intent(rt, pending)
await _converge_pair(rt, pending)
return
except Exception: # noqa: BLE001 - one retry handles fail-after-write too
_LOGGER.warning("House Plan import pair write failed; retrying target", exc_info=True)
try:
await _persist_pair_intent(rt, pending)
await _converge_pair(rt, pending)
return
except Exception: # noqa: BLE001 - target is no longer the recovery policy
_LOGGER.exception("House Plan import target retry failed; restoring previous pair")
try:
await _persist_pair_intent(rt, rollback)
await _converge_pair(rt, rollback)
except Exception as rollback_error: # noqa: BLE001
_LOGGER.exception(
"House Plan import rollback could not finish; rollback intent remains for setup"
)
raise ImportFailure(
"commit_failed", "Import failed; the previous plan is pending recovery"
) from rollback_error
raise ImportFailure("commit_failed", "Import failed and the previous plan was restored")
# ---------------- portable backup / transfer ----------------
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/export/create",
vol.Required("kind"): vol.In(["full", "space"]),
vol.Optional("space_id"): str,
vol.Optional("card_version", default=""): str,
}
)
@websocket_api.async_response
async def ws_export_create(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Build a consistent full or one-space JSON snapshot."""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only editors may export House Plan")
return
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
try:
async with rt.write_lock:
config_data = await rt.config_store.async_load() or {}
layout_data = await rt.store.async_load() or {}
document, filename = await hass.async_add_executor_job(
partial(
create_export,
rt,
config_data,
layout_data,
kind=msg["kind"],
space_id=msg.get("space_id"),
card_version=msg.get("card_version", ""),
config_root=Path(hass.config.path("")),
)
)
except ImportFailure as err:
_send_import_error(connection, msg["id"], err)
return
except Exception: # noqa: BLE001
_LOGGER.exception("House Plan export failed")
connection.send_error(msg["id"], "invalid_config", "Could not create export")
return
connection.send_result(msg["id"], {"document": document, "filename": filename})
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/import/revalidate",
vol.Required("token"): str,
vol.Optional("duplicate_policy", default="skip"): vol.In(["skip", "virtual"]),
}
)
@websocket_api.async_response
async def ws_import_revalidate(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Re-evaluate a space candidate after its duplicate policy changes."""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only editors may import House Plan")
return
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
try:
async with rt.write_lock:
candidate = get_candidate(rt, msg["token"], _connection_user_id(connection))
config_data = await rt.config_store.async_load() or {}
layout_data = await rt.store.async_load() or {}
try:
registry_snapshot = import_registry_snapshot(hass)
except Exception: # noqa: BLE001 - summary must not block revalidation
_LOGGER.debug("House Plan import registry summary unavailable", exc_info=True)
registry_snapshot = None
result = await hass.async_add_executor_job(
partial(
revalidate_candidate,
candidate,
config_data,
layout_data,
duplicate_policy=msg["duplicate_policy"],
registry_snapshot=registry_snapshot,
config_root=Path(hass.config.path("")),
)
)
result["token"] = msg["token"]
result["expires_at"] = datetime.fromtimestamp(
candidate["expires"], UTC
).isoformat().replace("+00:00", "Z")
except ImportFailure as err:
_send_import_error(connection, msg["id"], err)
return
connection.send_result(msg["id"], result)
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/import/apply",
vol.Required("token"): str,
vol.Required("expected_config_rev"): int,
vol.Required("expected_layout_rev"): int,
vol.Optional("duplicate_policy"): vol.In(["skip", "virtual"]),
vol.Optional("confirm_missing_content", default=False): bool,
}
)
@websocket_api.async_response
async def ws_import_apply(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Commit exactly the previewed candidate as one crash-resumable pair."""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only editors may import House Plan")
return
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
kind = ""
details: dict[str, Any] = {}
try:
async with rt.write_lock:
candidate = get_candidate(rt, msg["token"], _connection_user_id(connection))
config_data = await rt.config_store.async_load() or {}
layout_data = await rt.store.async_load() or {}
config_rev = int(config_data.get("rev", 0))
layout_rev = int(layout_data.get("rev", 0))
if (
msg["expected_config_rev"] != config_rev
or msg["expected_layout_rev"] != layout_rev
or candidate.get("config_rev") != config_rev
or candidate.get("layout_rev") != layout_rev
):
raise ImportFailure("conflict", "Plan changed after the preview")
kind = str(candidate["document"]["kind"])
requested_policy = msg.get("duplicate_policy", candidate.get("duplicate_policy"))
if kind == "space" and requested_policy != candidate.get("duplicate_policy"):
raise ImportFailure("conflict", "Duplicate policy changed after the preview")
target_config, target_layout, details = await hass.async_add_executor_job(
partial(
prepare_apply,
candidate,
config_data.get("config") or {"spaces": [], "markers": [], "settings": {}},
layout_data.get("layout") or {},
duplicate_policy=candidate.get("duplicate_policy"),
confirm_missing_content=msg["confirm_missing_content"],
)
)
missing = await hass.async_add_executor_job(
_missing_internal_plans,
Path(hass.config.path(PLANS_DIR)),
target_config,
config_data.get("config"),
)
if missing:
raise ImportFailure(
"missing_plan",
"Plan file no longer exists: " + ", ".join(sorted(missing)),
)
new_config_rev = config_rev + 1
new_layout_rev = layout_rev + 1
backup = None
if kind == "full":
backup = {
"kind": "import",
"config": config_data.get("config") or DEFAULT_CONFIG,
"layout": layout_data.get("layout") or {},
"created": int(time.time()),
"after_config_rev": new_config_rev,
"after_layout_rev": new_layout_rev,
}
original_metadata = _layout_metadata(layout_data)
final_metadata = {
key: value for key, value in original_metadata.items()
if key not in {_OPTIMIZE_PENDING, _OPTIMIZE_BACKUP}
}
if backup is not None:
final_metadata[_OPTIMIZE_BACKUP] = backup
pending = {
"kind": "import",
"config": target_config,
"layout": target_layout,
"config_rev": new_config_rev,
"layout_rev": new_layout_rev,
"final_metadata": final_metadata,
}
rollback = {
"kind": "import_rollback",
"config": config_data.get("config") or DEFAULT_CONFIG,
"layout": layout_data.get("layout") or {},
"config_rev": config_rev,
"layout_rev": layout_rev,
"final_metadata": original_metadata,
}
await _commit_import_pair(rt, pending, rollback)
# A token becomes single-use only after both durable halves land.
get_candidate(rt, msg["token"], _connection_user_id(connection), consume=True)
except ImportFailure as err:
_send_import_error(connection, msg["id"], err)
return
except Exception: # noqa: BLE001
_LOGGER.exception("House Plan import commit failed")
connection.send_error(msg["id"], "commit_failed", "Import commit failed")
return
hass.bus.async_fire("houseplan_config_updated", {"rev": new_config_rev})
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_layout_rev})
_refresh_trail_recorder(hass)
if kind == "full":
recorder = hass.data.get(DOMAIN, {}).get("trail_recorder")
live_marker_ids = {
str(marker.get("id")) for marker in target_config.get("markers") or []
}
if recorder is not None:
for marker_id in list(getattr(getattr(recorder, "book", None), "data", {})):
if marker_id not in live_marker_ids:
try:
await recorder.async_delete(marker_id)
except Exception: # noqa: BLE001
_LOGGER.exception("House Plan: removing orphan import trail failed")
entry = get_entry(hass)
if entry is not None:
from .repairs import async_check_plan_files
hass.async_create_task(async_check_plan_files(hass, entry))
connection.send_result(msg["id"], {
"ok": True,
"kind": kind,
"config_rev": new_config_rev,
"layout_rev": new_layout_rev,
"counts": details.get("counts", {}),
"space_id": details.get("space_id"),
"can_undo": kind == "full",
})
# ---------------- layout ----------------
@@ -120,25 +486,7 @@ def _live_layout(config: dict[str, Any], layout: dict[str, Any]) -> dict[str, An
only a legacy naming convention, however; an explicit real marker remains
authoritative even when its id happens to begin with `v_`.
"""
markers = config.get("markers") or []
removed_ids = {
str(m.get("id")) for m in markers if m.get("removed") is True
}
virtual_ids = {
str(m.get("id")) for m in markers
if m.get("removed") is not True and m.get("binding") == "virtual"
}
explicit_live_ids = {
str(m.get("id")) for m in markers
if m.get("removed") is not True and m.get("binding") != "virtual"
}
return {
marker_id: pos for marker_id, pos in layout.items()
if marker_id not in removed_ids
and (not marker_id.startswith("v_")
or marker_id in virtual_ids
or marker_id in explicit_live_ids)
}
return live_layout(config, layout)
@websocket_api.websocket_command({vol.Required("type"): "houseplan/layout/get"})
@@ -155,6 +503,7 @@ async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
"layout": data.get("layout", {}),
"rev": int(data.get("rev", 0)),
"can_optimize_undo": _optimizer_backup_is_current(config_data, data),
"undo_kind": _undo_kind(config_data, data),
}
)
@@ -192,9 +541,10 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
return
layout = _live_layout(config_data.get("config") or {}, msg["layout"])
new_rev = current_rev + 1
await rt.store.async_save({**{k: v for k, v in data.items() if k not in (
"layout", "rev", _OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)},
"layout": layout, "rev": new_rev})
await async_save_layout_state(
rt, data, layout, new_rev,
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING),
)
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
@@ -256,9 +606,10 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
# optimistic locking on layout/set meaningless — every drag reset the
# counter to 0 (HP-1454-08)
new_rev = int(data.get("rev", 0)) + 1
await rt.store.async_save({**{k: v for k, v in data.items() if k not in (
"layout", "rev", _OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)},
"layout": layout, "rev": new_rev})
await async_save_layout_state(
rt, data, layout, new_rev,
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING),
)
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
@@ -311,7 +662,11 @@ async def ws_geometry_repair(hass: HomeAssistant, connection, msg: dict[str, Any
for key, pos in (backup.get("positions") or {}).items():
restored[key] = pos
new_rev = current_rev + 1
await rt.store.async_save({"layout": restored, "rev": new_rev})
await async_save_layout_state(
rt, data, restored, new_rev,
metadata=_optimizer_backup_after_layout_maintenance(data, new_rev),
remove=("repair_backup",),
)
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev,
"restored": len(backup.get("positions") or {})})
@@ -343,10 +698,14 @@ async def ws_geometry_repair(hass: HomeAssistant, connection, msg: dict[str, Any
# the backup rides the same store write: either both are durable or
# neither — the deletion-shy rules of this project apply to positions
# too
await rt.store.async_save({
"layout": new_layout, "rev": new_rev,
"repair_backup": {"space": space_id, "positions": touched},
})
await async_save_layout_state(
rt, data, new_layout, new_rev,
metadata={
**_optimizer_backup_after_layout_maintenance(data, new_rev),
"repair_backup": {"space": space_id, "positions": touched},
},
remove=("repair_backup",),
)
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev, "moved": len(preview)})
@@ -654,9 +1013,10 @@ async def ws_layout_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
if msg["device_id"] in layout:
del layout[msg["device_id"]]
new_rev = int(data.get("rev", 0)) + 1
await rt.store.async_save({**{k: v for k, v in data.items() if k not in (
"layout", "rev", _OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)},
"layout": layout, "rev": new_rev})
await async_save_layout_state(
rt, data, layout, new_rev,
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING),
)
if new_rev is not None:
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
@@ -687,6 +1047,7 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
"rev": data.get("rev", 0),
"can_write": may_write(hass, getattr(connection, "user", None)),
"can_optimize_undo": _optimizer_backup_is_current(data, layout_data),
"undo_kind": _undo_kind(data, layout_data),
},
)
@@ -781,6 +1142,15 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
f"Configuration was changed in another window (rev {current_rev} != {msg['expected_rev']})",
)
return
# Marker-to-marker light links are a semantic graph layered on top of
# the lossless controls array. Validate only edges introduced by this
# write so an unrelated edit can still round-trip a legacy broken ref.
try:
validate_marker_controls(msg["config"], data.get("config"))
validate_marker_value_badges(msg["config"], data.get("config"))
except MarkerControlError as err:
connection.send_error(msg["id"], err.code, str(err))
return
# An internal plan url must name a file that exists. The card can pick a
# plan and then delete it from the same dialog, and two clients can do
# the same thing in either order — the lock serialises them but says
@@ -876,6 +1246,16 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
)
return
# Optimization is a normal configuration write with an additional
# layout transaction. It must enforce the same marker-link semantics
# as config/set; otherwise a crafted client can persist a new cycle.
try:
validate_marker_controls(msg["config"], config_data.get("config"))
validate_marker_value_badges(msg["config"], config_data.get("config"))
except MarkerControlError as err:
connection.send_error(msg["id"], err.code, str(err))
return
missing = await hass.async_add_executor_job(
_missing_internal_plans,
Path(hass.config.path(PLANS_DIR)),
@@ -892,6 +1272,7 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
new_config_rev = config_rev + 1
new_layout_rev = layout_rev + 1
backup = {
"kind": "optimize",
"config": config_data.get("config") or DEFAULT_CONFIG,
"layout": layout_data.get("layout", {}),
"created": int(time.time()),
@@ -901,29 +1282,23 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
pending = {
"config": msg["config"],
"layout": msg["layout"],
"config_rev": new_config_rev,
"layout_rev": new_layout_rev,
"clear_backup": False,
}
layout_meta = {
k: v for k, v in layout_data.items()
if k not in ("layout", "rev", _OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)
}
# Intent first. A setup-time finisher completes whichever half a crash
# interrupted; until then the visible layout/revision remain unchanged.
await rt.store.async_save({
**layout_meta,
"layout": layout_data.get("layout", {}),
"rev": layout_rev,
_OPTIMIZE_BACKUP: backup,
_OPTIMIZE_PENDING: pending,
})
await async_save_layout_state(
rt, layout_data, layout_data.get("layout", {}), layout_rev,
metadata={_OPTIMIZE_BACKUP: backup, _OPTIMIZE_PENDING: pending},
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING),
)
await rt.config_store.async_save({"config": msg["config"], "rev": new_config_rev})
await rt.store.async_save({
**layout_meta,
"layout": msg["layout"],
"rev": new_layout_rev,
_OPTIMIZE_BACKUP: backup,
})
await async_save_layout_state(
rt, layout_data, msg["layout"], new_layout_rev,
metadata={_OPTIMIZE_BACKUP: backup},
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING),
)
hass.bus.async_fire("houseplan_config_updated", {"rev": new_config_rev})
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_layout_rev})
@@ -953,6 +1328,7 @@ async def ws_plan_optimize_undo(hass: HomeAssistant, connection, msg: dict[str,
if rt is None:
return
restored_kind = "optimize"
async with rt.write_lock:
config_data = await rt.config_store.async_load() or {}
layout_data = await rt.store.async_load() or {}
@@ -969,36 +1345,50 @@ async def ws_plan_optimize_undo(hass: HomeAssistant, connection, msg: dict[str,
return
backup = layout_data[_OPTIMIZE_BACKUP]
restored_kind = str(backup.get("kind") or "optimize")
restored_config = backup.get("config") or DEFAULT_CONFIG
restored_layout = backup.get("layout") or {}
new_config_rev = config_rev + 1
new_layout_rev = layout_rev + 1
pending = {
"kind": "import_undo" if restored_kind == "import" else "optimize_undo",
"config": restored_config,
"layout": restored_layout,
"config_rev": new_config_rev,
"layout_rev": new_layout_rev,
"clear_backup": True,
}
layout_meta = {
k: v for k, v in layout_data.items()
if k not in ("layout", "rev", _OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)
}
await rt.store.async_save({
**layout_meta,
"layout": layout_data.get("layout", {}),
"rev": layout_rev,
_OPTIMIZE_BACKUP: backup,
_OPTIMIZE_PENDING: pending,
})
await async_save_layout_state(
rt, layout_data, layout_data.get("layout", {}), layout_rev,
metadata={_OPTIMIZE_BACKUP: backup, _OPTIMIZE_PENDING: pending},
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING),
)
await rt.config_store.async_save({"config": restored_config, "rev": new_config_rev})
await rt.store.async_save({
**layout_meta,
"layout": restored_layout,
"rev": new_layout_rev,
})
await async_save_layout_state(
rt, layout_data, restored_layout, new_layout_rev,
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING, "repair_backup"),
)
hass.bus.async_fire("houseplan_config_updated", {"rev": new_config_rev})
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_layout_rev})
_refresh_trail_recorder(hass)
if restored_kind == "import":
recorder = hass.data.get(DOMAIN, {}).get("trail_recorder")
live_marker_ids = {
str(marker.get("id")) for marker in restored_config.get("markers") or []
}
if recorder is not None:
for marker_id in list(getattr(getattr(recorder, "book", None), "data", {})):
if marker_id not in live_marker_ids:
try:
await recorder.async_delete(marker_id)
except Exception: # noqa: BLE001
_LOGGER.exception("House Plan: removing orphan undo trail failed")
entry = get_entry(hass)
if entry is not None:
from .repairs import async_check_plan_files
hass.async_create_task(async_check_plan_files(hass, entry))
connection.send_result(msg["id"], {
"ok": True,
"config_rev": new_config_rev,
+8 -1
View File
@@ -223,8 +223,15 @@ export const makeLargeHouseFixture = () => {
};
});
const runtime = makeRuntime(spaces);
const lightMarkers = Object.entries(runtime.entities)
.filter(([entityId]) => entityId.startsWith('light.'))
.map(([_entityId, entity]) => ({
id: entity.device_id,
binding: `device:${entity.device_id}`,
is_light: true,
}));
return {
config: { spaces, markers: [], settings: { glow_radius_cm: 300 } },
config: { spaces, markers: lightMarkers, settings: { glow_radius_cm: 300 } },
...runtime,
counts: LARGE_HOUSE_COUNTS,
};
+27 -2
View File
@@ -2,6 +2,25 @@
const round = (value) => Number(value.toFixed(6));
// Golden fixtures must use the same persisted wall-key contract as real plan
// data. Arbitrary labels make every configured wall look virtual to the
// renderer, which lets a visually ineffective baseline pass unnoticed.
const WALL_KEY_PITCH = 1 / 240;
export const fixtureWallKey = (a, b) => {
const quantize = (value) => Math.round(value / WALL_KEY_PITCH) * WALL_KEY_PITCH;
const mx = quantize((a[0] + b[0]) / 2);
const my = quantize((a[1] + b[1]) / 2);
let dx = b[0] - a[0], dy = b[1] - a[1];
const length = Math.hypot(dx, dy);
if (length < 1e-12) { dx = 1; dy = 0; }
else { dx /= length; dy /= length; }
if (dx < -1e-12 || (Math.abs(dx) <= 1e-12 && dy < 0)) { dx = -dx; dy = -dy; }
let angle = Math.atan2(dy, dx);
if (angle < 0) angle += Math.PI;
const bucket = Math.round(angle * 1800) / 1800;
return `${mx.toFixed(4)},${my.toFixed(4)}@${bucket.toFixed(4)}`;
};
const uniqueEdges = (rooms) => {
const edges = new Map();
for (const room of rooms) {
@@ -16,7 +35,7 @@ const uniqueEdges = (rooms) => {
};
const wallsFor = (prefix, rooms, thickness) => uniqueEdges(rooms).map((edge, index) => ({
key: `${prefix}-wall-${index}`,
key: fixtureWallKey(edge.a, edge.b),
a: edge.a,
b: edge.b,
cm: typeof thickness === 'function' ? thickness(edge, index) : thickness,
@@ -112,6 +131,9 @@ const runtime = () => {
attributes: { azimuth: 180, elevation: 24 },
},
};
// Keep sun.sun state-only on purpose. Core/runtime entities and YAML
// entities without unique_id may have a live state without a registry row.
// The production projection must preserve them.
const layout = {};
const areas = Object.fromEntries(
[...geometryRooms, ...lightingRooms].map((room) => [room.area, { area_id: room.area, name: room.name }]),
@@ -163,7 +185,10 @@ export const VISUAL_MATRIX_COUNTS = Object.freeze({
export const makeVisualMatrixFixture = () => ({
config: {
spaces: [structuredClone(geometrySpace), structuredClone(lightingSpace)],
markers: [],
// A persisted marker is part of the fixture contract for scenarios that
// override per-source Glow controls. The device/layout alone are not a
// saved marker configuration and must not be silently treated as one.
markers: [{ id: 'golden-light-two', binding: 'device:golden-light-two' }],
settings: {
glow_radius_cm: 360,
north_deg: 0,
+5
View File
@@ -54,3 +54,8 @@ Future updates must still use the `golden-images` artifact produced by the Linux
CI job as the review set: desktop font rasterisation can differ from the CI
environment even with the same pinned Chromium. Pass its unpacked root via
`--from=...` when accepting it locally.
Scenarios may also declare a semantic pixel region (for example, a receiving
room that must contain warm light). `golden:capture` and `golden:verify` reject
the capture before baseline comparison when that visual precondition is empty;
a reviewed but meaningless PNG therefore cannot become the contract.
Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

@@ -1,43 +0,0 @@
{
"schema": 1,
"matrixVersion": 7,
"acceptedAt": "2026-08-10T21:45:34.131Z",
"sourceFingerprint": "aa95ec153b0073facce2c992e67dff7e0222933f55654bea03b8f4a1bc88a066",
"chromium": "151.0.7922.34",
"scenarios": {
"geometry-view-dark-fit": "fbce77f97180cd7d50a997c74d3fa1874ee3be2515a4c09b36dd4e792ebc66df",
"geometry-view-light-fit": "a425fa6d77081bb35d2fb81e469962402c6a3c86a8113ff957f28548908d0816",
"geometry-plan-editor-dark": "86d64305e4da13436aef14fe96b2c9eb5978d1d32a0bba2318005421a2fda161",
"geometry-devices-editor-dark": "93417ab7363ca50e65c9d6f0b876f4d5a6a367a8fabdf42306b244e6511693f1",
"geometry-decor-editor-dark": "25d341052e30e7acea13c17897e8bd0f1f08235342b949c02afd3db5b9936c64",
"tray-wide-selection-en": "c7350c3661f17f46d22b2c977524fde9fba1ebf9d6e10f021482f0d1caeffa8a",
"tray-wide-tool-ru": "5472a302ad7e7b70235089164a5c4558183504519d95280e8ff30883e304cb71",
"tray-medium-group-en": "59480e04ff5ea0f2cfb31b88c1c753ce8eeac785596ca21eac48dd99ec5e0eb6",
"tray-medium-selection-ru": "805c23560bc340582f9b951e910f39245ab62b6cd980d175c8d9e3b7e1ef1519",
"tray-narrow-palette-en": "e8dede0d90ebc32e1e294ba3c39621df918aaf7b3183f71aed9b60014eee51db",
"tray-narrow-tool-ru": "8889d58aca90daadf0bedd6a7c08afb2604c086a453f9435f32d29607aa49e42",
"geometry-diagonal-45-opening-dark": "fbce77f97180cd7d50a997c74d3fa1874ee3be2515a4c09b36dd4e792ebc66df",
"openings-thick-wall-dark": "5d9ac6add9a5709318180f4554ac62c1c7e2862bd016be7901a2bd450e021f0c",
"openings-hidden-view-dark": "799e791830ab63a1c445b8c77650a577d8e881daa1ff47d1dcf4e238eb1cff16",
"lighting-glow-sun-dark": "f427f126f57ce3b05fc51552e5d7ad6553cfb802540a206852d9bdfb28a5acb7",
"lighting-fill-light-axis-split-dark": "acdbebe845ede66dbbe9de979aad93ba78dc2779d1ec533b238a9bea0d047d1c",
"lighting-fill-temp-axis-split-dark": "2348b7c6a8594468664755e2fea1b49126e604192599be0a3965f24a0c2d2765",
"lighting-fill-lqi-axis-split-dark": "584adcfb5541b1dddc814c2674bfce8002566bbb667b10501b7ff49fedef6f9c",
"lighting-temp-glow-dark": "d7c2430496dca0302ea91f90326e89204997116293f4efa13e58afdec22a956c",
"lighting-temp-glow-light": "0d1f2063a5a34126486092a3881d3a21e9a8962b4bb592141bee249255b1b8b8",
"lighting-custom-glow-dark": "504b238cc3094c658d42f50a8dbc757024764c32c376f6843f709c8a93ddf7e0",
"lighting-opaque-glow-two-doorways-dark": "30e8f288852d4322c6ff182063b633f303ed62d03a2192633b36cc19f5ca03a6",
"lighting-custom-glow-light": "2af515770be22d4af284f971744589e19211f0fcaac1a5f4b15d1cd052f3764a",
"lighting-temp-glow-no-sources-dark": "a169cb08967ee59f5aeb415111936cf5952b1c33a96e2bbcee68a1ec029df1c4",
"lighting-temp-glow-room-override-dark": "0a183f34c8ccc1f8d686ee7e6c0332717705bd725ae8a95861cd157b9f041404",
"lighting-manual-auto-spill-overlap-dark": "ac27d844ac6e1996fd897cc36248d2bb0bbcf8765324a78da3b1b994ae7022ec",
"hover-over-glow-dark": "cf3bc7d690d33a4f28bca4f113610a98b257abdf5612f3b5bfd55851286c1d67",
"hover-nested-room-dark": "04936c209ebafce96e080fc5ba71611781caefda6e1637ff9ccb20b5ea122eae",
"large-house-zoom-040-dark": "463792710f162805aacd177142044603b813ba32e911e202d7cc792115e45a8b",
"large-house-zoom-250-dark": "dbb4707c8e4fd351e88c23a21513f0edc658ee05643afd5ba59d91eb0266fd7c",
"large-house-warm-remount-dark": "cb5e9c6ecb13affb6f3b4778ce80eff94bad99f6bd4036a301ecc58aa3714223",
"device-dialog-desktop-en": "b2c36a4833d09e3eecafd1781b9029534434a90319da2476533534263ae2a439",
"device-dialog-mobile-ru": "5b66639f71623d218f33df8db99f70edc139b9de60ffe291641ccf10665b78cb",
"decor-color-popover-mobile-ru": "eab3c5c76b27050dbdbc1627371a16916ea30473934de48842c6ab94a045f9db"
}
}
@@ -0,0 +1,48 @@
{
"schema": 1,
"matrixVersion": 13,
"acceptedAt": "2026-08-12T10:53:18.675Z",
"sourceFingerprint": "0581cf065ba06734ce9bd00277f3fbe18483ac09914323070e321cba319cdc68",
"chromium": "151.0.7922.34",
"scenarios": {
"geometry-view-dark-fit": "a538deed6141b98b3e396d7da1024b18aee345312edd7954ad458751f08f48a3",
"geometry-view-light-fit": "0c57dee930f30a1f9c16e4e704675156f57a7623a4edf839c14a4fecbf12881c",
"geometry-plan-editor-dark": "9b4cf27319ec91b0d58ca9ad6f93db84938eb5ac52d92c60f18cb46741e4363d",
"geometry-devices-editor-dark": "c850e83f1af747f063b895109fe26d6a5804356fab505b0976073c28ad146104",
"geometry-decor-editor-dark": "44a95fd0b397c2729fea65c750fedcb11df10042ae198f3690151ead077c84e1",
"tray-wide-selection-en": "29dcae474864028eead4e661354c57340221f471c503366150e9ca6c76f65a2c",
"tray-wide-tool-ru": "7a5c9a9464a2efd28edde85bffcaf62dab48abde84566974c524fae19e5580b8",
"tray-medium-group-en": "79fcdf2815db456aa2934bce7982e09332d8d327ea7813e3f2c542e78f03ae90",
"tray-medium-selection-ru": "ab91e88583b2863305434ce2775e31328638414671ff0a4f8ee82c8274c04405",
"tray-narrow-palette-en": "fb63483e7101d457d7fb1c83ae50435410ed797771adcbfde14dc2798a000ce1",
"tray-narrow-tool-ru": "60ce3c75de88b72fdb5d3fe7a16790184a8265c74f0f97e5be4fd83dbe0259fd",
"geometry-diagonal-45-opening-dark": "a538deed6141b98b3e396d7da1024b18aee345312edd7954ad458751f08f48a3",
"openings-thick-wall-dark": "5aa0b3d26894bef9ab9fca25c31bbef2f13f2c410f5f6d3f61c8d608ceb929f8",
"openings-filled-tunnel-dark": "074fb859d2ae1dc2ccd2045e2d6fb6d8389e430cbef20287419b8840cbbd5094",
"openings-hidden-view-dark": "c85cc04d1d8622b98215e2bb83f5bb233a7cfb0ac684c912475ef7bc44245897",
"lighting-glow-sun-dark": "a98eee332f25a43c8d9d126c118c8cfea4ee73752b1060227548efedaf0efcdd",
"lighting-sun-window-state-only-dark": "3bd581a23a2e0ebba58530db5182adea5cba6ec10bc032bee024415c19108a17",
"lighting-fill-light-axis-split-dark": "4f867528aeb9124229f81659876b03ff297a3a7a92bc57ffb32d5c24913c4938",
"lighting-fill-temp-axis-split-dark": "e0535b70701c9fe6753f74c943b9f288a0fb1a23a9cfc8590d4945e0a1724eb5",
"lighting-fill-lqi-axis-split-dark": "485ab183144913569ddc11154553ab4ac7db522ab188de74d652b717dc786d9c",
"lighting-temp-glow-dark": "ecaed039fb6aab4e1fdc9f1856c89e5f563219b8ff813877027737cecbeca41a",
"lighting-temp-glow-light": "5bc8a35aaa94c427d465d198f1cd5eecfdc704f5abeded9e407f32ce93aa7d32",
"lighting-custom-glow-dark": "899e334dfc0d3490ca291b7239bf7b88ca9d1ef3be199ddf347314bab1513f27",
"lighting-opaque-glow-two-doorways-dark": "413f5a8e39193ba941f72955a391ccac954c09f24322b7bf67494c7691275980",
"lighting-custom-glow-light": "266bba4ae1744a884b2cd224b37fdc36447d57405a1c5298ca30027e2957cc8a",
"lighting-temp-glow-no-sources-dark": "5100c81543fc30a7934a6db6f9e67e2c4fa185df0a974be5911879e43c0d3fc9",
"lighting-temp-glow-room-override-dark": "0a35d3508526187ea18e44456cfb8cd9e578a1864e896fad1c3eec2892c753e0",
"lighting-manual-auto-spill-overlap-dark": "6324dbe2079a255e7a194720c8c19f210549ac734e564270bc1373e6385b9cac",
"hover-over-glow-dark": "fc14ba6f6b670e61c0fb5be277e67551ea2da7a06b5c167a8c2c989f1de08910",
"hover-nested-room-dark": "b70e385829a4fe45a77f5e1c3f8af4e18efec5282fb9594b26fab08c1f113d52",
"large-house-zoom-040-dark": "5f11c4b78318a64c2a7cf803716661eea506609d4f0a6bb3d64a709f8c49db1d",
"large-house-zoom-250-dark": "c906426f888ff4e306c5c334c6329b387fc5ca368e229f55acc33c202351a1ac",
"large-house-warm-remount-dark": "6baf4baed1c735c64dfe1e69d9864ca287ffc0e8452d00e801f0873e98b187ee",
"device-dialog-desktop-en": "87b893df5f5f2d75cbf022edcb353a519e0221595c64ced993246cfb5c9f9c22",
"device-dialog-mobile-ru": "69fc5f5d9640b68893e5143890b69ca63a1a7817a4aa6a3cf04b3c6162ae4510",
"device-help-popover-light-ru": "0fd2b02c33f9cd3af3e8bd5f326a4b1e52f02875ec4ef02255b45b215aeb76c4",
"decor-color-popover-mobile-ru": "16d2859d1ed3715c1c4d3d451a8428c37e91ba00da17272c59cf83420a7b6c31",
"backup-full-preview-desktop-en": "7f12943d1027c89f6fe46978fa1f4e1bcdbf85a1216281d850d467e68f52cbda",
"backup-space-preview-mobile-ru": "998c6b52c1cc95109feb440a9966cade738154ceeae387246d40f8c56f9e8a3a"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 362 KiB

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 259 KiB

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 KiB

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 303 KiB

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 303 KiB

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 304 KiB

After

Width:  |  Height:  |  Size: 320 KiB

+130 -20
View File
@@ -51,11 +51,19 @@ async function stableEnvironment(page, scenario) {
/** Apply every data-only scenario override before the fixture crosses into the browser. */
export function prepareGoldenFixture(scenario) {
const fixture = fixtureFor(scenario.fixture);
if (scenario.deviceName && fixture.devices?.[scenario.deviceId])
const requireSpace = () => {
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
if (!space) throw new Error(`golden override references missing space: ${scenario.space}`);
return space;
};
if (scenario.deviceName) {
if (!scenario.deviceId || !fixture.devices?.[scenario.deviceId])
throw new Error(`golden deviceName references missing device: ${scenario.deviceId || '<empty>'}`);
fixture.devices[scenario.deviceId].name = scenario.deviceName;
}
if (scenario.fillMode || typeof scenario.glowEnabled === 'boolean'
|| typeof scenario.sunRays === 'boolean') {
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
const space = requireSpace();
space.settings = {
...(space.settings || {}),
...(scenario.fillMode ? { fill_mode: scenario.fillMode } : {}),
@@ -65,26 +73,56 @@ export function prepareGoldenFixture(scenario) {
};
}
if (scenario.extraOpenings?.length) {
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
const space = requireSpace();
const known = new Set((space.openings || []).map((opening) => opening.id));
for (const opening of scenario.extraOpenings) {
if (!opening?.id || known.has(opening.id))
throw new Error(`golden extraOpening has missing/duplicate id: ${opening?.id || '<empty>'}`);
if (!['door', 'window', 'gate'].includes(opening.type))
throw new Error(`golden extraOpening has unknown type: ${opening.type}`);
known.add(opening.id);
}
space.openings = [...(space.openings || []), ...structuredClone(scenario.extraOpenings)];
}
if (scenario.wallReplacements?.length) {
const space = requireSpace();
const samePoint = (a, b) => Array.isArray(a) && Array.isArray(b)
&& Math.abs(a[0] - b[0]) < 1e-9 && Math.abs(a[1] - b[1]) < 1e-9;
for (const replacement of scenario.wallReplacements) {
const index = (space.walls || []).findIndex((wall) => (
samePoint(wall.a, replacement.match?.a) && samePoint(wall.b, replacement.match?.b)
) || (
samePoint(wall.a, replacement.match?.b) && samePoint(wall.b, replacement.match?.a)
));
if (index < 0 || !replacement.segments?.length)
throw new Error(`golden wallReplacement cannot find a valid wall in ${space.id}`);
space.walls.splice(index, 1, ...structuredClone(replacement.segments));
}
}
if (scenario.hideOpenings) {
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
const space = requireSpace();
space.settings = { ...(space.settings || {}), hide_openings: true };
}
if (scenario.roomGlow) {
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
const space = requireSpace();
const unknown = new Set(Object.keys(scenario.roomGlow));
for (const room of space.rooms) {
if (!(room.id in scenario.roomGlow)) continue;
unknown.delete(room.id);
room.settings = { ...(room.settings || {}), glow: scenario.roomGlow[room.id] };
}
if (unknown.size) throw new Error(`golden roomGlow references missing room(s): ${[...unknown].join(', ')}`);
}
if (scenario.roomCustomFill) {
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
const space = requireSpace();
const unknown = new Set(Object.keys(scenario.roomCustomFill));
for (const room of space.rooms) {
if (!(room.id in scenario.roomCustomFill)) continue;
unknown.delete(room.id);
room.settings = { ...(room.settings || {}), custom_fill: scenario.roomCustomFill[room.id] };
}
if (unknown.size)
throw new Error(`golden roomCustomFill references missing room(s): ${[...unknown].join(', ')}`);
}
if (scenario.allLightsOff) {
for (const [entityId, state] of Object.entries(fixture.states || {})) {
@@ -94,7 +132,8 @@ export function prepareGoldenFixture(scenario) {
}
if (scenario.stateOverrides) {
for (const [entityId, override] of Object.entries(scenario.stateOverrides)) {
const current = fixture.states?.[entityId] || { entity_id: entityId, attributes: {} };
const current = fixture.states?.[entityId];
if (!current) throw new Error(`golden stateOverride references missing entity: ${entityId}`);
fixture.states[entityId] = {
...current,
...structuredClone(override),
@@ -104,12 +143,17 @@ export function prepareGoldenFixture(scenario) {
}
if (scenario.markerOverrides) {
const ids = new Set(scenario.markerOverrides.map((marker) => marker.id));
const known = new Set((fixture.config.markers || []).map((marker) => marker.id));
const missing = [...ids].filter((id) => !known.has(id));
if (missing.length) throw new Error(`golden markerOverrides reference missing marker(s): ${missing.join(', ')}`);
fixture.config.markers = [
...(fixture.config.markers || []).filter((marker) => !ids.has(marker.id)),
...structuredClone(scenario.markerOverrides),
];
}
if (scenario.layoutOverrides) {
const missing = Object.keys(scenario.layoutOverrides).filter((id) => !(id in (fixture.layout || {})));
if (missing.length) throw new Error(`golden layoutOverrides reference missing item(s): ${missing.join(', ')}`);
fixture.layout = { ...(fixture.layout || {}), ...structuredClone(scenario.layoutOverrides) };
}
@@ -130,6 +174,11 @@ export async function prepareGoldenScenario(page, scenario) {
await wait(15);
}
};
const settleMode = async (card) => {
await until(() => !card._modeTransitionBusy);
await card.updateComplete;
await frame();
};
window.__goldenCard?.remove?.();
window.__card?.remove?.();
localStorage.clear();
@@ -195,6 +244,7 @@ export async function prepareGoldenScenario(page, scenario) {
if (scenario.mode) {
card._setMode(scenario.mode);
await card.updateComplete;
await settleMode(card);
}
if (Number.isFinite(scenario.zoom)) {
card._applyView(scenario.zoom, 500, 500);
@@ -260,6 +310,7 @@ export async function prepareGoldenScenario(page, scenario) {
if (scenario.dialog === 'device') {
card._setMode('devices');
await card.updateComplete;
await settleMode(card);
const device = card._devices.find((item) => item.id === scenario.deviceId);
if (!device) throw new Error(`golden device missing: ${scenario.deviceId}`);
card._openMarkerDialog(device);
@@ -271,35 +322,72 @@ export async function prepareGoldenScenario(page, scenario) {
await card.updateComplete;
const dialog = card.renderRoot.querySelector('hp-dialog');
const body = dialog?.querySelector('.body');
const groups = [...(dialog?.querySelectorAll('.markerlightgroup') || [])];
const roleInputs = groups[0]?.querySelectorAll('input[name="marker-light-role"]');
const glowInputs = groups[1]?.querySelectorAll('input[name="marker-glow-mode"]');
const color = groups[1]?.querySelector('hp-color-opacity');
const brightness = groups[1]?.querySelector('input[type="range"]');
const radius = groups[1]?.querySelector('input.tempin[type="number"]');
if (!body || groups.length !== 2 || roleInputs?.length !== 3 || glowInputs?.length !== 3
const roleGroup = dialog?.querySelector('input[name="marker-light-role"]')?.closest('fieldset');
const glowGroup = dialog?.querySelector('input[name="marker-glow-mode"]')?.closest('fieldset');
const roleInputs = roleGroup?.querySelectorAll('input[name="marker-light-role"]');
const glowInputs = glowGroup?.querySelectorAll('input[name="marker-glow-mode"]');
const color = glowGroup?.querySelector('hp-color-opacity');
const brightness = glowGroup?.querySelector('input[type="range"]');
const radius = dialog?.querySelector('#marker-glow-radius');
if (!body || !roleGroup || !glowGroup || roleInputs?.length !== 3 || glowInputs?.length !== 3
|| !roleInputs[1]?.checked || !glowInputs[2]?.checked
|| !color || color.disabled || !brightness || brightness.disabled || !radius || radius.disabled)
throw new Error('golden device light-source controls are incomplete');
const bodyRect = body.getBoundingClientRect();
const roleRect = groups[0].getBoundingClientRect();
const roleRect = roleGroup.getBoundingClientRect();
body.scrollTop += roleRect.top - bodyRect.top - 8;
await frame();
const visibleBody = body.getBoundingClientRect();
const visibleRole = groups[0].getBoundingClientRect();
const visibleGlow = groups[1].getBoundingClientRect();
if (visibleRole.top < visibleBody.top - 1 || visibleGlow.bottom > visibleBody.bottom + 1)
const visibleRole = roleGroup.getBoundingClientRect();
const visibleRadius = radius.getBoundingClientRect();
if (visibleRole.top < visibleBody.top - 1 || visibleRadius.bottom > visibleBody.bottom + 1)
throw new Error('golden viewport does not show the complete device light-source controls');
}
if (scenario.openHelp) {
const help = card.renderRoot.querySelector(`hp-help[data-help-key="${scenario.openHelp}"]`);
await help?.updateComplete;
const trigger = help?.renderRoot?.querySelector('.trigger');
if (!trigger) throw new Error(`golden help trigger missing: ${scenario.openHelp}`);
trigger.click();
await help.updateComplete;
await frame();
const surface = help.renderRoot?.querySelector('.tooltip:popover-open')
|| card.renderRoot.querySelector('hp-dialog')?.renderRoot
?.querySelector('[data-hp-overlay="help"]')?.shadowRoot?.querySelector('.tooltip');
if (trigger.getAttribute('aria-expanded') !== 'true' || !surface?.getBoundingClientRect().width)
throw new Error(`golden help surface did not open: ${scenario.openHelp}`);
}
if (scenario.focusDialogClose) {
const dialog = card.renderRoot.querySelector('hp-dialog');
await dialog?.updateComplete;
dialog?.renderRoot?.querySelector('.close')?.focus();
}
} else if (scenario.dialog === 'backup-full' || scenario.dialog === 'backup-space') {
const full = scenario.dialog === 'backup-full';
card._backupImportDialog = {
filename: full ? 'houseplan-full-2026-08-11.json' : 'houseplan-space-ground.json',
size: 12345,
token: 'golden-token',
preview: {
kind: full ? 'full' : 'space', source: full ? 'foreign' : 'same',
created_at: '2026-08-11T10:00:00Z', space_title: 'Ground (2)',
counts: { spaces: 1, rooms: 4, markers: 12, layout: 15 },
duplicates: full ? 0 : 2,
confirmation_required: full,
content: full
? [{ url: '/api/houseplan/content/plans/_/ground.svg', state: 'detach_required' }]
: [{ url: 'https://example.test/ground.svg', state: 'external' }],
},
expectedConfigRev: 1, expectedLayoutRev: 1,
duplicatePolicy: 'skip', confirmMissing: false, busy: false, error: '',
};
card.requestUpdate();
await card.updateComplete;
} else if (scenario.dialog === 'decor-color') {
card._setMode('decor');
card._decorTool = 'select';
await card.updateComplete;
await settleMode(card);
const shape = card._decorList.find((item) => item.kind === 'line');
if (!shape) throw new Error('golden decor line missing');
card._decorShapeDbl(new MouseEvent('dblclick'), shape);
@@ -319,24 +407,46 @@ export async function prepareGoldenScenario(page, scenario) {
mode: card._mode,
devices: card._devices.length,
dialog: !!card.renderRoot.querySelector('hp-dialog'),
helpOpen: [...card.renderRoot.querySelectorAll('hp-help')]
.some((help) => help.renderRoot?.querySelector('.trigger')?.getAttribute('aria-expanded') === 'true'),
editorTray: card.renderRoot.querySelector('.editor-secondary-host.open .editor-secondary')
?.className || '',
...(scenario.sunRayPixels ? { sun: {
raw: card.hass?.states?.['sun.sun']?.attributes || null,
plan: card._planHass?.states?.['sun.sun']?.attributes || null,
render: card._renderPlanHass?.states?.['sun.sun']?.attributes || null,
north: card._effNorth(),
enabled: card._effSunRays(),
editing: card._editing,
cachedRays: card._sunRaysCache?.rays?.length || 0,
} } : {}),
};
}, { fixture, scenario });
}
export async function goldenClip(page, capture) {
if (capture === 'page') return null;
return page.evaluate(() => {
return page.evaluate((captureKind) => {
const card = window.__goldenCard;
const target = card?.renderRoot?.querySelector('.stage');
if (!target) throw new Error('golden stage capture target missing');
const rect = target.getBoundingClientRect();
if (captureKind === 'sun-window') {
// Stable crop around the exterior window and the first part of its ray:
// deliberately excludes room labels and device markers, whose font/icon
// rasterisation would add noise unrelated to the visual contract.
return {
x: Math.max(0, Math.floor(rect.left + rect.width * 0.10)),
y: Math.max(0, Math.floor(rect.top + rect.height * 0.02)),
width: Math.max(1, Math.ceil(rect.width * 0.35)),
height: Math.max(1, Math.ceil(rect.height * 0.25)),
};
}
const pad = 2;
const x = Math.max(0, Math.floor(rect.left - pad));
const y = Math.max(0, Math.floor(rect.top - pad));
const right = Math.min(window.innerWidth, Math.ceil(rect.right + pad));
const bottom = Math.min(window.innerHeight, Math.ceil(rect.bottom + pad));
return { x, y, width: Math.max(1, right - x), height: Math.max(1, bottom - y) };
});
}, capture);
}
+39 -2
View File
@@ -1,8 +1,11 @@
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
export const GOLDEN_MATRIX_VERSION = 7;
export const GOLDEN_MATRIX_VERSION = 13;
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
const sunWindow = { capture: 'sun-window', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.001 } };
export const GOLDEN_SCENARIOS = Object.freeze([
{ id: 'geometry-view-dark-fit', fixture: 'visual', space: 'golden-geometry', mode: 'view',
@@ -39,10 +42,30 @@ export const GOLDEN_SCENARIOS = Object.freeze([
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'openings-thick-wall-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
fillMode: 'none', glowEnabled: false, theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'openings-filled-tunnel-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
fillMode: 'custom', customFill: { c: '#66717c', a: 0.55 }, glowEnabled: false,
hideOpenings: true,
wallReplacements: [{
match: { a: [0.5, 0.1], b: [0.5, 0.88] },
segments: [
{ key: fixtureWallKey([0.5, 0.1], [0.5, 0.5]), a: [0.5, 0.1], b: [0.5, 0.5], cm: 18 },
{ key: fixtureWallKey([0.5, 0.5], [0.5, 0.88]), a: [0.5, 0.5], b: [0.5, 0.88], cm: 32 },
],
}],
tunnelContinuity: { openingId: 'light-door', insetPx: 2, maxChannelJump: 3 },
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'openings-hidden-view-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
fillMode: 'none', glowEnabled: false, hideOpenings: true, theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'lighting-glow-sun-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'lighting-sun-window-state-only-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
// The golden screenshot is backed by a second, sun-layer-hidden capture.
// A real painted ray must account for enough changed pixels; DOM-only
// presence or an accidentally accepted empty baseline is not sufficient.
glowEnabled: false, allLightsOff: true,
stateOverrides: { 'sun.sun': { attributes: { azimuth: 0, elevation: 24 } } },
sunRayPixels: { minPixels: 500, minChannelDelta: 4 },
theme: 'dark', viewport: { width: 1000, height: 900 }, ...sunWindow },
{ id: 'lighting-fill-light-axis-split-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
fillMode: 'light', glowEnabled: false, theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'lighting-fill-temp-axis-split-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
@@ -66,7 +89,10 @@ export const GOLDEN_SCENARIOS = Object.freeze([
extraOpenings: [
{ id: 'light-door-second', type: 'door', x: 0.50, y: 0.32, angle: 90, length: 0.13 },
],
layoutOverrides: { 'golden-light-one': { s: 'golden-lighting', x: 0.22, y: 0.48 } },
layoutOverrides: { 'golden-light-one': { s: 'golden-lighting', x: 0.40, y: 0.48 } },
// The golden is protection only if the receiving half actually contains
// rendered light. A data-only check once let a visually empty baseline pass.
warmPixelRegion: { x: 0.5, y: 0, w: 0.5, h: 1, minPixels: 2500, minRedBlueDelta: 25 },
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'lighting-custom-glow-light', fixture: 'visual', space: 'golden-lighting', mode: 'view',
fillMode: 'custom', customFill: { c: '#486a8f', a: 0.42 }, glowEnabled: true,
@@ -99,12 +125,23 @@ export const GOLDEN_SCENARIOS = Object.freeze([
{ id: 'device-dialog-desktop-en', fixture: 'visual', space: 'golden-lighting',
dialog: 'device', deviceId: 'golden-light-two', deviceLightControls: true,
deviceName: 'Living room light controller with an intentionally long title',
openHelp: 'marker.glow_mode.help', helpTextRegion: { key: 'marker.glow_mode.help', minPixels: 30 },
focusDialogClose: true, language: 'en', theme: 'dark', viewport: { width: 1180, height: 1200 }, ...page },
{ id: 'device-dialog-mobile-ru', fixture: 'visual', space: 'golden-lighting',
dialog: 'device', deviceId: 'golden-light-two', deviceLightControls: true,
deviceName: 'Контроллер освещения гостиной с намеренно очень длинным названием',
language: 'ru', theme: 'dark', viewport: { width: 390, height: 1000 }, ...page },
{ id: 'device-help-popover-light-ru', fixture: 'visual', space: 'golden-lighting',
dialog: 'device', deviceId: 'golden-light-two', deviceLightControls: true,
openHelp: 'marker.glow_mode.help', helpTextRegion: { key: 'marker.glow_mode.help', minPixels: 30 },
language: 'ru', theme: 'light', viewport: { width: 760, height: 900 }, ...page },
{ id: 'decor-color-popover-mobile-ru', fixture: 'visual', space: 'golden-geometry',
dialog: 'decor-color', language: 'ru', theme: 'dark',
viewport: { width: 390, height: 760 }, ...page },
{ id: 'backup-full-preview-desktop-en', fixture: 'visual', space: 'golden-geometry',
dialog: 'backup-full', language: 'en', theme: 'dark',
viewport: { width: 1000, height: 900 }, ...page },
{ id: 'backup-space-preview-mobile-ru', fixture: 'visual', space: 'golden-geometry',
dialog: 'backup-space', language: 'ru', theme: 'light',
viewport: { width: 390, height: 820 }, ...page },
]);
+13 -1
View File
@@ -1,4 +1,7 @@
export const GOLDEN_BASELINE_MANIFEST = 'baseline-manifest.json';
// The name must NOT end with `manifest.json`: the HACS submission check globs
// `*manifest.json` over the whole clone of the default branch and refuses a
// repository with more than one match (test/repo-hygiene.test.mjs).
export const GOLDEN_BASELINE_MANIFEST = 'baselines-index.json';
export const assertGoldenInvocation = (mode, scenarioFilter = '') => {
if (!['capture', 'verify'].includes(mode)) throw new Error(`unknown golden mode: ${mode}`);
@@ -6,6 +9,15 @@ export const assertGoldenInvocation = (mode, scenarioFilter = '') => {
throw new Error('golden verify must run the complete matrix; use capture for a diagnostic --scenario run');
};
/** A reviewed golden matrix is exact: neither an orphan PNG nor a stale hash
* entry may survive after a scenario is removed or renamed. */
export const goldenScenarioSetsMatch = (expected, indexed, baselineFiles) => {
const normalized = (values) => [...new Set(values)].sort();
const wanted = normalized(expected);
return JSON.stringify(normalized(indexed)) === JSON.stringify(wanted)
&& JSON.stringify(normalized(baselineFiles)) === JSON.stringify(wanted);
};
export const goldenRunFailed = (mode, manifestValid, results) => {
if (results.some((result) => result.status === 'error')) return true;
return mode === 'verify'
+239 -5
View File
@@ -1,13 +1,18 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { launch } from '../serve.mjs';
import { assertFreshDemoBundle } from '../bundle-freshness.mjs';
import { goldenClip, prepareGoldenScenario } from './harness.mjs';
import { GOLDEN_MATRIX_VERSION, GOLDEN_SCENARIOS } from './matrix.mjs';
import { assertGoldenInvocation, GOLDEN_BASELINE_MANIFEST, goldenRunFailed } from './policy.mjs';
import {
assertGoldenInvocation,
GOLDEN_BASELINE_MANIFEST,
goldenRunFailed,
goldenScenarioSetsMatch,
} from './policy.mjs';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const mode = process.argv.find((arg) => arg.startsWith('--mode='))?.slice(7) || 'capture';
@@ -105,6 +110,178 @@ async function comparePng(page, actual, baseline, threshold) {
});
}
/** Capture the identical visual frame with only the rendered sun-ray SVG
* hidden. Comparing this control frame with the reviewed golden proves that
* the layer changes real browser pixels, not merely that its DOM exists. */
async function captureWithoutSunRays(page, screenshotOptions) {
const layerState = await page.evaluate(async () => {
const layer = window.__goldenCard?.renderRoot?.querySelector('.sunlayer');
if (!layer) throw new Error('semantic golden sun layer is missing');
const shapes = layer.querySelectorAll('path, polygon').length;
if (!shapes) throw new Error('semantic golden sun layer has no painted shapes');
const previous = layer.style.visibility;
layer.style.visibility = 'hidden';
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
return { previous, shapes };
});
try {
return { png: await page.screenshot(screenshotOptions), shapes: layerState.shapes };
} finally {
await page.evaluate(async (previous) => {
const layer = window.__goldenCard?.renderRoot?.querySelector('.sunlayer');
if (layer) layer.style.visibility = previous;
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
}, layerState.previous);
}
}
async function countChangedPixels(page, actual, control, spec) {
return page.evaluate(async ({ actual64, control64, minChannelDelta }) => {
const decode = async (base64) => {
const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
return createImageBitmap(new Blob([bytes], { type: 'image/png' }));
};
const [actualImage, controlImage] = await Promise.all([decode(actual64), decode(control64)]);
if (actualImage.width !== controlImage.width || actualImage.height !== controlImage.height)
throw new Error('semantic golden sun control has different dimensions');
const canvas = document.createElement('canvas');
const controlCanvas = document.createElement('canvas');
canvas.width = controlCanvas.width = actualImage.width;
canvas.height = controlCanvas.height = actualImage.height;
const context = canvas.getContext('2d', { willReadFrequently: true });
const controlContext = controlCanvas.getContext('2d', { willReadFrequently: true });
context.drawImage(actualImage, 0, 0);
controlContext.drawImage(controlImage, 0, 0);
const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
const controlPixels = controlContext.getImageData(0, 0, canvas.width, canvas.height).data;
let changed = 0;
let maxDelta = 0;
for (let offset = 0; offset < pixels.length; offset += 4) {
const delta = Math.max(
Math.abs(pixels[offset] - controlPixels[offset]),
Math.abs(pixels[offset + 1] - controlPixels[offset + 1]),
Math.abs(pixels[offset + 2] - controlPixels[offset + 2]),
);
maxDelta = Math.max(maxDelta, delta);
if (delta >= minChannelDelta) changed++;
}
return { changed, maxDelta, size: [canvas.width, canvas.height] };
}, {
actual64: actual.toString('base64'),
control64: control.toString('base64'),
minChannelDelta: spec.minChannelDelta,
});
}
/** Assert scenario semantics against the actual capture, not only its data.
* This protects a reviewed-but-empty baseline from becoming the reference. */
async function countWarmPixels(page, png, region) {
return page.evaluate(async ({ png64, region }) => {
const bytes = Uint8Array.from(atob(png64), (char) => char.charCodeAt(0));
const image = await createImageBitmap(new Blob([bytes], { type: 'image/png' }));
const canvas = document.createElement('canvas');
canvas.width = image.width;
canvas.height = image.height;
const context = canvas.getContext('2d', { willReadFrequently: true });
context.drawImage(image, 0, 0);
const left = Math.max(0, Math.min(image.width, Math.floor(region.x * image.width)));
const top = Math.max(0, Math.min(image.height, Math.floor(region.y * image.height)));
const right = Math.max(left, Math.min(image.width, Math.ceil((region.x + region.w) * image.width)));
const bottom = Math.max(top, Math.min(image.height, Math.ceil((region.y + region.h) * image.height)));
const pixels = context.getImageData(left, top, right - left, bottom - top).data;
let warm = 0;
for (let offset = 0; offset < pixels.length; offset += 4) {
if (pixels[offset] - pixels[offset + 2] > region.minRedBlueDelta) warm++;
}
return { warm, bounds: [left, top, right, bottom] };
}, { png64: png.toString('base64'), region });
}
/** Semantic guard for issue #68: the reviewed bubble must contain rendered
* glyph pixels, not just an empty surface or a stale open-state flag. */
async function countHelpTextPixels(page, png, clip, spec) {
return page.evaluate(async ({ png64, clip, spec }) => {
const card = window.__goldenCard;
const help = card?.renderRoot?.querySelector(`hp-help[data-help-key="${spec.key}"]`);
const surface = help?.renderRoot?.querySelector('.tooltip:popover-open')
|| card?.renderRoot?.querySelector('hp-dialog')?.renderRoot
?.querySelector('[data-hp-overlay="help"]')?.shadowRoot?.querySelector('.tooltip');
if (!surface) throw new Error(`semantic golden help missing: ${spec.key}`);
const bounds = surface.getBoundingClientRect();
const match = getComputedStyle(surface).color.match(/[\d.]+/g)?.slice(0, 3).map(Number);
if (!match || match.length !== 3) throw new Error(`semantic golden help has invalid text color: ${spec.key}`);
const bytes = Uint8Array.from(atob(png64), (char) => char.charCodeAt(0));
const image = await createImageBitmap(new Blob([bytes], { type: 'image/png' }));
const canvas = document.createElement('canvas');
canvas.width = image.width;
canvas.height = image.height;
const context = canvas.getContext('2d', { willReadFrequently: true });
context.drawImage(image, 0, 0);
const pixels = context.getImageData(0, 0, image.width, image.height).data;
const originX = clip?.x || 0, originY = clip?.y || 0;
const left = Math.max(0, Math.ceil(bounds.left - originX) + 5);
const top = Math.max(0, Math.ceil(bounds.top - originY) + 5);
const right = Math.min(image.width - 1, Math.floor(bounds.right - originX) - 5);
const bottom = Math.min(image.height - 1, Math.floor(bounds.bottom - originY) - 5);
let textPixels = 0;
for (let y = top; y <= bottom; y++) {
for (let x = left; x <= right; x++) {
const offset = (y * image.width + x) * 4;
const distance = Math.abs(pixels[offset] - match[0])
+ Math.abs(pixels[offset + 1] - match[1])
+ Math.abs(pixels[offset + 2] - match[2]);
if (pixels[offset + 3] > 240 && distance <= 90) textPixels++;
}
}
return { textPixels, bounds: [left, top, right, bottom] };
}, { png64: png.toString('base64'), clip, spec });
}
/** Detect one-pixel SVG seams inside a room-coloured opening tunnel. The
* opening symbol is hidden by the scenario, so both centre lines should be a
* locally constant translucent surface. Sampling both axes catches the wall
* centre split as well as boundaries between atomic wall-profile pieces. */
async function inspectTunnelContinuity(page, png, clip, spec) {
return page.evaluate(async ({ png64, clip, spec }) => {
const card = window.__goldenCard;
const tunnel = card?.renderRoot?.querySelector(
`.opening-tunnels[data-layer="data"] [data-hp="opening-tunnel"][data-id="${spec.openingId}"]`,
);
if (!tunnel) throw new Error(`semantic golden tunnel missing: ${spec.openingId}`);
const rect = tunnel.getBoundingClientRect();
const bytes = Uint8Array.from(atob(png64), (char) => char.charCodeAt(0));
const image = await createImageBitmap(new Blob([bytes], { type: 'image/png' }));
const canvas = document.createElement('canvas');
canvas.width = image.width;
canvas.height = image.height;
const context = canvas.getContext('2d', { willReadFrequently: true });
context.drawImage(image, 0, 0);
const originX = clip?.x || 0, originY = clip?.y || 0;
const left = Math.max(0, Math.ceil(rect.left - originX) + spec.insetPx);
const top = Math.max(0, Math.ceil(rect.top - originY) + spec.insetPx);
const right = Math.min(image.width - 1, Math.floor(rect.right - originX) - spec.insetPx);
const bottom = Math.min(image.height - 1, Math.floor(rect.bottom - originY) - spec.insetPx);
if (right - left < 3 || bottom - top < 3)
throw new Error(`semantic golden tunnel is too small: ${left},${top},${right},${bottom}`);
const pixels = context.getImageData(0, 0, image.width, image.height).data;
const rgb = (x, y) => {
const offset = (y * image.width + x) * 4;
return [pixels[offset], pixels[offset + 1], pixels[offset + 2]];
};
let maxJump = 0, samplePairs = 0;
const compare = (a, b) => {
maxJump = Math.max(maxJump,
Math.abs(a[0] - b[0]), Math.abs(a[1] - b[1]), Math.abs(a[2] - b[2]));
samplePairs++;
};
const centreX = Math.round((left + right) / 2);
const centreY = Math.round((top + bottom) / 2);
for (let x = left + 1; x <= right; x++) compare(rgb(x - 1, centreY), rgb(x, centreY));
for (let y = top + 1; y <= bottom; y++) compare(rgb(centreX, y - 1), rgb(centreX, y));
return { maxJump, samplePairs, bounds: [left, top, right, bottom] };
}, { png64: png.toString('base64'), clip, spec });
}
let baselineManifest = null;
const baselineManifestPath = resolve(baselineRoot, GOLDEN_BASELINE_MANIFEST);
if (existsSync(baselineManifestPath)) {
@@ -137,16 +314,67 @@ try {
result.runtime = await prepareGoldenScenario(page, scenario);
if (pageErrors.length) throw new Error(`browser exception: ${pageErrors.join(' | ')}`);
const clip = await goldenClip(page, scenario.capture);
const actual = await page.screenshot({
const screenshotOptions = {
...(clip ? { clip } : {}),
animations: 'disabled',
caret: 'hide',
scale: 'css',
});
};
const actual = await page.screenshot(screenshotOptions);
const actualPath = resolve(actualRoot, `${scenario.id}.png`);
writeFileSync(actualPath, actual);
result.actualSha256 = sha256(actual);
result.actual = actualPath;
if (scenario.sunRayPixels) {
const control = await captureWithoutSunRays(page, screenshotOptions);
const sample = await countChangedPixels(page, actual, control.png, scenario.sunRayPixels);
result.sunRayShapes = control.shapes;
result.sunRayChangedPixels = sample.changed;
result.sunRayMaxChannelDelta = sample.maxDelta;
if (sample.changed < scenario.sunRayPixels.minPixels) {
throw new Error(
`semantic golden assertion failed: sun rays paint ${sample.changed} pixels, expected at least `
+ `${scenario.sunRayPixels.minPixels}`,
);
}
}
if (scenario.warmPixelRegion) {
const sample = await countWarmPixels(page, actual, scenario.warmPixelRegion);
result.warmPixels = sample.warm;
result.warmPixelBounds = sample.bounds;
if (sample.warm < scenario.warmPixelRegion.minPixels) {
throw new Error(
`semantic golden assertion failed: ${sample.warm} warm pixels, expected at least `
+ `${scenario.warmPixelRegion.minPixels}`,
);
}
}
if (scenario.helpTextRegion) {
const sample = await countHelpTextPixels(page, actual, clip, scenario.helpTextRegion);
result.helpTextPixels = sample.textPixels;
result.helpPixelBounds = sample.bounds;
if (sample.textPixels < scenario.helpTextRegion.minPixels) {
throw new Error(
`semantic golden assertion failed: help ${scenario.helpTextRegion.key} contains `
+ `${sample.textPixels} text pixels, expected at least ${scenario.helpTextRegion.minPixels}`,
);
}
}
if (scenario.tunnelContinuity) {
const sample = await inspectTunnelContinuity(
page, actual, clip, scenario.tunnelContinuity,
);
result.tunnelMaxChannelJump = sample.maxJump;
result.tunnelSamplePairs = sample.samplePairs;
result.tunnelPixelBounds = sample.bounds;
if (sample.maxJump > scenario.tunnelContinuity.maxChannelJump) {
throw new Error(
`semantic golden assertion failed: opening ${scenario.tunnelContinuity.openingId} `
+ `has a ${sample.maxJump}-channel local jump, expected at most `
+ `${scenario.tunnelContinuity.maxChannelJump}`,
);
}
}
const baselinePath = resolve(baselineRoot, `${scenario.id}.png`);
result.baseline = baselinePath;
if (!existsSync(baselinePath)) {
@@ -184,11 +412,17 @@ try {
await browser.close();
}
const expectedScenarioIds = GOLDEN_SCENARIOS.map((scenario) => scenario.id);
const indexedScenarioIds = Object.keys(baselineManifest?.scenarios || {});
const baselineScenarioIds = readdirSync(baselineRoot)
.filter((name) => name.endsWith('.png'))
.map((name) => name.slice(0, -'.png'.length));
const manifestValid = !!baselineManifest
&& !baselineManifest.invalid
&& baselineManifest.matrixVersion === GOLDEN_MATRIX_VERSION
&& baselineManifest.chromium === chromium
&& GOLDEN_SCENARIOS.every((scenario) => typeof baselineManifest.scenarios?.[scenario.id] === 'string');
&& expectedScenarioIds.every((id) => typeof baselineManifest.scenarios?.[id] === 'string')
&& goldenScenarioSetsMatch(expectedScenarioIds, indexedScenarioIds, baselineScenarioIds);
const report = {
schema: 1,
mode,
+126
View File
@@ -0,0 +1,126 @@
// #73 stable-release pixel gate. Unlike a loop of Playwright screenshots,
// CDP screencast observes compositor-presented frames and can therefore catch
// the single black/empty frame that motivated the visual-continuity contract.
import { mkdirSync, writeFileSync } from 'node:fs';
import { launch, check, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 820, height: 760 });
const session = await page.context().newCDPSession(page);
const frames = [];
let stopped = false;
session.on('Page.screencastFrame', (event) => {
if (frames.length < 120) frames.push(event.data);
void session.send('Page.screencastFrameAck', { sessionId: event.sessionId });
});
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
try {
await page.waitForFunction(() => {
const card = window.__card;
const root = card?.shadowRoot || card?.renderRoot;
const host = root?.querySelector('ha-card');
return host?.dataset.continuityState === 'steady' && !!host?.dataset.frameFingerprint;
});
const stage = await page.locator('houseplan-card').evaluate((card) => {
const node = (card.shadowRoot || card.renderRoot).querySelector('.stage');
const rect = node.getBoundingClientRect();
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
});
// The reference is an explicitly settled DOM screenshot, never the first
// compositor frame emitted by the screencast itself. A degraded first frame
// must fail against this baseline rather than widening its own tolerance.
const baselinePng = await page.screenshot({ clip: stage });
await session.send('Page.startScreencast', {
format: 'png', everyNthFrame: 1, maxWidth: 820, maxHeight: 760,
});
await wait(120);
await page.evaluate(() => window.__card._pageVisibility({
kind: 'visible', token: 73, at: Date.now(), hiddenFor: 20_000, long: true,
}));
// A harmless real paint gives the compositor enough damage to emit the
// candidate sequence even when the fresh server response equals the stale
// frame pixel-for-pixel.
await page.mouse.move(stage.x + stage.width * 0.42, stage.y + stage.height * 0.52);
await wait(850);
await page.mouse.move(2, 2);
await wait(180);
await session.send('Page.stopScreencast');
stopped = true;
const allMetrics = await page.evaluate(async ({ encoded, crop }) => {
const decode = (data) => new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = reject;
image.src = `data:image/png;base64,${data}`;
});
const out = [];
for (const data of encoded) {
const image = await decode(data);
const scaleX = image.width / innerWidth;
const scaleY = image.height / innerHeight;
const x = Math.max(0, Math.floor(crop.x * scaleX));
const y = Math.max(0, Math.floor(crop.y * scaleY));
const width = Math.max(1, Math.min(image.width - x, Math.floor(crop.width * scaleX)));
const height = Math.max(1, Math.min(image.height - y, Math.floor(crop.height * scaleY)));
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d', { willReadFrequently: true });
context.drawImage(image, x, y, width, height, 0, 0, width, height);
const pixels = context.getImageData(0, 0, width, height).data;
let count = 0;
let sum = 0;
let sum2 = 0;
let dark = 0;
for (let row = 0; row < height; row += 4) {
for (let column = 0; column < width; column += 4) {
const offset = (row * width + column) * 4;
const luma = pixels[offset] * 0.2126
+ pixels[offset + 1] * 0.7152 + pixels[offset + 2] * 0.0722;
count++;
sum += luma;
sum2 += luma * luma;
if (luma < 18) dark++;
}
}
const mean = sum / Math.max(1, count);
out.push({
mean,
variance: sum2 / Math.max(1, count) - mean * mean,
darkRatio: dark / Math.max(1, count),
});
}
return out;
}, { encoded: [baselinePng.toString('base64'), ...frames], crop: stage });
const baseline = allMetrics[0] || { mean: 0, variance: 0, darkRatio: 1 };
const metrics = allMetrics.slice(1);
const forbidden = metrics.filter((frame) => (
frame.variance < Math.max(12, baseline.variance * 0.08)
|| frame.mean < Math.max(4, baseline.mean * 0.35)
|| (baseline.darkRatio < 0.85 && frame.darkRatio > 0.96)
));
mkdirSync('artifacts/continuity-screencast', { recursive: true });
frames.forEach((data, index) => writeFileSync(
`artifacts/continuity-screencast/frame-${String(index).padStart(3, '0')}.png`,
Buffer.from(data, 'base64'),
));
writeFileSync('artifacts/continuity-screencast/metrics.json', JSON.stringify({
stage, frames: metrics, forbidden: forbidden.length,
}, null, 2));
const result = {
capturedPresentedFrames: frames.length >= 2,
baselineContainsPlanDetail: baseline.variance >= 12 && baseline.mean >= 4,
noEmptyOrBlackPresentedFrame: forbidden.length === 0,
};
for (const [name, value] of Object.entries(result)) check(name, value);
await finish(browser, result);
} finally {
if (!stopped) await session.send('Page.stopScreencast').catch(() => undefined);
await browser.close().catch(() => undefined);
}
+54
View File
@@ -0,0 +1,54 @@
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const result = await page.evaluate(async () => {
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
card._openSettingsDialog();
await card.updateComplete;
const group = [...root().querySelectorAll('.dispsection')]
.some((node) => node.textContent.trim() === card._t('gs.backup_group'));
const actions = root().querySelectorAll('.backupactions .btn').length;
const importTrigger = root().querySelector('.backupupload > button');
importTrigger?.focus();
const keyboardImport = importTrigger?.tagName === 'BUTTON'
&& importTrigger.tabIndex === 0
&& importTrigger.matches(':focus');
card._openBackupExport();
await card.updateComplete;
const exportRadios = root().querySelectorAll('input[name="backup-kind"]').length;
const exportWarning = !!root().querySelector('.backupwarn');
card._backupExportDialog = null;
card._backupImportDialog = {
filename: 'backup.json', size: 1024, token: 'opaque',
preview: {
kind: 'full', source: 'foreign', created_at: '2026-08-11T00:00:00Z',
counts: { spaces: 1, rooms: 2, markers: 3, layout: 4 },
confirmation_required: true,
content: [{ url: '/api/houseplan/content/plans/_/floor.svg', state: 'detach_required' }],
},
expectedConfigRev: 1, expectedLayoutRev: 2, duplicatePolicy: 'skip',
confirmMissing: false, busy: false, error: '',
};
await card.updateComplete;
const apply = root().querySelector('hp-dialog .btn.danger');
const importSafe = {
danger: !!apply,
disabledUntilConfirmed: !!apply?.disabled,
noHorizontalOverflow: root().querySelector('hp-dialog').scrollWidth
<= root().querySelector('hp-dialog').clientWidth,
};
return { group, actions, keyboardImport, exportRadios, exportWarning, importSafe };
});
checkAll(result, {
group: true,
actions: 2,
keyboardImport: true,
exportRadios: 2,
exportWarning: true,
importSafe: { danger: true, disabledUntilConfirmed: true, noHorizontalOverflow: true },
});
await finish(browser, result);
+16 -4
View File
@@ -8,6 +8,12 @@ const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const settleMode = async () => {
const started = performance.now();
do { await new Promise((resolve) => requestAnimationFrame(resolve)); }
while (c._modeTransitionBusy && performance.now() - started < 1500);
await c.updateComplete;
};
const stageBg = async () => {
c.requestUpdate();
await c.updateComplete;
@@ -90,9 +96,11 @@ const res = await page.evaluate(async () => {
// 8) editors keep their own canvas (inline bg only in view/kiosk)
c._setMode('decor');
await settleMode();
const editorBg = await waitStageBg((value) => value !== rgb('#0a2a4a'));
out.editorUnpainted = editorBg !== rgb('#0a2a4a');
c._setMode('view');
await settleMode();
out.viewPaintedAgain = (await waitStageBg((value) => value === rgb('#0a2a4a'))) === rgb('#0a2a4a');
// 9) kiosk instance respects the setting
@@ -165,6 +173,12 @@ const paper = await page.evaluate(async () => {
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();
do { await new Promise((resolve) => requestAnimationFrame(resolve)); }
while (c._modeTransitionBusy && performance.now() - started < 1500);
await c.updateComplete;
};
c._serverCfg.settings = { ...(c._serverCfg.settings || {}), bg_color: '#ff00ff' };
c._cfgRev = (c._cfgRev || 0) + 1;
await upd();
@@ -189,12 +203,10 @@ const paper = await page.evaluate(async () => {
out.imageBelowRooms = idxOf('image') < idxOf('.room, .room-outline');
// editors keep the paper too (their canvas ignores bg_color anyway)
c._setMode('decor');
await upd();
await new Promise((resolve) => setTimeout(resolve, 220));
await upd(); await settleMode();
out.editorKeepsPaper = !!sr().querySelector('.stage svg .hp-paper');
c._setMode('view');
await upd();
await new Promise((resolve) => setTimeout(resolve, 220));
await upd(); await settleMode();
// daynight night: the paper dims via the brightness filter ONLY, never alpha
c._serverCfg.settings.bg_mode = 'daynight';
c._serverCfg.settings.north_deg = 0;
+1 -1
View File
@@ -101,7 +101,7 @@ Object.assign(out, await page.evaluate(async () => {
await c.updateComplete;
const ac = c._devices.find((d) => d.entities?.includes('climate.ac'));
o.liveTempFromClimate = c._liveTemp(ac); // === 23.5
const badges = [...sr().querySelectorAll('.dev .tval')].map((el) => el.textContent.trim());
const badges = [...sr().querySelectorAll('.dev .value-badge')].map((el) => el.textContent.trim());
o.badgeVisible = badges.includes('23.5°');
// средняя гостиной: (20 + 23.5) / 2 = 21.75 → 21.8 по сетке карточки (0.1°)
o.avgWithOption = c._climate().get('living_room')?.temp;
+55 -10
View File
@@ -19,11 +19,30 @@ const res = await page.evaluate(async () => {
c._openMarkerDialog();
const room = c._spaceModel().rooms.find((r) => r.id && r.area);
c._markerDialog = { ...c._markerDialog, name: 'Выключатель', binding: 'virtual',
room: c._space + '#' + room.area, tapAction: 'toggle', controls: lights,
room: c._space + '#' + room.area, tapAction: 'toggle', tapActionTouched: true, controls: lights,
icon: 'mdi:light-switch', display: 'icon_ripple' };
await c._saveMarker(); await c.updateComplete;
const dev = c._devices.find((d) => d.name === 'Выключатель');
out.markerSaved = !!dev && JSON.stringify(dev.marker.controls) === JSON.stringify(lights);
// #84: a forced plan source without its own HA entity is selectable as a
// target and marker:* never leaks into the controller's HA service list.
const passiveId = 'smoke_passive_lamp';
c._serverCfg.markers.push({
id: passiveId, binding: 'virtual', name: 'Passive lamp', is_light: true,
space: c._space, area: room.area,
});
c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
const controller = c._devices.find((item) => item.id === dev.id);
c._openMarkerDialog(controller); await c.updateComplete;
c._markerDialog = { ...c._markerDialog, controlsFilter: 'Passive lamp' };
await c.updateComplete;
const planCandidate = c._controlCandidates(c._markerDialog)
.find((candidate) => candidate.value === `marker:${passiveId}`);
out.passivePlanSourceCandidate = !!planCandidate;
c._addControlRef(c._markerDialog, `marker:${passiveId}`); await c.updateComplete;
out.passiveRefStoredInDraft = c._markerDialog.controls.includes(`marker:${passiveId}`);
c._markerDialog = null;
c._setMode('view'); await c.updateComplete;
// 1) все выключены → клик включает все
await setSt({ [lights[0]]: 'off', [lights[1]]: 'off' });
@@ -40,8 +59,28 @@ const res = await page.evaluate(async () => {
out.aggregateActivityRuns = c._stateClass(dev2()).includes('activity-running');
await setSt({ [lights[0]]: 'off' });
out.stateOff = c._stateClass(dev2()) === '';
// 4) без tap_action=toggle клик НЕ переключает (инфо)
// #94/#73 boundary: a complete visual frame may temporarily retain the old
// DevItem while live config has already changed. The click must resolve the
// current marker by id and never call the controls captured by that frame.
const cfg = c._serverCfg.markers.find((m) => m.name === 'Выключатель');
const staleSource = dev2();
const visuallyStale = {
...staleSource,
controls: [...(staleSource.controls || [])],
marker: staleSource.marker ? {
...staleSource.marker,
controls: [...(staleSource.marker.controls || [])],
} : null,
};
cfg.controls = [lights[1]];
c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
await setSt({ [lights[0]]: 'off', [lights[1]]: 'off' });
c._clickDevice(new MouseEvent('click'), visuallyStale);
out.staleFrameUsesCurrentControls = JSON.stringify(calls.at(-1))
=== JSON.stringify(['homeassistant', 'turn_on', [lights[1]]]);
cfg.controls = [...lights];
c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
// 4) без tap_action=toggle клик НЕ переключает (инфо)
cfg.tap_action = 'info'; c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
const n = calls.length;
c._clickDevice(new MouseEvent('click'), dev2());
@@ -77,17 +116,23 @@ const res = await page.evaluate(async () => {
// operates the entity, and an ON hood/fan still has working-state yellow.
const own = Object.keys(c.hass.states).find((eid) => eid.startsWith('switch.'));
out.hasStandaloneSwitch = !!own;
const self = own && {
id: 'self-control-regression', name: 'Hood', icon: 'mdi:fan',
space: c._space, area: room.area, entities: [own], primary: own,
bindingKind: 'entity', bindingRef: own, tapAction: 'toggle',
marker: { id: 'self-control-regression', binding: `entity:${own}`, controls: [own] },
};
const selfId = 'self-control-regression';
if (own) {
c._serverCfg.markers.push({
id: selfId, name: 'Hood', icon: 'mdi:fan', binding: `entity:${own}`,
space: c._space, area: room.area, tap_action: 'toggle', controls: [own],
});
c._regSignature = '';
c._maybeRebuildDevices();
await c.updateComplete;
}
const self = c._devices.find((device) => device.id === selfId);
if (self) {
await setSt({ [own]: 'off' });
c._clickDevice(new MouseEvent('click'), self);
out.selfControlUsesDirectToggle = JSON.stringify(calls.at(-1))
=== JSON.stringify(['homeassistant', 'toggle', own]);
const selfCall = calls.at(-1);
out.selfControlUsesDirectToggle = ['switch', 'homeassistant'].includes(selfCall?.[0])
&& selfCall?.[1] === 'turn_on' && selfCall?.[2] === own;
c._openMarkerDialog(self); await c.updateComplete;
out.selfControlAbsentFromDialog = c._markerDialog?.controls.length === 0;
c._markerDialog = null;
+7 -1
View File
@@ -105,7 +105,7 @@ const out = await page.evaluate(async () => {
o.openingRingIsTheBreathingOne = (() => {
const ring = devEl()?.querySelector('.activity-ring.transition i:first-child');
const cs = ring ? getComputedStyle(ring) : null;
return !!cs && cs.animationName === 'hp-activity-breathe' && cs.animationDuration === '2.2s';
return !!cs && cs.animationName === 'hp-pulse-continuous' && cs.animationDuration === '2.4s';
})();
await setCover('closing', { device_class: 'curtain' });
@@ -181,6 +181,8 @@ const out = await page.evaluate(async () => {
const valve = { id: 'v', primary: 'valve.water', entities: ['valve.water'], marker: null };
const clsOfState = (d, states) => {
const saved = c.hass;
const visibleSnapshot = c._visibleDeviceSnapshot;
const candidateSnapshot = c._candidateDeviceSnapshot;
const entities = Object.fromEntries(Object.keys(states).map((entity_id) => [
entity_id,
{ entity_id, platform: 'demo', disabled_by: null },
@@ -190,8 +192,12 @@ const out = await page.evaluate(async () => {
entities: { ...c.hass.entities, ...entities },
states: { ...c.hass.states, ...states },
};
c._visibleDeviceSnapshot = null;
c._candidateDeviceSnapshot = null;
const r = c._stateClass(d);
c.hass = saved;
c._visibleDeviceSnapshot = visibleSnapshot;
c._candidateDeviceSnapshot = candidateSnapshot;
return r;
};
o.valveOpenKeepsTheFrame = clsOfState(valve, { 'valve.water': { state: 'open', attributes: {} } }) === 'open';
+34 -14
View File
@@ -34,7 +34,7 @@ const out = await page.evaluate(async () => {
};
const coverState = (state, attrs = {}) => ({
entity_id: 'cover.office_curtain', state,
attributes: { friendly_name: 'Office curtain', ...attrs },
attributes: { friendly_name: 'Office curtain', supported_features: 11, ...attrs },
});
const push = async (state, attrs = {}) => {
c.hass = {
@@ -80,17 +80,21 @@ const out = await page.evaluate(async () => {
o.primaryIsTheCover = curtain()?.primary === 'cover.office_curtain';
o.coverIsAmongTheEntities = (curtain()?.entities || []).includes('cover.office_curtain');
// ---- the dialog offers the action and SAVES it -------------------------
// ---- the dialog offers the unified action and SAVES an intentional edit
c._setMode('devices'); await c.updateComplete;
c._openMarkerDialog(curtain()); await c.updateComplete;
const optionValues = () => [...sr().querySelectorAll('hp-dialog option')].map((e) => e.value);
o.dialogOffersCover = optionValues().includes('cover');
c._markerDialog = { ...c._markerDialog, tapAction: 'cover', display: 'icon_ripple' };
o.dialogOffersUnifiedToggle = optionValues().includes('toggle')
&& !optionValues().includes('cover');
c._markerDialog = {
...c._markerDialog,
tapAction: 'toggle', tapActionTouched: true, display: 'icon_ripple',
};
await c.updateComplete;
await c._saveMarker();
await c.updateComplete;
const saved = (c._serverCfg.markers || []).find((m) => m.binding === 'device:d_curtain');
o.markerSaved = saved?.tap_action === 'cover';
o.markerSavedAsUnifiedToggle = saved?.tap_action === 'toggle';
// ---- and the tap on THAT marker opens the curtain -----------------------
c._setMode('view'); c._regSignature = ''; c._maybeRebuildDevices();
@@ -100,8 +104,13 @@ const out = await page.evaluate(async () => {
await c.updateComplete;
await new Promise((r) => setTimeout(r, 10));
};
o.tapActionSurvivedTheRebuild = curtain()?.tapAction === 'cover';
o.tapActionSurvivedTheRebuild = curtain()?.tapAction === 'toggle';
o.coverServicesDeclared = ['open_cover', 'close_cover', 'stop_cover']
.every((service) => Object.hasOwn(c.hass.services?.cover || {}, service));
calls.length = 0;
const closedIntent = c._toggleIntent(curtain());
o.closedIntentResolvesExactTarget = closedIntent?.command?.service === 'open_cover'
&& closedIntent.command.data.entity_id === 'cover.office_curtain';
await tap();
o.closedOpensTheCover = JSON.stringify(calls[calls.length - 1] || [])
=== JSON.stringify(['cover', 'open_cover', { entity_id: 'cover.office_curtain' }]);
@@ -122,12 +131,12 @@ const out = await page.evaluate(async () => {
// the service switch, which used to be the whole story, is never touched
o.serviceSwitchNeverCalled = !calls.some((x) => String(x[2]?.entity_id).startsWith('switch.'));
// ---- the guarded classes still degrade, read off the COVER --------------
// ---- guarded classes are an explicit secure no-op -----------------------
await push('closed', { device_class: 'garage' });
calls.length = 0;
await tap();
o.garageCallsNothing = calls.length === 0;
o.garageShowsInfo = !!c._infoCard;
o.garageDoesNotFallbackToInfo = !c._infoCard;
c._infoCard = null; await c.updateComplete;
c._setMode('devices'); await c.updateComplete;
c._openMarkerDialog(curtain()); await c.updateComplete;
@@ -146,12 +155,10 @@ const out = await page.evaluate(async () => {
// SHOWS»): the marker indicates the entity its tap ACTS ON — the cover
// exactly when the owner has explicitly chosen «Открыть/закрыть».
const devEl = (dev) => {
const v = c._viewOr(c._baseVb());
const p = c._pos(dev);
const left = ((p.x - v.x) / v.w) * 100;
const top = ((p.y - v.y) / v.h) * 100;
return [...sr().querySelectorAll('.devlayer .dev')].find((e) =>
Math.abs(parseFloat(e.style.left) - left) < 0.4 && Math.abs(parseFloat(e.style.top) - top) < 0.4);
// The viewport may refit asynchronously after an editor transition. Use
// the stable public styling hook instead of racing rendered percentages
// against the next viewBox.
return dev ? sr().querySelector(`.devlayer .dev[data-id="${CSS.escape(dev.id)}"]`) : null;
};
const clsOf = (dev) => [...(devEl(dev)?.classList || [])];
const iconOf = (dev) => devEl(dev)?.querySelector('ha-icon')?.getAttribute('icon') || '';
@@ -194,6 +201,19 @@ const out = await page.evaluate(async () => {
await setSwitch('on');
o.withoutTheActionResolvedCoverSpeaks = clsOf(curtain()).includes('activity-transition')
&& !clsOf(curtain()).includes('on');
calls.length = 0;
c._infoCard = null;
// A local card must not depend on a second registry read at click time. The
// already-rendered curtain remains a valid informational snapshot while HA's
// registry is transiently unavailable; service-backed actions stay gated.
const liveBindingStatus = c._bindingStatus;
c._bindingStatus = () => ({
kind: 'unverified', reason: 'registry_unavailable', enabledEntityIds: [], allEntityIds: [],
});
try { await tap(); } finally { c._bindingStatus = liveBindingStatus; }
o.infoActionOpensTheHousePlanCard = c._infoCard?.id === curtain()?.id;
o.infoActionCallsNoHaService = calls.length === 0;
c._infoCard = null;
savedMarker.tap_action = 'cover';
await push('closed', { device_class: 'curtain' });
await setSwitch('off');
+6
View File
@@ -45,6 +45,8 @@ const out = await page.evaluate(async () => {
// =======================================================================
const withStates = (states, fn) => {
const saved = c.hass;
const visibleSnapshot = c._visibleDeviceSnapshot;
const candidateSnapshot = c._candidateDeviceSnapshot;
const entities = Object.fromEntries(Object.keys(states).map((entity_id) => [
entity_id,
{ entity_id, platform: 'demo', disabled_by: null },
@@ -54,8 +56,12 @@ const out = await page.evaluate(async () => {
entities: { ...c.hass.entities, ...entities },
states: { ...c.hass.states, ...states },
};
c._visibleDeviceSnapshot = null;
c._candidateDeviceSnapshot = null;
const r = fn();
c.hass = saved;
c._visibleDeviceSnapshot = visibleSnapshot;
c._candidateDeviceSnapshot = candidateSnapshot;
return r;
};
const st = (state, attrs = {}) => ({ state, attributes: attrs });
+29 -18
View File
@@ -1,5 +1,5 @@
// Covers: the «Open/close» tap action + the travelling indication
// (owner's contract 2026-08-03).
// Covers after issue #94: the unified «Toggle state» action + the travelling
// indication. Persisted `cover` remains a lossless legacy runtime token.
//
// closed -> cover.open_cover
// open / ajar -> cover.close_cover
@@ -7,9 +7,8 @@
// the next tap simply travels the other way)
// unknown -> cover.toggle, no morphing, no pulse
//
// The option is offered ONLY for a binding that HAS a cover entity, and never
// for the guarded classes (garage/door/gate) — for those a value smuggled into
// the config degrades to 'info', exactly like a card-wide toggle does.
// The editor never offers a separate cover option. Secure cover classes
// (garage/door/gate) resolve to a quiet no-op rather than an info-card fallback.
// With «Icon + activity», travelling breathes a soft activity ring (the
// vacuum puck's 2.2s period) and the plate stays NEUTRAL — yellow means work.
// Since 2026-08-04 the plate is neutral in EVERY cover state, the «открыто»
@@ -26,7 +25,10 @@ const out = await page.evaluate(async () => {
const gateEntity = (state, attrs) => ({
entity_id: 'cover.gate', state,
attributes: { friendly_name: 'Gate', ...attrs },
// Declare the capabilities exercised below. Issue #94 intentionally
// resolves services fail-closed, so an entity without feature metadata is
// not a valid fixture for open / close / stop dispatch.
attributes: { friendly_name: 'Gate', supported_features: 11, ...attrs },
});
/** Push a cover state into hass keeping the service recorder in place. */
const setCover = async (state, attrs = {}) => {
@@ -71,26 +73,35 @@ const out = await page.evaluate(async () => {
c.requestUpdate();
await c.updateComplete;
// ---- the dialog offers the option only where it makes sense ------------
// ---- the dialog offers one universal action -----------------------------
await setCover('closed', { device_class: 'curtain' });
await setMarker('cover');
c._setMode('devices'); await c.updateComplete;
const optionValues = () => [...sr().querySelectorAll('hp-dialog option')].map((e) => e.value);
c._openMarkerDialog(gate()); await c.updateComplete;
o.dialogOffersCoverForCurtain = optionValues().includes('cover');
// …and the confirmation checkbox follows the choice, like it does for run/toggle
const rowsBefore = sr().querySelectorAll('hp-dialog .srcrow').length;
c._markerDialog = { ...c._markerDialog, tapAction: 'cover' }; await c.updateComplete;
o.confirmCheckboxShownForCover = sr().querySelectorAll('hp-dialog .srcrow').length > rowsBefore;
o.dialogOffersOnlyUnifiedToggle = optionValues().includes('toggle')
&& !optionValues().includes('cover');
// Opening a legacy marker projects it to the unified selector without
// rewriting the persisted token until the user intentionally changes it.
o.legacyCoverProjectsToToggle = c._markerDialog?.tapAction === 'toggle'
&& c._markerDialog?.tapActionTouched === false;
o.untouchedLegacyCoverRemainsLossless =
c._markerTapActionFields(c._markerDialog)?.tap_action === 'cover';
o.intentionalEditWritesUnifiedToggle = c._markerTapActionFields({
...c._markerDialog, tapAction: 'toggle', tapActionTouched: true,
})?.tap_action === 'toggle';
c._markerDialog = null; await c.updateComplete;
// a GARAGE door never gets the option (owner: «нет, только шторы/жалюзи»)
// The same universal option is visible for a garage; runtime safety decides.
await setCover('closed', { device_class: 'garage' });
c._openMarkerDialog(gate()); await c.updateComplete;
o.dialogHidesCoverForGarage = !optionValues().includes('cover');
o.garageAlsoShowsUnifiedToggle = optionValues().includes('toggle')
&& !optionValues().includes('cover');
c._markerDialog = null; await c.updateComplete;
// …and neither does a device without a cover entity at all
c._openMarkerDialog(c._devices.find((x) => x.bindingRef === 'd_mower')); await c.updateComplete;
o.dialogHidesCoverWithoutCover = !optionValues().includes('cover');
o.virtualAlsoShowsUnifiedToggle = optionValues().includes('toggle')
&& !optionValues().includes('cover');
c._markerDialog = null; await c.updateComplete;
c._setMode('view'); c._space = 'garden'; c.requestUpdate(); await c.updateComplete;
@@ -138,14 +149,14 @@ const out = await page.evaluate(async () => {
await new Promise((r) => setTimeout(r, 10));
o.confirmOkOpens = calls.length === 1 && calls[0][1] === 'open_cover';
// ---- a saved 'cover' on a GARAGE degrades to the info card -------------
// ---- a saved 'cover' on a GARAGE is a secure, quiet no-op ---------------
await setMarker('cover');
await setCover('closed', { device_class: 'garage' });
calls.length = 0;
await tap();
await new Promise((r) => setTimeout(r, 10));
o.garageSavedCoverCallsNothing = calls.length === 0;
o.garageSavedCoverShowsInfo = !!c._infoCard;
o.garageSavedCoverDoesNotFallbackToInfo = !c._infoCard;
c._infoCard = null; await c.updateComplete;
// ---- the indication ----------------------------------------------------
@@ -163,7 +174,7 @@ const out = await page.evaluate(async () => {
o.onlyTheCoverOnThePlan = sr().querySelectorAll('.dev').length === 1;
o.pulseWhileOpening = sr().querySelectorAll('.dev.activity-transition').length === 1;
const a1 = afterAnim();
o.pulseIsBreathingRing = a1?.name === 'hp-activity-breathe' && a1?.dur === '2.2s';
o.pulseIsBreathingRing = a1?.name === 'hp-pulse-continuous' && a1?.dur === '2.4s';
// the plate stays NEUTRAL while travelling: yellow is reserved for «включено»
o.movingPlateNotYellow = getComputedStyle(devEl()).backgroundColor !== YELLOW;
+11 -2
View File
@@ -4,11 +4,17 @@ const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const settleMode = async () => {
const started = performance.now();
do { await new Promise((resolve) => requestAnimationFrame(resolve)); }
while (c._modeTransitionBusy && performance.now() - started < 1500);
await c.updateComplete;
};
const esc = async () => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await c.updateComplete; };
// 1) третья вкладка есть
const tabs = [...sr().querySelectorAll('.modetab')];
out.threeTabs = tabs.length === 3;
tabs[2].click(); await c.updateComplete;
tabs[2].click(); await settleMode();
out.decorMode = c._mode === 'decor';
out.decorBar = !!sr().querySelector('.editbar.decorbar');
// The compact colour/opacity control must escape the clipped editor/dialog
@@ -253,7 +259,10 @@ const res = await page.evaluate(async () => {
c._redoGeometry(); await c.updateComplete;
out.lineStyleUndoRedo = undoSolid
&& c._decorList.find((x) => x.id === 'dcprobe')?.line_style === 'dashed';
const selectHit = sr().querySelector('.decorlayer line.dselecthit');
// Selection handles intentionally sit above the hit proxy. Clear the frame
// so this assertion measures the dashed line's gap, not an endpoint handle.
c._decorSel = null; c.requestUpdate(); await c.updateComplete;
const selectHit = sr().querySelector('.decorlayer line.dselecthit[data-id="dcprobe"]');
const dashedPaint = probe();
let gapPicked = null;
if (selectHit && dashedPaint) {
+7 -1
View File
@@ -21,6 +21,12 @@ const res = await page.evaluate(async () => {
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const settleMode = async () => {
const started = performance.now();
do { await new Promise((resolve) => requestAnimationFrame(resolve)); }
while (c._modeTransitionBusy && performance.now() - started < 1500);
await c.updateComplete;
};
// null-safe: на сборке ДО этих правок у надписи нет ни data-id, ни tspan —
// смок должен показать список провалов, а не упасть с исключением
const el = (id) => sr().querySelector(`.decorlayer text.dtext[data-id="${id}"]`);
@@ -41,7 +47,7 @@ const res = await page.evaluate(async () => {
cancelable: true, pointerId: 7, clientX, clientY, button: 0, isPrimary: true, ...extra }));
};
sr().querySelectorAll('.modetab')[2].click(); await c.updateComplete;
sr().querySelectorAll('.modetab')[2].click(); await settleMode();
c._curSpaceCfg.decor = [];
c._decorTool = 'select'; c._decorSel = null; await c.updateComplete;
+78 -2
View File
@@ -14,11 +14,15 @@ const res = await page.evaluate(async () => {
classes: semantic.filter((name) => node.classList.contains(name)),
icon: node.querySelector(':scope > ha-icon')?.getAttribute('icon') || '',
value: node.querySelector('.valtext')?.textContent?.trim() || '',
temp: node.querySelector('.tval')?.textContent?.trim() || '',
hum: node.querySelector('.hval')?.textContent?.trim() || '',
badge: node.querySelector('.value-badge')?.textContent?.trim() || '',
badgePosition: [...(node.querySelector('.value-badge')?.classList || [])]
.find((name) => name.startsWith('pos-')) || '',
scale: node.style.getPropertyValue('--dev-scale'),
rippleScale: node.style.getPropertyValue('--ripple-scale'),
rippleColor: node.style.getPropertyValue('--ripple-color'),
pulse: [...(node.querySelector('.device-pulse')?.classList || [])]
.filter((name) => ['alarm', 'short', 'continuous', 'event', 'presence', 'transition', 'running'].includes(name)),
reducedMotionDot: !!node.querySelector('.activity-dot'),
};
};
@@ -36,6 +40,71 @@ const res = await page.evaluate(async () => {
const previewFace = face(preview?.renderRoot?.querySelector('.dev'));
const providerShown = /demo/i.test(preview?.renderRoot?.querySelector('.previewfacts')?.textContent || '');
// Dynamic option creation must not leave a freshly mounted native select on
// its first row when a different display/room/badge choice is persisted.
// Unmount the dialog first: replacing one open marker can leave old option
// nodes around and would miss the real first-open regression.
c._markerDialog = null;
await c.updateComplete;
const badgeDevice = c._devices.find((item) => item.id === 'd_temp');
c._openMarkerDialog({
...badgeDevice,
marker: {
...(badgeDevice.marker || {}),
display: 'value',
value_badge: {
enabled: true,
source: { kind: 'derived_lqi' },
position: 'left',
},
},
});
await c.updateComplete;
const displaySelect = sr().querySelector('#marker-display');
const roomSelect = sr().querySelector('#marker-room');
const badgeSourceSelect = sr().querySelector('#marker-value-badge-source');
const badgePositionSelect = sr().querySelector('#marker-value-badge-position');
const savedBadgePreview = sr().querySelector('hp-device-preview');
await savedBadgePreview?.updateComplete;
const savedDisplaySelected = displaySelect?.value === 'value'
&& displaySelect?.selectedOptions?.[0]?.value === 'value';
const expectedRoom = c._markerDialog?.room || '';
const savedRoomSelected = expectedRoom !== '' && roomSelect?.value === expectedRoom
&& roomSelect?.selectedOptions?.[0]?.value === expectedRoom;
const savedBadgeSourceSelected = badgeSourceSelect?.value === 'derived:lqi'
&& badgeSourceSelect?.selectedOptions?.[0]?.value === 'derived:lqi';
const savedBadgePositionSelected = badgePositionSelect?.value === 'left'
&& badgePositionSelect?.selectedOptions?.[0]?.value === 'left';
const savedBadgePreviewMatches = savedBadgePreview?.renderRoot
?.querySelector('.value-badge')?.textContent?.trim() === '154';
const savedBadgeUntouched = c._markerDialog?.valueBadgeTouched === false;
// Issue #90: every explicit anchor, including a long value and the largest
// activity ring, stays inside the preview safe area with a real measured gap.
c.hass.states['sensor.hp_preview_long'] = {
entity_id: 'sensor.hp_preview_long', state: '12345678901234567890', attributes: {},
};
let badgeBounds = true;
for (const position of ['right', 'bottom', 'left', 'top']) {
c._markerDialog = {
...c._markerDialog,
display: 'icon_ripple', rippleSize: 8,
valueBadgeEnabled: true, valueBadgeTouched: true,
valueBadgeSource: { kind: 'entity_state', entity_id: 'sensor.hp_preview_long' },
valueBadgePosition: position,
};
c.requestUpdate();
await c.updateComplete;
const currentPreview = sr().querySelector('hp-device-preview');
await currentPreview?.updateComplete;
const stage = currentPreview?.renderRoot?.querySelector('.previewstage')?.getBoundingClientRect();
const badge = currentPreview?.renderRoot?.querySelector('.value-badge')?.getBoundingClientRect();
const gap = 3;
badgeBounds &&= !!stage && !!badge
&& badge.left >= stage.left + gap && badge.right <= stage.right - gap
&& badge.top >= stage.top + gap && badge.bottom <= stage.bottom - gap;
}
await customElements.whenDefined('houseplan-space-card');
const card = document.createElement('houseplan-space-card');
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
@@ -52,6 +121,13 @@ const res = await page.evaluate(async () => {
planPreviewEqual: JSON.stringify(planFace) === JSON.stringify(previewFace),
planStaticEqual: JSON.stringify(planFace) === JSON.stringify(staticFace),
providerShown,
savedDisplaySelected,
savedRoomSelected,
savedBadgeSourceSelected,
savedBadgePositionSelected,
savedBadgePreviewMatches,
savedBadgeUntouched,
badgeBounds,
staticBindingHook: staticNode?.getAttribute('data-binding-status') === 'active',
};
});
+19 -15
View File
@@ -5,6 +5,12 @@ const res = await page.evaluate(async () => {
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const tabs = () => [...sr().querySelectorAll('.modetab')];
const settleMode = async () => {
const started = performance.now();
do { await new Promise((resolve) => requestAnimationFrame(resolve)); }
while (c._modeTransitionBusy && performance.now() - started < 1500);
await c.updateComplete;
};
// 1) две вкладки, Просмотра нет, крестиков в неактивных нет
out.twoTabs = tabs().length === 3; // третья — Редактор подложки (v1.33.0)
out.labels = tabs().map((t) => t.textContent.trim());
@@ -28,10 +34,7 @@ const res = await page.evaluate(async () => {
// 3) повторный клик по активной вкладке — ничего
tabs()[0].click(); await c.updateComplete;
out.reclickNoop = c._mode === 'plan';
// Let the View -> editor grid transition finish first. A real user cannot
// switch editors in the same zero-layout frame, and competing WAAPI/CSS
// height animations in that frame are intentionally suppressed.
await new Promise((resolve) => setTimeout(resolve, 220));
await settleMode();
// HP-UX-11: transient tool controls live in a stage overlay. Opening the
// thickness controls must not resize/refit the stage or move pinned Close.
const stageBeforeContext = sr().querySelector('.stage').getBoundingClientRect();
@@ -117,30 +120,31 @@ const res = await page.evaluate(async () => {
out.devBarBtns = sr().querySelectorAll('.editbar.devbar .btn:not(.barclose)').length === 3; // add/show-all/rules (v1.33.2: Reset removed)
const swapChrome = sr().querySelector('.editorchrome');
const swapInner = swapChrome.querySelector('.editorchrome-inner');
out.editorSwapAnimatesHeight = swapChrome.getAnimations()
.some((animation) => animation.id === 'hp-editor-height-swap');
out.editorSwapAnimatesContent = swapInner.getAnimations()
.some((animation) => animation.id === 'hp-editor-content-swap');
out.editorSwapAnimatesHeight = c._modeTransitionBusy
&& sr().querySelector('.stage').classList.contains('mode-transition');
out.editorSwapAnimatesContent = c._modeTransitionBusy && !!swapInner
&& (c._modeTransitionVisual?.toolbarContentOpacity ?? 1) < 1;
await settleMode();
// 5) инструменты устройств из шапки исчезли (в .bar их больше нет)
out.headerCleanInDev = !sr().querySelector('.bar > .btn[title*="' + (c._t('title.add_device')) + '"]');
// 6) крестик на панели → Просмотр
sr().querySelector('.editbar .barclose').click(); await c.updateComplete;
const chrome = sr().querySelector('.editorchrome');
out.barCloseStarts = c._mode === 'view' && !chrome.classList.contains('open')
&& chrome.getAttribute('aria-hidden') === 'true';
// The last bar deliberately remains mounted while its grid row collapses.
await new Promise((resolve) => setTimeout(resolve, 220));
out.barCloseStarts = c._mode === 'view' && c._modeTransitionBusy
&& sr().querySelector('.stage').inert
&& sr().querySelector('.stage').classList.contains('mode-transition');
await settleMode();
out.barCloseWorks = c._mode === 'view'
&& getComputedStyle(chrome).visibility === 'hidden'
&& chrome.getBoundingClientRect().height < 1;
// 7) крестик в самой вкладке → Просмотр (и не переключает режим)
tabs()[1].click(); await c.updateComplete;
tabs()[1].querySelector('.closex').click(); await c.updateComplete;
tabs()[1].click(); await settleMode();
tabs()[1].querySelector('.closex').click(); await settleMode();
out.tabCrossWorks = c._mode === 'view';
// Layout-independent editor history: physical KeyZ works when `key` is the
// Russian «я», while an input keeps its native browser history.
tabs()[0].click(); await c.updateComplete;
tabs()[0].click(); await settleMode();
c._tool = 'draw'; c.requestUpdate(); await c.updateComplete;
const realUndo = c._undoGeometry;
const realRedo = c._redoGeometry;
-2
View File
@@ -141,8 +141,6 @@ const out = await page.evaluate(async () => {
const mockTitleRect = mockTitle?.getBoundingClientRect();
const dangerRect = partitionLayout.danger?.getBoundingClientRect();
const commitRect = partitionLayout.commit?.getBoundingClientRect();
o.physicalTitleUsesAutoHeight = !!haDialog && getComputedStyle(haDialog)
.getPropertyValue('--ha-dialog-header-title-height').trim() === 'auto';
o.physicalTitleWrapsAndIsContained = !!titleRect && !!mockTitleRect
&& titleRect.height > 35
&& titleRect.top >= mockTitleRect.top - 1
+1 -1
View File
@@ -53,7 +53,7 @@ check('bundleVersionFound', typeof BUNDLE_VERSION === 'string' && BUNDLE_VERSION
checkAll(res, {
"rows": 15, // 11 цветов (включая wall_fill) + радиус свечения + фон
// + «Оптимизировать планы» (docs/CANVAS.md §9)
"groups": ["Fill: lights", "Fill: temperature", "Fill: zigbee signal", "Light-source glow", "Walls", "Stage background", "Sun", "Plan maintenance", "About"],
"groups": ["Fill: lights", "Fill: temperature", "Fill: zigbee signal", "Light-source glow", "Walls", "Stage background", "Sun", "Backup and transfer", "Plan maintenance", "About"],
"aboutVersion": `Houseplan Card v${BUNDLE_VERSION}`, // та же константа, что в баннере
"aboutLinks": [
{ "href": "https://github.com/Matysh/houseplan-card", "target": "_blank", "rel": "noopener" },
+17 -1
View File
@@ -109,9 +109,12 @@ const res = await page.evaluate(async () => {
// region: a hair on screen, never a soft sector pasted over the plan.
const glowFilters = [...sr().querySelectorAll('defs filter[id^="hp-glow"]')];
const pools = sr().querySelector('.glow-pools');
const frameFilter = sr().querySelector('.glow-pools-frame')?.getAttribute('filter');
const featherTemporarilySuspended = frameFilter === null
&& Date.now() < Number(c._glowFeatherSuspendUntil || 0);
out.edgeFeatherIsAHair = glowFilters.length === 1
&& glowFilters[0].querySelectorAll('feGaussianBlur').length === 1
&& sr().querySelector('.glow-pools-frame')?.getAttribute('filter') === 'url(#hp-glowfeather)'
&& (frameFilter === 'url(#hp-glowfeather)' || featherTemporarilySuspended)
&& Number(pools?.dataset.featherPx) > 0 && Number(pools?.dataset.featherPx) <= 3
&& [...sr().querySelectorAll('.glowlayer [mask], .glow-pools [filter]')].length === 0;
// Дверь наружу: свет через неё не выходит — есть проём, но за ним нет пола.
@@ -239,14 +242,27 @@ const res = await page.evaluate(async () => {
roomEl?.dispatchEvent(new MouseEvent('mouseenter'));
await c.updateComplete;
const hoverFill = sr().querySelector('.room-hover-fill');
const hoverFillLayer = sr().querySelector('.room-hover-fill-layer');
const hoverHalo = sr().querySelector('.room-hover-halo');
const hoverOutline = sr().querySelector('.room-hover-outline');
const hoverOutlineLayer = sr().querySelector('.room-hover-outline-layer');
const glowLayer = sr().querySelector('.glow-pools-frame');
const wallLayer = sr().querySelector('.wallbodies');
out.hoverKeepsGlowDom = poolBeforeHover === sr().querySelector('.glow-pool')
&& gradientBeforeHover === sr().querySelector('radialGradient[id^="hp-glow-"]');
out.hoverUsesPlainSvg = !!roomEl && !!hoverFill && !!hoverHalo && !!hoverOutline
&& getComputedStyle(roomEl).filter === 'none'
&& getComputedStyle(hoverHalo).filter === 'none'
&& getComputedStyle(hoverOutline).filter === 'none';
const fillStyle = hoverFill ? getComputedStyle(hoverFill) : null;
out.hoverUsesNeutralDarkening = fillStyle?.fill === 'rgb(0, 0, 0)'
&& Math.abs(Number(fillStyle.fillOpacity) - 0.22) < 0.001;
out.hoverLayerOrder = !!hoverFillLayer && !!glowLayer && !!hoverOutlineLayer
&& !!(hoverFillLayer.compareDocumentPosition(glowLayer) & Node.DOCUMENT_POSITION_FOLLOWING)
// The default smoke fixture has no thick wall body. When one exists, the
// late outline must follow it; absence is not a layer-order failure.
&& (!wallLayer
|| !!(wallLayer.compareDocumentPosition(hoverOutlineLayer) & Node.DOCUMENT_POSITION_FOLLOWING));
roomEl?.dispatchEvent(new MouseEvent('mouseleave'));
await c.updateComplete;
return out;
+79 -1
View File
@@ -73,7 +73,85 @@ const res = await page.evaluate(async () => {
c._markerDialog = null; await c.updateComplete;
// --- 6) general settings: opacity sliders take the ha branch too -------
// --- 6) #88: the leading-entity selector is contextual ---------------
// Give one demo device a second controllable channel in the same registry
// shape that Home Assistant provides for real multi-channel relays.
const extra = 'switch.ceiling_aux';
c.hass = { ...c.hass, states: { ...c.hass.states,
[extra]: { entity_id: extra, state: 'on', attributes: { friendly_name: 'Ceiling auxiliary' } } } };
const multi = c._devices.find((device) => device.id === 'd_light1');
// The production registry adapter is covered by unit/backend tests. Stub
// only the transactional projection here so the smoke owns no shared
// registry singleton state and remains deterministic.
const realPreview = c._markerPreviewDevice.bind(c);
c._markerPreviewDevice = (draft) => draft.devId === multi.id ? {
...multi,
entities: [multi.primary, extra],
marker: {
...(multi.marker || {}), id: multi.id, binding: `device:${multi.id}`,
is_light: draft.lightRole === 'always' ? true : null,
light_entity: draft.lightEntity || null,
},
} : realPreview(draft);
c._openMarkerDialog({
...multi,
marker: { ...(multi.marker || {}), is_light: true, light_entity: extra },
});
await c.updateComplete;
out.leadingDraftIsAlways = c._markerDialog.lightRole === 'always';
out.leadingPreviewHasExtra = c._markerPreviewDevice(c._markerDialog)?.entities?.includes(extra) === true;
const leading = sr().querySelector('hp-dialog #marker-light-entity');
out.leadingSelectorForMultiple = !!leading && leading.options.length === 3;
out.savedLeadingEntitySelected = leading?.value === extra
&& leading?.selectedOptions?.[0]?.value === extra;
if (leading) {
leading.value = multi.primary;
leading.dispatchEvent(new Event('change', { bubbles: true }));
await c.updateComplete;
out.leadingUpdatesPreview = c._markerDialog.lightEntity === multi.primary
&& c._markerSpatialSource(c._markerDialog)?.eid === multi.primary;
} else {
out.leadingUpdatesPreview = false;
}
c._markerDialog = null; await c.updateComplete;
c._markerPreviewDevice = realPreview;
const single = c._devices.find((device) => device.id === 'd_lamp');
c._openMarkerDialog(single); await c.updateComplete;
c._setMarkerLightRole('always'); await c.updateComplete;
out.noLeadingSelectorForSingle = !sr().querySelector('hp-dialog #marker-light-entity');
c._markerDialog = null; await c.updateComplete;
// --- 7) Rebinding keeps lossless marker:* links -----------------------
const rebindDevice = c._devices.find((item) => item.bindingKind !== 'virtual');
c._openMarkerDialog(rebindDevice); await c.updateComplete;
c._markerDialog = { ...c._markerDialog, controls: ['marker:future-passive-lamp'] };
await c.updateComplete;
sr().querySelector('hp-dialog input[name="bmode"]')?.click();
await c.updateComplete;
out.rebindKeepsMarkerLink = c._markerDialog.controls.includes('marker:future-passive-lamp');
c._markerDialog = null; await c.updateComplete;
// --- 8) #84: passive Always keeps and persists manual Glow -----------
c._openMarkerDialog(); await c.updateComplete;
c._markerDialog = { ...c._markerDialog, name: 'Passive lamp', bindingMode: 'virtual',
binding: 'virtual', lightRole: 'always', lightRoleTouched: true };
await c.updateComplete;
const glowRadios = [...sr().querySelectorAll('hp-dialog input[name="marker-glow-mode"]')];
out.passiveLiveModeDisabled = glowRadios.find((radio) => radio.value === 'auto')?.disabled === true;
out.passiveManualModesEnabled = glowRadios.filter((radio) => radio.value !== 'auto')
.every((radio) => radio.disabled === false);
out.passiveRadiusEnabled = sr().querySelector('hp-dialog #marker-glow-radius')?.disabled === false;
const brightness = sr().querySelector('hp-dialog .markerglowvalue ha-slider');
brightness.value = 42;
brightness.dispatchEvent(new Event('input', { bubbles: true }));
await c.updateComplete;
const passiveFields = c._markerLightFields(c._markerDialog);
out.passiveManualGlowPersists = c._markerDialog.glowMode === 'fixed'
&& passiveFields.glow_color?.bri === 0.42;
c._markerDialog = null; await c.updateComplete;
// --- 9) general settings: opacity sliders take the ha branch too -------
c._openSettingsDialog(); await c.updateComplete;
out.gsHaSliders = sr().querySelectorAll('hp-dialog ha-slider').length >= 9;
const gsl = sr().querySelector('hp-dialog ha-slider');
+236
View File
@@ -0,0 +1,236 @@
// Issue #68: contextual help trigger parity, dialog overlay ownership and the
// real non-Popover fallback. Run by the prerelease smoke suite, not on edits.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 390, height: 780 }, 1);
await page.keyboard.press('Tab');
const keyboardFocus = await page.evaluate(async () => {
const frame = () => new Promise((resolve) => requestAnimationFrame(resolve));
const deepActive = () => {
let active = document.activeElement;
while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
return active;
};
const dialog = document.createElement('hp-dialog');
dialog.title = 'Keyboard help smoke';
const help = document.createElement('hp-help');
help.text = 'Keyboard description';
help.ariaLabel = 'Help: keyboard description';
dialog.append(help);
document.body.append(dialog);
await dialog.updateComplete;
await help.updateComplete;
dialog._focusInitial();
await frame();
const button = help.shadowRoot?.querySelector('.trigger');
const result = deepActive() === button
&& button?.matches(':focus-visible')
&& button?.getAttribute('aria-expanded') === 'true'
&& dialog._focusableElements().includes(button);
dialog.remove();
return result;
});
const res = await page.evaluate(async () => {
const out = {};
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const wait = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));
const frame = () => new Promise((resolve) => requestAnimationFrame(resolve));
const deepActive = () => {
let active = document.activeElement;
while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
return active;
};
const helpButton = (key) => root().querySelector(`hp-help[data-help-key="${key}"]`)?.shadowRoot?.querySelector('.trigger');
const helpSurface = (key) => {
const help = root().querySelector(`hp-help[data-help-key="${key}"]`);
return help?.shadowRoot?.querySelector('.tooltip')
|| root().querySelector('hp-dialog')?.shadowRoot?.querySelector('[data-hp-overlay="help"]')?.shadowRoot?.querySelector('.tooltip');
};
// Empty or incomplete content must not leave a dead focus target behind.
const incompleteHelp = document.createElement('hp-help');
incompleteHelp.text = ' ';
incompleteHelp.ariaLabel = 'Help: unavailable description';
document.body.append(incompleteHelp);
await incompleteHelp.updateComplete;
out.emptyTextHidesTrigger = !incompleteHelp.shadowRoot?.querySelector('.trigger')
&& getComputedStyle(incompleteHelp).display === 'none';
incompleteHelp.text = 'Available description';
incompleteHelp.ariaLabel = ' ';
await incompleteHelp.updateComplete;
out.emptyAriaHidesTrigger = !incompleteHelp.shadowRoot?.querySelector('.trigger')
&& getComputedStyle(incompleteHelp).display === 'none';
incompleteHelp.ariaLabel = 'Help: available description';
await incompleteHelp.updateComplete;
const restoredTrigger = incompleteHelp.shadowRoot?.querySelector('.trigger');
out.circledQuestionIcon = restoredTrigger?.querySelector('svg[viewBox="0 0 24 24"] path')
?.getAttribute('d')?.length > 0;
incompleteHelp.remove();
card._setMode('devices');
const marker = card._devices[0];
card._openMarkerDialog(marker);
await card.updateComplete;
await frame();
const dialogBody = root().querySelector('hp-dialog .body');
const scrollGeometry = () => dialogBody ? {
clientHeight: dialogBody.clientHeight,
scrollHeight: dialogBody.scrollHeight,
scrollTop: dialogBody.scrollTop,
} : null;
const roleHelp = root().querySelector('hp-help[data-help-key="marker.light_role.help"]');
const roleButton = helpButton('marker.light_role.help');
const roleDescription = roleHelp?.shadowRoot?.querySelector('[role="tooltip"]');
out.realButton = roleButton?.tagName === 'BUTTON'
&& !!roleDescription?.id
&& !roleButton.hasAttribute('aria-describedby');
const beforeHoverGeometry = scrollGeometry();
roleButton?.dispatchEvent(new PointerEvent('pointerenter', { pointerType: 'mouse', bubbles: true, composed: true }));
await wait(330);
out.mouseHover = roleButton?.getAttribute('aria-expanded') === 'true'
&& roleButton.getAttribute('aria-describedby') === roleDescription?.id
&& !!helpSurface('marker.light_role.help');
out.hoverKeepsDialogScrollGeometry = JSON.stringify(scrollGeometry())
=== JSON.stringify(beforeHoverGeometry);
roleButton?.dispatchEvent(new PointerEvent('pointerleave', { pointerType: 'mouse', bubbles: true, composed: true }));
await wait(180);
out.mouseLeave = roleButton?.getAttribute('aria-expanded') === 'false';
// Escape dismisses a mouse-hovered bubble without moving keyboard focus
// away from the form control the user was editing.
const nameInput = root().querySelector('hp-dialog .namein');
nameInput?.focus();
roleButton?.dispatchEvent(new PointerEvent('pointerenter', { pointerType: 'mouse', bubbles: true, composed: true }));
await wait(330);
nameInput?.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Escape', bubbles: true, composed: true, cancelable: true,
}));
await frame();
out.hoverEscapeKeepsFocus = roleButton?.getAttribute('aria-expanded') === 'false'
&& deepActive() === nameInput;
roleButton?.click();
await frame();
document.body.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true }));
await frame();
out.outsidePointer = roleButton?.getAttribute('aria-expanded') === 'false';
roleButton?.click();
await frame();
out.touchClick = roleButton?.getAttribute('aria-expanded') === 'true';
roleButton?.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Escape', bubbles: true, composed: true, cancelable: true,
}));
await card.updateComplete;
out.escapeHelpFirst = roleButton?.getAttribute('aria-expanded') === 'false' && !!card._markerDialog;
// The help in the first legend remains a real enabled target even while the
// glow fieldset itself is disabled.
card._markerDialog = { ...card._markerDialog, lightRole: 'never' };
await card.updateComplete;
const disabledHelpHost = root().querySelector('hp-help[data-help-key="marker.glow_mode.help"]');
const disabledHelp = helpButton('marker.glow_mode.help');
out.disabledExplanation = !!disabledHelp && !disabledHelp.disabled
&& !!disabledHelpHost?.closest('fieldset[disabled]')
&& !!disabledHelpHost?.closest('legend');
// Exclusive transient surfaces: opening help dismisses an open colour picker.
card._markerDialog = { ...card._markerDialog, lightRole: 'always', glowMode: 'fixed' };
await card.updateComplete;
const picker = root().querySelector('hp-color-opacity');
const pickerButton = picker?.shadowRoot?.querySelector('.trigger');
pickerButton?.click();
await frame();
const glowHelpButton = helpButton('marker.glow_mode.help');
const focusables = root().querySelector('hp-dialog')?._focusableElements?.() || [];
out.shadowFocusables = focusables.includes(glowHelpButton) && focusables.includes(pickerButton);
glowHelpButton?.click();
await frame();
out.exclusive = pickerButton?.getAttribute('aria-expanded') === 'false'
&& glowHelpButton?.getAttribute('aria-expanded') === 'true';
const surface = helpSurface('marker.glow_mode.help');
const box = surface?.getBoundingClientRect();
const viewport = window.visualViewport;
out.insideViewport = !!box && box.left >= (viewport?.offsetLeft || 0)
&& box.top >= (viewport?.offsetTop || 0)
&& box.right <= (viewport?.offsetLeft || 0) + (viewport?.width || innerWidth)
&& box.bottom <= (viewport?.offsetTop || 0) + (viewport?.height || innerHeight);
pickerButton?.click();
await frame();
out.reverseExclusive = glowHelpButton?.getAttribute('aria-expanded') === 'false'
&& pickerButton?.getAttribute('aria-expanded') === 'true';
pickerButton?.click();
glowHelpButton?.click();
await frame();
card._showToast('overlay priority');
await frame();
out.toastClosesTransient = glowHelpButton?.getAttribute('aria-expanded') === 'false'
&& !!card._markerDialog;
glowHelpButton?.click();
await frame();
// Scrolling the owning dialog closes help instead of leaving a detached bubble.
const body = glowHelpButton?.getRootNode()?.host?.closest('hp-dialog')?.querySelector('.body');
body?.dispatchEvent(new Event('scroll', { bubbles: false }));
await frame();
out.ownScrollCloses = glowHelpButton?.getAttribute('aria-expanded') === 'false';
// Force feature detection down the true portal fallback branch.
const show = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'showPopover');
const hide = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'hidePopover');
Object.defineProperty(HTMLElement.prototype, 'showPopover', { configurable: true, value: undefined });
Object.defineProperty(HTMLElement.prototype, 'hidePopover', { configurable: true, value: undefined });
const beforeFallbackGeometry = scrollGeometry();
glowHelpButton?.click();
await frame();
const dialog = root().querySelector('hp-dialog');
const fallback = dialog?.shadowRoot?.querySelector('[data-hp-overlay="help"]');
const fallbackBox = fallback?.shadowRoot?.querySelector('.tooltip')?.getBoundingClientRect();
out.realFallback = !!fallback && !!fallbackBox?.width && !!fallbackBox?.height;
out.fallbackKeepsDialogScrollGeometry = JSON.stringify(scrollGeometry())
=== JSON.stringify(beforeFallbackGeometry);
glowHelpButton?.click();
pickerButton?.click();
await frame();
const pickerFallback = dialog?.shadowRoot?.querySelector('[data-hp-overlay="color-opacity"]');
const pickerControl = pickerFallback?.shadowRoot?.querySelector('input');
out.fallbackPickerFocusable = !!pickerControl
&& dialog?._focusableElements?.().includes(pickerControl);
pickerButton?.click();
if (show) Object.defineProperty(HTMLElement.prototype, 'showPopover', show);
else delete HTMLElement.prototype.showPopover;
if (hide) Object.defineProperty(HTMLElement.prototype, 'hidePopover', hide);
else delete HTMLElement.prototype.hidePopover;
// Explicit card language wins over HA locale and updates an existing component.
card._config = { ...card._config, language: 'ru' };
card.requestUpdate();
await card.updateComplete;
out.cardLanguage = /Определяет|источник/.test(root()
.querySelector('hp-help[data-help-key="marker.light_role.help"]')?.text || '');
const finalButton = helpButton('marker.light_role.help');
finalButton?.click();
await frame();
finalButton?.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Escape', bubbles: true, composed: true, cancelable: true,
}));
await card.updateComplete;
finalButton?.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Escape', bubbles: true, composed: true, cancelable: true,
}));
await card.updateComplete;
out.secondEscapeClosesDialog = !card._markerDialog;
return out;
});
res.keyboardFocus = keyboardFocus;
checkAll(res);
await finish(browser, res);
+4 -2
View File
@@ -208,7 +208,8 @@ Object.assign(out, await page.evaluate(async () => {
while (!card.renderRoot?.querySelector('[style*="left"]') && Date.now() - t0 < 6000) {
await new Promise((r) => setTimeout(r, 60));
}
await card.updateComplete;
await Promise.all([c.updateComplete, card.updateComplete]);
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const st = [...card.renderRoot.querySelectorAll('[style*="left"]')]
.find((el) => /%$/.test(el.style.left || '') && el.style.top);
// docs/CANVAS.md §4: статичная карточка теперь кадрирует СОДЕРЖИМОЕ, как и
@@ -265,7 +266,8 @@ Object.assign(out, await page.evaluate(async () => {
while (!card.renderRoot?.querySelector('.room') && Date.now() - t0 < 6000) {
await new Promise((r) => setTimeout(r, 60));
}
await card.updateComplete;
await Promise.all([c.updateComplete, card.updateComplete]);
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const stRoom = [...card.renderRoot.querySelectorAll('.room')]
.find((r) => (r.getAttribute('style') || '').includes('--room-fill'));
const stFill = stRoom ? (stRoom.getAttribute('style').match(/--room-fill:([^;]+)/) || [])[1] : null;
+4 -4
View File
@@ -77,13 +77,13 @@ const out2 = await page.evaluate(async () => {
// --- temp-плашка (.tval) рядом со значком ---
await setMarker({ size: 1 });
const t1 = devEl().querySelector('.tval').getBoundingClientRect();
const tf1 = fs(devEl().querySelector('.tval'));
const t1 = devEl().querySelector('.value-badge').getBoundingClientRect();
const tf1 = fs(devEl().querySelector('.value-badge'));
// дефолт: line-height 0.68 * icon-size + рамка 1px×2
o.tvalDefaultKept = Math.abs(t1.height - (iconPx * 0.68 + 2)) < 1;
await setMarker({ size: 2 });
const t2 = devEl().querySelector('.tval').getBoundingClientRect();
const tf2 = fs(devEl().querySelector('.tval'));
const t2 = devEl().querySelector('.value-badge').getBoundingClientRect();
const tf2 = fs(devEl().querySelector('.value-badge'));
o.tvalScales = (t2.height - 2) / (t1.height - 2) > 1.8 && (t2.height - 2) / (t1.height - 2) < 2.2
&& tf2 / tf1 > 1.8 && tf2 / tf1 < 2.2;
+28 -15
View File
@@ -2,8 +2,10 @@
// 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 the icons keep their proportion to it.
import { writeFileSync, mkdirSync } from 'node:fs';
import { writeFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { resolve } from 'node:path';
import { launch, check, checkAll, finish } from './serve.mjs';
const SHOTS = process.env.HP_SHOTS || '';
@@ -125,22 +127,33 @@ out.wsCarriedFarPosition = !!layMsg && JSON.stringify(layMsg).includes('3.4');
// §3: ±5000). Skipped, not failed, where voluptuous is not installed.
out.backendAcceptsFarPayload = (() => {
if (!cfgMsg) return false;
const f = '/tmp/hp_infinite_payload.json';
const directory = mkdtempSync(resolve(tmpdir(), 'hp-infinite-'));
const f = resolve(directory, '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; }
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;
}
} finally {
rmSync(directory, { recursive: true, force: true });
}
})();
// ---- an OUTLIER: the view ignores it, the hint offers it ------------------
+9 -3
View File
@@ -7,6 +7,12 @@ const out = await page.evaluate(async () => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const settleMode = async () => {
const started = performance.now();
do { await new Promise((resolve) => requestAnimationFrame(resolve)); }
while (c._modeTransitionBusy && performance.now() - started < 1500);
await c.updateComplete;
};
const setFill = async (mode) => {
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
const f1 = cfg.spaces.find((s) => s.id === 'f1');
@@ -47,18 +53,18 @@ const out = await page.evaluate(async () => {
o.morphInGlow = le?.querySelector('ha-icon')?.getAttribute('icon')?.includes('lightbulb') ?? false;
// --- редакторы используют ту же жёлтую подложку независимо от слоя glow --
c._setMode('devices'); c.requestUpdate(); await c.updateComplete;
c._setMode('devices'); await settleMode();
le = devEl('d_lamp');
o.devicesEditorLampYellow = !!le && le.classList.contains('on');
o.devicesEditorHasGlow = !!sr().querySelector('.stage svg radialGradient, .stage svg [id*=glow]');
c._setMode('plan'); c.requestUpdate(); await c.updateComplete;
c._setMode('plan'); await settleMode();
// ИМЕННО лампа (mutation-проверка аудита HP-1521-01: розетка тоже on и
// удовлетворяла бы поиску «любой .dev.on», пряча регресс самой лампы)
le = devEl('d_lamp');
o.planEditorLampYellow = !!le && le.classList.contains('on'); // слой скрыт — жёлтый вернулся
o.planEditorKettleStillOn = !!devEl('d_kettle')?.classList.contains('on');
o.planEditorNoGlow = !sr().querySelector('.stage svg radialGradient, .stage svg [id*=glow]');
c._setMode('view'); c.requestUpdate(); await c.updateComplete;
c._setMode('view'); await settleMode();
// --- выключенная лампа тёмная в обоих режимах ---------------------------
c.hass = { ...c.hass, states: { ...c.hass.states,
+29 -1
View File
@@ -10,9 +10,37 @@ const res = await page.evaluate(async () => {
// лампа (primary light) без явного действия → клик = toggle
const lamp = c._devices.find((d) => d.primary?.startsWith('light.') && !d.tapAction);
out.hasLamp = !!lamp;
const lampState = c.hass.states[lamp.primary]?.state;
const expectedService = lampState === 'off' ? 'turn_on'
: (lampState === 'unknown' || lampState === '' ? 'toggle' : 'turn_off');
c._infoCard = null;
c._clickDevice(new MouseEvent('click'), lamp);
out.lampToggles = JSON.stringify(calls.at(-1)) === JSON.stringify(['homeassistant', 'toggle', lamp.primary]);
const lampCall = calls.at(-1);
out.lampToggles = ['light', 'homeassistant'].includes(lampCall?.[0])
&& lampCall?.[1] === expectedService && lampCall?.[2] === lamp.primary;
// The untouched select is an effective projection, not a frozen draft.
// Reproduce the field race where a dialog was opened before HA identified
// the leading entity as light: runtime already sees light-default toggle,
// and the UI must catch up without materializing a stored tap_action.
c._setMode('devices'); await c.updateComplete;
c._openMarkerDialog(lamp); await c.updateComplete;
c._markerDialog = {
...c._markerDialog,
tapAction: 'info', tapActionTouched: false,
originalHasTapAction: false, originalTapAction: undefined,
};
await c.updateComplete;
const tapSelect = c.renderRoot.querySelector('#marker-tap-action');
out.defaultLampDialogShowsEffectiveToggle = tapSelect?.value === 'toggle';
out.defaultLampActionRemainsUnmaterialized = !c._markerDialog.tapActionTouched
&& !c._markerDialog.originalHasTapAction;
tapSelect.value = 'info';
tapSelect.dispatchEvent(new Event('change'));
await c.updateComplete;
out.explicitDeviceCardStaysSelected = c.renderRoot.querySelector('#marker-tap-action')?.value === 'info'
&& c._markerDialog.tapActionTouched;
c._markerDialog = null;
c._setMode('view'); await c.updateComplete;
// устройство с не-light primary (сенсор) → клик = инфо
const sensorDev = c._devices.find((d) => d.primary?.startsWith('sensor.') && !d.tapAction);
const n = calls.length;
+64
View File
@@ -0,0 +1,64 @@
// #101: View/editor navigation has one controller for toolbar height, stage
// geometry and camera. This smoke deliberately observes real animation frames;
// it never asserts an exact wall-clock millisecond.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 820, height: 760 });
const result = await page.evaluate(async () => {
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const sleepFrame = () => new Promise((resolve) => requestAnimationFrame(resolve));
const sampleUntilSettled = async () => {
const samples = [];
const started = performance.now();
do {
await sleepFrame();
const stage = root().querySelector('.stage');
const svg = stage?.querySelector(':scope .zoomwrap > svg');
if (stage && svg) {
const vb = svg.viewBox.baseVal;
samples.push({
busy: card._modeTransitionBusy,
inert: stage.inert,
height: stage.getBoundingClientRect().height,
aspect: vb.width / vb.height,
stageAspect: stage.clientWidth / stage.clientHeight,
});
}
} while (card._modeTransitionBusy && performance.now() - started < 1000);
return samples;
};
await card.updateComplete;
card._setMode('view');
await card.updateComplete;
const viewHeight = root().querySelector('.stage').clientHeight;
card._setMode('plan');
const enter = await sampleUntilSettled();
const planHeight = root().querySelector('.stage').clientHeight;
const distinctEnterHeights = new Set(enter.map((sample) => Math.round(sample.height))).size;
card._setMode('devices');
const swap = await sampleUntilSettled();
const chromeInner = root().querySelector('.editorchrome-inner');
card._setMode('view');
const leave = await sampleUntilSettled();
const restoredHeight = root().querySelector('.stage').clientHeight;
const allFrames = [...enter, ...swap, ...leave];
return {
enterHadIntermediateGeometry: distinctEnterHeights >= 3,
editorConsumesViewportHeight: planHeight < viewHeight,
stageInertDuringMotion: allFrames.filter((sample) => sample.busy).every((sample) => sample.inert),
cameraMatchesEveryStageAspect: allFrames.every((sample) =>
Number.isFinite(sample.aspect) && Math.abs(sample.aspect - sample.stageAspect) < 0.02),
editorSwapUsesSharedTimeline: swap.some((sample) => sample.busy) && !!chromeInner,
viewHeightRestored: Math.abs(restoredHeight - viewHeight) <= 2,
controllerSettled: card._modeTransitionBusy === false
&& !root().querySelector('.stage').classList.contains('mode-transition'),
};
});
checkAll(result);
await finish(browser, result);
+5 -3
View File
@@ -44,7 +44,7 @@ await page.evaluate(async () => {
out.viewHoldInfo = await page.evaluate(() => { const r = !!window.__card._infoCard; window.__card._infoCard = null; window.__card._holdFired = false; return r; });
// 3) режим План
await page.evaluate(() => window.__card._setMode('plan'));
await page.waitForTimeout(200);
await page.waitForFunction(() => window.__card._modeTransitionBusy === false);
out.plan = await st();
out.planIconsHidden = await page.evaluate(() => {
const sr = window.__card.shadowRoot || window.__card.renderRoot;
@@ -53,15 +53,16 @@ out.planIconsHidden = await page.evaluate(() => {
});
// 4) режим Устройства: drag работает, клик открывает редактор
await page.evaluate(() => window.__card._setMode('devices'));
await page.waitForTimeout(200);
await page.waitForFunction(() => window.__card._modeTransitionBusy === false);
out.devices = await st();
out.devDragWorks = await page.evaluate(() => {
out.devDragWorks = await page.evaluate(async () => {
const c = window.__card;
const d = c._devices.find((x) => x.space === 'f1');
const before = { ...c._pos(d) };
c._pointerDown({ preventDefault(){}, clientX: 10, clientY: 10, target: { setPointerCapture(){} }, pointerId: 3 }, d);
c._pointerMove({ clientX: 100, clientY: 70 }, d);
c._pointerUp({}, d);
await c.updateComplete;
const after = { ...c._pos(d) };
return Math.abs(after.x - before.x) + Math.abs(after.y - before.y) > 0.5;
});
@@ -77,6 +78,7 @@ out.devClickOpensEditor = await page.evaluate(async () => {
});
// 5) назад в view
await page.evaluate(() => window.__card._setMode('view'));
await page.waitForFunction(() => window.__card._modeTransitionBusy === false);
out.backToView = (await st()).mode;
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
checkAll(out, {
+5 -5
View File
@@ -2,7 +2,7 @@
// в момент обнаружения; cool-down не пульсирует; присутствие = статичное
// кольцо пока обитаемо». Датчик ДВИЖЕНИЯ (device_class motion) при переходе
// off→on получает activity-event — три последовательные волны
// (hp-activity-event, ~3.3 c), после чего эффект снимается, ДАЖЕ если сущность всё ещё
// (hp-pulse-short, ~3.3 c), после чего эффект снимается, ДАЖЕ если сущность всё ещё
// висит в on (это cool-down датчика, а не движение). Новый цикл off→on —
// новая вспышка. Датчики ПРИСУТСТВИЯ (occupancy/presence) при on несут
// activity-presence — статичное кольцо без анимации (opacity 0.4). Жёлтая
@@ -55,7 +55,7 @@ const res = await page.evaluate(async () => {
out.flashIsNotOn = !!flashEl && !flashEl.classList.contains('on') && !flashEl.classList.contains('open');
const waves = flashEl ? [...flashEl.querySelectorAll('.activity-ring.event i')] : [];
const firstWave = waves[0] ? getComputedStyle(waves[0]) : null;
out.flashAnimated = !!firstWave && firstWave.animationName.startsWith('hp-activity-event');
out.flashAnimated = !!firstWave && firstWave.animationName.startsWith('hp-pulse-short');
out.flashFinite = waves.length === 3
&& waves.every((w) => getComputedStyle(w).animationIterationCount === '1');
// подложка нейтральная — как у обычного .dev, не как у .dev.on
@@ -85,7 +85,7 @@ const res = await page.evaluate(async () => {
// timeline, и после его конца (base opacity 0) второй trip был невидим.
await sleep(3600); await c.updateComplete; // дать текущей вспышке догореть
const senseAnim = (el) => el && el.getAnimations({ subtree: true })
.find((a) => a.animationName && a.animationName.startsWith('hp-activity-event') && a.playState === 'running');
.find((a) => a.animationName && a.animationName.startsWith('hp-pulse-short') && a.playState === 'running');
await setMotion('off', 'motion');
await setMotion('on', 'motion'); // trip №1
out.rapidFirstTripFlashes = sr().querySelectorAll('.dev.activity-event').length === 1;
@@ -117,8 +117,8 @@ const res = await page.evaluate(async () => {
out.occupancyHolds = !!holdEl;
const holdRing = holdEl?.querySelector('.activity-ring.presence i:first-child');
const hAfter = holdRing ? getComputedStyle(holdRing) : null;
out.holdNotAnimated = !!hAfter && hAfter.animationName === 'none';
out.holdRingFaint = !!hAfter && Math.abs(parseFloat(hAfter.opacity) - 0.4) < 0.01;
out.holdAnimated = !!hAfter && hAfter.animationName === 'hp-pulse-continuous';
out.holdAnimationCalm = !!hAfter && hAfter.animationDuration === '2.4s';
out.holdBgNeutral = !!holdEl && !!neutral
&& getComputedStyle(holdEl).backgroundColor === getComputedStyle(neutral).backgroundColor;
out.holdNoFlashClass = !!holdEl && !holdEl.classList.contains('activity-event');
+26 -4
View File
@@ -6,7 +6,17 @@ const res = await page.evaluate(async () => {
// выбрать f2 и редактор устройств
c._space = 'garden'; c._setMode('devices'); await c.updateComplete;
const nav = JSON.parse(localStorage.getItem('houseplan_card_nav_v1'));
out.saved = nav.space === 'garden' && nav.mode === 'devices';
out.saved = nav.space === 'garden' && !Object.hasOwn(nav, 'mode');
// #95: during a same-route remount Lit may still present the old editor DOM
// while the new instance fails closed to View and holds the editor only as a
// pending intent until can_write arrives. The visible close button is a
// newer user command and must cancel that pending intent even though the
// runtime field already equals View.
c._mode = 'view';
c._pendingNavMode = 'devices';
c._setMode('view');
out.sameModeCloseCancelsPendingEditor = c._mode === 'view' && c._pendingNavMode === null;
c._setMode('devices'); await c.updateComplete;
// пересоздать карточку (эмуляция закрытия вкладки; кэш конфига в LS уже есть)
const c2 = document.createElement('houseplan-card');
c2.setConfig({ type: 'custom:houseplan-card' });
@@ -16,7 +26,19 @@ const res = await page.evaluate(async () => {
c2.hass = { ...c.hass };
await c2.updateComplete;
out.spaceRestored = c2._space === 'garden';
out.modeRestored = c2._mode === 'devices';
out.modeResetToView = c2._mode === 'view';
// A legacy record may still contain the old mode field. It is ignored.
localStorage.setItem('houseplan_card_nav_v1', JSON.stringify({ space: 'garden', mode: 'decor' }));
const legacy = document.createElement('houseplan-card');
legacy.setConfig({ type: 'custom:houseplan-card' });
legacy.hass = c.hass;
document.body.appendChild(legacy);
await new Promise((r) => setTimeout(r, 300));
out.legacySpaceOnly = legacy._space === 'garden' && legacy._mode === 'view';
legacy._setMode('devices'); await legacy.updateComplete;
const migrated = JSON.parse(localStorage.getItem('houseplan_card_nav_v1'));
out.legacyRewrittenOnNextNav = migrated.space === 'garden'
&& !Object.hasOwn(migrated, 'mode');
// хэш приоритетнее сохранённого
location.hash = '#space=f1';
const c3 = document.createElement('houseplan-card');
@@ -24,9 +46,9 @@ const res = await page.evaluate(async () => {
c3.hass = c.hass;
document.body.appendChild(c3);
await new Promise((r) => setTimeout(r, 300));
out.hashWins = c3._space === 'f1';
out.hashWins = c3._space === 'f1' && c3._mode === 'view';
location.hash = '';
c2.remove(); c3.remove();
c2.remove(); c3.remove(); legacy.remove();
// вернуть исходное
c._setMode('view'); c._space = 'f1'; c._saveNav(); await c.updateComplete;
return out;
+35 -3
View File
@@ -15,6 +15,12 @@ const result = await page.evaluate(async () => {
c.requestUpdate();
await c.updateComplete;
};
const settleMode = async () => {
const started = performance.now();
do { await new Promise((resolve) => requestAnimationFrame(resolve)); }
while (c._modeTransitionBusy && performance.now() - started < 1500);
await c.updateComplete;
};
const pitch = 1 / 240;
const wallKey = (a, b) => {
const q = (v) => Math.round(v / pitch) * pitch;
@@ -57,7 +63,7 @@ const result = await page.evaluate(async () => {
},
};
c._setMode('view');
await update();
await update(); await settleMode();
const tunnels = (layer) => [...root().querySelectorAll(
`.opening-tunnels[data-layer="${layer}"] [data-hp="opening-tunnel"]`,
@@ -109,7 +115,7 @@ const result = await page.evaluate(async () => {
&& tunnels('data').length === 2 && tunnels('glow-base').length === 2;
c._setMode('decor');
await update();
await update(); await settleMode();
out.backdropUsesSingleGroupOpacity = tunnels('data').length === 2
&& tunnels('glow-base').length === 2
&& [...root().querySelectorAll('.opening-tunnels')]
@@ -120,9 +126,35 @@ const result = await page.evaluate(async () => {
&& byId('tunnel-gate', 'glow-base')?.getAttribute('fill-opacity') === '0.42';
c._setMode('plan');
await update();
await update(); await settleMode();
out.planGestureCannotLeaveStalePatch = tunnels('data').length === 0
&& tunnels('glow-base').length === 0;
// Regression #81: atomic wall pieces, including real thickness steps, are
// one continuous SVG surface per side. Two faces therefore mean exactly two
// contours in one path, never a row of touching translucent rectangles.
c._setMode('view');
sp.rooms = [{ id: 'atomic-room', name: 'Atomic', area: 'atomic_area',
poly: [[0.1, 0.2], [0.9, 0.2], [0.9, 0.7], [0.1, 0.7]],
settings: { fill_mode: 'custom', custom_fill: { c: '#112233', a: 0.21 } } }];
const atomicSegments = [[0.1, 0.3], [0.3, 0.5], [0.5, 0.7], [0.7, 0.9]];
sp.walls = atomicSegments.map(([x0, x1]) => wall([x0, 0.2], [x1, 0.2], 15));
sp.openings = [{ id: 'atomic-door', type: 'door', x: 0.5, y: 0.2, angle: 0, length: 0.6 }];
sp.settings.hide_openings = true;
sp.settings.show_borders = true;
await update(); await settleMode();
const sameDepth = byId('atomic-door', 'data');
out.equalAtomicPiecesAreOneSurface = sameDepth?.tagName.toLowerCase() === 'path'
&& ((sameDepth.getAttribute('d') || '').match(/\bM /g) || []).length === 2;
sp.walls = atomicSegments.map(([x0, x1], index) => (
wall([x0, 0.2], [x1, 0.2], [10, 20, 15, 15][index])
));
await update();
const stepped = byId('atomic-door', 'data');
out.steppedAtomicPiecesAreOneSurface = stepped?.tagName.toLowerCase() === 'path'
&& ((stepped.getAttribute('d') || '').match(/\bM /g) || []).length === 2
&& stepped.getAttribute('fill-opacity') === '0.21';
return out;
});
+19 -6
View File
@@ -48,21 +48,34 @@ const rec = await page.evaluate(async () => {
out.noHpbootClass = !!stage && !stage.classList.contains('hpboot');
out.veilGone = !sr().querySelector('.bootveil');
// A detach during resume/nav motion kills their timers. The transient state
// must die with the timers, otherwise the same element comes back veiled or
// permanently clipped by nav-enter.
// A detach during resume/mode motion kills the one RAF owner. The transient
// state must die with it, otherwise the same element comes back inert or
// permanently pinned to an intermediate camera.
c._resumeSettling = true;
c._navMotion = 'enter';
c._slide = 'left';
const from = c._currentModeVisual('view');
c._mode = 'plan';
if (from) c._modeTransition.start(from, {
...from,
presentedMode: 'plan',
editorChromeHeight: 80,
stageHeight: Math.max(1, from.stageHeight - 80),
architectureOpacity: 1,
backdropOpacity: 1,
editorWeight: 1,
viewWeight: 0,
toolbarContentOpacity: 1,
}, 'plan', 1000);
c.requestUpdate(); await c.updateComplete;
c.remove();
out.detachClearsResumeAndNav = c._resumeSettling === false
&& c._navMotion === '' && c._slide === '';
&& c._modeTransition.active === false
&& c._modeTransitionVisual === null && c._slide === '';
wrap.appendChild(c);
await c.updateComplete;
const stageAfterMotionReattach = sr().querySelector('.stage');
out.reattachHasNoStuckMotionClasses = !!stageAfterMotionReattach
&& !stageAfterMotionReattach.classList.contains('hpnav')
&& !stageAfterMotionReattach.classList.contains('mode-transition')
&& !stageAfterMotionReattach.classList.contains('hpresume');
// A2: a second detach DURING the fade must not leave a zombie veil either
+10 -4
View File
@@ -13,6 +13,12 @@ const res = await page.evaluate(async () => {
const sr = () => c.shadowRoot || c.renderRoot;
const sp = () => c._serverCfg.spaces.find((s) => s.id === c._space);
const upd = async () => { c._cfgEpoch++; c.requestUpdate(); await c.updateComplete; };
const settleMode = async () => {
const started = performance.now();
do { await new Promise((resolve) => requestAnimationFrame(resolve)); }
while (c._modeTransitionBusy && performance.now() - started < 1500);
await c.updateComplete;
};
sp().settings = { ...(sp().settings || {}), show_borders: true };
delete sp().walls;
@@ -22,7 +28,7 @@ const res = await page.evaluate(async () => {
c._setMode('plan');
c._tool = 'wallthick';
await upd();
await upd(); await settleMode();
c._wallThickClick([550, 250]);
await upd();
if (c._wallDialog) {
@@ -53,13 +59,13 @@ const res = await page.evaluate(async () => {
out.savedVirtualAboveRealInEditor = !!editorBody && !!editorVirtual
&& !!(editorBody.compareDocumentPosition(editorVirtual) & Node.DOCUMENT_POSITION_FOLLOWING);
c._setMode('view');
await upd();
await upd(); await settleMode();
const viewBody = sr().querySelector('.wallbodies');
const viewVirtual = sr().querySelector('.openwalls');
out.savedVirtualBelowRealInView = !!viewBody && !!viewVirtual
&& !!(viewVirtual.compareDocumentPosition(viewBody) & Node.DOCUMENT_POSITION_FOLLOWING);
c._setMode('decor');
await upd();
await upd(); await settleMode();
const decorBody = sr().querySelector('.wallbodies');
const decorVirtual = sr().querySelector('.openwalls');
out.realWallsFadeBehindDecor = !!decorBody
@@ -67,7 +73,7 @@ const res = await page.evaluate(async () => {
out.virtualWallsFadeBehindDecor = !!decorVirtual
&& getComputedStyle(decorVirtual).opacity === '0.35';
c._setMode('plan');
await upd();
await upd(); await settleMode();
out.atomicThicknessBefore = c._intervalCm([550, 145, 550, 195]) === 20
&& c._intervalCm([550, 205, 550, 295]) === 0
&& c._intervalCm([550, 305, 550, 455]) === 20;
+23 -5
View File
@@ -11,7 +11,10 @@ const res = await page.evaluate(async () => {
const batchSizes = [];
let round = 0;
c.hass = { ...c.hass, callWS: async (m) => {
// This probe owns its signer authority. Reusing the demo card's connection
// would intentionally share its cache and make the batch contract depend on
// whichever smoke content the main card has already rendered.
c.hass = { ...c.hass, connection: Object.create(c.hass.connection), callWS: async (m) => {
if (m.type === 'houseplan/content/sign') {
batchSizes.push(m.paths.length);
const urls = {};
@@ -25,7 +28,12 @@ const res = await page.evaluate(async () => {
// 201 вложение, разложенное по маркерам: столько же подписанных ссылок
const pdfs = [];
for (let i = 0; i < 201; i++) pdfs.push({ name: 'm' + i, url: '/api/houseplan/content/files/m/doc' + i + '.pdf' });
c._serverCfg = { ...c._serverCfg, markers: [{ id: 'mk1', pdfs }] };
c._serverCfg = {
...c._serverCfg,
markers: [{
id: 'mk1', binding: 'virtual', name: 'Attachment probe', space: c._space, pdfs,
}],
};
c._cfgEpoch++;
round = 1;
@@ -44,7 +52,13 @@ const res = await page.evaluate(async () => {
out.allRefreshed = vals.length === 201 && vals.every((u) => u.endsWith('authSig=R2'));
// ссылка, исчезнувшая из конфига, выбывает из кэша и не занимает слот
c._serverCfg = { ...c._serverCfg, markers: [{ id: 'mk1', pdfs: pdfs.slice(0, 5) }] };
c._serverCfg = {
...c._serverCfg,
markers: [{
id: 'mk1', binding: 'virtual', name: 'Attachment probe', space: c._space,
pdfs: pdfs.slice(0, 5),
}],
};
c._cfgEpoch++;
batchSizes.length = 0;
round = 3;
@@ -55,11 +69,15 @@ const res = await page.evaluate(async () => {
// протухшая подпись не отдаётся: она вернула бы 401 и «попытку входа»
const one = pdfs[0].url;
c._signer.entries[one] = { url: one + '?authSig=OLD', at: Date.now() - 25 * 3600 * 1000 };
c._signer.shared.signed[one] = {
url: one + '?authSig=OLD', at: Date.now() - 25 * 3600 * 1000, loaded: true,
};
out.expiredNotServed = c._display(one) === '';
out.expiredDropped = c._signer.entries[one] === undefined;
// а стареющая, но ещё живая — отдаётся, пока едет замена
c._signer.entries[one] = { url: one + '?authSig=AGING', at: Date.now() - 20 * 3600 * 1000 };
c._signer.shared.signed[one] = {
url: one + '?authSig=AGING', at: Date.now() - 20 * 3600 * 1000, loaded: true,
};
out.agingStillServed = c._display(one) === one + '?authSig=AGING';
return out;
});
+62 -23
View File
@@ -4,30 +4,53 @@
// каждом рендере получал 401. Проверяем весь контракт подписи для этой карточки.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
await page.route('**/api/houseplan/content/plans/_/*.tok.svg?authSig=*', (route) => route.fulfill({
status: 200,
contentType: 'image/svg+xml',
body: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><rect width="16" height="16" fill="#789"/></svg>',
}));
const res = await page.evaluate(async () => {
const out = {};
await customElements.whenDefined('houseplan-space-card');
const main = window.__card;
const raw = '/api/houseplan/content/plans/_/f1.tok.svg';
const baselineRaw = '/api/houseplan/content/plans/_/f1-baseline.tok.svg';
// подложка на защищённом эндпоинте + управляемый ответ на подпись
const cfg = JSON.parse(JSON.stringify(main._serverCfg));
cfg.spaces = cfg.spaces.map((s) => (s.id === 'f1' ? { ...s, plan_url: raw } : s));
const baselineCfg = JSON.parse(JSON.stringify(main._serverCfg));
baselineCfg.spaces = baselineCfg.spaces.map((s) => (
s.id === 'f1' ? { ...s, plan_url: baselineRaw } : s
));
const candidateCfg = JSON.parse(JSON.stringify(baselineCfg));
candidateCfg.spaces = candidateCfg.spaces.map((s) => (
s.id === 'f1' ? { ...s, plan_url: raw } : s
));
let servedCfg = baselineCfg;
let signCalls = 0;
let failFirst = true;
let failCandidateOnce = true;
const requestedHrefs = [];
const hass = { ...main.hass, callWS: async (m) => {
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
if (m.type === 'houseplan/layout/get') return { layout: {} };
if (m.type === 'houseplan/content/sign') {
signCalls++;
if (failFirst && signCalls === 1) throw new Error('ws down');
const urls = {};
for (const p of m.paths) urls[p] = p + '?authSig=SIG' + signCalls;
return { urls };
}
return { ok: true };
} };
// A separately mounted test card gets its own HA connection authority. In
// production cards sharing one connection deliberately share config/signing
// caches, which is not the behaviour this isolated failure probe exercises.
const hass = {
...main.hass,
connection: Object.create(main.hass.connection),
callWS: async (m) => {
if (m.type === 'houseplan/config/get') return { config: servedCfg, rev: signCalls + 1 };
if (m.type === 'houseplan/layout/get') return { layout: {} };
if (m.type === 'houseplan/content/sign') {
signCalls++;
if (failCandidateOnce && m.paths.includes(raw)) {
failCandidateOnce = false;
throw new Error('ws down');
}
const urls = {};
for (const p of m.paths) urls[p] = p + '?authSig=SIG' + signCalls;
return { urls };
}
return { ok: true };
},
};
const host = document.createElement('div');
document.body.appendChild(host);
@@ -35,6 +58,10 @@ const res = await page.evaluate(async () => {
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
card.hass = hass;
host.appendChild(card);
// connectedCallback may synchronously adopt the module-level warm snapshot;
// force this isolated probe's server candidate after that warm paint.
await new Promise((r) => setTimeout(r, 80));
await card._load(true);
const stage = async () => {
const t0 = Date.now();
@@ -46,23 +73,32 @@ const res = await page.evaluate(async () => {
};
const href = async () => { const im = await stage(); return im ? im.getAttribute('href') : null; };
// Establish a decoded protected baseline first. A failed replacement must
// retain this exact URL, not an unrelated public image from the warm cache.
await stage();
const baselineHref = await href();
servedCfg = candidateCfg;
await card._load(true);
// 1) первая подпись упала → сырой URL в DOM не попадает (иначе 401)
await stage();
await new Promise((r) => setTimeout(r, 120));
out.hrefAfterFailedSign = await href();
out.failedSignKeptProtectedBaseline = baselineHref?.includes('f1-baseline.tok.svg?authSig=SIG1')
&& out.hrefAfterFailedSign === baselineHref;
// 2) сразу повтора нет: после ошибки подпись уходит в backoff (ревью R4-2),
// иначе нестабильный сокет получал бы по запросу на каждый рендер
for (let i = 0; i < 5; i++) { card.requestUpdate(); await card.updateComplete; }
await new Promise((r) => setTimeout(r, 150));
out.noRetryStorm = signCalls === 1;
out.noRetryStorm = signCalls === 2;
// 3) после выдержки повтор проходит
await new Promise((r) => setTimeout(r, 2100));
card.requestUpdate(); await card.updateComplete;
await new Promise((r) => setTimeout(r, 150));
out.hrefAfterRetry = await href();
out.retried = signCalls === 2;
out.retried = signCalls === 3;
// 4) повторный рендер не теряет подпись и не просит её заново
const before = signCalls;
@@ -71,11 +107,11 @@ const res = await page.evaluate(async () => {
out.noExtraSignOnRerender = signCalls === before;
// 5) протухшая подпись не отдаётся, стареющая — отдаётся, пока едет замена
const ent = card._signer.entries;
ent[raw] = { url: raw + '?authSig=OLD', at: Date.now() - 25 * 3600 * 1000 };
const ent = card._signer.shared.signed;
ent[raw] = { url: raw + '?authSig=OLD', at: Date.now() - 25 * 3600 * 1000, loaded: true };
card.requestUpdate(); await card.updateComplete;
out.hrefWhenExpired = await href();
ent[raw] = { url: raw + '?authSig=AGING', at: Date.now() - 20 * 3600 * 1000 };
ent[raw] = { url: raw + '?authSig=AGING', at: Date.now() - 20 * 3600 * 1000, loaded: true };
card.requestUpdate(); await card.updateComplete;
out.hrefWhenAging = await href();
@@ -86,11 +122,14 @@ const res = await page.evaluate(async () => {
});
// зафиксировано прогоном на v1.45.1 и сверено с кодом
checkAll(res, {
hrefAfterFailedSign: null,
// #73 keeps the last complete frame until the replacement is signed and
// decoded, so a transient signing failure must not blank the background.
hrefAfterFailedSign: '/api/houseplan/content/plans/_/f1-baseline.tok.svg?authSig=SIG1',
failedSignKeptProtectedBaseline: true,
noRetryStorm: true,
hrefAfterRetry: '/api/houseplan/content/plans/_/f1.tok.svg?authSig=SIG2',
hrefAfterRetry: '/api/houseplan/content/plans/_/f1.tok.svg?authSig=SIG3',
retried: true,
hrefStable: '/api/houseplan/content/plans/_/f1.tok.svg?authSig=SIG2',
hrefStable: '/api/houseplan/content/plans/_/f1.tok.svg?authSig=SIG3',
noExtraSignOnRerender: true,
hrefWhenExpired: null,
hrefWhenAging: '/api/houseplan/content/plans/_/f1.tok.svg?authSig=AGING',
+6
View File
@@ -24,6 +24,8 @@ const out = await page.evaluate(async () => {
angle: node.querySelector(':scope > ha-icon')?.style.transform || '',
});
const show = (device, states) => {
const visibleSnapshot = card._visibleDeviceSnapshot;
const candidateSnapshot = card._candidateDeviceSnapshot;
card.hass = {
...saved,
entities: {
@@ -34,8 +36,12 @@ const out = await page.evaluate(async () => {
},
states: { ...saved.states, ...states },
};
card._visibleDeviceSnapshot = null;
card._candidateDeviceSnapshot = null;
const presentation = card._devicePresentation(device);
card.hass = saved;
card._visibleDeviceSnapshot = visibleSnapshot;
card._candidateDeviceSnapshot = candidateSnapshot;
return {
classes: presentation.classes,
icon: presentation.icon,
+1 -1
View File
@@ -34,7 +34,7 @@ const res = await page.evaluate(async () => {
await setSun(180, 40);
out.dayComputedWhite = comp() === 'rgb(255, 255, 255)';
out.dayNoDim = dim().includes('brightness(1.000)');
out.dayNoDim = !dim().includes('brightness(') || dim().includes('brightness(1.000)');
out.dayRays = polys() > 0;
// the whole point: a NEW hass, nothing else. No reload, no requestUpdate.
+8 -5
View File
@@ -4,16 +4,19 @@ const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
// 1) в селекте действий 3 опции, дефолт «Карточка устройства»
// 1) Единый селектор содержит ровно четыре актуальных действия;
// legacy cover и пустая авто-опция в UI не возвращаются.
c._setMode('devices'); await c.updateComplete;
// v1.39.0: у ЛАМП дефолт 'toggle', поэтому для проверки дефолта 'info'
// берём заведомо не-световое устройство
const dev = c._devices.find((d) => !d.virtual && d.primary && !d.primary.startsWith('light.'));
c._openMarkerDialog(dev); await c.updateComplete;
const sel = [...sr().querySelectorAll('hp-dialog select')].find((s) =>
[...s.options].some((o) => o.textContent === c._t('tap.toggle')));
out.threeOptions = sel && sel.options.length === 4; // + «Запустить…» (2026-07-29)
out.noAutoOption = sel && ![...sel.options].some((o) => o.value === '');
const sel = sr().querySelector('#marker-tap-action');
const values = sel ? [...sel.options].map((o) => o.value) : [];
out.fourCurrentOptions = JSON.stringify(values) === JSON.stringify([
'info', 'more-info', 'toggle', 'run',
]);
out.noLegacyOptions = !values.includes('') && !values.includes('cover');
out.defaultInfo = sel && sel.value === 'info';
c._markerDialog = null; await c.updateComplete;
// 2) правый клик в Просмотре открывает more-info

Some files were not shown because too many files have changed in this diff Show More