Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01980ac3e6 | ||
|
|
36e81e9fb1 | ||
|
|
6d0f97ef82 | ||
|
|
bd9b6a6d75 | ||
|
|
4b03b888ff | ||
|
|
58d952e94e | ||
|
|
5d3f580df0 | ||
|
|
4dc3fdef36 | ||
|
|
8a417539e0 | ||
|
|
c41231a7ba | ||
|
|
2158e5d3f6 | ||
|
|
554d2e6544 | ||
|
|
2cf5c2748e | ||
|
|
59d028caf7 | ||
|
|
4381f65fde | ||
|
|
446f33ed31 | ||
|
|
9419842333 | ||
|
|
02373bb31f | ||
|
|
9a4ecbf961 | ||
|
|
48eebfd8a5 | ||
|
|
5c5833d69a | ||
|
|
a41a75eba5 | ||
|
|
6f34c06d74 | ||
|
|
c237baaffd |
@@ -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
|
||||
|
||||
@@ -6,3 +6,4 @@ __pycache__/
|
||||
.pytest_cache/
|
||||
.venv-backend/
|
||||
artifacts/
|
||||
.agents/
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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 с высоким риском**, но работа станет
|
||||
декомпозируемой и проверяемой; менять выбранную продуктовую концепцию не нужно.
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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()},
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
After Width: | Height: | Size: 105 KiB |
|
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"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 362 KiB After Width: | Height: | Size: 354 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 259 KiB After Width: | Height: | Size: 292 KiB |
|
Before Width: | Height: | Size: 258 KiB After Width: | Height: | Size: 280 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 303 KiB After Width: | Height: | Size: 320 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 156 KiB After Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 127 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 165 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 166 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 156 KiB After Width: | Height: | Size: 167 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 197 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 175 KiB |
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 175 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 157 KiB After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 303 KiB After Width: | Height: | Size: 320 KiB |
|
Before Width: | Height: | Size: 304 KiB After Width: | Height: | Size: 320 KiB |
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
]);
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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');
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 ------------------
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
@@ -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, {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||