Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc17109249 | ||
|
|
e79f8f5aa1 | ||
|
|
024a1accd8 | ||
|
|
47c6f10a9d | ||
|
|
52ec0fb54f | ||
|
|
bcd280afb9 | ||
|
|
ba56d4f768 |
@@ -58,7 +58,7 @@ while read -r local_ref local_sha remote_ref remote_sha; do
|
||||
|
||||
echo "process-gate: $local_ref, диапазон ${base}..${local_sha}" >&2
|
||||
# shellcheck disable=SC2086
|
||||
if ! node "$gate" --range "${base}..${local_sha}" --target-ref "$remote_ref" $issues_flag >&2; then
|
||||
if ! node "$gate" --range "${base}..${local_sha}" $issues_flag >&2; then
|
||||
status=1
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
name: Mutation gate
|
||||
|
||||
# Реестр известных поломок (issue #85): каждый мутант ломает продуктовый код
|
||||
# известным способом, и объявленный тест ОБЯЗАН на этом покраснеть. Тест,
|
||||
# оставшийся зелёным на сломанном коде, ничего не защищает — он лишь выглядит
|
||||
# защитой, и это хуже его отсутствия.
|
||||
#
|
||||
# Прогон дорогой: пересборка бандла на каждого мутанта. Поэтому он не входит в
|
||||
# Validate и не идёт на каждый push. Его место — перед стабильным релизом
|
||||
# (PROCESS.md §8) и раз в неделю по расписанию, чтобы дрейф тестов не копился
|
||||
# до релиза. Дешёвая половина — «якоря патчей живы, guard-файлы существуют» —
|
||||
# идёт с обычными юнитами: test/mutation-gate.test.mjs.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# Понедельник, 05:20 UTC — до начала рабочего дня владельца.
|
||||
- cron: '20 5 * * 1'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: mutation-gate
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
mutants:
|
||||
runs-on: ubuntu-latest
|
||||
# Шесть мутантов × (сборка + браузерный смок) — это десятки минут, и это
|
||||
# нормально: гейт предрелизный. Час — потолок против зависшего Chromium.
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: dev
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Установить Chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Реестр применим к текущему коду
|
||||
run: node scripts/mutation-gate.mjs --check
|
||||
|
||||
- name: Каждый тест ловит свою поломку
|
||||
run: node scripts/mutation-gate.mjs
|
||||
@@ -48,7 +48,6 @@ jobs:
|
||||
BLOCKED: ${{ contains(github.event.issue.labels.*.name, 'blocked') }}
|
||||
EXHAUSTED: ${{ contains(github.event.issue.labels.*.name, 'review-4') }}
|
||||
SMALL: ${{ contains(github.event.issue.labels.*.name, 'small') }}
|
||||
TRIVIAL: ${{ contains(github.event.issue.labels.*.name, 'trivial') }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
# Этап определяется первым: от него зависит, какие вердикты считать.
|
||||
@@ -59,9 +58,8 @@ jobs:
|
||||
*) echo "метка $LABEL конвейер не запускает" ;;
|
||||
esac
|
||||
|
||||
# Лимит циклов: 4 обычный, 2 на лёгком и коротком треке (PROCESS.md §4).
|
||||
limit=4
|
||||
if [ "$SMALL" = "true" ] || [ "$TRIVIAL" = "true" ]; then limit=2; fi
|
||||
# Лимит циклов: 4 обычный, 2 на лёгком треке (PROCESS.md §4).
|
||||
limit=4; [ "$SMALL" = "true" ] && limit=2
|
||||
|
||||
# Счётчик считает вердикты ТОЛЬКО своего этапа. Раньше он брал все
|
||||
# подряд, и вердикт по ТЗ съедал цикл из бюджета код-ревью: на #89
|
||||
@@ -136,15 +134,8 @@ jobs:
|
||||
fetch-depth: 0
|
||||
ref: dev
|
||||
|
||||
# Окружение готовит workflow, а не модель своими ходами. Раньше промпт
|
||||
# велел ревьюеру самому выполнить `npm ci`: минуты уходили на установку без
|
||||
# кэша, платились из бюджета 45 минут и из лимитов подписки, а ходы модели
|
||||
# тратились на работу инфраструктуры. В validate.yml кэш стоит на всех
|
||||
# тяжёлых job, здесь его не было.
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
with: { node-version: 22 }
|
||||
|
||||
# Материал ревью живёт в ветке задачи: ТЗ в docs/specs/ и код коммитятся
|
||||
# в issue/<NN>-slug. Если ветка запушена — переключаемся на неё, иначе
|
||||
@@ -165,24 +156,6 @@ jobs:
|
||||
echo "МАТЕРИАЛ НЕ ЗАПУШЕН" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
# Зависимости ставятся ПОСЛЕ переключения на ветку задачи: lockfile мог
|
||||
# измениться именно в ней, и установка по копии из dev дала бы не то дерево.
|
||||
- name: Установить зависимости
|
||||
run: npm ci
|
||||
|
||||
# Браузер нужен не всякому ревью (см. правило выбора гейтов в промпте),
|
||||
# но когда нужен — качать его заново дороже, чем держать в кэше.
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Установить Chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Review
|
||||
id: review
|
||||
uses: anthropics/claude-code-action@v1
|
||||
@@ -232,38 +205,10 @@ jobs:
|
||||
По каждому AC: либо он доказан автотестом и ты убедился, что тест
|
||||
умеет падать, либо разобран по коду с явной записью «проверено
|
||||
чтением, не исполнением». «Verified» без названной команды и её
|
||||
результата доказательством не является. Зависимости уже установлены
|
||||
workflow, Chromium тоже — `npm ci` выполнять не нужно. Проверь
|
||||
трейлеры Issue и User-Visible, при User-Visible: yes — правки в оба
|
||||
changelog в том же коммите.
|
||||
|
||||
**Объём гейтов соразмерен задаче.** Прогонять весь набор на каждой
|
||||
правке — не тщательность, а потеря времени: полные наборы это
|
||||
предрелизный гейт (PROCESS.md §8), а не гейт ревью.
|
||||
|
||||
Всегда, они дешёвые:
|
||||
`npx tsc --noEmit`, `npm test`, `npm run build` со сверкой трёх
|
||||
копий бандла.
|
||||
|
||||
По необходимости, и «необходимость» определяется diff'ом и AC:
|
||||
- браузерные смоки `demo/smoke_*.mjs` — названные в AC плюс
|
||||
относящиеся к тронутым поверхностям. Их 127; прогон всех уместен
|
||||
только когда задача действительно задевает всё;
|
||||
- `npm run golden:verify` — если diff может изменить видимый
|
||||
результат: рендер, геометрия, стили, слои;
|
||||
- `python -m pytest tests_backend -q` — если тронут
|
||||
`custom_components/**/*.py`;
|
||||
- performance-профили — если названы в AC либо тронуты
|
||||
чувствительные к перфу пути.
|
||||
|
||||
Дисциплина «тест должен уметь падать» не отменяется, но применяется к
|
||||
тем тестам, которые ты прогонял.
|
||||
|
||||
**В комментарии обязателен перечень: какие гейты прогнал, какие нет и
|
||||
почему.** Это условие честности такого сужения: непрогнанный гейт
|
||||
становится видимым решением, а не молчаливым пропуском. Раздел «чего
|
||||
не проверял» в документе ревью — не формальность, а главный его
|
||||
раздел на коротких задачах.
|
||||
результата доказательством не является. Зависимостей в рабочей
|
||||
копии нет: перед гейтами выполни `npm ci`. Проверь трейлеры Issue и
|
||||
User-Visible, при User-Visible: yes — правки в оба changelog в том же
|
||||
коммите.
|
||||
|
||||
Ты НЕ правишь ни ТЗ, ни продуктовый код. Только оцениваешь.
|
||||
|
||||
@@ -304,21 +249,14 @@ jobs:
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
# Ветки задачи может не быть: у задач, размеченных до появления
|
||||
# конвейера, ТЗ лежит прямо в dev. Раньше шаг в этом случае молча
|
||||
# выходил с нулём, и разбор ревью терялся — оставался только вердикт
|
||||
# комментарием. Это тот же тихий отказ: шаг сообщал об успехе тем, что
|
||||
# ничего не сделал. Документ ложится туда же, где лежит само ТЗ.
|
||||
target="${BRANCH:-dev}"
|
||||
if [ -z "$BRANCH" ]; then
|
||||
echo "::warning::ветки задачи нет — документ ревью ляжет в dev"
|
||||
echo "ветки задачи нет — документ некуда класть"; exit 0
|
||||
fi
|
||||
git checkout -- . 2>/dev/null || true
|
||||
git clean -fd -e docs/reviews -e node_modules >/dev/null 2>&1 || true
|
||||
git add docs/reviews 2>/dev/null || true
|
||||
if git diff --cached --quiet; then
|
||||
echo "::warning::документ ревью не создан"
|
||||
exit 0
|
||||
echo "документ ревью не создан"; exit 0
|
||||
fi
|
||||
git -c user.name="claude[bot]" \
|
||||
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
|
||||
@@ -328,23 +266,9 @@ jobs:
|
||||
Issue: #$NUM
|
||||
User-Visible: no
|
||||
EOF
|
||||
# Публикация в dev идёт из детачнутого состояния поверх ветки задачи
|
||||
# либо dev, поэтому push нужен с явным перебазированием при гонке:
|
||||
# dev мог уйти вперёд, пока шло ревью — оно длится до 45 минут.
|
||||
if ! git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
|
||||
"HEAD:$target"; then
|
||||
git fetch -q origin "$target"
|
||||
if ! git -c user.name="claude[bot]" \
|
||||
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
|
||||
rebase "origin/$target"; then
|
||||
git rebase --abort || true
|
||||
echo "::error::документ ревью не удалось опубликовать в $target: конфликт"
|
||||
exit 0
|
||||
fi
|
||||
git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
|
||||
"HEAD:$target"
|
||||
fi
|
||||
echo "документ опубликован в $target"
|
||||
git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
|
||||
"HEAD:$BRANCH"
|
||||
echo "документ опубликован в $BRANCH"
|
||||
|
||||
- name: Решение по вердикту
|
||||
id: decide
|
||||
|
||||
@@ -51,7 +51,6 @@ jobs:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
TARGET_REF: ${{ github.ref }}
|
||||
# Публичный репозиторий: штатного токена хватает на чтение issue.
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
|
||||
@@ -41,15 +41,6 @@ of a status and `rejected` on a closed issue. Exactly one `S*` label per open
|
||||
issue. [GitHub Projects (v2)](https://github.com/users/Matysh/projects/1) is a
|
||||
human-facing view synchronised from the labels, not the source of truth.
|
||||
|
||||
Two shortcuts exist for small work. `small` — the light track: the spec lives in
|
||||
the issue body and its review is a comment. `trivial` — the short track: no spec
|
||||
stage at all, `S2-analysis` straight to `S5-ready`, with the AC written into the
|
||||
issue body first. `trivial` requires a bug confined to one surface with no new UX
|
||||
contract, no migration, no i18n, no perf or touch impact, at most three checkable
|
||||
AC, **and expected behaviour already on record** — nothing left to decide. Code
|
||||
review is never skipped on either track; it is what stands in for testing.
|
||||
`PROCESS.md` §5 and §5.1 hold the criteria.
|
||||
|
||||
An issue filed by an outsider is worked exactly like one of the owner's own, once
|
||||
the owner has decided to take it. The check sits **at the entrance**, not on every
|
||||
step: while an issue carries no status label it is outside the process and the
|
||||
|
||||
@@ -70,8 +70,7 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
→ S6-in-progress → S7-code-review ⟲ → S8-merged → закрыт при выпуске беты
|
||||
|
||||
служебные: blocked (поверх статуса) rejected (закрыт)
|
||||
⟲ — возврат на правки, не более 4 циклов (§4), на лёгком и коротком треке 2
|
||||
короткий трек (`trivial`, §5.1) идёт S2-analysis → S5-ready, минуя S3 и S4
|
||||
⟲ — возврат на правки, не более 4 циклов (§4), на лёгком треке 2
|
||||
```
|
||||
|
||||
Переходы `S4-spec-review` и `S7-code-review` выполняются **автоматически**: метка
|
||||
@@ -295,42 +294,6 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
модуль) — метка `small` снимается, issue возвращается в `S3-spec` и получает
|
||||
нормальный файл ТЗ. Это не провал, это ранняя диагностика.
|
||||
|
||||
### 5.1 Короткий трек (метка `trivial`)
|
||||
|
||||
Решение владельца 2026-08-13, issue #128. Лёгкий трек делает ТЗ дешёвым; короткий
|
||||
обходится без него совсем.
|
||||
|
||||
**Маршрут:** `S1-new` → `S2-analysis` → `S5-ready` → `S6-in-progress` →
|
||||
`S7-code-review` → `S8-merged`. Стадии `S3-spec` и `S4-spec-review` пропускаются.
|
||||
|
||||
`S2-analysis` остаётся: это комментарий, а не прогон CI, и именно там владелец
|
||||
решает приоритет и ценность. AC пишет автор в теле issue при переводе в
|
||||
`S5-ready` — до перехода, иначе ревьюеру нечего будет сверять.
|
||||
|
||||
**Критерии, все обязательны:**
|
||||
|
||||
- тип `bug`;
|
||||
- правка ограничена одной поверхностью, нового UX-контракта нет;
|
||||
- нет миграции конфига, новых ключей i18n, влияния на перф и touch;
|
||||
- AC выражаются тремя проверяемыми утверждениями или меньше;
|
||||
- **ожидаемое поведение уже зафиксировано** — в `docs/USER-GUIDE.ru.md`, в
|
||||
каноническом документе подсистемы либо однозначно в самом отчёте. Решать нечего.
|
||||
Если есть что решать, это `S3-spec`, и никакая экономия этого не отменяет.
|
||||
|
||||
Метка ставится в `S2-analysis` вместе с остальными оценками, одним комментарием,
|
||||
где владелец утверждает и приоритет.
|
||||
|
||||
**Что не упрощается:** issue, оценка, статусы, трейлеры, changelog и **код-ревью**.
|
||||
Лимит циклов код-ревью — 2, как на лёгком треке.
|
||||
|
||||
Если по ходу выясняется, что критерий нарушен, метка снимается и issue уходит в
|
||||
`S3-spec` за нормальным ТЗ. Как и на лёгком треке, это не провал, а ранняя
|
||||
диагностика.
|
||||
|
||||
**Чем этот трек опасен.** Он убирает единственное место, где решение проверялось
|
||||
до написания кода. Признак «решать нечего» держит всю конструкцию, и его нельзя
|
||||
подтверждать ощущением — только ссылкой на уже зафиксированное поведение.
|
||||
|
||||
---
|
||||
|
||||
## 6. Роли
|
||||
@@ -468,17 +431,6 @@ npm run golden:verify # если менялся визуал
|
||||
python -m pytest tests_backend -q # py3.13, если менялся бэкенд
|
||||
```
|
||||
|
||||
**Объём гейтов на код-ревью соразмерен задаче** (issue #127). Всегда:
|
||||
`typecheck`, `npm test`, `npm run build` со сверкой трёх копий бандла. По
|
||||
необходимости, определяемой diff'ом и AC: браузерные смоки (их 127 — прогон всех
|
||||
уместен только когда задача задевает всё), `golden:verify` при изменении видимого
|
||||
результата, `pytest tests_backend` при правках в Python, performance-профили при
|
||||
названном в AC влиянии. **Полные наборы — предрелизный гейт, а не гейт ревью.**
|
||||
|
||||
Условие честности такого сужения: ревьюер обязан перечислить, какие гейты прогнал,
|
||||
какие нет и почему. Непрогнанный гейт становится видимым решением, а не молчаливым
|
||||
пропуском.
|
||||
|
||||
**Гейт беты** (условие закрытия issue): CI Validate зелёный на точном SHA тега.
|
||||
|
||||
Часть гейтов запускается только здесь, то есть **после** пройденного код-ревью.
|
||||
@@ -512,8 +464,8 @@ Project v2 остаётся человеческим представление
|
||||
| `blocked` | Ждём внешнего или владельца, **поверх** статусной метки |
|
||||
| `rejected` | Отклонено, issue закрыт |
|
||||
|
||||
Модификаторы: `small` (лёгкий трек, сложность ≤3), `trivial` (короткий трек,
|
||||
§5.1), `hotfix`, `process`, `review-4`; приоритет `P1`/`P2`/`P3`; тип `bug`/`feature`/`tech-debt`.
|
||||
Модификаторы: `small` (лёгкий трек, сложность ≤3), `hotfix`, `process`,
|
||||
`review-4`; приоритет `P1`/`P2`/`P3`; тип `bug`/`feature`/`tech-debt`.
|
||||
Тематические метки (`polish`, `infra`, `tests`, `docs`, `security`, `vacuum`)
|
||||
ортогональны процессу.
|
||||
|
||||
@@ -608,7 +560,7 @@ Project v2 остаётся человеческим представление
|
||||
{`S5-ready`, `S6-in-progress`, `S7-code-review`, `S8-merged`}; закрытый,
|
||||
недоступный или помеченный `blocked` — отказ (**fail closed**).
|
||||
|
||||
Три оговорки к проверке 8 выяснились при реализации.
|
||||
Две оговорки к проверке 8, обе выяснились при реализации.
|
||||
|
||||
**`S8-merged` входит в множество**, хотя по смыслу задача уже принята. Причина
|
||||
механическая: конвейер (§10.4) сливает ветку в `dev` **раньше**, чем ставит метку,
|
||||
@@ -621,13 +573,6 @@ Validate стартует от этого push и успевает прочит
|
||||
документ ревью: он ложится в ветку задачи, пока та в `S4-spec-review` или
|
||||
`S7-code-review`, то есть заведомо вне рабочего множества.
|
||||
|
||||
**При продвижении в `main` не перепроверяются коммиты, уже достижимые из
|
||||
prerelease-тега.** После выпуска беты их issue по §2.8 должны быть закрыты, а
|
||||
stable fast-forward снова включает эти коммиты в диапазон `old-main..candidate`.
|
||||
Pre-push передаёт целевую remote ref через `--target-ref`, а Validate — через
|
||||
`TARGET_REF`; оба исключают только уже опубликованную prerelease-историю. Любой
|
||||
post-beta коммит остаётся в проверке и по закрытому issue отклоняется fail-closed.
|
||||
|
||||
Не реализовано и остаётся долгом:
|
||||
|
||||
9. `npm run release:prerelease -- --issues=…` не проверяет, есть ли у issue
|
||||
|
||||
@@ -312,17 +312,6 @@ 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.
|
||||
|
||||
To make that virtual lamp manually switchable without creating a Home
|
||||
Assistant helper, also choose **Tap action → Toggle state** on the lamp itself.
|
||||
This exact combination — virtual binding, **Light source → Always**, and
|
||||
**Toggle state** — stores a shared on/off state in the House Plan integration.
|
||||
It survives page reloads and Home Assistant restarts and updates Glow, room
|
||||
fill/statistics, full cards and `houseplan-space-card` together. Any signed-in
|
||||
dashboard viewer may toggle it. While this manual mode is active, saved
|
||||
**Controls other light sources** remain intact but are not called; changing the
|
||||
role, binding or tap action restores their normal behaviour. This operational
|
||||
state is deliberately not part of plan exports or Home Assistant entities.
|
||||
|
||||
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
|
||||
|
||||
@@ -24,12 +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,
|
||||
async_save_config_state,
|
||||
async_save_layout_state,
|
||||
create_data,
|
||||
)
|
||||
from .store import HouseplanConfigEntry, async_save_layout_state, create_data
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -65,10 +60,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
await data.config_store.async_load()
|
||||
except Exception as err: # noqa: BLE001 — corrupt/unreadable .storage
|
||||
raise ConfigEntryNotReady(f"House Plan storage is not readable: {err}") from err
|
||||
try:
|
||||
await data.virtual_light_store.async_load()
|
||||
except Exception: # noqa: BLE001 — operational state fails safe to default on
|
||||
_LOGGER.exception("House Plan: virtual-light storage is not readable; using default on")
|
||||
entry.runtime_data = data
|
||||
|
||||
# server-side vacuum trails: the integration records the path itself
|
||||
@@ -161,7 +152,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
rev = int(stored.get("rev", 0))
|
||||
if cfg and migrate_config(cfg): # 2. the config half
|
||||
rev += 1
|
||||
await async_save_config_state(data, cfg, rev, previous_rev=rev - 1)
|
||||
await data.config_store.async_save({"config": cfg, "rev": rev})
|
||||
migrate_layout(layout, merged) # 3. the layout half + intent cleared
|
||||
await async_save_layout_state(
|
||||
data, lay_stored, layout, lay_rev + 1, remove=("geom_pending",)
|
||||
@@ -195,14 +186,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
"layout_rev", layout_rev + (lay_stored.get("layout", {}) != target_layout)
|
||||
))
|
||||
if stored.get("config") != target_config or config_rev < target_config_rev:
|
||||
previous_config_rev = config_rev
|
||||
config_rev = max(config_rev, target_config_rev)
|
||||
await async_save_config_state(
|
||||
data,
|
||||
target_config,
|
||||
config_rev,
|
||||
previous_rev=previous_config_rev,
|
||||
)
|
||||
await data.config_store.async_save({
|
||||
"config": target_config,
|
||||
"rev": config_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")
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
DOMAIN = "houseplan"
|
||||
STORAGE_KEY = f"{DOMAIN}.layout"
|
||||
STORAGE_CONFIG_KEY = f"{DOMAIN}.config"
|
||||
STORAGE_VIRTUAL_LIGHTS_KEY = f"{DOMAIN}.virtual_lights"
|
||||
STORAGE_VERSION = 1
|
||||
STORAGE_MINOR_VERSION = 1
|
||||
FRONTEND_URL = "/houseplan_files/houseplan-card.js"
|
||||
@@ -46,7 +45,7 @@ PLAN_ORPHAN_TTL_S = 3600
|
||||
SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.63.0"
|
||||
VERSION = "1.63.0-beta.1"
|
||||
|
||||
# Portable backup format. This is deliberately independent from the Home
|
||||
# Assistant Store version above: storage migrations and files exported by a
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.63.0"
|
||||
"version": "1.63.0-beta.1"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
@@ -11,17 +10,7 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.storage import Store
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
STORAGE_CONFIG_KEY,
|
||||
STORAGE_KEY,
|
||||
STORAGE_MINOR_VERSION,
|
||||
STORAGE_VERSION,
|
||||
STORAGE_VIRTUAL_LIGHTS_KEY,
|
||||
)
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
from .const import DOMAIN, STORAGE_CONFIG_KEY, STORAGE_KEY, STORAGE_MINOR_VERSION, STORAGE_VERSION
|
||||
|
||||
|
||||
class HouseplanStore(Store):
|
||||
@@ -51,7 +40,6 @@ class HouseplanData:
|
||||
|
||||
store: HouseplanStore
|
||||
config_store: HouseplanStore
|
||||
virtual_light_store: HouseplanStore
|
||||
# One lock for every load→modify→save cycle of both stores: prevents
|
||||
# lost updates from concurrent WS calls and makes the rev check atomic.
|
||||
write_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
@@ -83,12 +71,6 @@ def create_data(hass: HomeAssistant) -> HouseplanData:
|
||||
config_store=HouseplanStore(
|
||||
hass, STORAGE_VERSION, STORAGE_CONFIG_KEY, minor_version=STORAGE_MINOR_VERSION
|
||||
),
|
||||
virtual_light_store=HouseplanStore(
|
||||
hass,
|
||||
STORAGE_VERSION,
|
||||
STORAGE_VIRTUAL_LIGHTS_KEY,
|
||||
minor_version=STORAGE_MINOR_VERSION,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -157,43 +139,3 @@ async def async_save_layout_state(
|
||||
)
|
||||
await runtime.store.async_save(payload)
|
||||
return payload
|
||||
|
||||
|
||||
async def async_save_config_state(
|
||||
runtime: HouseplanData,
|
||||
config: dict[str, Any],
|
||||
rev: int,
|
||||
*,
|
||||
previous_rev: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Persist configuration and reconcile dependent operational state.
|
||||
|
||||
Callers already hold ``runtime.write_lock``. Reading the previous
|
||||
revision here keeps less common writers (import recovery and undo) on the
|
||||
same path as ordinary editor saves without duplicating lifecycle rules.
|
||||
"""
|
||||
if previous_rev is None:
|
||||
previous = await runtime.config_store.async_load() or {}
|
||||
try:
|
||||
previous_rev = int(previous.get("rev", 0))
|
||||
except (TypeError, ValueError):
|
||||
previous_rev = 0
|
||||
|
||||
payload = {"config": config, "rev": rev}
|
||||
await runtime.config_store.async_save(payload)
|
||||
|
||||
# The config is already durable at this point. Reconciliation remains a
|
||||
# separate Store write; an interrupted pair is detected from config_rev on
|
||||
# the next read and fails safe to the compatibility default (all on).
|
||||
from .virtual_lights import async_reconcile_virtual_lights
|
||||
|
||||
try:
|
||||
await async_reconcile_virtual_lights(
|
||||
runtime.virtual_light_store,
|
||||
config,
|
||||
rev,
|
||||
previous_config_rev=previous_rev,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - config commit already stands
|
||||
_LOGGER.exception("House Plan: virtual-light state reconciliation failed")
|
||||
return payload
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
"""Persistent operational state for manual virtual lights."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .store import HouseplanStore
|
||||
|
||||
|
||||
EVENT_VIRTUAL_LIGHT_UPDATED = "houseplan_virtual_light_updated"
|
||||
|
||||
|
||||
def is_manual_virtual_light(marker: Any) -> bool:
|
||||
"""Return whether a marker uses the exact persistent manual-light mode."""
|
||||
return (
|
||||
isinstance(marker, dict)
|
||||
and isinstance(marker.get("id"), str)
|
||||
and bool(marker["id"])
|
||||
and marker.get("binding") == "virtual"
|
||||
and marker.get("is_light") is True
|
||||
and marker.get("tap_action") == "toggle"
|
||||
and marker.get("removed") is not True
|
||||
)
|
||||
|
||||
|
||||
def eligible_virtual_light_ids(config: Any) -> set[str]:
|
||||
"""Collect live marker ids eligible for persistent manual state."""
|
||||
if not isinstance(config, dict):
|
||||
return set()
|
||||
markers = config.get("markers")
|
||||
if not isinstance(markers, list):
|
||||
return set()
|
||||
return {marker["id"] for marker in markers if is_manual_virtual_light(marker)}
|
||||
|
||||
|
||||
def _integer(value: Any, default: int = 0) -> int:
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return max(0, parsed)
|
||||
|
||||
|
||||
def _read_state(stored: Any) -> tuple[int, int, set[str]]:
|
||||
if not isinstance(stored, dict):
|
||||
return 0, 0, set()
|
||||
raw_off = stored.get("off")
|
||||
off = (
|
||||
{item for item in raw_off if isinstance(item, str) and item}
|
||||
if isinstance(raw_off, list)
|
||||
else set()
|
||||
)
|
||||
return _integer(stored.get("rev")), _integer(stored.get("config_rev")), off
|
||||
|
||||
|
||||
def _wire(rev: int, config_rev: int, off: set[str]) -> dict[str, Any]:
|
||||
return {"rev": rev, "config_rev": config_rev, "off": sorted(off)}
|
||||
|
||||
|
||||
async def async_virtual_light_snapshot(
|
||||
store: HouseplanStore,
|
||||
config: dict[str, Any],
|
||||
config_rev: int,
|
||||
) -> dict[str, Any]:
|
||||
"""Return a coherent snapshot, repairing stale or interrupted state.
|
||||
|
||||
A revision gap means an older writer may have changed eligibility without
|
||||
knowing about this Store. Clearing every manual-off bit is conservative:
|
||||
it restores the pre-feature/default-on behaviour and cannot resurrect an
|
||||
old off state for a marker whose role changed in the meantime.
|
||||
"""
|
||||
stored = await store.async_load() or {}
|
||||
rev, state_config_rev, stored_off = _read_state(stored)
|
||||
eligible = eligible_virtual_light_ids(config)
|
||||
off = stored_off & eligible if state_config_rev == config_rev else set()
|
||||
if off != stored_off:
|
||||
rev += 1
|
||||
payload = _wire(rev, config_rev, off)
|
||||
if payload != stored:
|
||||
await store.async_save(payload)
|
||||
return payload
|
||||
|
||||
|
||||
async def async_reconcile_virtual_lights(
|
||||
store: HouseplanStore,
|
||||
config: dict[str, Any],
|
||||
config_rev: int,
|
||||
*,
|
||||
previous_config_rev: int,
|
||||
) -> dict[str, Any]:
|
||||
"""Carry eligible state across one known configuration transition."""
|
||||
stored = await store.async_load() or {}
|
||||
rev, state_config_rev, stored_off = _read_state(stored)
|
||||
eligible = eligible_virtual_light_ids(config)
|
||||
off = stored_off & eligible if state_config_rev == previous_config_rev else set()
|
||||
if off != stored_off:
|
||||
rev += 1
|
||||
payload = _wire(rev, config_rev, off)
|
||||
if payload != stored:
|
||||
await store.async_save(payload)
|
||||
return payload
|
||||
|
||||
|
||||
async def async_toggle_virtual_light(
|
||||
store: HouseplanStore,
|
||||
config: dict[str, Any],
|
||||
config_rev: int,
|
||||
marker_id: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Atomically invert one eligible marker and persist before returning."""
|
||||
if marker_id not in eligible_virtual_light_ids(config):
|
||||
return None
|
||||
snapshot = await async_virtual_light_snapshot(store, config, config_rev)
|
||||
off = set(snapshot["off"])
|
||||
if marker_id in off:
|
||||
off.remove(marker_id)
|
||||
else:
|
||||
off.add(marker_id)
|
||||
payload = _wire(_integer(snapshot["rev"]) + 1, config_rev, off)
|
||||
await store.async_save(payload)
|
||||
return {
|
||||
"marker_id": marker_id,
|
||||
"on": marker_id not in off,
|
||||
"rev": payload["rev"],
|
||||
}
|
||||
@@ -44,16 +44,10 @@ from .store import (
|
||||
OPTIMIZE_BACKUP as _OPTIMIZE_BACKUP,
|
||||
OPTIMIZE_PENDING as _OPTIMIZE_PENDING,
|
||||
HouseplanData,
|
||||
async_save_config_state,
|
||||
async_save_layout_state,
|
||||
get_data,
|
||||
get_entry,
|
||||
)
|
||||
from .virtual_lights import (
|
||||
EVENT_VIRTUAL_LIGHT_UPDATED,
|
||||
async_toggle_virtual_light,
|
||||
async_virtual_light_snapshot,
|
||||
)
|
||||
from .registry_snapshot import import_registry_snapshot
|
||||
from .validation import (
|
||||
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES,
|
||||
@@ -132,7 +126,6 @@ def async_register(hass: HomeAssistant) -> None:
|
||||
websocket_api.async_register_command(hass, ws_layout_update)
|
||||
websocket_api.async_register_command(hass, ws_layout_delete)
|
||||
websocket_api.async_register_command(hass, ws_config_get)
|
||||
websocket_api.async_register_command(hass, ws_virtual_light_toggle)
|
||||
websocket_api.async_register_command(hass, ws_config_set)
|
||||
websocket_api.async_register_command(hass, ws_plan_optimize)
|
||||
websocket_api.async_register_command(hass, ws_plan_optimize_undo)
|
||||
@@ -201,11 +194,10 @@ async def _persist_pair_intent(
|
||||
|
||||
async def _converge_pair(rt: HouseplanData, pending: dict[str, Any]) -> None:
|
||||
"""Write both target halves and remove the durable intent last."""
|
||||
await async_save_config_state(
|
||||
rt,
|
||||
pending["config"],
|
||||
int(pending["config_rev"]),
|
||||
)
|
||||
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,
|
||||
@@ -1066,28 +1058,14 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
async with rt.write_lock:
|
||||
data = await rt.config_store.async_load() or {}
|
||||
layout_data = await rt.store.async_load() or {}
|
||||
config = {**DEFAULT_CONFIG, **data.get("config", {})}
|
||||
config_rev = int(data.get("rev", 0))
|
||||
try:
|
||||
virtual_lights = await async_virtual_light_snapshot(
|
||||
rt.virtual_light_store,
|
||||
config,
|
||||
config_rev,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - config remains independently readable
|
||||
_LOGGER.exception("House Plan: reading virtual-light state failed")
|
||||
# Never expose a stale off bit after an unreadable/revision-gap
|
||||
# operational store. Compatibility/default on is the safe frame.
|
||||
virtual_lights = {"rev": 0, "config_rev": config_rev, "off": []}
|
||||
data = await rt.config_store.async_load() or {}
|
||||
layout_data = await rt.store.async_load() or {}
|
||||
config = {**DEFAULT_CONFIG, **data.get("config", {})}
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{
|
||||
"config": config,
|
||||
"rev": config_rev,
|
||||
"virtual_lights": virtual_lights,
|
||||
"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),
|
||||
@@ -1095,42 +1073,6 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/virtual_light/toggle",
|
||||
vol.Required("marker_id"): vol.All(str, vol.Length(min=1, max=500)),
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_virtual_light_toggle(
|
||||
hass: HomeAssistant, connection, msg: dict[str, Any]
|
||||
) -> None:
|
||||
"""Atomically toggle one eligible virtual light for any signed-in user."""
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
async with rt.write_lock:
|
||||
data = await rt.config_store.async_load() or {}
|
||||
config = {**DEFAULT_CONFIG, **data.get("config", {})}
|
||||
result = await async_toggle_virtual_light(
|
||||
rt.virtual_light_store,
|
||||
config,
|
||||
int(data.get("rev", 0)),
|
||||
msg["marker_id"],
|
||||
)
|
||||
if result is None:
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
"not_toggleable",
|
||||
"Marker is not an active virtual light with tap_action=toggle",
|
||||
)
|
||||
return
|
||||
# Both the reply and event follow the durable Store write. There is no
|
||||
# optimistic client state, so all cards converge on this revision.
|
||||
connection.send_result(msg["id"], result)
|
||||
hass.bus.async_fire(EVENT_VIRTUAL_LIGHT_UPDATED, result)
|
||||
|
||||
|
||||
|
||||
def _internal_plan_names(config: dict[str, Any]) -> set[str]:
|
||||
"""Plan file names a configuration names through OUR urls.
|
||||
@@ -1273,12 +1215,7 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
)
|
||||
return
|
||||
new_rev = current_rev + 1
|
||||
await async_save_config_state(
|
||||
rt,
|
||||
msg["config"],
|
||||
new_rev,
|
||||
previous_rev=int(current_rev),
|
||||
)
|
||||
await rt.config_store.async_save({"config": msg["config"], "rev": new_rev})
|
||||
try:
|
||||
await _discard_optimizer_snapshot(rt)
|
||||
except Exception: # noqa: BLE001 — stale backup cleanup is best-effort
|
||||
@@ -1403,12 +1340,7 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
metadata={_OPTIMIZE_BACKUP: backup, _OPTIMIZE_PENDING: pending},
|
||||
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING),
|
||||
)
|
||||
await async_save_config_state(
|
||||
rt,
|
||||
msg["config"],
|
||||
new_config_rev,
|
||||
previous_rev=config_rev,
|
||||
)
|
||||
await rt.config_store.async_save({"config": msg["config"], "rev": new_config_rev})
|
||||
await async_save_layout_state(
|
||||
rt, layout_data, msg["layout"], new_layout_rev,
|
||||
metadata={_OPTIMIZE_BACKUP: backup},
|
||||
@@ -1478,12 +1410,7 @@ async def ws_plan_optimize_undo(hass: HomeAssistant, connection, msg: dict[str,
|
||||
metadata={_OPTIMIZE_BACKUP: backup, _OPTIMIZE_PENDING: pending},
|
||||
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING),
|
||||
)
|
||||
await async_save_config_state(
|
||||
rt,
|
||||
restored_config,
|
||||
new_config_rev,
|
||||
previous_rev=config_rev,
|
||||
)
|
||||
await rt.config_store.async_save({"config": restored_config, "rev": new_config_rev})
|
||||
await async_save_layout_state(
|
||||
rt, layout_data, restored_layout, new_layout_rev,
|
||||
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING, "repair_backup"),
|
||||
|
||||
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 105 KiB |
@@ -1,32 +1,29 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"matrixVersion": 18,
|
||||
"acceptedAt": "2026-08-13T19:26:19.387Z",
|
||||
"sourceFingerprint": "27fda3d75e4cda95b9d85a9481f15ccd44b96d8c9d3311e0476718d36e2588c5",
|
||||
"matrixVersion": 17,
|
||||
"acceptedAt": "2026-08-13T14:30:05.929Z",
|
||||
"sourceFingerprint": "66f31850eafc963848acdc4e56e363a364bbb9b189bf4ade73d3c111ae2fc375",
|
||||
"chromium": "151.0.7922.34",
|
||||
"scenarios": {
|
||||
"split-corner-wall-before-dark": "3176dc67f54d5309f87c94e1077b4f69eb1db9f660469fbf97953038323430f3",
|
||||
"split-corner-wall-thin-dark": "6da64905a3a4f8e4b4d457e5b20d2d55e0e7c2c601316a088c4bcc6557d35cc6",
|
||||
"split-corner-wall-thick-dark": "494d559aa71ee85f90b8cfa11c1d3087fa123e963dec2b0975e7ce6c1520852a",
|
||||
"isometric-geometry-view-dark": "73939fa61bebe2254591a788fad455a026a5e3b7394eeff27d4fe9a905de7ad2",
|
||||
"isometric-geometry-view-light": "fc62f6ee8935d2e0c57b4918bf5fe554056a5956747b53a752ae265349ce662e",
|
||||
"isometric-geometry-view-dark": "6db601f322fe55e33a1a89fe62f5d3a7d53f12bac4a68c7911b12f6382ef4fca",
|
||||
"isometric-geometry-view-light": "c4c005fa55f64280abf9fccccdf7dc8efc3497bd626572bc11f45cc3cddaa159",
|
||||
"isometric-live-layers-dark": "869c62bf9cd762c36d342d1a3bbd992969425b46b132f3243439735e2c75c14f",
|
||||
"isometric-no-borders-dark": "36f972f95704bff81ea1a59bdf3cd2cf7b636ec7871780460e98b23e3ebc3da2",
|
||||
"isometric-touch-kiosk-dark": "5eba7794e563e1ef5b9387184693c819976454d0efd222bd12b38aad8ea03e20",
|
||||
"isometric-touch-kiosk-dark": "36c83bbe39809f346ebb5a0f4ed633c938e2fed028defaa48097c281db8e23a3",
|
||||
"isometric-large-warm-remount-dark": "798d312671dffebf59034a39f2865a65ece27a84b150e77bc59038bb2567d07c",
|
||||
"geometry-view-dark-fit": "3df272f6c3c3d20e9e375ea037f3dbb885657b94b0b29d0067505f4a73741237",
|
||||
"geometry-view-light-fit": "a7f2c9667d9872dd84a37d5318fd238c9eabcc0f413017eb19e02204587b4e1a",
|
||||
"geometry-plan-editor-dark": "19b5c84943b70074ba1aae51d1e54f9a59c44dde34fff89e32f0babb1e90a2ca",
|
||||
"opening-placement-door-thick-wall-dark": "395c03bbf5d968e83664fd6621f0ac25902e718022f2e92ffbcddb8ce629cf9c",
|
||||
"geometry-devices-editor-dark": "a9e4846ce5453400b87e6ad3d575882bc23a07b59dc3eecb612ed872b0c871ec",
|
||||
"geometry-decor-editor-dark": "435b36096bbb2996d56ff0af262ddebff4a727edd841b0fad9b8d4f507b987ac",
|
||||
"tray-wide-selection-en": "024ac666ac4dac01a36f7d1fdbf3bb41f76a627377f7c02dc373f429dfe96d43",
|
||||
"tray-wide-tool-ru": "669fc1cf04433b936c0e9d05799ad19eda8bc003d5ab71937d0512c8f49ba921",
|
||||
"tray-medium-group-en": "50cd3980b21f554bec2e76f4d2e8032f4bd30c0679477eb2f5cbe22f68935b7c",
|
||||
"tray-medium-selection-ru": "4e5f235be8ed6296e136641d172e727a6a6b7a9d061f0928a96ccc1103c19f1f",
|
||||
"tray-narrow-palette-en": "88b9846e4b451ed95b7ae7d2c3183a2ea191d7668768992a1364c6a7a53eb0c6",
|
||||
"tray-narrow-tool-ru": "c4130715b3cb31c68619dfc706a3aa308e86edf272bfae6833b20666764ece2b",
|
||||
"geometry-diagonal-45-opening-dark": "01206d25631c8fd09fa077932fbb5c1ee115b65ba76b38e6f7b09315dbbf6002",
|
||||
"geometry-view-dark-fit": "a538deed6141b98b3e396d7da1024b18aee345312edd7954ad458751f08f48a3",
|
||||
"geometry-view-light-fit": "0c57dee930f30a1f9c16e4e704675156f57a7623a4edf839c14a4fecbf12881c",
|
||||
"geometry-plan-editor-dark": "6b06213324c5ff50c7176451307a33d8ac31ee636c96698f8efe61e8db763bae",
|
||||
"opening-placement-door-thick-wall-dark": "24f472818f2246eeaa6568648ca4a6c42d9d84723fbcdd879435253399e2995d",
|
||||
"geometry-devices-editor-dark": "c850e83f1af747f063b895109fe26d6a5804356fab505b0976073c28ad146104",
|
||||
"geometry-decor-editor-dark": "44a95fd0b397c2729fea65c750fedcb11df10042ae198f3690151ead077c84e1",
|
||||
"tray-wide-selection-en": "468ac6acfd7fcdcbfa947e032843ff117a32ae01b4ec30937a6cddecff534504",
|
||||
"tray-wide-tool-ru": "388e03d7bf7a2391d0581e8446d0692048b9792e80bf3b9dd9bca86222fe9418",
|
||||
"tray-medium-group-en": "190d5c356461ad2aa8b63b132416e8958a285047f8dea92301213678c7ac5a92",
|
||||
"tray-medium-selection-ru": "ab91e88583b2863305434ce2775e31328638414671ff0a4f8ee82c8274c04405",
|
||||
"tray-narrow-palette-en": "fb63483e7101d457d7fb1c83ae50435410ed797771adcbfde14dc2798a000ce1",
|
||||
"tray-narrow-tool-ru": "60ce3c75de88b72fdb5d3fe7a16790184a8265c74f0f97e5be4fd83dbe0259fd",
|
||||
"geometry-diagonal-45-opening-dark": "3736a75163d47a71f60c45cd554d604f27cfbc81119b5b55e8134da61a2a0f0a",
|
||||
"openings-thick-wall-dark": "5aa0b3d26894bef9ab9fca25c31bbef2f13f2c410f5f6d3f61c8d608ceb929f8",
|
||||
"openings-filled-tunnel-dark": "167d92c11e6a8b3ff0f31177ac5905f8db4b5fb03ee78b4965c40bc45aeee50f",
|
||||
"openings-hidden-view-dark": "c85cc04d1d8622b98215e2bb83f5bb233a7cfb0ac684c912475ef7bc44245897",
|
||||
@@ -45,15 +42,15 @@
|
||||
"lighting-temp-glow-room-override-dark": "0a35d3508526187ea18e44456cfb8cd9e578a1864e896fad1c3eec2892c753e0",
|
||||
"lighting-manual-auto-spill-overlap-dark": "6324dbe2079a255e7a194720c8c19f210549ac734e564270bc1373e6385b9cac",
|
||||
"hover-over-glow-dark": "fc14ba6f6b670e61c0fb5be277e67551ea2da7a06b5c167a8c2c989f1de08910",
|
||||
"hover-nested-room-dark": "6c09526ad885c4555063def5b43287b41124a81d90972c425084dba6e622d055",
|
||||
"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": "d6fcc83aa1335df1041e2b1aa445b0019d1e3567f98ef8f47a889894051f2b62",
|
||||
"device-dialog-mobile-ru": "8cb928853ddacb61882804c3d00ead31da31bc4559ee6a8e293ef6b55cd5a463",
|
||||
"device-help-popover-light-ru": "f1bf21d62a5dd349aa57b746069c5aef58d7a26b0b9d9e0c233fde0c1d56d7eb",
|
||||
"decor-color-popover-mobile-ru": "46d4c2e4dd20c3a38e90efe3db59b3e878bdbcf273fbc1aa23de4b230723fa6e",
|
||||
"backup-full-preview-desktop-en": "1957c1c797c7793fb8dcf592ca74c9f2e3eccfcf4bac1d0187482c055bebb93b",
|
||||
"decor-color-popover-mobile-ru": "16d2859d1ed3715c1c4d3d451a8428c37e91ba00da17272c59cf83420a7b6c31",
|
||||
"backup-full-preview-desktop-en": "7f12943d1027c89f6fe46978fa1f4e1bcdbf85a1216281d850d467e68f52cbda",
|
||||
"backup-space-preview-mobile-ru": "998c6b52c1cc95109feb440a9966cade738154ceeae387246d40f8c56f9e8a3a"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 291 KiB After Width: | Height: | Size: 292 KiB |
|
Before Width: | Height: | Size: 280 KiB After Width: | Height: | Size: 280 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 320 KiB After Width: | Height: | Size: 320 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 322 KiB After Width: | Height: | Size: 323 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 187 KiB After Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 320 KiB After Width: | Height: | Size: 321 KiB |
|
Before Width: | Height: | Size: 320 KiB After Width: | Height: | Size: 321 KiB |
@@ -1,7 +1,7 @@
|
||||
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 = 19;
|
||||
export const GOLDEN_MATRIX_VERSION = 18;
|
||||
|
||||
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
|
||||
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
|
||||
@@ -15,7 +15,6 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
{ id: 'split-corner-wall-thick-dark', fixture: 'visual', space: 'golden-corner-split',
|
||||
cornerSplitWall: 'thick', mode: 'view', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-geometry-view-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
// Stage 2 material/floor-edge plus door, window, gate and nested-room coverage.
|
||||
labs: ['iso'], projection: 'iso', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-geometry-view-light', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
labs: ['iso'], projection: 'iso', theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// #122 Stage 2: Labs lifecycle, structural composition and flat editor boundary.
|
||||
// #89 Stage 1: Labs lifecycle, toggle, projection parity and flat editor boundary.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 1000, height: 850 });
|
||||
@@ -44,12 +44,7 @@ const out = await page.evaluate(async () => {
|
||||
const device = root().querySelector('[data-hp="device"]');
|
||||
const roomLabel = root().querySelector('[data-hp="room-label"]');
|
||||
result.isoRendered = isoToggle?.getAttribute('aria-pressed') === 'true'
|
||||
&& !!root().querySelector('[data-hp="iso-underlay"] .iso-floor-side')
|
||||
&& !!root().querySelector('[data-hp="iso-walls"] .iso-wall-top');
|
||||
result.sharedProjectionSnapshot = ['.iso-underlay-svg', '.plan-svg', '.iso-shadows-svg', '.iso-walls-svg']
|
||||
.map((selector) => root().querySelector(selector)?.getAttribute('viewBox'))
|
||||
.every((value, _index, values) => !!value && value === values[0]);
|
||||
result.stage2DefinitionsBounded = root().querySelectorAll('[id^="hp-iso-"]').length <= 5;
|
||||
result.preferenceStored = JSON.parse(localStorage.getItem('houseplan_card_view_v1') || '{}').f1 === 'iso';
|
||||
result.anchorsFinite = [device, roomLabel].every((node) => node
|
||||
&& center(node).every((value) => Number.isFinite(value)));
|
||||
@@ -84,7 +79,7 @@ const out = await page.evaluate(async () => {
|
||||
build: () => {
|
||||
attempts++;
|
||||
if (shouldFail) throw new Error('injected isometric failure');
|
||||
return { walls: [], floor: [], openings: [] };
|
||||
return [];
|
||||
},
|
||||
});
|
||||
card.requestUpdate();
|
||||
@@ -113,8 +108,7 @@ const out = await page.evaluate(async () => {
|
||||
await frame();
|
||||
result.removalIsImmediateFlat = JSON.stringify(window.__hpLabs) === '[]'
|
||||
&& !root().querySelector('[data-hp="projection-toggle"]')
|
||||
&& !root().querySelector('[data-hp="iso-walls"]')
|
||||
&& !root().querySelector('[id^="hp-iso-"]');
|
||||
&& !root().querySelector('[data-hp="iso-walls"]');
|
||||
return result;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// #122 Stage 2: live-floor preservation, vertical openings, touch and remount paths.
|
||||
// #89 Stage 1: Q6 live-floor preservation plus touch, kiosk and remount paths.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch(
|
||||
@@ -26,8 +26,6 @@ const out = await page.evaluate(async () => {
|
||||
contact: 'binary_sensor.window' },
|
||||
{ id: 'iso-door', type: 'door', x: 0.55, y: 0.36, angle: 90, length: 0.12,
|
||||
contact: 'binary_sensor.window', lock: 'lock.front_door' },
|
||||
{ id: 'iso-gate', type: 'gate', x: 0.72, y: 0.14, angle: 0, length: 0.16,
|
||||
contact: 'binary_sensor.gate', lock: 'lock.front_door' },
|
||||
];
|
||||
configSpace.partitions = [{
|
||||
id: 'iso-live-wall', a: [0.15, 0.12], b: [0.85, 0.12], cm: 15,
|
||||
@@ -88,7 +86,6 @@ const out = await page.evaluate(async () => {
|
||||
glowSources: count('.glowlayer [data-glow-source]'),
|
||||
sun: count('.sunlayer'),
|
||||
openings: count('[data-hp="opening"]'),
|
||||
verticalOpenings: count('[data-hp="iso-openings"] .iso-opening-panel'),
|
||||
devices: count('[data-hp="device"]'),
|
||||
hover: count('.room-hover-fill'),
|
||||
vacuumPucks: count('.vacpuck'),
|
||||
@@ -107,8 +104,6 @@ const out = await page.evaluate(async () => {
|
||||
const wallFingerprint = root(original).querySelector('[data-hp="iso-walls"]')?.dataset.fingerprint;
|
||||
const cachedGeometry = original._isoGeometryCache.get(wallFingerprint)?.geometry;
|
||||
const light = original.hass.states['light.ceiling'];
|
||||
const openingPanelBefore = root(original)
|
||||
.querySelector('[data-hp="iso-openings"] [data-id="iso-door"]')?.getAttribute('d');
|
||||
original.hass = {
|
||||
...original.hass,
|
||||
states: {
|
||||
@@ -116,9 +111,6 @@ const out = await page.evaluate(async () => {
|
||||
'light.ceiling': {
|
||||
...light, attributes: { ...light.attributes, rgb_color: [255, 64, 32] },
|
||||
},
|
||||
'binary_sensor.window': {
|
||||
...original.hass.states['binary_sensor.window'], state: 'off',
|
||||
},
|
||||
},
|
||||
};
|
||||
await original.updateComplete;
|
||||
@@ -126,26 +118,22 @@ const out = await page.evaluate(async () => {
|
||||
const afterSpillParts = [...root(original).querySelectorAll('.glow-pool')]
|
||||
.map((node) => node.getAttribute('data-lit-parts'));
|
||||
const ordered = [
|
||||
root(original).querySelector('.iso-underlay-svg'),
|
||||
root(original).querySelector('.hp-backdrop'),
|
||||
root(original).querySelector('.decorlayer'),
|
||||
root(original).querySelector('[data-hp="room"]'),
|
||||
root(original).querySelector('.glow-base-layer'),
|
||||
root(original).querySelector('.glowlayer'),
|
||||
root(original).querySelector('.sunlayer'),
|
||||
root(original).querySelector('.iso-shadows-svg'),
|
||||
root(original).querySelector('.iso-walls-svg'),
|
||||
root(original).querySelector('.vacpuck'),
|
||||
];
|
||||
const result = {
|
||||
isoOnTouch: !!root(original).querySelector('[data-hp="iso-walls"]'),
|
||||
flatIsoLayerParity: JSON.stringify({ ...flat, openings: 0, verticalOpenings: before.verticalOpenings })
|
||||
=== JSON.stringify(before)
|
||||
flatIsoLayerParity: JSON.stringify(flat) === JSON.stringify(before)
|
||||
&& JSON.stringify(flatSpillParts) === JSON.stringify(beforeSpillParts),
|
||||
liveLayersPresent: before.rooms > 0 && before.roomFills > 0 && before.decor >= 2
|
||||
&& before.furniture > 0 && before.backdrop > 0 && before.glowBase > 0
|
||||
&& before.glowSources >= 2 && before.sun > 0 && before.openings === 0
|
||||
&& before.verticalOpenings === 5
|
||||
&& before.glowSources >= 2 && before.sun > 0 && before.openings >= 2
|
||||
&& before.devices > 0 && before.hover > 0 && before.vacuumPucks > 0
|
||||
&& before.vacuumTrails >= 2,
|
||||
liveLayersStable: JSON.stringify(after) === JSON.stringify(before),
|
||||
@@ -159,50 +147,9 @@ const out = await page.evaluate(async () => {
|
||||
.some((stop) => stop.getAttribute('stop-color') === '#ff4020'),
|
||||
sameWallFingerprint: root(original).querySelector('[data-hp="iso-walls"]')?.dataset.fingerprint === wallFingerprint,
|
||||
haUpdateReusesGeometry: original._isoGeometryCache.get(wallFingerprint)?.geometry === cachedGeometry,
|
||||
contactUpdateMovesOnlyLivePanel: !!openingPanelBefore
|
||||
&& root(original).querySelector('[data-hp="iso-openings"] [data-id="iso-door"]')?.getAttribute('d')
|
||||
!== openingPanelBefore
|
||||
&& original._isoGeometryCache.size === 1,
|
||||
oneLightModel: !root(original).querySelector('.window-light,.iso-window-light,.iso-glow,.iso-sun'),
|
||||
oldFloorSymbolsNotDuplicated: before.openings === 0,
|
||||
noStage1VerticalOpenings: !root(original).querySelector('.iso-window,.window-light,.vertical-door'),
|
||||
};
|
||||
|
||||
configSpace.settings.hide_openings = true;
|
||||
original._cfgEpoch++;
|
||||
original.requestUpdate();
|
||||
await original.updateComplete;
|
||||
result.hideOpeningsKeepsStructure = !!root(original).querySelector('[data-hp="iso-walls"]')
|
||||
&& !root(original).querySelector('[data-hp="iso-openings"]')
|
||||
&& !root(original).querySelector('[data-hp="opening"]')
|
||||
&& root(original).querySelectorAll('.glowlayer [data-glow-source]').length >= 2;
|
||||
|
||||
configSpace.settings.hide_openings = false;
|
||||
configSpace.settings.show_borders = false;
|
||||
original._cfgEpoch++;
|
||||
original.requestUpdate();
|
||||
await original.updateComplete;
|
||||
result.noBordersUsesFloorSymbols = !root(original).querySelector('[data-hp="iso-walls"]')
|
||||
&& !root(original).querySelector('[data-hp="iso-underlay"]')
|
||||
&& root(original).querySelectorAll('[data-hp="opening"]').length === 3;
|
||||
|
||||
configSpace.settings.show_borders = true;
|
||||
original._cfgEpoch++;
|
||||
original.requestUpdate();
|
||||
await original.updateComplete;
|
||||
result.visibleBordersRestoreStage2 = !!root(original).querySelector('[data-hp="iso-openings"]')
|
||||
&& original._isoGeometryCache.size === 1;
|
||||
|
||||
const supports = CSS.supports;
|
||||
CSS.supports = () => false;
|
||||
original.requestUpdate();
|
||||
await original.updateComplete;
|
||||
result.unsupportedDecorationKeepsIsoStructure = !!root(original).querySelector('[data-hp="iso-walls"]')
|
||||
&& !!root(original).querySelector('[data-hp="iso-openings"]')
|
||||
&& !root(original).querySelector('[data-hp="iso-shadows"]');
|
||||
CSS.supports = supports;
|
||||
original.requestUpdate();
|
||||
await original.updateComplete;
|
||||
|
||||
const calls = [];
|
||||
original.hass = {
|
||||
...original.hass,
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
// #131: a read-only HA session may read the House Plan snapshot while HA
|
||||
// rejects event subscriptions. The initial frame must still select one exact
|
||||
// space and render every raw-space layer before any user click.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
import { makeVisualMatrixFixture } from './fixtures/visual-matrix.mjs';
|
||||
|
||||
const fixture = makeVisualMatrixFixture();
|
||||
const { page, browser } = await launch({ width: 820, height: 760 });
|
||||
|
||||
const result = await page.evaluate(async (rawFixture) => {
|
||||
const out = {};
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const HP = customElements.get('houseplan-card');
|
||||
const host = document.getElementById('host');
|
||||
window.__card.remove();
|
||||
HP._warmBootReset?.();
|
||||
for (const key of Object.keys(localStorage)) {
|
||||
if (key.startsWith('houseplan_card_')) localStorage.removeItem(key);
|
||||
}
|
||||
history.replaceState(null, '', '/demo.html');
|
||||
|
||||
const fixture = structuredClone(rawFixture);
|
||||
const lighting = fixture.config.spaces.find((space) => space.id === 'golden-lighting');
|
||||
const geometry = fixture.config.spaces.find((space) => space.id === 'golden-geometry');
|
||||
lighting.id = 'home';
|
||||
lighting.title = 'Home';
|
||||
lighting.settings = {
|
||||
...lighting.settings,
|
||||
fill_mode: 'custom',
|
||||
custom_fill: { c: '#cdbb96', a: 0.42 },
|
||||
glow_enabled: true,
|
||||
show_borders: true,
|
||||
};
|
||||
lighting.decor = [{
|
||||
id: 'readonly-furniture', kind: 'rect', x: 0.16, y: 0.18, w: 0.12, h: 0.08,
|
||||
color: '#8d6e63', opacity: 1, width_cm: 2, fill: true,
|
||||
fill_color: '#bcaaa4', fill_opacity: 1,
|
||||
}];
|
||||
geometry.id = 'upstairs';
|
||||
geometry.title = 'Upstairs';
|
||||
fixture.config.spaces = [lighting, geometry];
|
||||
fixture.config.markers.push({
|
||||
id: 'golden-light-one', binding: 'device:golden-light-one', display: 'value',
|
||||
});
|
||||
fixture.layout = Object.fromEntries(Object.entries(fixture.layout).map(([id, pos]) => [
|
||||
id,
|
||||
{ ...pos, s: pos.s === 'golden-lighting' ? 'home' : pos.s === 'golden-geometry' ? 'upstairs' : pos.s },
|
||||
]));
|
||||
|
||||
const base = window.__mkHass();
|
||||
const makeHass = (subscriptionMode = 'partial') => {
|
||||
const calls = { configGets: 0, events: {}, unsubscribed: [] };
|
||||
const connection = {
|
||||
subscribeEvents: async (callback, event) => {
|
||||
calls.events[event] = (calls.events[event] || 0) + 1;
|
||||
if (String(event).startsWith('houseplan_')
|
||||
&& (subscriptionMode === 'reject-all'
|
||||
|| event === 'houseplan_config_updated' || event === 'houseplan_trail_updated')) {
|
||||
throw new Error('unauthorized');
|
||||
}
|
||||
return () => { calls.unsubscribed.push(event); };
|
||||
},
|
||||
subscribeMessage: async () => () => {},
|
||||
};
|
||||
const hass = {
|
||||
...base,
|
||||
user: { id: 'readonly', name: 'Readonly', is_admin: false },
|
||||
devices: fixture.devices,
|
||||
entities: fixture.entities,
|
||||
areas: fixture.areas,
|
||||
states: fixture.states,
|
||||
connection,
|
||||
callWS: async (message) => {
|
||||
if (message.type === 'houseplan/config/get') {
|
||||
calls.configGets += 1;
|
||||
return { config: structuredClone(fixture.config), rev: 131, can_write: false };
|
||||
}
|
||||
if (message.type === 'houseplan/layout/get') {
|
||||
return { layout: structuredClone(fixture.layout), rev: 131 };
|
||||
}
|
||||
if (message.type === 'houseplan/trail/get') return { trails: {} };
|
||||
if (message.type === 'config/device_registry/list') return Object.values(fixture.devices);
|
||||
if (message.type === 'config/entity_registry/list') return Object.values(fixture.entities);
|
||||
if (message.type === 'config_entries/get') {
|
||||
return [{ entry_id: 'golden_entry', domain: 'houseplan_golden', title: 'Golden' }];
|
||||
}
|
||||
if (message.type === 'manifest/list') {
|
||||
return [{ domain: 'houseplan_golden', name: 'Golden' }];
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
return { hass, calls };
|
||||
};
|
||||
|
||||
const layerSnapshot = (card) => {
|
||||
const root = card.shadowRoot || card.renderRoot;
|
||||
return {
|
||||
exactSpace: card._curSpaceCfg?.id || null,
|
||||
activeSpace: root.querySelector('[data-hp="space-tab"].active')?.getAttribute('data-id') || null,
|
||||
rooms: root.querySelectorAll('[data-hp="room"]').length,
|
||||
decor: root.querySelectorAll('[data-hp="decor"]').length,
|
||||
walls: root.querySelectorAll('.wallbodies .wallbody-fill').length,
|
||||
glow: root.querySelectorAll('.glow-spot').length,
|
||||
devices: root.querySelectorAll('[data-hp="device"]').length,
|
||||
values: root.querySelectorAll('[data-hp="device"] .valtext').length,
|
||||
};
|
||||
};
|
||||
const complete = (snapshot) => snapshot.exactSpace === 'home'
|
||||
&& snapshot.activeSpace === 'home' && snapshot.rooms > 0 && snapshot.decor > 0
|
||||
&& snapshot.walls > 0 && snapshot.glow > 0 && snapshot.devices > 0 && snapshot.values > 0;
|
||||
const waitForCard = async (card, calls, kiosk = false) => {
|
||||
const until = performance.now() + 9000;
|
||||
while (performance.now() < until) {
|
||||
const snapshot = layerSnapshot(card);
|
||||
const allSubscriptionsTried = ['houseplan_config_updated', 'houseplan_trail_updated', 'houseplan_layout_updated']
|
||||
.every((event) => calls.events[event] === 1);
|
||||
if (card._loadOk && !card._loading && card._booting === false
|
||||
&& allSubscriptionsTried && complete({
|
||||
...snapshot,
|
||||
activeSpace: kiosk ? 'home' : snapshot.activeSpace,
|
||||
})) return snapshot;
|
||||
await sleep(25);
|
||||
}
|
||||
throw new Error(`readonly card did not settle: ${JSON.stringify({
|
||||
state: layerSnapshot(card), calls, space: card._space, loadOk: card._loadOk,
|
||||
})}`);
|
||||
};
|
||||
const mount = async (title, options = {}) => {
|
||||
const runtime = makeHass(options.subscriptionMode);
|
||||
const card = document.createElement('houseplan-card');
|
||||
card.setConfig({ type: 'custom:houseplan-card', title, kiosk: !!options.kiosk });
|
||||
host.appendChild(card);
|
||||
card.hass = runtime.hass;
|
||||
const snapshot = await waitForCard(card, runtime.calls, !!options.kiosk);
|
||||
return { card, snapshot, ...runtime };
|
||||
};
|
||||
|
||||
const cold = await mount('Readonly cold start');
|
||||
out.coldSelectsExactSpace = cold.card._space === 'home'
|
||||
&& cold.snapshot.exactSpace === 'home' && cold.snapshot.activeSpace === 'home';
|
||||
out.coldRendersAllSpatialLayers = complete(cold.snapshot);
|
||||
out.readOnlyStaysReadOnly = cold.card._canEdit === false
|
||||
&& !(cold.card.shadowRoot || cold.card.renderRoot).querySelector('.modetab');
|
||||
out.allSubscriptionsAttempted = ['houseplan_config_updated', 'houseplan_trail_updated', 'houseplan_layout_updated']
|
||||
.every((event) => cold.calls.events[event] === 1);
|
||||
out.allowedSubscriptionAdopted = typeof cold.card._unsubLayout === 'function'
|
||||
&& !cold.card._unsubCfg && !cold.card._unsubTrail;
|
||||
out.cacheWritten = !!localStorage.getItem('houseplan_card_cfg_v1');
|
||||
const beforeClick = layerSnapshot(cold.card);
|
||||
cold.card._pickSpace('home');
|
||||
await cold.card.updateComplete;
|
||||
out.activeTabClickIsNoop = JSON.stringify(layerSnapshot(cold.card)) === JSON.stringify(beforeClick);
|
||||
await sleep(700);
|
||||
out.optionalFailureNoFullRetry = cold.calls.configGets === 1;
|
||||
cold.card.remove();
|
||||
await sleep(20);
|
||||
out.successfulSubscriptionCleanedUp = cold.calls.unsubscribed.includes('houseplan_layout_updated');
|
||||
|
||||
const warm = await mount('Readonly cold start');
|
||||
out.warmRemountKeepsCompleteSpace = complete(warm.snapshot) && warm.card._space === 'home';
|
||||
warm.card.remove();
|
||||
await sleep(20);
|
||||
|
||||
HP._warmBootReset?.();
|
||||
const reload = await mount('Readonly simulated reload');
|
||||
out.cachedReloadKeepsCompleteSpace = complete(reload.snapshot) && reload.card._space === 'home';
|
||||
reload.card.remove();
|
||||
await sleep(20);
|
||||
|
||||
HP._warmBootReset?.();
|
||||
localStorage.removeItem('houseplan_card_cfg_v1');
|
||||
localStorage.removeItem('houseplan_card_nav_v1');
|
||||
const kiosk = await mount('Readonly cold kiosk', { kiosk: true, subscriptionMode: 'reject-all' });
|
||||
const kioskRoot = kiosk.card.shadowRoot || kiosk.card.renderRoot;
|
||||
out.coldKioskCompleteWithoutHeaderAction = kiosk.card._space === 'home'
|
||||
&& kiosk.snapshot.exactSpace === 'home' && kiosk.snapshot.rooms > 0
|
||||
&& kiosk.snapshot.decor > 0 && kiosk.snapshot.walls > 0 && kiosk.snapshot.glow > 0
|
||||
&& kiosk.snapshot.devices > 0 && getComputedStyle(kioskRoot.querySelector('.hdr')).display === 'none';
|
||||
out.kioskCacheWritten = !!localStorage.getItem('houseplan_card_cfg_v1');
|
||||
kiosk.card.remove();
|
||||
|
||||
return out;
|
||||
}, fixture);
|
||||
|
||||
checkAll(result);
|
||||
await finish(browser, result);
|
||||
@@ -1,171 +0,0 @@
|
||||
// #107: persistent manual virtual light, shared by two full cards and one static card.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 1200, height: 900 });
|
||||
const result = await page.evaluate(async () => {
|
||||
const first = window.__card;
|
||||
const markerId = 'smoke_manual_virtual_light';
|
||||
const space = first._model[0];
|
||||
const room = space.rooms.find((candidate) => candidate.id && candidate.area);
|
||||
const serverConfig = structuredClone(first._serverCfg);
|
||||
serverConfig.markers = [...(serverConfig.markers || []), {
|
||||
id: markerId,
|
||||
name: 'Manual virtual lamp',
|
||||
binding: 'virtual',
|
||||
is_light: true,
|
||||
tap_action: 'toggle',
|
||||
tap_confirm: false,
|
||||
controls: ['light.ceiling'],
|
||||
space: space.id,
|
||||
area: room.area,
|
||||
room_id: room.id,
|
||||
}];
|
||||
const serverLayout = {
|
||||
...first._layout,
|
||||
[markerId]: { s: space.id, x: 0.32, y: 0.32 },
|
||||
};
|
||||
const virtual = { rev: 0, config_rev: first._cfgRev, off: [] };
|
||||
const subscriptions = new Map();
|
||||
const serviceCalls = [];
|
||||
let toggleCalls = 0;
|
||||
const emit = (event, data) => {
|
||||
for (const listener of subscriptions.get(event) || []) listener({ data });
|
||||
};
|
||||
const baseHass = first.hass;
|
||||
const baseCallWS = baseHass.callWS.bind(baseHass);
|
||||
const connection = {
|
||||
...baseHass.connection,
|
||||
addEventListener: baseHass.connection?.addEventListener?.bind(baseHass.connection),
|
||||
removeEventListener: baseHass.connection?.removeEventListener?.bind(baseHass.connection),
|
||||
subscribeMessage: baseHass.connection?.subscribeMessage?.bind(baseHass.connection),
|
||||
subscribeEvents: async (callback, event) => {
|
||||
const listeners = subscriptions.get(event) || new Set();
|
||||
listeners.add(callback);
|
||||
subscriptions.set(event, listeners);
|
||||
return () => listeners.delete(callback);
|
||||
},
|
||||
};
|
||||
const hass = {
|
||||
...baseHass,
|
||||
connection,
|
||||
callService: async (domain, service, data) => {
|
||||
serviceCalls.push({ domain, service, data });
|
||||
},
|
||||
callWS: async (message) => {
|
||||
if (message.type === 'houseplan/config/get') return {
|
||||
config: serverConfig,
|
||||
rev: virtual.config_rev,
|
||||
can_write: true,
|
||||
virtual_lights: structuredClone(virtual),
|
||||
};
|
||||
if (message.type === 'houseplan/layout/get') return {
|
||||
layout: serverLayout,
|
||||
rev: first._layoutRev,
|
||||
};
|
||||
if (message.type === 'houseplan/virtual_light/toggle') {
|
||||
toggleCalls++;
|
||||
const off = new Set(virtual.off);
|
||||
if (off.has(message.marker_id)) off.delete(message.marker_id);
|
||||
else off.add(message.marker_id);
|
||||
virtual.off = [...off].sort();
|
||||
virtual.rev++;
|
||||
const reply = {
|
||||
marker_id: message.marker_id,
|
||||
on: !off.has(message.marker_id),
|
||||
rev: virtual.rev,
|
||||
};
|
||||
queueMicrotask(() => emit('houseplan_virtual_light_updated', reply));
|
||||
return reply;
|
||||
}
|
||||
return baseCallWS(message);
|
||||
},
|
||||
};
|
||||
|
||||
// Reconnect the already mounted card to the fake server authority.
|
||||
first._unsubVirtual?.();
|
||||
first._unsubVirtual = null;
|
||||
first.hass = hass;
|
||||
await first._reloadConfigOnly(true);
|
||||
first._layout = serverLayout;
|
||||
first._regSignature = '';
|
||||
first._maybeRebuildDevices();
|
||||
first._ensureLiveSyncSubscriptions();
|
||||
await first.updateComplete;
|
||||
|
||||
const second = document.createElement('houseplan-card');
|
||||
second.setConfig({ type: 'custom:houseplan-card', icon_size: 3.4 });
|
||||
document.body.append(second);
|
||||
second.hass = hass;
|
||||
|
||||
const staticCard = document.createElement('houseplan-space-card');
|
||||
staticCard.setConfig({ type: 'custom:houseplan-space-card', space: space.id });
|
||||
document.body.append(staticCard);
|
||||
staticCard.hass = hass;
|
||||
|
||||
const waitFor = async (predicate, timeout = 6000) => {
|
||||
const started = Date.now();
|
||||
while (!predicate() && Date.now() - started < timeout) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
}
|
||||
return !!predicate();
|
||||
};
|
||||
await waitFor(() => second._devices?.some((device) => device.id === markerId));
|
||||
await waitFor(() => staticCard.renderRoot?.querySelector(`[data-id="${markerId}"]`));
|
||||
await second.updateComplete;
|
||||
await staticCard.updateComplete;
|
||||
|
||||
const deviceOf = (card) => card._devices.find((device) => device.id === markerId);
|
||||
const on = (card) => card._stateClass(deviceOf(card)).includes('on');
|
||||
const staticOn = () => staticCard.renderRoot
|
||||
.querySelector(`[data-id="${markerId}"]`)?.classList.contains('on');
|
||||
const initialEverywhereOn = on(first) && on(second) && staticOn();
|
||||
|
||||
const markerNode = first.renderRoot.querySelector(`.dev[data-id="${markerId}"]`);
|
||||
markerNode.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
|
||||
await waitFor(() => virtual.rev === 1 && !on(second) && staticOn() === false);
|
||||
await first.updateComplete;
|
||||
const clickEverywhereOff = !on(first) && !on(second) && staticOn() === false;
|
||||
|
||||
// A second real click returns to on; the saved outgoing control never calls HA.
|
||||
markerNode.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
|
||||
await waitFor(() => virtual.rev === 2 && on(second) && staticOn() === true);
|
||||
const secondClickEverywhereOn = on(first) && on(second) && staticOn();
|
||||
|
||||
const beforeTouch = toggleCalls;
|
||||
markerNode.dispatchEvent(new PointerEvent('pointerdown', {
|
||||
bubbles: true, composed: true, pointerType: 'touch', pointerId: 41,
|
||||
clientX: 100, clientY: 100,
|
||||
}));
|
||||
markerNode.dispatchEvent(new PointerEvent('pointerup', {
|
||||
bubbles: true, composed: true, pointerType: 'touch', pointerId: 41,
|
||||
clientX: 100, clientY: 100,
|
||||
}));
|
||||
markerNode.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
|
||||
await waitFor(() => virtual.rev === 3 && !on(second) && staticOn() === false);
|
||||
const touchSingleToggle = toggleCalls === beforeTouch + 1;
|
||||
|
||||
// A fresh card must paint the server's off snapshot, not wait for an event.
|
||||
const reloaded = document.createElement('houseplan-card');
|
||||
reloaded.setConfig({ type: 'custom:houseplan-card', icon_size: 3.4 });
|
||||
document.body.append(reloaded);
|
||||
reloaded.hass = hass;
|
||||
await waitFor(() => reloaded._devices?.some((device) => device.id === markerId));
|
||||
await reloaded.updateComplete;
|
||||
const reloadFirstStateOff = !on(reloaded);
|
||||
|
||||
second.remove();
|
||||
staticCard.remove();
|
||||
reloaded.remove();
|
||||
return {
|
||||
initialEverywhereOn,
|
||||
clickEverywhereOff,
|
||||
secondClickEverywhereOn,
|
||||
touchSingleToggle,
|
||||
reloadFirstStateOff,
|
||||
oneServerTogglePerGesture: toggleCalls === 3,
|
||||
noHaServiceCalls: serviceCalls.length === 0,
|
||||
};
|
||||
});
|
||||
|
||||
checkAll(result);
|
||||
await finish(browser, result);
|
||||
@@ -340,31 +340,6 @@ cache guarded by a structural geometry fingerprint. This is computed render
|
||||
state only: it never rewrites rooms or wall entries, and an HA state tick does
|
||||
not rebuild topology.
|
||||
|
||||
### Hidden Isometric Stage 2 composition (#122)
|
||||
|
||||
The hidden `iso` View reuses that masonry but has one bounded structural scene,
|
||||
not a second house model. `_isoGeometryCache` remains an eight-entry LRU keyed
|
||||
by room/wall/opening geometry (including opening flips), scale/camera, fixed
|
||||
wall/floor-edge heights and an algorithm revision. Each value holds wall faces,
|
||||
the room/exterior slab edge, immutable opening jamb bases and the projected
|
||||
frame. HA state, theme, hover and filter support are presentation inputs and
|
||||
never enter this key.
|
||||
|
||||
`floorFootprintGeometry()` derives only the union of room floors and exterior
|
||||
masonry; unlike wall volume, it has no independent partition/column input.
|
||||
`buildIsoFloorGeometry()` emits visible low faces for outer component rings,
|
||||
not internal edges or holes. `src/iso-openings.ts` stores jamb/axis topology and
|
||||
applies `openingAmount()` only during live projection, keeping contact updates
|
||||
out of the boolean geometry path.
|
||||
|
||||
Composition is shared-viewBox SVG: ambient shadow/floor edge → the existing
|
||||
affine-projected floor/live scene → contact/leaf shadows → wall material and
|
||||
vertical panels → existing screen-facing HTML overlays. A constant set of
|
||||
gradients/filters serves every face. Unsupported decoration or forced colours
|
||||
remove nuance/shadows without changing projection; only structural failure
|
||||
uses the Stage 1 latched Flat fallback. Details and fixed ratios are recorded in
|
||||
`docs/adr/122-isometric-stage2-composition.md`.
|
||||
|
||||
## Markup editor (v1.4.0+)
|
||||
|
||||
State inside the card: `_markup` (mode), `_tool` (draw/partition/column/merge/split/resize/opening/
|
||||
@@ -489,8 +464,7 @@ spans are clipped per atomic body.
|
||||
| `houseplan/layout/get` | — | `{layout: {device_id: {x,y}}, rev}` |
|
||||
| `houseplan/layout/set` | `layout`, `expected_rev?` | `{ok, rev}` / err `conflict`; event `houseplan_layout_updated` |
|
||||
| `houseplan/layout/update` | `device_id`, `pos` | `{ok, rev}`; event `houseplan_layout_updated` |
|
||||
| `houseplan/config/get` | — | `{config, rev, virtual_lights:{rev,config_rev,off[]}}` (`virtual_lights` optional for rolling compatibility) |
|
||||
| `houseplan/virtual_light/toggle` | `marker_id` | `{marker_id,on,rev}` / err `not_toggleable`; event `houseplan_virtual_light_updated` |
|
||||
| `houseplan/config/get` | — | `{config, rev}` |
|
||||
| `houseplan/trail/get` | — | `{trails: {marker: {current, previous}}}` — vacuum runs, raw robot coords |
|
||||
| `houseplan/trail/delete` | `marker_id` | `{ok, removed}` — erase current/previous runs after marker deletion |
|
||||
| `houseplan/config/set` | `config`, `expected_rev?` | `{ok, rev}` / err `conflict`; event `houseplan_config_updated` |
|
||||
@@ -511,23 +485,6 @@ spans are clipped per atomic body.
|
||||
Manual attachments upload over HTTP (streaming, transactional staging), not WS —
|
||||
the old `houseplan/file/set` was removed in v1.10.0.
|
||||
|
||||
Manual virtual-light state is operational data, not plan configuration. The
|
||||
integration owns a separate versioned `houseplan.virtual_lights` Store whose
|
||||
bounded payload contains only `{rev, config_rev, off[]}`. The existing shared
|
||||
write lock serializes config reconciliation and atomic toggles. Eligibility is
|
||||
always recalculated from server config; the toggle command accepts no desired
|
||||
state, entity id or service. It is intentionally available to every
|
||||
authenticated View user, while config writers remain governed by `may_write`.
|
||||
The durable save precedes both reply and event. A config-revision gap from an
|
||||
older writer clears manual off bits to the compatibility default `on`.
|
||||
|
||||
The first `config/get` frame carries the coherent operational snapshot. Full
|
||||
cards subscribe directly to the update event; all `houseplan-space-card`
|
||||
instances share the module-level config cache and one subscription. Local
|
||||
storage may retain the last snapshot for continuity, but never authorizes an
|
||||
optimistic toggle. The data is excluded from marker/layout schemas, portable
|
||||
export/import and the HA entity registry.
|
||||
|
||||
Portable import preview uses authenticated
|
||||
`POST /api/houseplan/import/preview`. The endpoint streams at most 8 MiB,
|
||||
strictly rejects duplicate/prototype keys, non-finite numbers and future model
|
||||
@@ -694,24 +651,6 @@ localized opaque recovery overlay, after a 150 ms delay. The overlay never
|
||||
steals initial focus; while visible it alone is interactive and the scene is
|
||||
`inert`.
|
||||
|
||||
**The initial snapshot does not depend on live-sync subscriptions** (#131).
|
||||
`houseplan-card` first accepts config and layout, builds the model, chooses one
|
||||
exact space, caches the accepted snapshot and restores its viewport. Only then
|
||||
is the mandatory load complete. Config, trail and layout event subscriptions
|
||||
start together as independent best-effort enrichments: one rejected channel
|
||||
does not prevent the others from subscribing, does not erase the usable
|
||||
snapshot and does not schedule a full-load retry solely for that rejection.
|
||||
Missing channels get another attempt on the next normal load or reconnect.
|
||||
|
||||
`src/initial-load.ts` is the shared authority for the exact space used by a
|
||||
cached snapshot, a live snapshot and its protected-backdrop candidate. A cold
|
||||
load considers only valid ids in this order: URL hash, saved navigation,
|
||||
`default_floor`, first live space. Once an initial URL hash has been consumed,
|
||||
a valid same-route current selection is preserved instead of repeatedly
|
||||
snapping back to that hash. The legacy field initializer is never a cold-start
|
||||
choice by itself. A plan with no spaces keeps `null` authority and does not
|
||||
invent an id.
|
||||
|
||||
**Room climate is one pass per hass snapshot** (review R2-3). `areaClimateMap()`
|
||||
classifies the whole registry once and returns `Map<area, {temp, hum}>`; the
|
||||
card memoizes it on `hass` identity, which Home Assistant replaces on every
|
||||
|
||||
@@ -2,38 +2,11 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Read-only View and kiosk cards now paint a complete first frame even when
|
||||
Home Assistant refuses live-sync event subscriptions. The selected space,
|
||||
room fills, decor, walls, Glow and device values no longer require clicking
|
||||
the already active space tab ([#131](https://github.com/Matysh/houseplan-card/issues/131)).
|
||||
- Virtual markers configured as **Light source → Always** with **Toggle state**
|
||||
can now be switched directly without an HA helper. Their shared state
|
||||
survives reloads/restarts and stays consistent across Glow, room light
|
||||
presentation, full cards and static space cards, including markers that retain
|
||||
saved outgoing controls
|
||||
([#107](https://github.com/Matysh/houseplan-card/issues/107)).
|
||||
|
||||
## v1.63.0 — 2026-08-13
|
||||
|
||||
- Preserved explicit door, window and gate bindings when their standalone
|
||||
sensor or lock marker is removed, and fixed the supported empty state after
|
||||
deleting the last space
|
||||
([#104](https://github.com/Matysh/houseplan-card/issues/104),
|
||||
[#111](https://github.com/Matysh/houseplan-card/issues/111)).
|
||||
- Splitting a room from an existing corner no longer deforms the exterior
|
||||
facade, including with thick dividers. Flat, static and hidden isometric
|
||||
rendering use the same preserved wall geometry
|
||||
([#123](https://github.com/Matysh/houseplan-card/issues/123)).
|
||||
- Small fixes and improvements.
|
||||
|
||||
## v1.63.0-beta.2 — 2026-08-13
|
||||
|
||||
- Splitting a room from an existing corner no longer deforms the exterior wall
|
||||
or pulls a thick internal divider through the facade. Plan, View, kiosk,
|
||||
static cards, hidden isometric rendering and light obstacles now use the same
|
||||
preserved exterior geometry, including already saved plans
|
||||
([#123](https://github.com/Matysh/houseplan-card/issues/123)).
|
||||
- Small fixes and improvements.
|
||||
|
||||
## v1.63.0-beta.1 — 2026-08-13
|
||||
|
||||
|
||||
@@ -8,39 +8,11 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Read-only карточки в режимах View и киоска теперь сразу показывают полный
|
||||
первый кадр, даже если Home Assistant запрещает подписки на события live-sync.
|
||||
Выбранное пространство, заливки комнат, декор, стены, Glow и значения
|
||||
устройств больше не требуют нажатия на уже активную вкладку пространства
|
||||
([#131](https://github.com/Matysh/houseplan-card/issues/131)).
|
||||
- Виртуальные маркеры с сочетанием **Источник света → Всегда** и
|
||||
**Переключить состояние** теперь можно включать и выключать без helper/entity
|
||||
Home Assistant. Общее состояние переживает перезагрузку страницы и HA и
|
||||
одинаково отображается в Glow, свете комнаты, полной и статической карточках,
|
||||
в том числе при сохранённых исходящих связях управления
|
||||
([#107](https://github.com/Matysh/houseplan-card/issues/107)).
|
||||
|
||||
## v1.63.0 — 2026-08-13
|
||||
|
||||
- Сохранены явные привязки дверей, окон и ворот после удаления отдельного
|
||||
маркера датчика или замка; исправлено предусмотренное пустое состояние после
|
||||
удаления последнего пространства
|
||||
([#104](https://github.com/Matysh/houseplan-card/issues/104),
|
||||
[#111](https://github.com/Matysh/houseplan-card/issues/111)).
|
||||
- Split из существующего угла комнаты больше не деформирует наружный фасад,
|
||||
в том числе с толстым разделителем. Плоский, статичный и скрытый
|
||||
изометрический рендер используют одну сохранённую геометрию стен
|
||||
([#123](https://github.com/Matysh/houseplan-card/issues/123)).
|
||||
- Мелкие исправления и улучшения.
|
||||
|
||||
## v1.63.0-beta.2 — 2026-08-13
|
||||
|
||||
- Split из существующего угла комнаты больше не деформирует наружную стену и
|
||||
не вытягивает толстый внутренний разделитель сквозь фасад. Редактор плана,
|
||||
View, киоск, статичная карточка, скрытая изометрия и световые препятствия
|
||||
используют одну сохранённую наружную геометрию, в том числе для уже
|
||||
сохранённых планов ([#123](https://github.com/Matysh/houseplan-card/issues/123)).
|
||||
- Мелкие исправления и улучшения.
|
||||
|
||||
## v1.63.0-beta.1 — 2026-08-13
|
||||
|
||||
|
||||
@@ -142,29 +142,3 @@ while an untouched old broken source remains readable and round-trippable.
|
||||
space transfer remaps an internal target and disables/counts a link whose
|
||||
target is outside the transfer. Older clients ignore the field and may erase
|
||||
it if they reconstruct the same marker after a downgrade.
|
||||
|
||||
## Persistent manual virtual-light state
|
||||
|
||||
The exact `virtual` + `is_light:true` + `tap_action:toggle` combination has a
|
||||
shared operational on/off state. It is not a Marker/ServerConfig field: the
|
||||
integration stores `{rev, config_rev, off[]}` under a separate versioned Store
|
||||
key and exposes an optional `virtual_lights` snapshot in `houseplan/config/get`.
|
||||
It is excluded from layout, full/space export, import and HA entities. Missing
|
||||
Store data or a missing wire field projects to `on`.
|
||||
|
||||
Compatibility matrix:
|
||||
|
||||
| Frontend | Backend | Behaviour |
|
||||
|---|---|---|
|
||||
| old | new | Extra snapshot/event are ignored; the marker keeps historical #84/#94 behaviour and config remains intact |
|
||||
| new | old | Missing snapshot starts `on`; an unsupported toggle command reports an error and creates no optimistic local state |
|
||||
| new | new | Server snapshot is authoritative; revisioned events synchronize full and static cards |
|
||||
|
||||
Every current config writer reconciles the Store. Rename, move, hide and
|
||||
unrelated edits preserve off bits for still-eligible stable marker ids;
|
||||
binding/role/action changes, tombstones and deletion prune them. Re-entering the
|
||||
triple therefore starts on. If `config_rev` skips the revision recorded by the
|
||||
operational Store — for example after downgrade, an old writer or an interrupted
|
||||
pair — all off bits are cleared rather than resurrected against unknown marker
|
||||
lifecycle history. Older integrations safely ignore the separate Store on
|
||||
downgrade.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Hidden Isometric View internals
|
||||
# Isometric Stage 1 internals
|
||||
|
||||
Issue [#89](https://github.com/Matysh/houseplan-card/issues/89) implements a
|
||||
hidden, presentation-only volumetric View experiment. The normative contract is
|
||||
@@ -90,79 +90,3 @@ the rollback path and does not depend on the iso cache.
|
||||
Golden references are accepted only from the complete reviewed Linux artifact.
|
||||
The full `large-house-isometric-v1` performance comparison is also canonical on
|
||||
the exact Linux CI SHA.
|
||||
|
||||
## Stage 2 composition (#122)
|
||||
|
||||
Stage 2 evolves the same hidden `iso` experiment; it does not add a flag,
|
||||
setting or public activation path. The accepted implementation contract is
|
||||
`docs/specs/122-isometric-stage2.md` and the fixed composition decisions are in
|
||||
`docs/adr/122-isometric-stage2-composition.md`. The Labs `since: 1.62.0` and
|
||||
exclusive `expires: 1.65.0` boundary are unchanged.
|
||||
|
||||
### One structural scene, live opening leaves
|
||||
|
||||
The per-card LRU remains capped at eight entries. Its Stage 2 value contains:
|
||||
|
||||
- canonical wall top/sides and the physical-wall contact path;
|
||||
- a room/exterior floor footprint and its low visible outer faces;
|
||||
- immutable opening jamb/axis bases, including type, flips and selected wall
|
||||
face;
|
||||
- the shared projected frame, including wall/opening tops and the low floor
|
||||
edge.
|
||||
|
||||
The key includes rooms, masonry/opening geometry, flips, scale/camera, wall and
|
||||
edge heights and algorithm revision. It excludes HA state, theme, hover,
|
||||
day/night and filter capability. `openingAmount()` is applied only after an LRU
|
||||
hit by `projectIsoOpening()`, so a contact update projects O(O) leaves without
|
||||
repeating a wall or floor boolean operation.
|
||||
|
||||
`floorFootprintGeometry()` deliberately accepts no independent physical-body
|
||||
input. The slab is the union of room floors and derived exterior masonry:
|
||||
internal room boundaries and nested holes make no decorative step, detached
|
||||
room components keep separate outside edges, while partitions and columns do
|
||||
not enlarge it.
|
||||
|
||||
### Layer order and materials
|
||||
|
||||
All geometry roots use one scene `viewBox`. The existing floor/live nodes are
|
||||
grouped under the Stage 1 affine matrix; HTML anchors still use
|
||||
`projectPlanPoint()`.
|
||||
|
||||
```text
|
||||
stage background
|
||||
→ shared ambient shadow + low exterior floor edge
|
||||
→ existing floor SVG (paper/image, room fills/hover, decor, Glow, sun)
|
||||
→ shared contact and live leaf shadows
|
||||
→ canonical wall sides/top + inert vertical opening panels
|
||||
→ existing HTML devices, labels/cards, locks and vacuum overlays
|
||||
```
|
||||
|
||||
Wall top and side use two shared matte gradients. Ambient, contact and leaf
|
||||
shadows use three shared filters; definition count is constant per card, never
|
||||
per face or opening. Forced colours use solid `Canvas`/`CanvasText` faces and
|
||||
omit decoration. A runtime without the required filter paint keeps solid
|
||||
structure, floor edge and vertical panels but emits no Stage 2 shadows; this
|
||||
does not enter the structural fallback latch.
|
||||
|
||||
### Vertical openings and display settings
|
||||
|
||||
`src/iso-openings.ts` mirrors the existing opening-symbol transform algebra:
|
||||
door has one jamb-hinged leaf, gate has two leaves with the established
|
||||
0–10° exterior-face turn, and window has two light neutral casements. Heights
|
||||
are fixed presentation ratios of `ISO_WALL_HEIGHT`; there is no schema field.
|
||||
Panels/shadows are pointer- and ARIA-inert. Existing lock badges/cards and HA
|
||||
actions remain the only interactive opening surface.
|
||||
|
||||
- borders visible: vertical panels replace the floor-plane symbols;
|
||||
- `hide_openings: true`: panels and leaf shadows disappear, while masonry
|
||||
cuts, Glow/sun and contact/lock meaning remain;
|
||||
- `show_borders: false`: Stage 2 roots are absent and the established floor
|
||||
symbols and Stage 1 projected frame return (subject to `hide_openings`),
|
||||
avoiding floating panels or an invisible Stage 2 bound that reframes them;
|
||||
- Flat, editors and `houseplan-space-card` retain their old symbols and DOM.
|
||||
|
||||
Stage 2 adds no window beam, Glow source, sun renderer, material config,
|
||||
network request or HA service path. Structural topology/projection exceptions
|
||||
still use the Stage 1 latched Flat fallback. The known independent exact-SHA
|
||||
view-toggle performance debt remains tracked in #124; #122 neither weakens its
|
||||
budget nor treats fallback as benchmark success.
|
||||
|
||||
@@ -134,18 +134,6 @@ source even when the marker has no controllable HA entity. That last case is a
|
||||
- its position, room, colour, brightness and radius belong to the target lamp,
|
||||
not to the switch which drives it.
|
||||
|
||||
There is one explicit manual-authority exception. An active marker with a
|
||||
`virtual` binding, `is_light: true` (Always) and `tap_action: toggle` reads its
|
||||
on/off value from the integration's revisioned operational store. Absence is
|
||||
`on`. While the triple remains active, that value overrides incoming controller
|
||||
OR for the marker source and therefore reaches every consumer of
|
||||
`resolvedLightSources()` — Glow, room fill/counts, device presentation, preview
|
||||
and both card types. Saved controls remain lossless but a tap on this exact
|
||||
marker performs the operational toggle, never an HA service call. Leaving the
|
||||
triple restores the normal controller rules and clears any stored off bit;
|
||||
hiding alone does neither. The operational revision is part of the resolver
|
||||
cache key, so an event changes the projection without an HA state tick.
|
||||
|
||||
The controller picker can therefore link a smart relay to a virtual marker for
|
||||
a dumb physical lamp. Multiple controllers use OR. A direct entity reference
|
||||
and a marker reference resolving to the same stateful source are deduplicated.
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
<!-- release: v1.63.0 -->
|
||||
<!-- release: v1.63.0-beta.1 -->
|
||||
|
||||
## Основное
|
||||
|
||||
- Удаление отдельного маркера больше не разрывает явную привязку двери, окна или ворот.
|
||||
- После удаления последнего пространства интеграция остаётся рабочей и позволяет создать новое.
|
||||
- Разделение комнаты из существующего угла больше не искажает наружный фасад даже при толстом разделителе.
|
||||
- Удаление маркера датчика или замка больше не разрывает его привязку к двери, окну или воротам.
|
||||
- Исправлено падение после удаления последнего пространства; из пустого состояния снова можно добавить пространство.
|
||||
- Мелкие исправления и улучшения.
|
||||
|
||||
## Highlights
|
||||
|
||||
- Removing a standalone marker no longer breaks an explicit door, window or gate binding.
|
||||
- The integration remains usable after deleting the last space and allows a new one to be created.
|
||||
- Splitting a room from an existing corner no longer deforms the exterior facade, even with a thick divider.
|
||||
- Deleting a sensor or lock marker no longer breaks its door, window or gate binding.
|
||||
- Fixed the crash after deleting the last space; a space can again be added from the empty state.
|
||||
- Small fixes and improvements.
|
||||
|
||||
[Полный список изменений на русском](https://github.com/Matysh/houseplan-card/blob/v1.63.0/docs/CHANGELOG.ru.md)
|
||||
· [Full changelog in English](https://github.com/Matysh/houseplan-card/blob/v1.63.0/docs/CHANGELOG.md)
|
||||
[Полный список изменений на русском](https://github.com/Matysh/houseplan-card/blob/v1.63.0-beta.1/docs/CHANGELOG.ru.md)
|
||||
· [Full changelog in English](https://github.com/Matysh/houseplan-card/blob/v1.63.0-beta.1/docs/CHANGELOG.md)
|
||||
|
||||
@@ -17,13 +17,13 @@ change must pass through a published beta/RC before stable. Stable release
|
||||
commits are promotion-only (versions, generated bundles and release/changelog
|
||||
metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
|
||||
|
||||
## Snapshot (2026-08-14)
|
||||
## Snapshot (2026-08-13)
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Version | **v1.63.0** everywhere (manifest, const.py, package.json, CARD_VERSION) — stable promotion candidate after published v1.63.0-beta.2 |
|
||||
| Current local cycle | v1.63.0 is the stable base. #122 Stage 2 is being implemented on `issue/122-isometric-stage2` under an explicit owner arbitration to start while the independent #124 exact-SHA view-toggle performance debt remains open; no Stage 2 release has been published. |
|
||||
| Hidden Labs Stage | #89 Stage 1 ships in v1.63.0-beta.1. #122 evolves the same hidden, expiring `iso` experiment with matte walls, a low exterior floor edge, restrained shared shadows and live vertical door/window/gate panels. Flat remains default; editors and `houseplan-space-card` remain flat; live floor effects and HA actions remain unchanged. Public activation is explicitly a separate task. |
|
||||
| Version | **v1.63.0-beta.1** everywhere (manifest, const.py, package.json, CARD_VERSION) — prerelease candidate |
|
||||
| Current local cycle | v1.63.0-beta.1 fixes the empty-plan crash (#111) and preserves explicit opening sensor/lock references after standalone marker deletion (#104). Development after that beta preserves the exterior facade when Split starts or ends at a room corner (#123), using one wall geometry for flat/static/isometric rendering and light. The line also carries the reviewed process automation work #105 and #118–#121. |
|
||||
| Hidden Labs Stage | #89 Stage 1 ships in v1.63.0-beta.1 as a hidden, expiring `iso` Labs experiment: a fixed near-top orthographic volumetric View. Flat remains default; editors and `houseplan-space-card` remain flat; all existing floor live effects and HA actions are preserved. This is internal, not a public feature. |
|
||||
| Workflow | Owner's rule since 2026-08-07: ordinary fixes/features are made **locally, without tests and without commits**. A requested pre-release gets a production build plus the smallest targeted unit/smoke set covering the changed surfaces, one tested `dev` commit/tag and a GitHub Release with `prerelease=true`; `main` stays untouched. The complete local frontend/backend/smoke gate runs only before a stable release, after which `main` is fast-forwarded to the exact tested `dev` SHA and the GitHub Release uses `prerelease=false`. Release bodies are short and bilingual (Russian first): only significant user changes get individual bullets, while minor/code-only work is grouped as `Мелкие исправления и улучшения` / `Small fixes and improvements`; every body ends with separate links to the Russian and English changelogs. Detailed RU/EN changelog bullets may link the corresponding closed GitHub Issues; open or partially delivered issues are never presented as shipped. Telegram announcements are sent only for stable releases; beta and RC publication is silent. `docs/RELEASE-NOTES.md` is the current canonical body instance; `npm run release:prerelease -- <tag> --issues=… --yes` is the primary local publication path and the manual `Publish prerelease` workflow is its GitHub-only equivalent once present on `main`. Nothing is copied to the home instance by hand |
|
||||
| GitHub | https://github.com/Matysh/houseplan-card — [Issues](https://github.com/Matysh/houseplan-card/issues) are the canonical task records and the linked [Project v2](https://github.com/users/Matysh/projects/1) is the canonical priority/status view; both must stay current. `main` carries stable releases; pre-release tags may point directly at `dev`. Work lands on `dev` and is merged into `main` for a stable release, so `dev` is normally equal to or ahead of `main`, never behind. Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
|
||||
| CI | Prerelease publication requires a green exact-SHA Validate: frontend/backend, smoke (including the #73 rAF frame sampler), golden, HACS/Hassfest and a short absolute-ceiling performance smoke. Obsolete same-ref Validate runs are cancelled. Full seven-sample base/candidate performance moved to `performance.yml` (`main` push, weekly, manual); stable release assets fail closed unless Validate and Full Performance are green for the exact tagged SHA and the stable-only CDP compositor screencast finds no empty/black presented frame. |
|
||||
|
||||
@@ -1,38 +1,5 @@
|
||||
# Manual testing checklist
|
||||
|
||||
## Правила для новых тестов (issue #85) — обязательны
|
||||
|
||||
Зелёный тест в этом проекте несколько раз означал «ничего не проверено»:
|
||||
смок непрерывности не заметил удаления механизма, который защищает; golden-сцена,
|
||||
заведённая под #71, была пустой; смок теней был зелёным, пока тени физически не
|
||||
рисовались. Общее у всех случаев — **тест ни разу не проверяли на способность
|
||||
падать**. Отсюда правила.
|
||||
|
||||
1. **Наличие атрибута, класса или узла — не проверка поведения.** Такой ассерт
|
||||
допустим только рядом с пиксельным либо поведенческим: атрибут доказывает,
|
||||
что код выполнился, а не что механизм сработал.
|
||||
2. **Golden-сцена, заведённая под конкретную задачу, несёт семантический
|
||||
ассерт** (`warmPixelRegion` и родственные в `demo/golden/run.mjs`): сцена
|
||||
обязана падать, если перестала показывать то, ради чего заведена. Пиксельный
|
||||
дифф с эталоном этого не заменяет — пустая сцена совпадает со своим пустым
|
||||
эталоном идеально.
|
||||
3. **Фикстура содержит слои, которые тест защищает.** Смок непрерывности без
|
||||
подложки, Glow и декора проверяет пустую страницу; солнце с азимутом, при
|
||||
котором луч не достигает единственного окна (#89), — та же ошибка в
|
||||
геометрии.
|
||||
4. **Тест, охраняющий механизм, сопровождается мутантом** в
|
||||
`scripts/mutation-gate.mjs`: 2–5 строк патча, воспроизводящего поломку,
|
||||
против которой тест заведён, и тест обязан на ней краснеть. Чистым функциям
|
||||
с обычными юнитами мутант не нужен.
|
||||
5. **Тавтологический ассерт — читающий то же свойство, которое код только что
|
||||
выставил, — не пишется вовсе.** Он может упасть только при удалении строки,
|
||||
но не при её неработоспособности.
|
||||
|
||||
Проверка: `node scripts/mutation-gate.mjs --check` — якоря патчей живы;
|
||||
полный прогон — workflow `mutation-gate.yml`, перед стабильным релизом и по
|
||||
понедельникам. Дешёвая половина идёт с юнитами: `test/mutation-gate.test.mjs`.
|
||||
|
||||
|
||||
## Device value badge (#90)
|
||||
|
||||
- [ ] An untouched legacy thermometer/humidity marker remains pixel-identical;
|
||||
|
||||
@@ -495,22 +495,6 @@ House Plan читает реестры устройств, сущностей и
|
||||
`marker:*` является только внутренней ссылкой плана и никогда не отправляется
|
||||
в Home Assistant как entity ID.
|
||||
|
||||
Если у самой виртуальной лампы одновременно выбраны **Является источником
|
||||
света → Всегда** и **Действие по нажатию → Переключить состояние**, нажатие
|
||||
переключает её собственное ручное состояние. Новая лампа начинается
|
||||
включённой; состояние общее для всех полных и статических карточек, сохраняется
|
||||
после перезагрузки страницы и Home Assistant и одинаково меняет Glow, заливку
|
||||
«Свет» и статистику комнаты. Переключать может любой вошедший пользователь
|
||||
дашборда — права редактора плана для этого не требуются.
|
||||
|
||||
Это точное исключение из обычной схемы `controls`. Пока три условия активны,
|
||||
сохранённые внешние `controls` не вызывают сервисы HA и не переопределяют
|
||||
ручное состояние, но остаются в настройках без потерь. Смена роли, привязки
|
||||
или действия возвращает обычное управление; при повторном включении ручного
|
||||
режима лампа снова начинается включённой. Скрытие сохраняет состояние, удаление
|
||||
маркера очищает его. Ручное состояние не является entity/helper HA и не входит
|
||||
в экспорт или импорт плана.
|
||||
|
||||
Удаление и скрытие — разные операции. Скрытый маркер можно показать через
|
||||
**Скрытые и деактивированные**, и он продолжает участвовать в предусмотренных для скрытых
|
||||
устройств данных. Удалённый маркер там не показывается, не участвует в LQI,
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
# ADR #122 — Stage 2 isometric composition
|
||||
|
||||
- Issue: https://github.com/Matysh/houseplan-card/issues/122
|
||||
- Status: accepted for implementation
|
||||
- Date: 2026-08-14
|
||||
- Normative spec: `docs/specs/122-isometric-stage2.md`, revision 1
|
||||
- Predecessor: `docs/adr/089-isometric-stage1-renderer.md`
|
||||
|
||||
## Context
|
||||
|
||||
Stage 1 established one fixed affine floor projection, canonical wall volume,
|
||||
screen-facing HTML anchors and a bounded structural LRU. Stage 2 must add matte
|
||||
depth, an exterior floor edge, grounding shadows and vertical openings without
|
||||
creating another plan/light model or making HA state rebuild topology.
|
||||
|
||||
The owner explicitly allowed implementation to begin with #124's known
|
||||
view-toggle performance debt still open. That arbitration removes the DoR
|
||||
blocker only: the unchanged exact-SHA performance budget remains a gate and
|
||||
fallback may not be forced to make it green.
|
||||
|
||||
## Decision
|
||||
|
||||
### Structural/live boundary
|
||||
|
||||
The existing eight-entry Iso LRU becomes a structural scene cache. Each entry
|
||||
contains wall faces, floor footprint/edge, immutable opening bases and one
|
||||
projected frame. The fingerprint includes opening geometry and flips plus the
|
||||
fixed wall/edge heights and algorithm revision. It excludes HA state and every
|
||||
decorative capability.
|
||||
|
||||
An opening basis stores its resolved wall face, jamb hinge, closed vector,
|
||||
quarter-turn vector and fixed height bounds. Rendering applies the existing
|
||||
`openingAmount()` after the cache lookup. Door/window/gate live changes are
|
||||
therefore O(O) vector projection and cannot invoke polygon boolean operations.
|
||||
|
||||
### Floor footprint
|
||||
|
||||
The slab source is the canonical union of rooms plus derived exterior masonry.
|
||||
Independent partitions, drafts and columns remain canonical wall volume and
|
||||
light occluders but are not accepted as floor-footprint input. Stage 2 projects
|
||||
only outer rings down by ten plan units; holes and internal/shared boundaries
|
||||
cannot become steps. Disconnected outer polygons remain disconnected.
|
||||
|
||||
### Composition
|
||||
|
||||
Four absolute SVG roots share the effective scene `viewBox`: underlay, the
|
||||
existing floor scene, shadows and wall/opening volume. The old floor nodes are
|
||||
not copied; they live under the same affine matrix Stage 1 used. HTML overlays
|
||||
stay above every SVG root.
|
||||
|
||||
The stable order is ambient shadow, floor edge, current floor/live layers,
|
||||
contact/leaf shadows, wall sides/top, vertical panels, then HTML overlays.
|
||||
All new geometry is pointer/focus/ARIA inert.
|
||||
|
||||
### Material and degradation
|
||||
|
||||
Two shared SVG gradients distinguish matte wall top and side. Three shared
|
||||
filters provide ambient/contact/leaf softness. Definitions are O(1) per card;
|
||||
there are no per-face filters, raster textures, data URLs or runtime
|
||||
dependencies.
|
||||
|
||||
Filter-paint failure removes shadows only. Forced colours also remove gradient
|
||||
nuance and use solid system colours. Structural wall/opening/edge geometry and
|
||||
all existing live layers remain Iso. Only a structural topology/projection
|
||||
exception enters the established `space|fingerprint` Flat fallback latch.
|
||||
|
||||
### Opening presentation and settings
|
||||
|
||||
- door: one full jamb-hinged leaf, 92% of wall height;
|
||||
- gate: two half leaves, 88%, preserving the existing 0–10° face-aware turn;
|
||||
- window: two light inserts between 27% and 78% of wall height.
|
||||
|
||||
These are presentation constants, not persisted fields. With visible borders,
|
||||
vertical panels replace the old floor symbols. `hide_openings` hides panels and
|
||||
leaf shadows. With borders disabled, every Stage 2 root is absent and the
|
||||
existing floor symbols return, so there are no floating vertical leaves. The
|
||||
no-borders branch also keeps the accepted Stage 1 projected frame (floor plus
|
||||
the fixed wall-height allowance): absent Stage 2 floor/opening geometry cannot
|
||||
contribute invisible bounds and reframe the live floor. With borders visible,
|
||||
fit includes every Stage 2 structural bound; blur capability never changes it.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Flat, editors, static card, schema, storage keys, i18n, backend and HA actions
|
||||
are unchanged.
|
||||
- Glow/spill and sunlight remain the only room/window light models.
|
||||
- Theme, hover, filter fallback and HA-only updates do not grow the structural
|
||||
LRU.
|
||||
- Fit includes structural opening/wall tops and the low floor edge, never blur.
|
||||
- Golden and browser evidence must review new Iso pixels; Flat and the existing
|
||||
no-borders baseline remain unchanged.
|
||||
- Stage 2 stays hidden and expires with the existing `iso` Labs entry unless a
|
||||
separate owner-reviewed rollout/graduation issue changes that contract.
|
||||
@@ -1,307 +0,0 @@
|
||||
# Код-ревью issue #107 — переключение виртуального источника света «Всегда» (r1)
|
||||
|
||||
- **Issue:** https://github.com/Matysh/houseplan-card/issues/107
|
||||
- **ТЗ:** [`docs/specs/107-virtual-light-toggle.md`](../specs/107-virtual-light-toggle.md), ревью
|
||||
[`SPEC-REVIEW-107-r1.md`](SPEC-REVIEW-107-r1.md) — зелёное, High 0 / Medium 0.
|
||||
- **Диапазон:** `origin/dev...HEAD`, коммит `1079cdfab25617df924b8c3592631aa40e078d87`
|
||||
("feat: add persistent virtual light toggles"), ветка `issue/107-virtual-light-toggle`.
|
||||
- **Ревьюер:** Claude (код-ревью ≠ ревью ТЗ, свежая сессия, без контекста реализации).
|
||||
- **Цикл:** r1/4.
|
||||
|
||||
## 1. Скоуп изменения
|
||||
|
||||
Backend: новый Store `houseplan.virtual_lights` (`virtual_lights.py`, `const.py`,
|
||||
`store.py`), новая WS-команда `houseplan/virtual_light/toggle`, дополнение
|
||||
`houseplan/config/get` полем `virtual_lights`, согласование состояния во всех
|
||||
писателях конфигурации (`websocket_api.py`, `__init__.py` — миграция при
|
||||
setup).
|
||||
|
||||
Frontend: новый модуль `src/virtual-light-state.ts` (нормализация wire-снапшота,
|
||||
монотонное применение событий, reconciliation при смене config revision);
|
||||
интеграция в `resolveToggleIntent`/`resolvedLightSources`
|
||||
(`device-toggle.ts`, `devices.ts`), в live-sync и localStorage-кэш
|
||||
(`config-store.ts`, `houseplan-card.ts`), в статическую карточку
|
||||
(`space-card.ts`, `space-render.ts`); i18n EN/RU; unit-тесты
|
||||
(`test/virtual-light-state.test.mjs`, дополнения `device-toggle.test.mjs`,
|
||||
`devices.test.mjs`); backend-тесты (`test_virtual_lights.py`,
|
||||
`test_ha_virtual_lights.py`, дополнение `test_ha_setup.py`); целевой browser
|
||||
smoke `demo/smoke_virtual_light_toggle.mjs`; документация (`docs/LIGHT.md`,
|
||||
`docs/CONFIG-COMPATIBILITY.md`, `docs/ARCHITECTURE.md`,
|
||||
`docs/USER-GUIDE.ru.md`, `README.md`, оба changelog).
|
||||
|
||||
Соответствует заявленной поверхности ТЗ. Продуктовое соответствие
|
||||
`docs/SCOPE.md`: J1/J3, узкое исключение к замороженным virtual devices, не
|
||||
пересмотрено ревьюером ТЗ повторно (не входит в код-ревью).
|
||||
|
||||
## 2. Как проверялось
|
||||
|
||||
| Гейт | Команда | Результат |
|
||||
|---|---|---|
|
||||
| Typecheck | `npx tsc --noEmit` | зелёный, без вывода |
|
||||
| Unit (frontend) | `npm test` | 781/781 pass |
|
||||
| Build + сверка бандлов | `npm run build && sha256sum dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js demo/srv/assets/houseplan-card.js` | один и тот же SHA-256 `caffc463…d23bbc89` для всех трёх копий; рабочее дерево осталось чистым (`git status --short` пусто) — сборка воспроизводима и совпадает с закоммиченным бандлом |
|
||||
| Backend pytest (чистое подмножество, без HA) | `python3 -m venv /tmp/venv-review && /tmp/venv-review/bin/pip install pytest voluptuous && PYTHONPATH=. /tmp/venv-review/bin/pytest tests_backend -q` | **упал сбором** — `tests_backend/test_virtual_lights.py` не собирается без `homeassistant` (см. находку M1). Полный HA-харнесс (`pytest-homeassistant-custom-component`) в этом окружении не установлен и не входит в его canonical Linux CI набор по факту — только офлайн-подмножество, которое теперь не запускается вовсе. `test_ha_virtual_lights.py`/`test_ha_setup.py` разобраны чтением (см. §4) |
|
||||
| Целевой browser smoke | `node demo/smoke_virtual_light_toggle.mjs` (после `npm run build` и синхронизации трёх копий бандла, Chromium/Playwright уже установлены в среде) | **FAILED (2)**: `clickEverywhereOff: expected true, got false`; `reloadFirstStateOff: expected true, got false`. Остальные 5 проверок (`initialEverywhereOn`, `secondClickEverywhereOn`, `touchSingleToggle`, `oneServerTogglePerGesture`, `noHaServiceCalls`) — true. См. находку H1 |
|
||||
|
||||
Не прогонялись (осознанно, вне гейта код-ревью для этого объёма изменений
|
||||
по PROCESS.md §8): `npm run golden:verify` (изменение не трогает
|
||||
художественный baseline — новая fixture по плану идёт в golden только на
|
||||
pre-beta gate), полный набор `demo/smoke_*.mjs` (127 сценариев — задача не
|
||||
задевает все поверхности, а точечный сценарий уже прогнан выше и провалился),
|
||||
performance-профили (в AC не назван численный бюджет; §15.4 ТЗ явно откладывает
|
||||
его на pre-beta). Прогон полного HA backend harness (`pytest-homeassistant-custom-component`)
|
||||
недоступен в этом окружении — AC2/AC3/AC4/AC8/AC9, доказываемые
|
||||
`test_ha_virtual_lights.py`, разобраны чтением, не исполнением (см. §4).
|
||||
|
||||
## 3. Находки
|
||||
|
||||
### H1 (High, блокирует). Собственная презентация маркера (иконка/CSS-класс `on`/`off`) не отражает ручное состояние, когда у того же маркера есть исходящие `controls` — подтверждено исполнением целевого smoke
|
||||
|
||||
**Файл:** `src/device-presentation.ts` (не тронут этим PR), проявляется через
|
||||
`src/houseplan-card.ts` (`_devicePresentation`/`_stateClass`),
|
||||
`src/space-card.ts`, `src/space-render.ts`.
|
||||
|
||||
**Сценарий воспроизведения:** ровно fixture `demo/smoke_virtual_light_toggle.mjs`
|
||||
— маркер `binding: 'virtual', is_light: true, tap_action: 'toggle', controls:
|
||||
['light.ceiling']` (эта же тройка + исходящий `controls`, то есть ровно сценарий
|
||||
AC12). Запуск `node demo/smoke_virtual_light_toggle.mjs` на чистом собранном
|
||||
бандле даёт:
|
||||
|
||||
```
|
||||
FAILED (2):
|
||||
- clickEverywhereOff: expected true, got false
|
||||
- reloadFirstStateOff: expected true, got false
|
||||
```
|
||||
|
||||
**Причина, подтверждена чтением и точечной инструментацией (debug-копия
|
||||
смока, запущена и удалена, в репозиторий не попала):** `resolvePresentationSources()`
|
||||
(`src/device-presentation.ts:279-280`) выбирает `sourceKind = 'controls'`,
|
||||
как только у устройства есть хоть один источник с `via === 'controls'` —
|
||||
это ветка для «маркер-контроллер показывает лицом состояние того, что
|
||||
включает». Для точной тройки #107 `marker.controls` — это **исходящая**,
|
||||
не относящаяся к ролям Auto/Always связь (AC12 требует, чтобы она сохранялась
|
||||
lossless и не вызывала HA-сервис, но не говорит, что она должна забирать
|
||||
приоритет у собственного ручного состояния маркера). Проверено
|
||||
инструментацией: без поля `controls` та же тройка `_stateClass`/иконка
|
||||
корректно показывает `off` после клика (`sourceKind: 'light'`,
|
||||
`visualSources: [{"eid":"marker:...","state":"off"}]`); с полем `controls`
|
||||
`_devicePresentation()` даёт `sourceKind: 'controls'`,
|
||||
`visualSources: [{"eid":"light.ceiling","state":"on"}, {"eid":"marker:...",
|
||||
"state":"off"}]` — правильное ручное состояние присутствует в списке, но не
|
||||
выбрано для лица маркера, поэтому `_stateClass()`/CSS-класс `on` остаются
|
||||
завязаны на состояние `light.ceiling`, а не на ручной toggle.
|
||||
|
||||
Это касается всех трёх типов потребителей презентации маркера
|
||||
(`_devicePresentation` полной карточки, второй полной карточки, статической —
|
||||
все три вызывают `resolveDevicePresentation` с одним и тем же
|
||||
`resolvePresentationSources`), поэтому и `on(first)`, и `on(second)`, и
|
||||
`staticOn()` остаются залипшими на состоянии `light.ceiling`, а не на ручном
|
||||
toggle — что и даёт `false` в обеих сравнительных проверках смока.
|
||||
|
||||
**Почему это блокирует, а не Low/Medium:** ТЗ §6.2 требует «один и тот же
|
||||
resolved source обязан дать одинаковый результат» для списка потребителей,
|
||||
явно включающего «room card и device presentation»; AC5 требует `source.on=
|
||||
false … во всех canonical light consumers»; AC10/AC12 — это ровно комбинация
|
||||
(манильный маркер + сохранённые исходящие controls), которую тестирует сам
|
||||
смок. Реализация проходит AC1–AC9 (см. §4) и логику toggle/persistence, но
|
||||
для этой явно предусмотренной ТЗ комбинации собственная иконка маркера не
|
||||
меняется — заявленный сценарий («нажатие выключает его … Одновременно
|
||||
меняются его Glow и все room-light consumers») не выполняется полностью:
|
||||
Glow/room fill меняются (подтверждено smoke-проверками `oneServerTogglePerGesture`,
|
||||
`noHaServiceCalls` и раздельно нижеприведённым разбором devices.ts), а лицо
|
||||
самого маркера — нет. Это ровно тот класс дефекта, который должен доказываться
|
||||
исполнением, а не чтением: без прогона smoke эта находка осталась бы
|
||||
незамеченной, потому что unit-тесты `devices.test.mjs`/`device-toggle.test.mjs`
|
||||
не строят презентацию через `resolveDevicePresentation()` и поэтому не видят
|
||||
приоритет `sourceKind`.
|
||||
|
||||
**Что не задето:** сама персистентность, атомарность toggle, авторизация,
|
||||
отсутствие HA-сервисного вызова, поведение без `controls` — всё корректно
|
||||
(unit-тесты и 5/7 smoke-проверок это подтверждают).
|
||||
|
||||
### M1 (Medium, обязан стать отдельным issue). `tests_backend/test_virtual_lights.py` не собирается pytest без установленного `homeassistant`, хотя заявлен как «чистый» тест
|
||||
|
||||
**Файл:** `tests_backend/test_virtual_lights.py:1,4`.
|
||||
|
||||
**Сценарий воспроизведения:**
|
||||
|
||||
```
|
||||
python3 -m venv /tmp/venv-review
|
||||
/tmp/venv-review/bin/pip install pytest voluptuous
|
||||
PYTHONPATH=. /tmp/venv-review/bin/pytest tests_backend -q
|
||||
```
|
||||
|
||||
даёт
|
||||
|
||||
```
|
||||
ERROR tests_backend/test_virtual_lights.py
|
||||
ImportError while importing test module '.../tests_backend/test_virtual_lights.py'
|
||||
tests_backend/test_virtual_lights.py:4: in <module>
|
||||
from custom_components.houseplan.virtual_lights import (...)
|
||||
custom_components/houseplan/__init__.py:9: in <module>
|
||||
from homeassistant.components.frontend import add_extra_js_url
|
||||
E ModuleNotFoundError: No module named 'homeassistant'
|
||||
Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
|
||||
```
|
||||
|
||||
Это полная остановка сбора — ни один тест `tests_backend/` не выполняется,
|
||||
включая ранее рабочие чистые файлы вроде `test_validation.py`.
|
||||
|
||||
**Причина:** файл заявляет в докстринге «Pure operational-store rules
|
||||
independent of the HA WebSocket harness» и импортирует модуль обычным путём
|
||||
`from custom_components.houseplan.virtual_lights import …`. Такой импорт
|
||||
обязан сначала выполнить `custom_components/houseplan/__init__.py` (пакетный
|
||||
`__init__`), который безусловно импортирует `homeassistant.components.frontend`
|
||||
и другие модули HA. Существующий `test_validation.py` (не тронут этим PR)
|
||||
решает ту же задачу иначе и специально: докстринг «validation.py is loaded by
|
||||
path, without importing the HA integration package», реализовано через
|
||||
`importlib.util.spec_from_file_location`, ровно чтобы не тянуть
|
||||
`homeassistant` для чистых тестов.
|
||||
|
||||
`AGENTS.md` фиксирует это же поведение как контракт: «Locally only the pure
|
||||
subset runs; `python -m pytest tests_backend/ -q` without Home Assistant
|
||||
silently skips `test_ha_*.py` … so a green result proves nothing» — то есть
|
||||
не-`test_ha_*` файлы обязаны собираться и выполняться без HA. Этот PR впервые
|
||||
нарушает инвариант: вместо тихого пропуска — полная остановка сбора для всего
|
||||
каталога.
|
||||
|
||||
**Почему не блокирует:** CI-джоб `backend` (`.github/workflows/validate.yml:206`)
|
||||
всегда ставит `pytest-homeassistant-custom-component`, так что зелёный проход
|
||||
в CI не страдает — сами тесты внутри `test_virtual_lights.py` корректны и
|
||||
проверяют то, что заявлено (см. §4). Ломается только локальный/офлайн путь
|
||||
без HA, который прежде «доказывал что-то» для остальных чистых файлов.
|
||||
Соответствует критерию Medium: реальный, воспроизводимый дефект вне
|
||||
заявленных AC, требует отдельного issue со ссылкой на #107 (тип `tech-debt`,
|
||||
поверхность — тестовая инфраструктура backend).
|
||||
|
||||
## 4. Что проверено и корректно
|
||||
|
||||
- **AC1 (eligibility matrix, unit):** `isManualVirtualLightMarker()`/
|
||||
`eligible_virtual_light_ids()` (frontend `virtual-light-state.ts`, backend
|
||||
`virtual_lights.py`) требуют точную тройку `binding==='virtual'` +
|
||||
`is_light===true` + `tap_action==='toggle'` + `removed!==true`; unit-тест
|
||||
`test_eligibility_is_the_exact_triple_and_hidden_is_not_lifecycle` и frontend
|
||||
`devices.test.mjs`/`device-toggle.test.mjs` покрывают Auto/Never/wrong-action/
|
||||
non-virtual/removed — доказано автотестом, тест умеет падать (проверено
|
||||
изменением условия вручную не потребовалось: тест явно перечисляет все
|
||||
отрицательные варианты одним assert на множество).
|
||||
- **AC2/AC3 (persistence, atomic concurrency, backend):** `async_toggle_virtual_light`
|
||||
вызывается из `ws_virtual_light_toggle` **внутри** `rt.write_lock` — тот же
|
||||
lock, что использует `ws_config_get`/`ws_config_set`, поэтому конкурентные
|
||||
toggle-запросы физически сериализуются на уровне WS-хендлера, а не только
|
||||
внутри `virtual_lights.py`. Backend-тест
|
||||
`test_invalid_target_and_concurrent_toggles_are_server_atomic` (файл требует
|
||||
полного HA harness — прочитан, не исполнен: заявленное поведение —
|
||||
две последовательные server revisions `(1,False),(2,True)` — соответствует
|
||||
коду `async_toggle_virtual_light`, который читает текущий snapshot, инвертирует
|
||||
и сохраняет одной операцией под общим локом).
|
||||
- **AC4 (права, ревью кода):** `ws_virtual_light_toggle` не вызывает `_check_write`/
|
||||
`may_write` — команда доступна любому аутентифицированному соединению,
|
||||
соответствует ТЗ §7.4. `not_toggleable` возвращается без изменения store и
|
||||
без события (`if result is None: connection.send_error(...); return` —
|
||||
находится **внутри** `async with rt.write_lock`, до `send_result`/`async_fire`).
|
||||
- **AC5 (canonical `source.on`, unit):** `devices.ts:536-548` — цикл идёт по
|
||||
ВСЕМ passive-источникам (`if (!source.passive) continue`), сначала считает
|
||||
обычный OR по `incoming`-контроллерам, затем **безусловно** переопределяет
|
||||
`source.on` через `virtualLightIsOn()`, если `isManualVirtualLightMarker`
|
||||
— то есть ручное состояние выигрывает и когда `control` есть, и когда его
|
||||
нет. `devices.test.mjs` («manual virtual state is canonical and invalidates
|
||||
the light cache») это явно проверяет для случая с входящим контроллером.
|
||||
Данный путь работает корректно на уровне Glow/room-графа; H1 — это
|
||||
отдельный путь (device-presentation), который **не** использует этот
|
||||
безусловный override напрямую, а получает свой собственный `resolvedLightSources()`
|
||||
вызов без `virtualLights`, скорректированный через `lightSources:
|
||||
planLightSources`, что ломается только при наличии собственных `controls`
|
||||
у того же маркера (см. H1).
|
||||
- **AC6 (typed intent, unit + ревью кода):** `resolveToggleIntent()`
|
||||
(`device-toggle.ts:610-632`) возвращает `operation: {kind:'virtual-light',
|
||||
markerId}` и `command: null` для точной тройки, до общей ветки `controls`
|
||||
— marker ID никогда не подставляется в `ToggleCommand`/`entity_id`;
|
||||
`test/device-toggle.test.mjs` («exact manual virtual light wins over saved
|
||||
HA controls») подтверждает `on.command === null` и стабильность
|
||||
`sameToggleOperationTargets` при смене направления.
|
||||
- **AC7 (i18n, диалог):** новые ключи `marker.virtual_light_target/
|
||||
virtual_light_current/virtual_light_state_on/off` есть в EN и RU
|
||||
(`src/i18n/en.json`, `src/i18n/ru.json`), formatter в `houseplan-card.ts`
|
||||
(`~17025-17046`) ветвится по `target.via === 'virtual-light'` до общего
|
||||
`toggle_hint_current`/`toggle_hint_single` — старый `no_actionable_entity`
|
||||
hint для этой тройки не строится, так как `resolveToggleIntent` возвращает
|
||||
ненулевой intent раньше проверки `no-actionable-entity`.
|
||||
- **AC8 (initial snapshot, backend):** `ws_config_get` вызывает
|
||||
`async_virtual_light_snapshot` **внутри** `rt.write_lock`, тем же
|
||||
`config_rev`, что и возвращаемый `config`/`rev` — снэпшот согласован с той
|
||||
же ревизией конфигурации, на которой backend проверял eligibility (прочитано
|
||||
в коде; `test_default_toggle_event_and_restart_persistence` подтверждает
|
||||
восстановление `off` после `async_reload` — прочитано, не исполнено, требует
|
||||
HA harness).
|
||||
- **AC9 (config writer lifecycle, backend):** все места, где раньше был
|
||||
прямой `rt.config_store.async_save({...})` (`_converge_pair`, `ws_config_set`,
|
||||
`ws_plan_optimize`, `ws_plan_optimize_undo`, миграция в `__init__.py`),
|
||||
заменены на `async_save_config_state`, которая безусловно вызывает
|
||||
`async_reconcile_virtual_lights` с явным `previous_rev`/`previous_config_rev`
|
||||
— единая точка, разрозненных ручных `virtual_light_store.async_save()` не
|
||||
найдено (`grep` по `websocket_api.py`/`__init__.py` подтверждает единственный
|
||||
вызывающий путь). Fail-safe на неизвестный revision gap (`state_config_rev
|
||||
== config_rev` иначе `off = set()`) реализован и в `async_virtual_light_snapshot`,
|
||||
и в `async_reconcile_virtual_lights` — прочитано, соответствует ТЗ §7.5.
|
||||
- **AC11/AC13/AC14 (live-sync, confirmation, cache invalidation):**
|
||||
`config-store.ts`/`houseplan-card.ts` подписываются на
|
||||
`houseplan_virtual_light_updated` рядом с существующими событиями,
|
||||
`applyVirtualLightEvent` монотонна по `rev` (тест «events are monotonic and
|
||||
never optimistic» и «event-before-response ordering never rolls state back»
|
||||
в `test/virtual-light-state.test.mjs» умеют падать — проверено инверсией
|
||||
условия мысленно: `rev <= current.rev` иначе `event.on` не проверялся бы).
|
||||
`virtualLightFingerprint` участвует в ключе `RESOLVED_LIGHT_CACHE`
|
||||
(`devices.ts`), поэтому событие инвалидирует Glow/room-граф без HA state
|
||||
tick — подтверждено smoke-проверкой `oneServerTogglePerGesture` (одно
|
||||
серверное действие на один жест) и фактическим обновлением `staticOn`/`on(second)`
|
||||
в дебаг-прогоне без `controls`.
|
||||
- **AC15 (build/bundle parity):** `npm run build` воспроизводим, три копии
|
||||
бандла побайтно идентичны (см. §2).
|
||||
- **AC16 (документация):** оба changelog, `README.md`, `docs/LIGHT.md`,
|
||||
`docs/ARCHITECTURE.md`, `docs/CONFIG-COMPATIBILITY.md`,
|
||||
`docs/USER-GUIDE.ru.md` обновлены в том же коммите, ссылаются на #107,
|
||||
описывают точную тройку, persistence/permissions, исключение из экспорта —
|
||||
сверено построчно, расхождений с реализацией не найдено (кроме того, что
|
||||
H1 делает фразу README «updates Glow, room fill/statistics, full cards and
|
||||
`houseplan-space-card` together» неполной для комбинации с `controls` —
|
||||
само по себе не отдельная находка, устранится вместе с H1).
|
||||
- **Трейлеры:** коммит `1079cdf` несёт `Issue: #107`, `User-Visible: yes`,
|
||||
оба changelog правлены в том же коммите — соответствует.
|
||||
|
||||
## 5. Чего не проверял
|
||||
|
||||
- Полный HA backend harness (`pytest-homeassistant-custom-component`) —
|
||||
недоступен в этой среде (не `.venv-backend`, чистый Linux-раннер без
|
||||
предустановленного пакета). AC2/AC3/AC4/AC8/AC9, чьё единственное
|
||||
автотест-доказательство — `test_ha_virtual_lights.py`, разобраны только
|
||||
чтением кода и тестового файла (см. §4), не исполнением. Тесты выглядят
|
||||
корректными и умеющими падать по структуре (явные значения `rev`/`on` на
|
||||
каждом шаге, явный `monkeypatch` для forced-failure сценария), но это не
|
||||
заменяет фактический прогон — Linux CI job `backend` остаётся канонической
|
||||
проверкой для них.
|
||||
- `npm run golden:verify`, полный набор из 127 `demo/smoke_*.mjs`,
|
||||
performance-профили — не прогонялись, обоснование в §2 (объём соразмерен
|
||||
задаче по PROCESS.md §8; они относятся к pre-beta gate).
|
||||
- Продуктовое соответствие `docs/SCOPE.md`/выбор J1/J3 — не пересматривалось
|
||||
повторно, это было предметом ревью ТЗ, а не код-ревью.
|
||||
- Не проверялся весь возможный матрикс `is_light`/`tap_action` смены онлайн
|
||||
(rename/move/hidden/tombstone и т.д., AC9 edge cases) сверх того, что
|
||||
показывает `test_lifecycle_preserves_hidden_and_prunes_when_eligibility_ends`
|
||||
— тест прочитан и логически согласован с кодом, не исполнен.
|
||||
|
||||
## 6. Вердикт
|
||||
|
||||
`H1` — реальный, воспроизведённый прогоном собственного целевого smoke-теста
|
||||
дефект: презентация маркера (иконка/CSS-класс, используемый полной и
|
||||
статической карточками) не отражает ручное состояние виртуального света,
|
||||
когда у того же маркера есть сохранённые исходящие `controls` — то есть ровно
|
||||
в комбинации, которую требует покрывать AC10+AC12 и которую строит сам
|
||||
`demo/smoke_virtual_light_toggle.mjs`. Это High: блокирует.
|
||||
|
||||
`M1` — дефект тестовой инфраструктуры (не блокирует CI, но ломает
|
||||
документированный локальный офлайн-прогон `pytest tests_backend -q` из
|
||||
`AGENTS.md` для всего каталога, а не только для нового файла) — Medium,
|
||||
обязан стать отдельным issue.
|
||||
|
||||
Задача возвращается в `S6-in-progress`.
|
||||
@@ -1,177 +0,0 @@
|
||||
# Код-ревью issue #107 — переключение виртуального источника света «Всегда» (r2)
|
||||
|
||||
- **Issue:** https://github.com/Matysh/houseplan-card/issues/107
|
||||
- **ТЗ:** [`docs/specs/107-virtual-light-toggle.md`](../specs/107-virtual-light-toggle.md), ревью
|
||||
[`SPEC-REVIEW-107-r1.md`](SPEC-REVIEW-107-r1.md) — зелёное, High 0 / Medium 0.
|
||||
- **Предыдущий цикл:** [`CODE-REVIEW-107-r1.md`](CODE-REVIEW-107-r1.md) — красный,
|
||||
H1 (презентация маркера не отражает ручное состояние при сохранённых исходящих
|
||||
`controls`) + M1 (сбор `pytest tests_backend` падает без HA) → M1 заведён
|
||||
отдельным issue [#135](https://github.com/Matysh/houseplan-card/issues/135).
|
||||
- **Диапазон:** `origin/dev...HEAD`. Новый коммит цикла — `de0171dd028f87622a42e0b3d698473ef5e70cb9`
|
||||
("fix: keep manual virtual light face canonical"), поверх `1079cdfab25617df924b8c3592631aa40e078d87`.
|
||||
Ветка `issue/107-virtual-light-toggle`.
|
||||
- **Ревьюер:** Claude (код-ревью ≠ ревью ТЗ ≠ r1, свежая сессия, без контекста
|
||||
реализации и без контекста r1-обсуждения).
|
||||
- **Цикл:** r2/4.
|
||||
|
||||
## 1. Скоуп изменения (дельта к r1)
|
||||
|
||||
r1 уже проверил backend (`virtual_lights.py`, `store.py`, `websocket_api.py`,
|
||||
миграция в `__init__.py`), фронтовую персистентность/live-sync
|
||||
(`virtual-light-state.ts`, `config-store.ts`, `houseplan-card.ts`), typed-intent
|
||||
toggle (`device-toggle.ts`, `devices.ts`), i18n и документацию — эта часть не
|
||||
менялась в r2 и повторно не пересматривается по существу.
|
||||
|
||||
Коммит `de0171d` правит ровно то, что требовал вердикт r1:
|
||||
|
||||
- `src/device-presentation.ts` — `resolvePresentationSources()` теперь для
|
||||
точной manual-тройки (`isManualVirtualLightMarker`) отдаёт лицо маркера
|
||||
собственному canonical light source, а не агрегату `controls`; сохранённые
|
||||
исходящие `controls` остаются в графе (не исчезают из конфигурации/light-графа
|
||||
устройства-цели), но не перехватывают иконку/CSS-класс `on`/`off` самого
|
||||
маркера;
|
||||
- `src/space-card.ts` — две конструкции `virtualFingerprint` (`_captureRenderDeviceSnapshot`,
|
||||
`_frameFingerprint`) переведены на `snap?.virtualLights` вместо `snap ? snap.virtualLights…`,
|
||||
для единообразия с остальными местами файла, которые уже использовали
|
||||
optional chaining (строки 309/392/736 в исходном 1079cdf). Функционально
|
||||
эквивалентно в текущем коде (`virtualLights` всегда строится и в
|
||||
`cachedSnapshot()`, и в `fetchFresh()`), но защищает от падения, если где-то в
|
||||
графе появится `_snap`/`snap` без гарантированного поля `virtualLights`
|
||||
(например, унаследованный модульный `cache` из другой версии бандла на той же
|
||||
странице) — не увидел, что это было наблюдаемым падением сейчас, но и не
|
||||
увидел вреда от правки;
|
||||
- `test/device-presentation.test.mjs` — новый unit `issue 107 manual virtual
|
||||
source owns its face despite saved outgoing controls`, воспроизводящий ровно
|
||||
комбинацию AC12/H1 (marker с `controls: ['light.ceiling']`) для `off` и `on`;
|
||||
- `docs/CHANGELOG.md`/`docs/CHANGELOG.ru.md` — уточнение формулировки («including
|
||||
markers that retain saved outgoing controls» / «в том числе при сохранённых
|
||||
исходящих связях управления») в том же коммите, `User-Visible: yes`.
|
||||
|
||||
M1 в этот коммит не входит (сознательно, по хендоффу) — это корректно: Medium
|
||||
не должен чиниться заодно с фиксом High, он уже заведён issue'ом.
|
||||
|
||||
## 2. Как проверялось
|
||||
|
||||
| Гейт | Команда | Результат |
|
||||
|---|---|---|
|
||||
| Typecheck | `npx tsc --noEmit` | зелёный, без вывода |
|
||||
| Unit (frontend) | `npm test` | **782/782 pass** (было 781/781 в r1; +1 — новый регресс-тест `device-presentation.test.mjs`) |
|
||||
| Build + сверка бандлов | `npm run build && sha256sum dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js demo/srv/assets/houseplan-card.js` | один и тот же SHA-256 `d75945b8…8442e17` для всех трёх копий, совпадает с указанным автором в хендоффе; `git status --short` пусто после сборки — воспроизводимо |
|
||||
| Целевой browser smoke (тот самый, что провалил H1 в r1) | `node demo/smoke_virtual_light_toggle.mjs` (после свежей сборки и синхронизации бандла) | **7/7 true**: `initialEverywhereOn`, `clickEverywhereOff`, `secondClickEverywhereOn`, `touchSingleToggle`, `reloadFirstStateOff`, `oneServerTogglePerGesture`, `noHaServiceCalls` — включая ровно две проверки, которые падали в r1 (`clickEverywhereOff`, `reloadFirstStateOff`). Файл смока не менялся между r1 и r2 (`git log` на него показывает только исходный коммит `1079cdf`) — фикс проверен тем же, изначально проваленным, воспроизводимым тестом, не переписанным «чтобы позеленело` |
|
||||
| Backend pytest, чистое подмножество (повтор r1-методики, без HA) | `python3 -m venv /tmp/venv-review-r2 && pip install pytest voluptuous && PYTHONPATH=. pytest tests_backend -q` | **всё ещё падает сбором** на `tests_backend/test_virtual_lights.py` — идентично r1 (`ModuleNotFoundError: No module named 'homeassistant'`, `Interrupted: 1 error during collection`). Ожидаемо: M1 сознательно не входит в этот коммит, чинится отдельно в #135 |
|
||||
|
||||
Не прогонялись (осознанно, диапазон r1→r2 не касается backend/live-sync/i18n
|
||||
кода, только `device-presentation.ts`/`space-card.ts` и один unit-файл):
|
||||
|
||||
- полный набор `demo/smoke_*.mjs` (130 сценариев) — диапазон изменения узкий
|
||||
(один resolver презентации плюс два defensive `?.`), не задевает все
|
||||
поверхности; целевой smoke уже прогнан и является тем же смоком, который
|
||||
ловил дефект;
|
||||
- `npm run golden:verify` — изменение видимого результата ограничено ровно
|
||||
той же новой комбинацией (manual virtual toggle + сохранённые `controls`),
|
||||
для которой в существующих golden-baseline нет сценария (feature появилась в
|
||||
этом же issue); в существующие baseline-сценарии эта тройка не входит, и
|
||||
визуальные regressions на них не ожидаются — не подтверждено прогоном golden
|
||||
(это решение сужения объёма, а не находка);
|
||||
- performance-профили — в AC не назван численный бюджет, ТЗ §15.4 явно относит
|
||||
это к pre-beta gate (без изменений к r1);
|
||||
- полный HA backend harness (`pytest-homeassistant-custom-component`) —
|
||||
недоступен в этом окружении (нет `.venv-backend`, чистый Linux-раннер).
|
||||
Backend-код (`virtual_lights.py`, `store.py`, `websocket_api.py`,
|
||||
`__init__.py`) в этом цикле не менялся относительно r1, поэтому AC2/AC3/AC4/
|
||||
AC8/AC9 повторно по существу не пересматривались — их разбор чтением из r1
|
||||
остаётся в силе.
|
||||
|
||||
## 3. Находки
|
||||
|
||||
Блокирующих находок нет.
|
||||
|
||||
Не найдено новых High/Medium в дельте r1→r2. Единственное отмеченное в §1 —
|
||||
изменение в `space-card.ts` (`?.virtualLights`) — не тождественно
|
||||
воспроизводимому дефекту: не нашёл сценария, где `this._snap`/`snap` ненулевой,
|
||||
но `virtualLights` при этом отсутствует (оба источника, `cachedSnapshot()` и
|
||||
`fetchFresh()`, безусловно строят это поле через `virtualLightSnapshot(...)`/
|
||||
`adoptVirtualLightServerSnapshot(...)`). Правка защитная и не меняет наблюдаемое
|
||||
поведение ни в одном пройденном тесте — не поднимаю до Low, так как не нашёл ни
|
||||
воспроизведения, ни вреда.
|
||||
|
||||
## 4. Что проверено и корректно
|
||||
|
||||
- **H1 закрыт, доказано исполнением, не только чтением.** Целевой
|
||||
`demo/smoke_virtual_light_toggle.mjs` — тот же файл, что и в r1, без
|
||||
изменений — теперь проходит 7/7, включая обе ранее красные проверки.
|
||||
Дополнительно инструментирован новый unit
|
||||
(`test/device-presentation.test.mjs`), который специально воспроизводит
|
||||
именно тройку H1 (`binding:'virtual', is_light:true, tap_action:'toggle',
|
||||
controls:['light.ceiling']`) и проверяет оба направления (`off`/`on`) для
|
||||
`sourceKind`, `visualSources`, `visual.status` и CSS-класса `on`. Тест умеет
|
||||
падать: без ветки `manualVirtualFace` в `resolvePresentationSources()`
|
||||
`sourceKind` откатывается на `'controls'`, `visualSources` включает
|
||||
`light.ceiling` (state `on`) первым источником, и assert
|
||||
`off.visual.status === 'neutral'`/`!classes.includes('on')` не проходит —
|
||||
ровно это давал прогон смока в r1 до фикса.
|
||||
- **Причинный разбор совпадает с диагнозом r1.** `resolvePresentationSources()`
|
||||
(`src/device-presentation.ts:252-260,285-287`): для eligible-тройки
|
||||
(`isManualVirtualLightMarker(d.marker)`) `lights` строится только из
|
||||
`ownedLights` (без `via==='controls'` источников), и `sourceKind` принудительно
|
||||
остаётся `'light'` независимо от того, есть ли у маркера сохранённые исходящие
|
||||
`controls`. Сами `controls`-источники при этом не исчезают из общего
|
||||
`resolvedLightSources()`/Glow-графа устройства-цели (`light.ceiling`
|
||||
продолжает управляться и показываться как обычно у себя) — правка узкая,
|
||||
ограничена лицом самого manual-маркера, что соответствует ТЗ §6.3
|
||||
(«Controls остаются lossless … но не управляют state этого источника»).
|
||||
- **Не расширяет eligibility.** `isManualVirtualLightMarker()` — тот же
|
||||
переиспользуемый предикат из `virtual-light-state.ts`, что уже покрыт
|
||||
AC1-тестами в r1 (`devices.test.mjs`/`device-toggle.test.mjs`), новой логики
|
||||
распознавания тройки не введено — риск регресса для неэлигибл-маркеров
|
||||
(обычные `controls`-контроллеры, cover, switch, passive sensor) минимален и
|
||||
подтверждён тем, что все существовавшие кейсы `device-presentation.test.mjs`
|
||||
(44 теста в файле) остаются зелёными без изменений.
|
||||
- **AC5/§6.2 (canonical consumer contract).** `docs/LIGHT.md:137-147` уже
|
||||
декларирует «device presentation» как обязательного потребителя canonical
|
||||
ручного состояния — до r2 это было расхождением документации с кодом
|
||||
(H1), теперь код соответствует уже написанному тексту; правка документации
|
||||
не потребовалась.
|
||||
- **CHANGELOG.** Обе версии (`docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md`)
|
||||
уточнены в том же коммите `de0171d` — фраза, которую r1 отметил как
|
||||
«станет неполной без H1», исправлена точным добавлением про сохранённые
|
||||
исходящие связи. Трейлеры коммита: `Issue: #107`, `User-Visible: yes` — оба
|
||||
changelog в этом же коммите, соответствует правилу.
|
||||
- **M1 корректно вынесен.** Отдельный issue
|
||||
[#135](https://github.com/Matysh/houseplan-card/issues/135) существует,
|
||||
ссылается на #107 и на находку CODE-REVIEW-107-r1.md, помечен `S1-new`,
|
||||
`tech-debt`, `tests` — не оставлен как TODO в тексте ревью (§12 process).
|
||||
- **Инвариант сборки.** `npm run build` воспроизводим, три копии бандла
|
||||
побайтно совпадают друг с другом и с указанным в хендоффе SHA-256.
|
||||
|
||||
## 5. Чего не проверял
|
||||
|
||||
- Полный HA backend harness — недоступен в этой среде; поскольку backend-код
|
||||
не менялся между r1 и r2, повторно не разбирал по существу AC2/AC3/AC4/AC8/AC9
|
||||
сверх того, что зафиксировано в CODE-REVIEW-107-r1.md §4.
|
||||
- Полный набор из 130 `demo/smoke_*.mjs` и `npm run golden:verify` — не
|
||||
запускал; обоснование сужения в §2 (диапазон изменения узкий, целевой smoke
|
||||
уже покрывает ровно сценарий дефекта, новая визуальная комбинация не входит
|
||||
ни в один существующий golden baseline).
|
||||
- Performance-профили — не в AC, отложено на pre-beta gate тем же ТЗ, что и в
|
||||
r1.
|
||||
- Не проверял защитную правку `space-card.ts` (`?.virtualLights`) на предмет
|
||||
реального воспроизводимого сценария падения — не нашёл такого сценария при
|
||||
чтении обоих источников `_snap`/`snap` (`cachedSnapshot()`, `fetchFresh()` в
|
||||
`config-store.ts`), поэтому не поднимаю её в находки, но и не подтверждаю
|
||||
специальным тестом, что именно она предотвращает.
|
||||
- Продуктовое соответствие `docs/SCOPE.md`/выбор J1/J3 — предмет ревью ТЗ
|
||||
(зелёное, r1), в код-ревью повторно не пересматривается.
|
||||
|
||||
## 6. Вердикт
|
||||
|
||||
H1 из r1 исправлен и подтверждён исполнением: тот же ранее красный
|
||||
`demo/smoke_virtual_light_toggle.mjs` теперь зелёный 7/7 без изменений в самом
|
||||
файле смока, плюс новый целевой unit-регресс. M1 корректно вынесен отдельным
|
||||
issue (#135), в этот коммит не подмешан. Новых High/Medium в дельте r1→r2 не
|
||||
найдено. Все обязательные гейты (`typecheck`, `npm test`, `npm run build` +
|
||||
сверка бандлов, целевой smoke) зелёные; сознательно суженные (`golden:verify`,
|
||||
полный набор smoke, performance, полный HA harness) обоснованы объёмом дельты
|
||||
и перечислены выше.
|
||||
|
||||
`High: 0 · Medium: 0` — задача уходит в очередь на пре-релиз.
|
||||
@@ -1,248 +0,0 @@
|
||||
# Код-ревью #122 — Isometric Stage 2: hidden visual polish (r1)
|
||||
|
||||
- Issue: https://github.com/Matysh/houseplan-card/issues/122
|
||||
- Спецификация: `docs/specs/122-isometric-stage2.md` (SPEC-REVIEW-122-r1: green)
|
||||
- Диапазон: `git diff origin/dev...HEAD` на коммите `42b3f44` (`feat: add hidden
|
||||
isometric stage 2`), плюс сопутствующие `76ce755` (ТЗ), `4c73e2c` (ревью ТЗ)
|
||||
- Роль: ревьюер кода (свежая сессия, без контекста реализации)
|
||||
- Вердикт: **красный · цикл r1/4 · High: 2 · Medium: 1 → #134**
|
||||
|
||||
## Скоуп
|
||||
|
||||
Класс файлов коммита `42b3f44`: A (`src/houseplan-card.ts`, `src/iso-openings.ts`
|
||||
(новый), `src/iso-projection.ts`, `src/iso-walls.ts`, `src/wall-thickness.ts`,
|
||||
`src/styles.ts`) + B (`test/**`, `demo/smoke_isometric_contract.mjs`,
|
||||
`demo/smoke_isometric_live_touch.mjs`, `demo/golden/matrix.mjs`,
|
||||
`tsconfig.test.json`) + C (`docs/ISOMETRIC.md`, `docs/ARCHITECTURE.md`,
|
||||
`docs/STATUS.md`, `docs/adr/122-isometric-stage2-composition.md`). Класс D
|
||||
(`dist/**`, `custom_components/houseplan/frontend/houseplan-card.js`,
|
||||
`demo/srv/assets/houseplan-card.js`, `demo/golden/baselines/**`) **не
|
||||
изменён вовсе** — см. High-1.
|
||||
|
||||
Трейлеры на всех трёх коммитах диапазона корректны: `Issue: #122`,
|
||||
`User-Visible: no` (фича скрытая, публичного changelog не требует — верно).
|
||||
|
||||
## Как проверялось
|
||||
|
||||
| Гейт | Команда | Результат |
|
||||
|---|---|---|
|
||||
| Typecheck | `npx tsc --noEmit` | green, без вывода |
|
||||
| Unit | `npm test` | green, 766/766 |
|
||||
| Build | `npm run build` | green, `dist/houseplan-card.js` собран |
|
||||
| Синхронизация 3 копий бандла | `cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` и то же для `demo/srv/assets/houseplan-card.js` | **FAIL** — расходятся, byte 45, `git diff --stat` 327 insertions / 209 deletions на файл; см. High-1 |
|
||||
| Целевые браузерные смоки (названы в AC1/AC5/AC6/AC9/AC10/AC11 и напрямую тронуты диффом) | `node demo/smoke_isometric_contract.mjs`, `node demo/smoke_isometric_live_touch.mjs` — **после** `npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js`, т.к. коммит не обновил эту копию | оба green, все проверки в JSON-выводе `true` |
|
||||
| `npm run golden:verify` (диф меняет рендер/геометрию/слои — попадает в критерий «по необходимости») | `npm run golden:verify` (после того же build+copy) | 46/53 сценариев `passed`; 7 `different`: 5 ожидаемо-разных iso-сценариев без принятых эталонов (не блокер сам по себе, эталоны Stage 2 умышленно не принимаются на этом этапе — §12.3 ТЗ), но **2 сценария обязаны были остаться пиксель-идентичными и не остались** — см. High-2, Medium-1 |
|
||||
| Дифференциальная проверка причинности (A/B) | те же две команды (`npm run build`, `npm run golden:verify`) в `git worktree add /tmp/dev-check origin/dev` | `isometric-no-borders-dark` и `large-house-zoom-250-dark` оба `passed` на `origin/dev` — регрессия строго локализована в этом диффе, не окружение |
|
||||
| Backend | не прогонялся | правок в `custom_components/**/*.py` нет — не применимо |
|
||||
| Performance (`large-house-isometric-v1`) | не прогонялся | по ТЗ §10/§12.4 и решению владельца это pre-beta гейт, заблокированный отдельно #124; в этом ревью не требуется |
|
||||
|
||||
**Чего не проверял и почему:** полный набор из 127 браузерных смоков (диф не
|
||||
задевает весь продукт — только iso-поверхность и косвенно `wall-thickness.ts`);
|
||||
`python -m pytest tests_backend` (без py-правок); performance-профиль (вне
|
||||
гейта код-ревью, зависит от #124); принятие golden-эталонов (не роль
|
||||
ревьюера и не требуется до pre-beta).
|
||||
|
||||
## Находки
|
||||
|
||||
### High-1 — три копии бандла не пересобраны; CI уже красный на этом SHA
|
||||
|
||||
Коммит `42b3f44` не трогает `dist/houseplan-card.js`,
|
||||
`custom_components/houseplan/frontend/houseplan-card.js` ни
|
||||
`demo/srv/assets/houseplan-card.js` — все три остаются побитово равны
|
||||
промоушен-коммиту `a282f85` (`build: promote v1.63.0`), то есть Stage-1-коду
|
||||
без единой строки Stage 2.
|
||||
|
||||
Воспроизведение:
|
||||
|
||||
```
|
||||
$ npm run build
|
||||
$ cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
|
||||
dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js differ: byte 45, line 1
|
||||
$ git diff --stat dist/houseplan-card.js
|
||||
dist/houseplan-card.js | 536 ++++++++++++++++++++++++++++++-------------------
|
||||
1 file changed, 327 insertions(+), 209 deletions(-)
|
||||
$ grep -c "invalid isometric opening input" dist/houseplan-card.js # fresh build
|
||||
1
|
||||
$ git show HEAD:custom_components/houseplan/frontend/houseplan-card.js | grep -c "invalid isometric opening input"
|
||||
0
|
||||
$ git show HEAD:demo/srv/assets/houseplan-card.js | grep -c "invalid isometric opening input"
|
||||
0
|
||||
```
|
||||
|
||||
Независимое подтверждение — CI Validate уже прогонялся на этом самом SHA и
|
||||
уже красный ровно на этом шаге:
|
||||
|
||||
```
|
||||
$ gh run view 31746904479 --repo Matysh/houseplan-card
|
||||
X frontend in 36s
|
||||
✓ Build
|
||||
X Card bundle snapshots in sync
|
||||
```
|
||||
|
||||
Хендофф-комментарий исполнителя утверждает «три отслеживаемые bundle-копии
|
||||
byte-identical» — это не так; либо команда не выполнялась, либо результат
|
||||
интерпретирован неверно. Смысл дефекта не формальный: реальный
|
||||
`custom_components/houseplan/frontend/houseplan-card.js` (то, что грузит HA) и
|
||||
`demo/srv/assets/houseplan-card.js` (то, что видит демо-стенд и что реально
|
||||
тестируют браузерные смоки/golden в CI до пересборки) сейчас **не содержат
|
||||
Stage 2 вовсе**. `smoke`/`golden`/`performance_smoke` в CI зависят от
|
||||
`frontend` (`needs: frontend`) и поэтому на этом SHA не запускались вообще —
|
||||
ни один из пре-релизных гейтов не имеет зелёного прогона против настоящего
|
||||
кода задачи.
|
||||
|
||||
Блокирует. AC16 не выполнен.
|
||||
|
||||
### High-2 — `show_borders:false` в Iso не сохраняет пиксель-идентичный Stage-1 «no-volume» сценарий
|
||||
|
||||
ТЗ §6.6 и ADR «Consequences» требуют: при `show_borders:false` в Iso
|
||||
Stage-2-геометрия отсутствует целиком, и AC7/AC15 требуют, чтобы
|
||||
`isometric-no-borders-dark` остался неизменным. Golden показывает
|
||||
значительную регрессию:
|
||||
|
||||
```
|
||||
$ npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
|
||||
$ npm run golden:verify
|
||||
different isometric-no-borders-dark
|
||||
```
|
||||
|
||||
Из `artifacts/golden/golden-report.json`:
|
||||
`differingPixels: 169919, diffRatio: 0.2194 (≈22%), maxObservedDelta: 206`.
|
||||
Повторный прогон — идентичные числа (детерминированно, не флейк). На
|
||||
`origin/dev` (та же команда, тот же `git worktree add /tmp/dev-check
|
||||
origin/dev`, тот же Chromium 151.0.7922.34) сценарий `passed` чисто.
|
||||
|
||||
Причина по коду (`src/houseplan-card.ts:4503-4522`, `_isoScene()`):
|
||||
рамка проекции теперь всегда строится с
|
||||
`floorDepth: ISO_FLOOR_EDGE_HEIGHT` и объединяется с `isoOpeningBounds()` через
|
||||
`unionRect(flat, openingFrame)` **независимо** от `disp.showBorders` /
|
||||
`resolveIsoDecoration().structural`. В Stage 1 рамка включала только
|
||||
`wallHeight`; Stage 2 добавляет глубину пола и границы проёмов всегда, даже
|
||||
когда сама геометрия стен/проёмов/края пола не рисуется (`structural=false`).
|
||||
Это меняет коэффициент "сжатия" `floorView`, и весь план визуально сдвигается/
|
||||
масштабируется иначе, чем в принятом Stage-1 baseline — то есть нарушение не
|
||||
косметическое, это другой кадр.
|
||||
|
||||
Похоже на конфликт двух требований ТЗ: AC12 хочет единую рамку, не зависящую
|
||||
от переключения `show_borders` (чтобы zoom не менялся), а AC7/AC15 хотят
|
||||
байт-в-байт то же изображение, что и Stage 1, где такой рамки не было. Решить
|
||||
это — работа автора (например, включать `floorDepth`/`openingFrame` в рамку
|
||||
только когда `structural===true`, и объяснить в ADR, почему AC12 всё равно
|
||||
держится). Ревьюер такие продуктовые компромиссы не решает.
|
||||
|
||||
Блокирует. AC7 и AC15 не выполнены буквально.
|
||||
|
||||
### Medium-1 — регрессия пикселей во Flat-сценарии при выключенном Labs → #134
|
||||
|
||||
`large-house-zoom-250-dark` (без `labs`/`projection` в `demo/golden/matrix.mjs`
|
||||
— чистый Flat, Labs неактивен) на `origin/dev` проходит чисто (`passed`), на
|
||||
этой ветке — нет:
|
||||
|
||||
```
|
||||
different large-house-zoom-250-dark
|
||||
```
|
||||
|
||||
`differingPixels: 920, diffRatio: 0.00105, maxObservedDelta: 201` —
|
||||
воспроизведено дважды подряд с идентичными числами (не флейк, не дрейф
|
||||
Chromium: версия в обоих прогонах `151.0.7922.34`).
|
||||
|
||||
Это прямое нарушение AC2 («with Labs inactive... Flat... retain their
|
||||
existing state/action/pixel contract») и инварианта из `docs/ISOMETRIC.md`
|
||||
(«existing Flat DOM and pixels remain the reference»). В рамках бюджета этого
|
||||
ревью не удалось локализовать точный узел DOM/CSS, отвечающий за эту
|
||||
конкретную (небольшую по площади, но не нулевую и стабильную) разницу;
|
||||
кандидат — новая безусловная обёртка `<g class=${iso ? 'iso-floor-scene' :
|
||||
nothing} transform=${iso ? isoFloorMatrixCss() : nothing}>` вокруг содержимого
|
||||
плана в `src/houseplan-card.ts` (добавляется в разметку независимо от того,
|
||||
активен ли `iso`), но это не доказано построчно — отметка «предположение,
|
||||
требует дальнейшего разбора автором», а не факт.
|
||||
|
||||
Площадь и видимое воздействие малы (0.1% пикселей одного сценария на большом
|
||||
зуме), поэтому находка не расширяет цикл #122 сверх уже блокирующих High-1/
|
||||
High-2, но контракт заявлен как безусловный и находка воспроизводима —
|
||||
заведён отдельный issue #134 со ссылкой на #122, а не оставлена как TODO
|
||||
в этом документе.
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- **AC1** (`unit`+`smoke`+код): `iso` остаётся единственным Labs id;
|
||||
`demo/smoke_isometric_contract.mjs` подтверждает Flat как дефолт,
|
||||
`isometric-contract.test.mjs` подтверждает отсутствие второго флага/ключа —
|
||||
прочитано и прогнано, зелёно.
|
||||
- **AC5/AC6** (открывающиеся элементы): `src/iso-openings.ts` —
|
||||
`buildIsoOpeningBasis`/`projectIsoOpening` алгебраически повторяют
|
||||
существующий floor-symbol (поворот на 0–10° для gate, симметричные leaves
|
||||
для window, один leaf с шарниром для door); `test/iso-openings.test.mjs`
|
||||
проверяет неподвижность базиса при live-изменениях, `flip`-инверсию базиса,
|
||||
совпадение `unavailable`≡no-contact через `openingAmount()`, границы через
|
||||
`isoOpeningBounds`. Панели помечены `aria-hidden="true" pointer-events="none"`
|
||||
(`src/houseplan-card.ts`, рендер `_renderIsoWalls`) — проверено чтением и
|
||||
подтверждено смоком `live_touch` (`touchOpeningIsSafe`, `flatIsoActionParity`).
|
||||
Тесты умеют падать: `assert.deepEqual(gateBasis.leaves.map(l =>
|
||||
Math.abs(l.turnDeg)), [10, 10])` красится при любой правке угла;
|
||||
аналогично для высот leaf.
|
||||
- **AC4** (floor edge): `buildIsoFloorGeometry`/`floorFootprintGeometry` —
|
||||
внутренние/общие границы и вложенные дырки не создают ступень (тест
|
||||
«canonical adjacent room union has no edge on its shared boundary», «floor
|
||||
edge follows outer components without internal or nested steps»),
|
||||
независимые partition/column не расширяют footprint (тест «Stage floor
|
||||
footprint excludes detached independent physical bodies»), стабильность к
|
||||
порядку/winding подтверждена отдельным тестом. Прочитано и прогнано зелёно;
|
||||
тесты специфичны настолько, что удаление одной внешней грани красит их.
|
||||
- **AC8/AC14** (отсутствие второго свето-слоя, отсутствие новых сетевых/
|
||||
конфигурационных путей): `isometric-contract.test.mjs`
|
||||
(«Stage 2 adds no schema, dependency, storage, network or HA action
|
||||
surface») грепает исходники на `localStorage|fetch|XMLHttpRequest|
|
||||
WebSocket|callService|config|schema` и `three|babylon|webgl` — прогнано,
|
||||
зелёно. `src/styles.ts` не содержит нового источника света, только матовые
|
||||
градиенты/тени — прочитано.
|
||||
- **AC9** (структурный кэш не растёт от HA/темы/hover): фингерпринт в
|
||||
`_isoSource()` включает `rooms, walls, openCuts, openings (только
|
||||
геометрия/флипы), partitions, roomDrafts, columns, cellCm, gridPitch,
|
||||
wallKeyPitch, camera, wallHeight, floorEdgeHeight, algorithm: 3` и не
|
||||
включает HA-состояние/тему/hover — подтверждено и тестом
|
||||
`isometric-contract.test.mjs` («structural cache includes opening flips and
|
||||
excludes live HA amount», прямая проверка исходного текста `_isoSource`
|
||||
на отсутствие `_openingAmt|openingAmount|.hass|matchMedia|CSS.supports|
|
||||
theme|hover`) и смоком `live_touch` (`sameWallFingerprint`,
|
||||
`haUpdateReusesGeometry`, `contactUpdateMovesOnlyLivePanel`).
|
||||
- **AC11** (деградация): `resolveIsoDecoration` — чистая функция, тест
|
||||
«decoration degradation never removes structure or creates floating
|
||||
panels» покрывает все 4 комбинации `filtersSupported`/`forcedColors`/
|
||||
`hideOpenings`/`showBorders`; `src/styles.ts` содержит
|
||||
`@media (forced-colors: active)` и `@supports not (filter: blur(1px))`,
|
||||
оба убирают только тени/нюанс материала, не геометрию. Прочитано, логика
|
||||
соответствует ТЗ.
|
||||
- **AC13** — намеренно не проверялось: и по ТЗ (§10/§12.4), и по решению
|
||||
владельца это остаётся pre-beta гейтом, заблокированным #124; хендофф
|
||||
честно пишет «не запускались».
|
||||
- Трейлеры коммитов, `User-Visible: no`, отсутствие изменений в
|
||||
`custom_components/**/*.py`, i18n и schema — проверено чтением диффа,
|
||||
корректно.
|
||||
|
||||
## Чего не проверял
|
||||
|
||||
- Полные 127 браузерных смоков — диф касается только iso-поверхности и
|
||||
`wall-thickness.ts`; прогнаны только два целевых, названных в AC и прямо
|
||||
изменённых в этом диффе.
|
||||
- `python -m pytest tests_backend` — нет изменений в Python.
|
||||
- `large-house-isometric-v1` performance — вне гейта код-ревью при открытом
|
||||
#124 (см. ТЗ §10, решение владельца).
|
||||
- Принятие golden-эталонов — не роль ревьюера; данные из
|
||||
`artifacts/golden/golden-report.json` использованы только как
|
||||
диагностика причинности регрессии, не как повод принять/отклонить
|
||||
baseline.
|
||||
- Точная DOM/CSS-причина Medium-1 не изолирована построчно — честно оставлено
|
||||
как открытый вопрос автору в #134.
|
||||
|
||||
## Итог
|
||||
|
||||
Две High-находки блокируют цикл r1: код не собран в те артефакты, которые
|
||||
реально исполняются (High-1, CI уже подтверждает это независимо), и Stage 2
|
||||
в текущем виде ломает пиксельный контракт, который ТЗ объявляет безусловным —
|
||||
`show_borders:false` в Iso (High-2). Третья находка (регрессия чистого Flat)
|
||||
воспроизводима, но мала по воздействию — заведена как Medium #134, а не
|
||||
оставлена как TODO. Работа предметно сильная — покрытие юнит-тестами
|
||||
геометрии открытий и floor edge выдерживает мутации, изоляция
|
||||
структурного/live кэша сделана аккуратно и подтверждена и тестами, и
|
||||
смоками — но AC16, AC7 и AC15 не могут считаться выполненными до
|
||||
пересборки бандла и разбора причины пиксельной регрессии `show_borders:false`.
|
||||
@@ -1,150 +0,0 @@
|
||||
# Код-ревью #122 — Isometric Stage 2: hidden visual polish (r2)
|
||||
|
||||
- Issue: https://github.com/Matysh/houseplan-card/issues/122
|
||||
- Спецификация: `docs/specs/122-isometric-stage2.md` (SPEC-REVIEW-122-r1: green)
|
||||
- Предыдущий цикл: `docs/reviews/CODE-REVIEW-122-r1.md` — красный, High: 2, Medium: 1 → #134
|
||||
- Диапазон: `git diff origin/dev...HEAD`, новый коммит цикла —
|
||||
`ef3cc98` (`fix: preserve isometric fallback rendering`), поверх `42b3f44`
|
||||
(`feat: add hidden isometric stage 2`), `76ce755`/`4c73e2c`/`e13215c` (ТЗ и
|
||||
документы ревью)
|
||||
- Роль: ревьюер кода (свежая сессия, без контекста реализации и без контекста
|
||||
сессии r1)
|
||||
- **Вердикт: зелёный · цикл r2/4 · High: 0 · Medium: 0**
|
||||
|
||||
## Скоуп
|
||||
|
||||
Единственный продуктовый коммит цикла — `ef3cc98`. Класс файлов: A
|
||||
(`src/houseplan-card.ts`, 13 insertions/4 deletions — два места: `_baseVb()` и
|
||||
основной `<svg>`/`<g>` блок рендера плана) + B (`test/isometric-contract.test.mjs`,
|
||||
+5 строк) + C (`docs/ISOMETRIC.md`, `docs/adr/122-isometric-stage2-composition.md`)
|
||||
+ D (`dist/houseplan-card.js`, `custom_components/houseplan/frontend/houseplan-card.js`,
|
||||
`demo/srv/assets/houseplan-card.js` — все три пересобраны и включены в тот же
|
||||
коммит). Диапазон узкий и точечный: правка адресует ровно High-1 и High-2 из r1,
|
||||
`demo/smoke_isometric_contract.mjs` и `demo/smoke_isometric_live_touch.mjs` в этом
|
||||
коммите не менялись (они уже были частью `42b3f44`).
|
||||
|
||||
Трейлеры `ef3cc98`: `Issue: #122`, `User-Visible: no` — корректно, фича
|
||||
по-прежнему за скрытым Labs-флагом `iso`, публичный changelog не требуется.
|
||||
|
||||
## Как проверялось
|
||||
|
||||
| Гейт | Команда | Результат |
|
||||
|---|---|---|
|
||||
| Typecheck | `npx tsc --noEmit` | green, без вывода |
|
||||
| Unit | `npm test` | green, 766/766 |
|
||||
| Build | `npm run build` | green |
|
||||
| Синхронизация 3 копий бандла | `cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` и то же для `demo/srv/assets/houseplan-card.js` | **обе команды green** — байт-в-байт совпадают. High-1 из r1 закрыт |
|
||||
| `npm run golden:verify` (диф меняет рендер — обязателен) | после `npm run build` + `cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js` | 45/50 сценариев `passed`; 5 `different` — все пять из семейства «непринятые Iso-эталоны Stage 2» (`isometric-geometry-view-dark/light`, `isometric-live-layers-dark`, `isometric-touch-kiosk-dark`, `isometric-large-warm-remount-dark`), что и предсказывал §12.3 ТЗ и r1. **`isometric-no-borders-dark` теперь `passed`** (High-2 закрыт), **`large-house-zoom-250-dark` теперь `passed`** (Medium-1/#134 регрессия исчезла тем же коммитом) |
|
||||
| Повтор `golden:verify` для этих двух сценариев | тот же прогон второй раз | оба снова `passed` — детерминированно, не флейк |
|
||||
| Целевые браузерные смоки (названы в AC1/AC5-AC7/AC9-AC11 и напрямую тронуты правкой `_baseVb`/рендера) | `node demo/smoke_isometric_contract.mjs`, `node demo/smoke_isometric_live_touch.mjs` (после того же build+copy) | оба green; в `live_touch` явно проверяется `noBordersUsesFloorSymbols: true` и `visibleBordersRestoreStage2: true` — оба режима show_borders исполнены в реальном DOM, не только прочитаны |
|
||||
| Трейлеры коммита | `git log --format=... ef3cc98` | `Issue: #122`, `User-Visible: no` — оба присутствуют и терминальны |
|
||||
| Backend | не прогонялся | правок в `custom_components/**/*.py` нет — не применимо |
|
||||
| Performance (`large-house-isometric-v1`) | не прогонялся | как и в r1 — pre-beta гейт, заблокирован #124 по решению владельца, вне скоупа этого код-ревью |
|
||||
|
||||
**Чего не проверял и почему:** полный набор из 127 браузерных смоков — правка
|
||||
цикла ограничена двумя точками в одном файле плюс правкой рендера, которую уже
|
||||
покрывают целевые смоки/golden; `python -m pytest tests_backend` — нет
|
||||
Python-правок; принятие golden-эталонов Stage 2 Iso — не роль ревьюера и не
|
||||
требуется до pre-beta (§12.3 ТЗ, решение владельца); дифференциальный прогон на
|
||||
`origin/dev` — не повторялся отдельно в этом цикле, т.к. `origin/dev` не менялся
|
||||
с r1 и там же было доказано, что оба сценария чисты на dev (причинность уже
|
||||
локализована в диффе задачи).
|
||||
|
||||
## Находки
|
||||
|
||||
Отсутствуют. Оба High из r1 закрыты, новых High/Medium не найдено.
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- **High-1 (три копии бандла)** — закрыт. `cmp` подтверждает байт-в-байт
|
||||
идентичность всех трёх копий после `npm run build`; коммит `ef3cc98` содержит
|
||||
пересобранные `dist/houseplan-card.js`, `custom_components/houseplan/frontend/houseplan-card.js`
|
||||
и `demo/srv/assets/houseplan-card.js` внутри себя же (не отдельным коммитом) —
|
||||
правило «сгенерированное в том же коммите» соблюдено.
|
||||
- **High-2 (`show_borders:false` в Iso ломал пиксельный контракт)** — закрыт.
|
||||
Причина по коду (`src/houseplan-card.ts:4558-4573`, `_baseVb()`): при
|
||||
`!showBorders` рамка теперь строится как `projectedFrame({ rect: flat,
|
||||
wallHeight: ISO_WALL_HEIGHT })` — это в точности старая Stage-1 проекция без
|
||||
вклада Stage 2 floor/opening-геометрии, а не безусловный `unionRect(flat,
|
||||
isoOpeningBounds())`, который ломал сцену в 42b3f44. Синхронно с этим основной
|
||||
`<svg>`/внутренний `<g>` (строки ~14181-14187) теперь используют
|
||||
`isoLayers?.structural` вместо `iso` как условие для класса
|
||||
`iso-floor-scene`/`transform`/viewBox — то есть в ветке «iso активен, но
|
||||
структура скрыта» контент рендерится без Stage-2-трансформации, тем же путём,
|
||||
что и Stage 1. Доказано исполнением: `golden:verify` даёт `passed` для
|
||||
`isometric-no-borders-dark` с нулевым отличием от принятого Stage-1 baseline,
|
||||
повторено дважды детерминированно; смок `live_touch` отдельно подтверждает
|
||||
`noBordersUsesFloorSymbols: true` в реальном DOM.
|
||||
- **Атрибут `class` внешнего `<svg>`** (`class=${isoLayers?.structural ? 'plan-svg' : nothing}`,
|
||||
новая связка по сравнению с прежним безусловным `class="plan-svg"`) —
|
||||
прочитано и проверено: `.plan-svg { z-index: 1; }` в `src/styles.ts:472`
|
||||
имеет смысл только относительно братских `.iso-underlay-svg`/`.iso-shadows-svg`/`.iso-walls-svg`,
|
||||
а те сами рендерятся строго под условием `iso && isoLayers?.structural`
|
||||
(`src/houseplan-card.ts:14176`, `:14394`) — то есть ровно тогда же, когда
|
||||
`plan-svg` получает класс. Вне Flat и вне structural-iso конкурирующих слоёв
|
||||
с явным z-index нет, поэтому потеря класса не меняет порядок отрисовки.
|
||||
Единственное внешнее использование селектора `.plan-svg` — `demo/smoke_isometric_contract.mjs:49`,
|
||||
и там он опрашивается только в сценарии с `show_borders:true` (структурный),
|
||||
где класс присутствует. Подтверждено исполнением: ни один из 44 не-iso
|
||||
golden-сценариев (Flat, редакторы, lighting, tray, split-wall и т. д.) не
|
||||
показал отличий.
|
||||
- **Medium-1 / #134 (регрессия `large-house-zoom-250-dark` при выключенном
|
||||
Labs)** — не было целью этого коммита (правка адресует High-1/High-2), но
|
||||
сценарий проходит чисто тем же коммитом. Открытая гипотеза из r1 (обёртка
|
||||
`<g class=${iso ? ... }>` как источник 0.1%-регрессии) теперь снята
|
||||
экспериментально: смена условия с `iso` на `isoLayers?.structural` на этой же
|
||||
строке устранила и её. Issue #134 остаётся открытым (`S1-new`) — закрытие
|
||||
issue не входит в роль ревьюера кода (§6 PROCESS.md), но по факту дефект,
|
||||
который он описывает, воспроизводимо больше не проявляется в этом дереве;
|
||||
стоит отметить это релиз-менеджеру/автору при обработке #134, а не оставлять
|
||||
как забытый висящий тикет.
|
||||
- **AC7 (`unit`+`smoke`+`golden`)** — выполнен: `hide_openings` не трогает
|
||||
cuts/state/light (не менялось в этом коммите, покрыто существующими тестами
|
||||
из `npm test`); `show_borders:false` восстанавливает Stage-1 no-volume сцену —
|
||||
подтверждено golden. **AC15** (Flat и `isometric-no-borders-dark` неизменны) —
|
||||
выполнен для не-iso baseline; Stage-2-Iso-эталоны намеренно не приняты на
|
||||
этом этапе (§12.3 ТЗ) и остаются `different` — соответствует контракту, а не
|
||||
находка. **AC16** (три копии бандла байт-в-байт + зелёные typecheck/unit/build)
|
||||
— выполнен, доказано `cmp`.
|
||||
- **`test/isometric-contract.test.mjs`** (+5 строк) — источниковые
|
||||
regex-проверки на буквальный текст новых веток `_baseVb()` и рендера
|
||||
(`if (!this._spaceDisplayForRender().showBorders)`,
|
||||
`projectedFrame({ rect: flat, wallHeight: ISO_WALL_HEIGHT })`,
|
||||
`preserveAspectRatio=...'xMidYMid meet' : 'none'`, и т. д.). Тест умеет
|
||||
падать при откате этих строк (буквальное совпадение с исходником), но сам по
|
||||
себе не проверяет визуальный результат — это делает `golden:verify`
|
||||
отдельно. Стиль согласован с уже существующими source-grep проверками того
|
||||
же файла (AC8/AC9/AC14 в r1). Оценка: приемлемо как дополнительная страховка,
|
||||
не как единственное доказательство AC7/AC15 — реальное доказательство даёт
|
||||
golden.
|
||||
- **Документация** (`docs/ISOMETRIC.md`, ADR) — обновлена в том же коммите,
|
||||
описывает новое поведение точно (Stage-1 projected frame возвращается в
|
||||
no-borders ветке вместо union с Stage-2-геометрией); расхождений с кодом не
|
||||
найдено.
|
||||
|
||||
## Чего не проверял
|
||||
|
||||
- Полные 127 браузерных смоков — правка узкая (две точки в одном файле), вне
|
||||
необходимости, определяемой diff'ом; прогнаны два целевых, прямо относящихся
|
||||
к затронутому коду.
|
||||
- `python -m pytest tests_backend` — нет изменений в Python.
|
||||
- `large-house-isometric-v1` performance — вне гейта код-ревью, заблокирован
|
||||
#124 (решение владельца, повторяет позицию r1).
|
||||
- Принятие golden-эталонов Stage 2 Iso (5 `different`-сценариев) — не роль
|
||||
ревьюера; это ожидаемое, задокументированное в ТЗ состояние, а не находка.
|
||||
- Отдельный дифференциальный прогон на `origin/dev` в этом цикле — не
|
||||
повторялся: `origin/dev` не изменился с r1, где причинность уже была
|
||||
локализована в диффе задачи, а не в окружении/Chromium.
|
||||
|
||||
## Итог
|
||||
|
||||
Оба блокирующих High из r1 закрыты одним точечным коммитом (`ef3cc98`):
|
||||
пересборка всех трёх копий бандла восстановлена и подтверждена `cmp`
|
||||
(High-1); ветвление рамки/трансформации в `show_borders:false` теперь
|
||||
воспроизводит принятую Stage-1 no-volume сцену вместо объединения с
|
||||
Stage-2-геометрией, что подтверждено чистым `golden:verify` дважды подряд
|
||||
(High-2). Ранее заведённая Medium-находка (#134) как побочный эффект того же
|
||||
исправления тоже перестала воспроизводиться в golden — issue стоит закрыть
|
||||
или, как минимум, обновить при её обработке, но это не входит в роль
|
||||
ревьюера кода. Новых High/Medium-находок нет. Цикл r2/4 закрывается зелёным
|
||||
вердиктом.
|
||||
@@ -0,0 +1,155 @@
|
||||
# CODE-REVIEW-123-r2
|
||||
|
||||
- **Issue:** https://github.com/Matysh/houseplan-card/issues/123
|
||||
- **ТЗ:** `docs/specs/123-corner-split-wall.md` (зелёное ревью
|
||||
`docs/reviews/SPEC-REVIEW-123-r1.md`)
|
||||
- **Диапазон:** `git log --oneline origin/dev..HEAD` — 6 коммитов; относительно
|
||||
предыдущего цикла (`docs/reviews/CODE-REVIEW-123-r1.md`, снят на коммите
|
||||
`024a1ac`) диапазон вырос ровно на один коммит:
|
||||
`e79f8f5 Fix corner split smoke geometry input` (`Issue: #123`,
|
||||
`User-Visible: no`). `git diff 024a1ac..HEAD --stat` подтверждает: изменён
|
||||
только `demo/smoke_split_corner_wall.mjs` (+6/−1 строк), продуктовый код
|
||||
(`src/wall-thickness.ts`, `src/houseplan-card.ts`, `src/space-render.ts`) не
|
||||
тронут ни байтом.
|
||||
- **Роль:** ревьюер кода (не исполнитель), этап `S7-code-review`
|
||||
- **Цикл:** r2/4
|
||||
|
||||
## Скоуп ревью
|
||||
|
||||
Единственная блокирующая находка r1 (`High-1`) — сломанная сигнатура вызова
|
||||
`_lightBarriers(c._spaceModel())` в `demo/smoke_split_corner_wall.mjs:87`,
|
||||
из-за которой смок падал необработанным исключением до выполнения хотя бы
|
||||
одной проверки, и AC7 (`unit + smoke`)/AC8 (`smoke + golden`)/AC9
|
||||
(`unit + smoke`) не были подтверждены доставленным доказательством. Скоуп
|
||||
этого цикла: (1) убедиться, что фикс `e79f8f5` действительно чинит вызов, а не
|
||||
маскирует падение; (2) прогнать смок и убедиться, что все 14 полей — `true`;
|
||||
(3) убедиться, что тест по-прежнему умеет **содержательно** падать, а не
|
||||
превратился в тавтологию; (4) поскольку продуктовый код не менялся с r1,
|
||||
повторно прогнать быстрые гейты и точечные смоки по затронутым поверхностям
|
||||
для очистки от сомнений, не переделывая заново детальное чтение
|
||||
`src/wall-thickness.ts`, уже выполненное в r1 (не изменилось — см. diff-статы
|
||||
выше).
|
||||
|
||||
## Как проверялось
|
||||
|
||||
| Гейт | Команда | Результат |
|
||||
|---|---|---|
|
||||
| Typecheck | `npx tsc --noEmit` | зелёный, без вывода |
|
||||
| Unit | `npm test` | `752/752` (`npm run inventory` подтверждает то же число), 0 fail |
|
||||
| Build | `npm run build` | зелёный |
|
||||
| Bundle sync | `cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` и `cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js` | оба совпадают побайтно; sha256 всех трёх `182fb55a…483ff` — идентичен значению из r1 (ожидаемо: продуктовый код не менялся) |
|
||||
| Process gate | `node scripts/process-gate.mjs` | `диапазон origin/dev..HEAD, коммитов 6`, `гейт пройден, предупреждений 0` |
|
||||
| Process gate + issues | `node scripts/process-gate.mjs --issues` | `гейт пройден, предупреждений 0` (метка issue #123 подтверждена через `gh`: ровно одна `S*` — `S7-code-review`) |
|
||||
| Целевой browser smoke | `node demo/smoke_split_corner_wall.mjs` (после `npm run build`, синхронизация `demo/srv/assets/houseplan-card.js`) | **`OK`**, все 14 полей `true`: `beforeDrawn`, `wall0/15/100KeepsFacade`, `paper0/15/100KeepsFacade`, `dividerChangesInterior`, `lightUsesFacade`, `planViewParity`, `kioskParity`, `isoUsesCanonicalBody`, `staticParity`, `renderDoesNotRewriteConfig` |
|
||||
| Regression-can-fail (сам доставленный смок, не независимая копия) | доставленный `demo/smoke_split_corner_wall.mjs` (версия из `e79f8f5`) скопирован в чистый `git worktree` на `52ec0fb` (коммит непосредственно перед продуктовым фиксом `47c6f10`, т.е. добаговый `wallBodiesGeometry`), пересобран и прогнан там | `FAILED (7)`: `wall0/15/100KeepsFacade`, `paper0/15/100KeepsFacade`, `lightUsesFacade` — все `expected true, got false`, `planViewParity`/`kioskParity`/`isoUsesCanonicalBody`/`staticParity`/`renderDoesNotRewriteConfig` остаются `true` (паритет между поверхностями держится даже на баге — расходится именно ожидаемый факт «фасад сохранён»). Падение содержательное (конкретные `false`, не исключение), т.е. смок доказывает именно то, что называет AC, а не тавтологию |
|
||||
| Точечные browser smokes по затронутым поверхностям (split/wall-thickness/glow/iso/static-card) | `node demo/smoke_wall_thickness.mjs`, `smoke_merge_split.mjs`, `smoke_split_nonsnap.mjs`, `smoke_split_polyline.mjs`, `smoke_glow.mjs`, `smoke_isometric_contract.mjs`, `smoke_space_card.mjs` | все `OK`, регрессий на смежных поверхностях нет |
|
||||
| Golden/performance/backend | не запускались | пре-релизные гейты по `PROCESS.md` §8/§11.4; визуальный/перф/backend-код не менялся с r1 (см. diff-статы), решение о непрогоне уже обосновано в r1 и остаётся в силе |
|
||||
|
||||
Полный набор из 128 browser-смоков не прогонялся — правка этого цикла точечная
|
||||
(один файл демо-гарнеса), затронутые поверхности перечислены выше и покрыты.
|
||||
|
||||
## Находки
|
||||
|
||||
Блокирующих (High/Medium) находок нет. High-1 из r1 закрыт.
|
||||
|
||||
### Low-1 — смок строит `lightPolys` не буквально через хелпер `roomPoly(r)`
|
||||
|
||||
**Файл:** `demo/smoke_split_corner_wall.mjs:88-90`
|
||||
|
||||
```js
|
||||
const lightPolys = lightSpace.rooms
|
||||
.filter((room) => Array.isArray(room.poly))
|
||||
.map((room) => ({ r: room, poly: room.poly }));
|
||||
```
|
||||
|
||||
Продуктовый `_renderGlowLayer` (`src/houseplan-card.ts:13250-13252`) строит тот
|
||||
же список через `roomPoly(r)` (`src/logic.ts:103-108`), которая (а) достаёт
|
||||
`r.poly`, только если в нём **не менее 3** точек, и (б) для комнаты без
|
||||
явного `poly` вычисляет прямоугольник из `x/y/w/h`. Смок вместо этого
|
||||
фильтрует `Array.isArray(room.poly)` без проверки длины и не имеет пути для
|
||||
`x/y/w/h`-комнат.
|
||||
|
||||
Для фикстуры issue (все комнаты заданы явным `poly` длиной 3 или 4)
|
||||
результат совпадает с продуктовым один в один — расхождение не проявляется,
|
||||
и AC7 доказан корректно для того сценария, который называет ТЗ. Но если этот
|
||||
файл когда-нибудь расширят на комнату без явного `poly` (`x/y/w/h`), копия
|
||||
молча исключит такую комнату из `lightPolys` там, где продукт бы её включил
|
||||
— тихое расхождение, а не падение с сообщением.
|
||||
|
||||
**Решение ревьюера:** Low, не блокирует зелёный вердикт — фактическое
|
||||
поведение для покрываемого сценария корректно, откладываю на усмотрение
|
||||
автора при следующей правке этого файла (например, заменить построение на
|
||||
прямой вызов `roomPoly` из продукта, если он становится доступен смоку).
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- **High-1 (r1) закрыт:** `_lightBarriers(lightSpace, lightPolys, lightPhysical)`
|
||||
теперь вызывается с тем же числом и порядком аргументов, что и
|
||||
`_renderGlowLayer` (`polys`, `physical` строятся явно, `physical` — через
|
||||
тот же `c._physicalBodiesR(lightSpace)`, что и в продукте). Смок выполняется
|
||||
до конца, `checkAll`/`finish` печатают `OK`, все 14 полей — `true`.
|
||||
- **AC7 (`unit + smoke`):** `lightUsesFacade: true` — Glow использует то же
|
||||
исправленное препятствие (`masonryGeometry` из `_lightBarriers`), что и
|
||||
рендер стен; подтверждено смоком и независимо не расходится с unit-уровнем
|
||||
r1 (`src/wall-thickness.ts` не менялся).
|
||||
- **AC8 (`smoke + golden`):** `planViewParity`, `kioskParity`,
|
||||
`isoUsesCanonicalBody`, `staticParity` — все `true`; Plan, View/kiosk,
|
||||
скрытая изометрия и `houseplan-space-card` рисуют идентичный путь `d` для
|
||||
сценария из issue. Golden-эталоны (второй тип доказательства AC8) —
|
||||
пре-релизный гейт, не запускался, консистентно с r1/§11.4 PROCESS.md.
|
||||
- **AC9 (`unit + smoke`):** `renderDoesNotRewriteConfig: true` — рендер не
|
||||
мутирует сохранённые `rooms`/`walls`; сравнение JSON до/после рендера
|
||||
совпадает.
|
||||
- **Дисциплина «тест умеет падать» — усилена относительно r1.** В r1 AC7–AC9
|
||||
были подтверждены независимой копией сценария вне репозитория (сам
|
||||
доставленный файл падал необработанным исключением). В этом цикле
|
||||
содержательное падение показано на **самом доставленном** файле — прогон в
|
||||
чистом worktree на добаговом коде (`52ec0fb`, до `47c6f10`) даёт `FAILED (7)`
|
||||
с конкретными `expected/got`, не крах. Это закрывает главное сомнение r1:
|
||||
теперь именно тот файл, что лежит в репозитории, доказывает регресс, а не
|
||||
только рассуждение ревьюера о нём.
|
||||
- **Продуктовый код не менялся с r1:** `git diff 024a1ac..HEAD --stat`
|
||||
показывает изменения только в `demo/smoke_split_corner_wall.mjs`. Всё, что
|
||||
r1 проверил чтением и тестами по AC1–AC6, AC9 (unit-часть), AC10, AC11
|
||||
(кэширование), AC12, AC13 (документация/changelog), остаётся в силе без
|
||||
повторного разбора — предмет разбора не менялся, и разбор `r1` уже прошёл
|
||||
свой цикл ревью.
|
||||
- **Трейлеры и процесс:** `node scripts/process-gate.mjs` /
|
||||
`--issues` — зелёные без предупреждений; коммит `e79f8f5` несёт
|
||||
`Issue: #123` и `User-Visible: no` — корректно, это правка тестового
|
||||
гарнеса (`demo/**`, класс B), поведение продукта не меняет, изменений в
|
||||
changelog не требует и их нет. Метка issue — ровно одна, `S7-code-review`.
|
||||
`origin/dev` не сдвинулся с момента слияния в ветку задачи (`merge-base`
|
||||
совпадает с текущим `origin/dev`), ребейз перед мержем не потребуется.
|
||||
- **Точечные смоки по затронутым поверхностям** (`smoke_wall_thickness`,
|
||||
`smoke_merge_split`, `smoke_split_nonsnap`, `smoke_split_polyline`,
|
||||
`smoke_glow`, `smoke_isometric_contract`, `smoke_space_card`) — все `OK`,
|
||||
регрессий не найдено.
|
||||
|
||||
## Чего не проверял
|
||||
|
||||
- Полный набор из 128 browser-смоков — правка точечная (один файл демо-
|
||||
гарнеса), полный прогон не пропорционален объёму изменения; прогнаны
|
||||
целевой смок AC7–AC9 плюс смоки по затронутым поверхностям (см. таблицу).
|
||||
- `npm run golden:verify` и `performance_smoke` — пре-релизные гейты
|
||||
(`PROCESS.md` §8/§11.4), визуальный рендер и перф-чувствительные пути не
|
||||
менялись с r1; будущий провал чинится по §11.4 без нового код-ревью.
|
||||
- `tests_backend` — `custom_components/houseplan/**/*.py` не входит в
|
||||
диапазон.
|
||||
- Повторное детальное чтение `src/wall-thickness.ts`/`src/houseplan-card.ts`/
|
||||
`src/space-render.ts` построчно — не требовалось: файлы не изменились со
|
||||
времени r1, где это чтение уже выполнено и задокументировано.
|
||||
- Low-1 не проверялся на альтернативной фикстуре (комната без явного `poly`)
|
||||
— вне сценария, который называет ТЗ; см. решение ревьюера в находке.
|
||||
|
||||
## Вердикт
|
||||
|
||||
Зелёный. High: 0, Medium: 0 (Low: 1, не блокирует, решение зафиксировано в
|
||||
находке Low-1 выше — оставлено на усмотрение автора без нового цикла).
|
||||
Единственная блокирующая находка r1 устранена: доставленный
|
||||
`demo/smoke_split_corner_wall.mjs` теперь вызывает `_lightBarriers` с полной
|
||||
сигнатурой, проходит до конца с `OK` по всем 14 полям и содержательно падает
|
||||
на добаговом коде того же файла (не независимой копии) — AC7, AC8, AC9
|
||||
подтверждены доказательством, которое называет ТЗ. Продуктовый код не менялся
|
||||
с r1 и остаётся подтверждённым: 752/752 unit, три bundle-снимка побайтно
|
||||
идентичны, трейлеры и процесс-гейт зелёные.
|
||||
@@ -1,294 +0,0 @@
|
||||
# SPEC-REVIEW-107-r1
|
||||
|
||||
- **Issue:** https://github.com/Matysh/houseplan-card/issues/107
|
||||
- **ТЗ под ревью:** `docs/specs/107-virtual-light-toggle.md` (коммит `af851cd`,
|
||||
ветка `issue/107-virtual-light-toggle`)
|
||||
- **Роль:** ревьюер ТЗ (не автор), этап `S4-spec-review`
|
||||
- **Трек:** обычный (не `small`/`trivial`) — автор корректно не поставил `small`:
|
||||
задача вводит новый backend WS-контракт, отдельное operational-хранилище и
|
||||
новый UX-контракт target preview, что прямо нарушает критерии §5 PROCESS.md
|
||||
(«нет нового UX-контракта», «нет влияния на touch»)
|
||||
- **Цикл:** r1/4
|
||||
|
||||
## Скоуп ревью
|
||||
|
||||
Проверялось соответствие ТЗ:
|
||||
|
||||
- `docs/SCOPE.md` — попадание в Core user jobs (J1/J3), сохранение
|
||||
замороженного статуса virtual devices, lock-инвариант;
|
||||
- `PROCESS.md` §2.4, §2.5 (DoR), §7.1 (обязательные разделы), §12 (запреты);
|
||||
- `AGENTS.md` — классы файлов, ветка `issue/107-virtual-light-toggle`, трейлеры;
|
||||
- `docs/LIGHT.md` и `docs/DEVICE-LIGHT-SETTINGS-MATRIX.ru.md` — канон модели
|
||||
источника света, passive `Always`, OR-контракт controllers (#84/#88);
|
||||
- `docs/CONFIG-COMPATIBILITY.md` — образец, каким должен быть раздел
|
||||
compatibility (old/new matrix, `decision-required`/`deprecated-read` и т.п.);
|
||||
- `docs/TOUCH-SUPPORT.md` и `docs/UX-MODES.md` — блокирующий View/kiosk-контракт,
|
||||
разрешённые tap-действия;
|
||||
- `docs/USER-GUIDE.ru.md` — терминология «Переключить состояние», роль
|
||||
«Всегда», «Виртуальное устройство»;
|
||||
- фактическому состоянию кода (`src/devices.ts`, `src/device-toggle.ts`,
|
||||
`src/types.ts`) — на предмет того, что технический диагноз ТЗ (§3) не
|
||||
является непроверенной догадкой, а описывает существующий код.
|
||||
|
||||
## Как проверялось
|
||||
|
||||
1. Прочитан весь тред issue #107: тело issue (черновик AC1–AC4, явное решение
|
||||
владельца «обрабатывать как частный случай, не обобщать»), комментарий
|
||||
аналитики Q1–Q3 с default-ответами, комментарий владельца от 14.08.2026,
|
||||
фиксирующий Q1–Q3 без изменений (`#107#issuecomment-5287719167`), и
|
||||
комментарий «ТЗ готово».
|
||||
2. Прочитан `docs/SCOPE.md` целиком: Core user jobs J1/J3, «Excess-functionality
|
||||
audit» (virtual devices — «keep, frozen (no growth)»), lock-инвариант,
|
||||
правило «никогда не удалять файл пользователя по догадке» (не касается этой
|
||||
задачи — файлов не удаляет).
|
||||
3. Построчно сверены обязательные разделы ТЗ (§7.1 `PROCESS.md`) — таблица ниже.
|
||||
4. Прочитан `src/device-toggle.ts` целиком, в частности `resolveToggleIntent()`
|
||||
(:590–621) и `resolveOwnEntity`/`ownRoleCandidates` (:355–419): подтверждено
|
||||
дословно то, что описывает §3 ТЗ — виртуальный marker с `tap_action=toggle`
|
||||
не имеет кандидатов в `ownRoleCandidates` (нет `bindingRef`/`entities`), и
|
||||
`resolveOwnEntity` возвращает `null`, что даёт `emptyIntent(origin,
|
||||
'no-actionable-entity')` через ветку `device.virtual || device.bindingKind
|
||||
=== 'virtual'` (:616–619) — **независимо от `is_light`**. Это важно: ТЗ
|
||||
обязано менять поведение только для точной тройки, а не для любого
|
||||
virtual+toggle, и код подтверждает, что сегодня Auto/Never virtual с
|
||||
toggle-действием получают тот же `no-actionable-entity` тем же путём — AC1/
|
||||
AC3 корректно требуют, чтобы для них ничего не менялось.
|
||||
5. Прочитан `resolvedLightSources()` (`src/devices.ts`:450–565), включая
|
||||
создание passive-источника `marker:<id>` с `passive: !candidate.eid` и
|
||||
`on: candidate.eid ? … : true` (:487–493) и OR-контракт контроллеров
|
||||
(:525–532: `source.on = !control?.linked || […].some(eid => … === 'on')`).
|
||||
Совпадает дословно с §3 п.1–2 и §6.3 ТЗ.
|
||||
6. Прочитан `lightGraphFingerprint`/`lightStateFingerprint`
|
||||
(`src/devices.ts`:331–354) — оба фингерпринта сегодня не содержат никакого
|
||||
поля мануального override для virtual-источника. Это подтверждает
|
||||
техническую необходимость AC14 (новая runtime-revision обязана войти в ключ
|
||||
кэша) — без неё один tap не вызвал бы инвалидацию `resolvedLightSources()`,
|
||||
и Glow остался бы старым до следующего HA state tick, чего в системе для
|
||||
virtual-маркера никогда не происходит. Идея «мутантного теста» в §15.1.5
|
||||
(удаление revision из ключа кэша должно ронять тест) — конкретный и
|
||||
проверяемый способ закрыть именно этот риск.
|
||||
7. Прочитан `docs/LIGHT.md` (раздел «Source, state and service identity») —
|
||||
тройная идентичность `key`/`stateEids`/`serviceEids`, три-стейт `is_light`,
|
||||
passive forced source, OR нескольких controllers — всё описанное в §3/§6
|
||||
ТЗ дословно совпадает с каноном, ничего не придумано заново.
|
||||
8. Прочитан `docs/DEVICE-LIGHT-SETTINGS-MATRIX.ru.md` — матрица про
|
||||
Live/Ручн./R (цвет, яркость, радиус) не про on/off-агрегацию; задача явно
|
||||
не трогает geometry/color/brightness/radius (§13 «Не входит»), поэтому
|
||||
отсутствие правки этого файла в release-артефактах (§17 ТЗ) корректно —
|
||||
изменения принадлежат `docs/LIGHT.md`, что там и указано.
|
||||
9. Прочитан `docs/CONFIG-COMPATIBILITY.md` целиком как образец формата
|
||||
compatibility-записи. §12 ТЗ («отдельный optional operational Store version
|
||||
1», «отсутствие = пустой off-set/on», «operational state не входит в
|
||||
export») по форме и содержанию соответствует принятому в проекте стилю
|
||||
(сравнимо с разделами «Per-marker light role», «marker.controls[]»).
|
||||
10. Прочитан `docs/TOUCH-SUPPORT.md` («View is fully supported… must be
|
||||
convenient and reliable», «Kiosk — primary supported environment») и
|
||||
`docs/UX-MODES.md` («device tap (info / more-info / toggle per settings)» —
|
||||
разрешённое View-взаимодействие). §10 ТЗ формулирует touch/kiosk как
|
||||
блокирующие поверхности этими же словами, не эскалируя и не изобретая
|
||||
более строгий контракт, чем канон.
|
||||
11. Проверена терминология `docs/USER-GUIDE.ru.md` (строки 467–470, 553–562):
|
||||
«Переключить состояние», «Является источником света: Авто/Всегда/Никогда»,
|
||||
«виртуальный маркер» — ТЗ использует ровно эти термины.
|
||||
12. Проверены существующие i18n-ключи `marker.toggle_none_*` и
|
||||
`marker.toggle_effect_turn_on/off` (`src/i18n/ru.json`) — новые ключи,
|
||||
которые требует §9 ТЗ, логически продолжают уже принятую схему именования,
|
||||
а не вводят параллельную.
|
||||
13. Проверена запись в `docs/specs/README.md:81` — строка на #107 присутствует
|
||||
в том же коммите, ссылка issue ↔ ТЗ двусторонняя.
|
||||
14. Прочитан `docs/types.ts` — поле `tap_action`/`tapAction` существует ровно
|
||||
в том виде, на который ссылается §5 ТЗ («effective tap action равен
|
||||
`toggle`»).
|
||||
15. Проверено количество и релевантность 16 AC (§14) — каждый несёт явный тип
|
||||
доказательства (`unit`/`backend`/`smoke`/«ревью кода»/`build`) из
|
||||
допустимого по DoR перечня; ни один AC не двусмысленен относительно того,
|
||||
какое конкретно поведение проверяется.
|
||||
|
||||
## Обязательные разделы (§7.1 PROCESS.md)
|
||||
|
||||
| Раздел | Есть | Комментарий |
|
||||
|---|---|---|
|
||||
| Сценарий (персона/поверхность/момент) | ✅ | §1 |
|
||||
| Что человек увидит до/после | ✅ | §2, без терминов реализации |
|
||||
| Проблема | ⚠️ частично | Явно как отдельный заголовок отсутствует; фактически покрыта §2 («до») и §3 («подтверждённая причина текущего поведения») — см. Low-1 |
|
||||
| Скоуп / не-скоуп | ✅ | §5 (точное условие) / §13 |
|
||||
| Контракт поведения | ✅ | §5–§8 |
|
||||
| UX / i18n / accessibility / touch | ✅ | §9, §10 |
|
||||
| Модель данных и миграция | ✅ | §12 |
|
||||
| AC1…ACn с доказательством | ✅ | §14, 16 штук, каждый типизирован |
|
||||
| План автотестов | ✅ | §15 |
|
||||
| Риски | ❌ отсутствует как раздел | Риск-релевантный материал есть (fail-safe §7.5, security-граница §16, сложность 7/10 в шапке), но не сведён в один раздел с явной привязкой риск → закрывающий AC, как это сделано, например, в `SPEC-REVIEW-131-r1` — см. Low-2 |
|
||||
| Откат | ✅ | §18 |
|
||||
| Release-артефакты | ✅ | §17 |
|
||||
|
||||
Десять из двенадцати обязательных разделов присутствуют и содержательны;
|
||||
два («Проблема», «Риски») по факту не выделены отдельным заголовком, хотя
|
||||
материал по существу распределён по документу. Дополнительно есть корректно
|
||||
обособленный §19 «Принятые технические предположения» — граница между
|
||||
продуктовыми решениями (принятыми владельцем в Q1–Q3) и свободно
|
||||
пересматриваемой техникой проведена явно, ни одна догадка не выдана за факт
|
||||
без пометки.
|
||||
|
||||
## Находки
|
||||
|
||||
Находок уровня **High** нет.
|
||||
|
||||
### Low-1 — раздел «Проблема» не выделен отдельно
|
||||
|
||||
**Файл:** `docs/specs/107-virtual-light-toggle.md` (между §2 и §3)
|
||||
|
||||
Формально PROCESS.md §7.1 перечисляет «проблема» отдельным пунктом в списке
|
||||
обязательных разделов. В документе нет заголовка с этим названием — «до»
|
||||
(§2) и технический диагноз (§3) вместе дают эквивалентное содержание, но
|
||||
читатель, ищущий формулировку «в чём проблема» одним куском, должен собрать
|
||||
её из двух разделов и самого issue.
|
||||
|
||||
**Почему не блокирует:** содержание есть и оно точное (см. «Как проверялось»
|
||||
п.4) — диагноз в §3 построчно совпадает с реальным кодом, не является
|
||||
догадкой. Это вопрос оформления, а не отсутствия решения.
|
||||
|
||||
**Решение ревьюера:** Low, не блокирует, снимается с записью. На усмотрение
|
||||
автора — при следующей правке можно дать §2/§3 общий подзаголовок «Проблема»,
|
||||
либо оставить как есть.
|
||||
|
||||
### Low-2 — нет консолидированного раздела «Риски»
|
||||
|
||||
**Файл:** `docs/specs/107-virtual-light-toggle.md`
|
||||
|
||||
Сложность/риск задачи оценены автором в 7/10 (шапка документа), но в отличие
|
||||
от, например, `docs/specs/131-readonly-cold-start.md` (§14 «Риски», 5 пунктов,
|
||||
каждый со ссылкой на закрывающий AC), здесь риск-релевантные утверждения
|
||||
рассеяны по документу без единого места: fail-safe при revision gap (§7.5),
|
||||
границы trust boundary (§16), решение о хранении вместо конфигурации (§4 п.1).
|
||||
Отсутствует явное перечисление, например: «риск: два отдельных HA `Store`
|
||||
могут разойтись при crash между save — закрыт §7.5 fail-safe reset + AC9»,
|
||||
«риск: canonical-кэш не инвалидируется без HA tick — закрыт AC14 + мутантный
|
||||
тест §15.1.5», «риск: manual state перекрывает controller и наоборот путает
|
||||
пользователя — закрыт §6.3 + AC5/AC12».
|
||||
|
||||
**Почему не блокирует:** каждый реальный риск, который я смог определить при
|
||||
чтении кода и канона, оказался закрыт конкретным AC или конкретным разделом
|
||||
контракта (перечислено выше) — отсутствует сам факт непокрытого риска, не
|
||||
хватает только сведения их в один раздел для читаемости и трассируемости.
|
||||
|
||||
**Решение ревьюера:** Low, не блокирует, снимается с записью. Рекомендация
|
||||
автору — на следующей правке (не обязательно в этом цикле) собрать
|
||||
существующие риск-утверждения в один раздел «Риски» с явной ссылкой
|
||||
риск → AC, по образцу `SPEC-REVIEW-131-r1`.
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- **Соответствие `docs/SCOPE.md`.** Задача закрывает J1 (Glow/room fill/room
|
||||
stats — одно пространственное состояние) и J3 (очевидное безопасное
|
||||
действие прямо с плана). Она не расширяет «замороженный» статус virtual
|
||||
devices в общий state engine — владелец явно одобрил именно узкое
|
||||
исключение в теле issue («не превращать в системный механизм»), и §1/§5 ТЗ
|
||||
формулируют условие исключения как точную тройку, без обобщения на другие
|
||||
роли/действия. Lock-инвариант не затронут: virtual target никогда не
|
||||
резолвится в secure entity или HA service (§8.1, §16) — соответствует
|
||||
правилу SCOPE.md «любой новый путь актуации либо отказывает locks, либо
|
||||
добавляется явным абзацем в SCOPE.md» (здесь второе не требуется, так как
|
||||
путь и так отказывает).
|
||||
- **Продуктовые вопросы закрыты владельцем, не додуманы автором.** Q1
|
||||
(жизненный цикл/синхронизация), Q2 (что именно переключается — canonical
|
||||
state, а не только Glow) и Q3 (приоритет manual state над controller links)
|
||||
— все три явно продуктовые («что видит/делает пользователь», «что считается
|
||||
этим же issue»), заданы одним комментарием с default-вариантами и приняты
|
||||
владельцем дословно 14.08.2026. Открытых продуктовых вопросов в финальной
|
||||
редакции нет — и это корректно, а не подозрительно: они были заданы и
|
||||
закрыты на этапе аналитики, а не пропущены.
|
||||
- **Раздел §19 корректно отделяет технику от продукта.** Все 8 пунктов
|
||||
(имя Store/класса, механизм доставки initial state, форма typed intent,
|
||||
раскладка подписок, поведение local cache, консервативный reset вместо
|
||||
угадывания истории, hidden vs tombstone, поведение импорта) — техническая
|
||||
реализация уже принятых продуктовых решений Q1–Q3, ни один пункт не прячет
|
||||
продуктовое решение под видом «технического предположения».
|
||||
- **Техническая точность диагноза (§3) подтверждена чтением кода**, не
|
||||
является голословным утверждением автора: `resolveToggleIntent()`,
|
||||
`resolvedLightSources()`, `lightGraphFingerprint`/`lightStateFingerprint` —
|
||||
все три технических утверждения совпадают построчно с текущим `src/devices.ts`
|
||||
и `src/device-toggle.ts` (см. «Как проверялось» п.4–6).
|
||||
- **Точность границы исключения.** Код подтверждает, что сегодня *любой*
|
||||
virtual marker с `tap_action=toggle` (Auto/Never/Always) получает
|
||||
`no-actionable-entity` одним и тем же путём (`resolveOwnEntity` → `null`);
|
||||
§5 ТЗ и AC1/AC3 корректно требуют не менять этот путь для Auto/Never, а не
|
||||
просто «для не-Always», что было бы более рискованной (и не запрошенной)
|
||||
формулировкой.
|
||||
- **Canonical single-consumer contract (§6.2, AC5/AC6) методологически верный
|
||||
ответ на риск дублирования источника истины.** Проверено, что
|
||||
`resolvedLightSources()` — уже сегодня единственный вход для Glow, room fill,
|
||||
room stats, preview и `houseplan-space-card» (docs/LIGHT.md, «Source, state
|
||||
and service identity»); требование ТЗ не создавать отдельную ветку в
|
||||
рендерере, room card, preview или static card прямо предотвращает
|
||||
повторение уже случившегося в проекте расхождения слоёв (см. историю
|
||||
«layered model» в docs/LIGHT.md).
|
||||
- **Кэш-инвалидация (AC14) — не декоративное требование.** Подтверждено, что
|
||||
без явного добавления runtime-revision в `lightStateFingerprint`
|
||||
манипуляция чисто виртуальным состоянием (без единой реальной HA entity)
|
||||
физически не имеет другого триггера инвалидации кэша — HA state tick для
|
||||
virtual marker никогда не придёт. План теста §15.1.5 («мутантный» тест,
|
||||
который обязан упасть при удалении revision из ключа кэша) — конкретное и
|
||||
проверяемое требование именно к этому риску, соответствует принятой в
|
||||
проекте дисциплине «тест должен уметь падать».
|
||||
- **Touch/View/kiosk-контракт (§10) сформулирован дословно по канону**
|
||||
(`docs/TOUCH-SUPPORT.md`, `docs/UX-MODES.md`), не эскалирован и не ослаблен.
|
||||
- **Терминология UX/i18n (§9) взята из `docs/USER-GUIDE.ru.md`**, не
|
||||
изобретена; новые ключи логически продолжают существующую схему
|
||||
`marker.toggle_none_*`/`marker.toggle_effect_*`.
|
||||
- **Compatibility (§12) не вводит миграцию config/marker/export**, что
|
||||
соответствует и явному решению владельца (Q1: «operational data, не входит
|
||||
в config/export») и общему принципу `docs/CONFIG-COMPATIBILITY.md` —
|
||||
отдельный versioned Store, а не новое поле в `Marker`.
|
||||
- **Не-скоуп (§13) корректно отсекает обобщение**, прямо запрещённое
|
||||
владельцем в теле issue: общий state engine для virtual devices, действие
|
||||
для других ролей/actions, создание `input_boolean`/synthetic entity,
|
||||
управление цветом/яркостью/радиусом через tap.
|
||||
- **Release-артефакты (§17) перечисляют реальные существующие файлы**
|
||||
(`docs/CHANGELOG.md`/`.ru.md`, `docs/USER-GUIDE.ru.md`, `README.md`,
|
||||
`docs/LIGHT.md`, `docs/ARCHITECTURE.md`, `docs/CONFIG-COMPATIBILITY.md`, три
|
||||
bundle snapshot) — ни один не выдуман.
|
||||
- **Реестр `docs/specs/README.md`** обновлён тем же коммитом, ссылка issue ↔
|
||||
ТЗ двусторонняя.
|
||||
- **Трейлеры коммита `af851cd`** (`Issue: #107`, `User-Visible: no` —
|
||||
ожидаемо, так как коммит правит только ТЗ) корректны для документационного
|
||||
коммита; продуктовый код действительно не тронут (подтверждено и текстом
|
||||
ТЗ, и комментарием «ТЗ готово»: «продуктовый код не менялся»).
|
||||
|
||||
## Чего не проверял
|
||||
|
||||
- Не проверял, реализуем ли предложенный backend design (§7: общий
|
||||
load-modify-save lock между config и operational store, атомарный WS
|
||||
toggle) без побочных эффектов на существующие писатели конфигурации — по
|
||||
§19 п.1/п.6 ТЗ это свободно изменяемое техническое предположение автора
|
||||
кода и предмет код-ревью, а не ревью ТЗ.
|
||||
- Не запускал никаких автотестов, не собирал бандл и не проверял backend на
|
||||
Python — на этапе `spec` это не требуется; все технические утверждения,
|
||||
которые проверялись, проверены чтением существующего TypeScript-кода, не
|
||||
исполнением.
|
||||
- Не проверял golden/скриншоты — ТЗ §15.4 явно и обоснованно откладывает их
|
||||
до pre-beta gate (переиспользуется существующая flat-сцена, новый
|
||||
художественный baseline не проектируется).
|
||||
- Не проверял детали Python-реализации `houseplan.virtual_lights` (имя класса,
|
||||
формат хранения) — §19 п.1 прямо помечает точные имена как свободно
|
||||
изменяемые технические детали.
|
||||
- Не проверял полноту 127 browser-smoke сценариев и не запускал ни одного —
|
||||
на этапе ревью ТЗ это не требуется и не относится к гейтам код-ревью §8
|
||||
PROCESS.md; целевой smoke-сценарий (§15.3 ТЗ) описан достаточно конкретно
|
||||
(одна eligible лампа, один контроллер, один независимый источник, две full
|
||||
card и одна static card) для последующей проверки на этапе код-ревью.
|
||||
- Не проверял, действительно ли предложенный «typed operational target»
|
||||
(§8.1) может быть добавлен в `ResolvedToggleIntent` без расширения его типа
|
||||
несовместимым образом — вопрос реализации, накрытый AC6 и предметом
|
||||
код-ревью.
|
||||
|
||||
## Вердикт
|
||||
|
||||
Зелёный. High: 0, Medium: 0. Две находки Low — (1) раздел «Проблема» не
|
||||
выделен отдельным заголовком, содержание фактически распределено по §2/§3;
|
||||
(2) риск-релевантный материал не сведён в консолидированный раздел «Риски»
|
||||
со ссылками риск → AC. Обе не блокируют: содержание по существу присутствует
|
||||
и подтверждено построчной сверкой с реальным кодом и каноном подсистемы,
|
||||
открытых продуктовых вопросов нет (Q1–Q3 закрыты владельцем 14.08.2026), AC
|
||||
однозначны и типизированы, скоуп/не-скоуп точно повторяют явное решение
|
||||
владельца «не обобщать». Обе находки сняты с записью в этом документе, на
|
||||
усмотрение автора учесть при следующей правке.
|
||||
@@ -1,241 +0,0 @@
|
||||
# SPEC-REVIEW-122-r1
|
||||
|
||||
- **Issue:** https://github.com/Matysh/houseplan-card/issues/122
|
||||
- **ТЗ под ревью:** `docs/specs/122-isometric-stage2.md` (коммит `76ce755`,
|
||||
ветка `issue/122-isometric-stage2`)
|
||||
- **Роль:** ревьюер ТЗ (не автор), этап `S4-spec-review`
|
||||
- **Трек:** обычный (не `small`) — аналитика владельца оценила сложность и
|
||||
риск 9/10, поверхностей больше одной (рендер/геометрия/UX/perf); лёгкий трек
|
||||
корректно не применён, `docs/specs/<NN>-*.md` присутствует
|
||||
- **Цикл:** r1/4 (полный трек, лимит 4)
|
||||
|
||||
## Скоуп ревью
|
||||
|
||||
Проверялось соответствие ТЗ:
|
||||
|
||||
- `docs/SCOPE.md` — попадание в Core user jobs (J1/J2/J3), узкое
|
||||
owner-approved исключение из фотореалистичного/3D-запрета, инвариант lock;
|
||||
- `PROCESS.md` §2.4/§2.5 (DoR), §7.1 (обязательные разделы ТЗ), §12
|
||||
(запреты), §3 (правила 1, 2, 6, 9);
|
||||
- `AGENTS.md` — классы файлов (только `docs/**` в этом коммите), ветка,
|
||||
трейлеры;
|
||||
- нормативному предшественнику `docs/specs/089-isometric-view-stage1.md` и
|
||||
`docs/adr/089-isometric-stage1-renderer.md` — что Stage 2 не переписывает
|
||||
принятые решения Stage 1 задним числом;
|
||||
- каноническим документам затронутой подсистемы: `docs/ISOMETRIC.md`,
|
||||
`docs/WALL-THICKNESS.md`, `docs/LIGHT.md`, `docs/TOUCH-SUPPORT.md`,
|
||||
`docs/UX-MODES.md`;
|
||||
- `docs/USER-GUIDE.ru.md` — что видимого пользователю текста не появляется и
|
||||
документ не должен меняться;
|
||||
- фактическому состоянию кода (`src/render/opening-symbol.ts`, `src/logic.ts`,
|
||||
`src/labs.ts`, `demo/golden/matrix.mjs`) — на предмет того, что технические
|
||||
утверждения ТЗ не являются непроверенной догадкой;
|
||||
- треду issue #122 целиком, включая аналитику S2, вопросы владельца Q0–Q6 и
|
||||
их принятие, а также связанному issue #124 (performance-блокер).
|
||||
|
||||
## Как проверялось
|
||||
|
||||
1. Прочитан весь тред issue #122: аналитика (ценность 7/10, P2, сложность
|
||||
9/10), пакет вопросов владельцу Q0–Q6 с default-вариантами, принятие всех
|
||||
default'ов владельцем 2026-08-13, хендофф автора «ТЗ готово к ревью».
|
||||
Подтверждено, что ни один вопрос не был техническим, замаскированным под
|
||||
продуктовый: Q0 (приоритет/ценность), Q1 (объём видимого сравнения
|
||||
Stage1/Stage2 — второй флаг или нет), Q2 (что задают референсы: материал
|
||||
или замена содержимого), Q3 (визуальное представление проёмов), Q4 (новый
|
||||
декоративный свет — да/нет), Q5 (floor edge/тени), Q6 (деградация) — все
|
||||
про то, что видит пользователь, и про объём этого issue.
|
||||
2. Сверены обязательные разделы ТЗ (§7.1 `PROCESS.md`) — таблица ниже.
|
||||
3. Прочитан `docs/specs/089-isometric-view-stage1.md` целиком и
|
||||
`docs/ISOMETRIC.md` — подтверждено, что Stage 2 ссылается на реальные
|
||||
решения Stage 1 (`wallBodiesGeometry()`, единая проекция, fingerprint без
|
||||
`_cfgEpoch`/HA state, LRU-кэш на 8 сцен, latched flat fallback,
|
||||
`projectedFrame()`, Labs-грамматика `iso`/`since 1.62.0`/`expires 1.65.0`)
|
||||
и не выдаёт их за собственное изобретение и не переписывает Stage 1 задним
|
||||
числом (ADR-89 не трогается, заводится новый `docs/adr/122-*.md`).
|
||||
4. Прочитан `src/labs.ts` (`since: '1.62.0'`, `expires: '1.65.0'`) и
|
||||
`package.json` (`version: 1.63.0`) — утверждение ТЗ «expiry не расширяется,
|
||||
`1.65.0-beta.1` уже мёртв» технически корректно и согласуется с описанным в
|
||||
`docs/ISOMETRIC.md` числовым сравнением ядра версии.
|
||||
5. Прочитан `src/render/opening-symbol.ts` — подтверждены буквально: gate
|
||||
открывается ровно на 10° (`10 * amount`, `docs/specs/122:213-215` против
|
||||
`opening-symbol.ts:97`), window — двухстворчатый casement
|
||||
(`rotate(${-90*amount})`/`rotate(${90*amount})`, `opening-symbol.ts:83-89`
|
||||
против §6.5 «two-leaf/casement»). Это не догадка автора, а точное описание
|
||||
существующего кода.
|
||||
6. Прочитан `openingAmount()` в `src/logic.ts:316-323` — подтверждено
|
||||
поведение «no contact → door/gate open (1), window closed (0)», которое ТЗ
|
||||
называет «static-open default when no contact exists» (§6.5, Door/Gate).
|
||||
7. Прочитан `docs/LIGHT.md` и `docs/WALL-THICKNESS.md` — подтверждено, что
|
||||
«один регион света на источник», «wallBodiesGeometry как единственный
|
||||
источник тела стены», «exterior silhouette из union центральных линий» —
|
||||
реальные, а не придуманные инварианты, на которые ТЗ ссылается в §6.3, §6.7,
|
||||
§7.
|
||||
8. Прочитан `docs/TOUCH-SUPPORT.md` целиком — формулировки ТЗ §6.8
|
||||
(«pointer/focus/ARIA inert», «View и kiosk остаются полностью
|
||||
поддерживаемыми», «touch-only failure — product defect») дословно
|
||||
отражают контракт, а не изобретены заново.
|
||||
9. Прочитан `docs/USER-GUIDE.ru.md` — поиском подтверждено отсутствие каких-
|
||||
либо упоминаний изометрии/объёмного вида; утверждение ТЗ §9 «документ
|
||||
остаётся молчащим про скрытый Iso» верно на текущий момент.
|
||||
10. Проверено существование golden-сцены `isometric-no-borders-dark` в
|
||||
`demo/golden/matrix.mjs:23` — AC15/§12.3 ссылаются на реальный, а не
|
||||
вымышленный сценарий.
|
||||
11. Прочитан issue #124 (`gh issue view 124`) — подтверждено: воспроизводимый
|
||||
performance-регресс `viewToggleMs.median 192.8мс` vs лимит `131.7мс` на
|
||||
exact-SHA профиле `large-house-isometric-v1`, статус `S1-new` (ещё не
|
||||
проанализирован). ТЗ §10 корректно описывает его как DoR-блокер
|
||||
реализации (не ревью), с двумя явными путями снятия (закрытие #124 или
|
||||
отдельное явное решение владельца) — ни то, ни другое не подменяет и не
|
||||
ослабляет существующий бюджет.
|
||||
12. Проверено `docs/specs/README.md:80` — строка на #122 добавлена в том же
|
||||
коммите, ссылка issue ↔ ТЗ двусторонняя.
|
||||
13. Проверены трейлеры коммита `76ce755`: `Issue: #122`, `User-Visible: no`;
|
||||
`git show --stat` подтверждает изменение только `docs/specs/122-*.md` и
|
||||
`docs/specs/README.md` — класс C, продуктовый код (класс A) не менялся,
|
||||
правило №1 `AGENTS.md`/`PROCESS.md` соблюдено на этапе `spec`.
|
||||
|
||||
## Обязательные разделы (§7.1 PROCESS.md)
|
||||
|
||||
| Раздел | Есть | Комментарий |
|
||||
|---|---|---|
|
||||
| Сценарий (персона/поверхность/момент) | ✅ | §1 |
|
||||
| Что человек увидит до/после (без терминов реализации) | ✅ | §1, одной фразой |
|
||||
| Проблема | ✅ | §2 |
|
||||
| Скоуп / не-скоуп | ✅ | §4 / §5 |
|
||||
| Контракт поведения | ✅ | §6 (8 подразделов) |
|
||||
| UX | ✅ | §9 (явно: нет нового UI/i18n) |
|
||||
| Модель данных и миграция | ✅ | §8 |
|
||||
| i18n | ✅ | §9 (пусто, обосновано) |
|
||||
| AC1…ACn с доказательством | ✅ | §11, 16 штук, каждый с типом |
|
||||
| План автотестов | ✅ | §12 (unit/smoke/golden/performance/backend) |
|
||||
| Риски | ✅ | §14, 12 строк с вероятностью/impact/mitigation |
|
||||
| Откат | ✅ | §15 |
|
||||
| Release-артефакты | ✅ | §13 |
|
||||
|
||||
Все обязательные разделы присутствуют и содержательны. Дополнительно
|
||||
присутствуют продуктовые под-разделы §7.1 (персона/поверхность/момент внутри
|
||||
§1) и явный блок §16 «принятые технические предположения — можно менять без
|
||||
пересмотра продукта», корректно отделённый от решений владельца §3.
|
||||
|
||||
## Находки
|
||||
|
||||
Находок уровня **High** и **Medium** нет.
|
||||
|
||||
### Low-1 — отсутствует буквальная декларация «Touch editor: …»
|
||||
|
||||
**Файл:** `docs/specs/122-isometric-stage2.md` (весь документ)
|
||||
|
||||
`docs/TOUCH-SUPPORT.md` («Documentation rule») требует от новых спецификаций
|
||||
редакторных фич явно указывать одно из: `Touch editor: supported` / `best
|
||||
effort / intentionally degraded` / `not exposed`. Предшественник, Stage 1
|
||||
(`docs/specs/089-isometric-view-stage1.md`, §7 «Touch editor: не exposed»),
|
||||
эту декларацию давал буквально, хотя ситуация идентична — редакторы Stage 2
|
||||
не касается вовсе (§6.1 «Editors and houseplan-space-card stay Flat»). Stage 2
|
||||
такой буквальной строки не содержит, хотя по содержанию §6.1/§6.8 поведение
|
||||
однозначно совпадает с «not exposed» — реальной неоднозначности для
|
||||
разработчика или ревьюера кода нет.
|
||||
|
||||
**Решение ревьюера:** Low, не блокирует. Формально Stage 2 не вводит новую
|
||||
редакторную фичу (правило `TOUCH-SUPPORT.md` адресовано именно им), а
|
||||
содержательно контракт «редакторы не меняются» зафиксирован дважды (§6.1,
|
||||
§9). Оставляю на усмотрение автора — можно добавить строку `Touch editor: not
|
||||
exposed` при следующей правке для единообразия с Stage 1, можно снять этой
|
||||
записью.
|
||||
|
||||
### Low-2 — три AC используют тип доказательства вне буквального перечня §2.5
|
||||
|
||||
**Файл:** `docs/specs/122-isometric-stage2.md:445-457` (AC13, AC15, AC16)
|
||||
|
||||
DoR (`PROCESS.md` §2.5) перечисляет типы доказательства как `unit` / `backend`
|
||||
/ `smoke` / `golden` / «ревью кода». AC13 помечен только `(performance)`,
|
||||
AC15 — `(golden + documentation review)`, AC16 — `(typecheck + unit + build)`.
|
||||
Буквально `performance`, `typecheck`, `build` и `documentation review` в этот
|
||||
перечень не входят. По существу все три доказуемы существующими механизмами:
|
||||
`performance_smoke`/Full Performance и `typecheck`/`build` — реальные
|
||||
release-blocking гейты `PROCESS.md` §8, а «documentation review» — часть
|
||||
обычного код-ревью (§2.7). Тот же паттерн уже был отмечен как Low-1 в
|
||||
`SPEC-REVIEW-123-r1` и оставлен автору без блокировки цикла.
|
||||
|
||||
**Решение ревьюера:** Low, не блокирует. Переформулировка в термины §2.5 (например,
|
||||
«ревью кода со ссылкой на `performance_smoke`/exact-SHA Full Performance» и
|
||||
«ревью кода со ссылкой на зелёный `typecheck`/`test`/`build`») повысила бы
|
||||
буквальную трассируемость, но не меняет, чем на практике будет доказан
|
||||
критерий. Оставляю на усмотрение автора или снимаю этой записью.
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- Соответствие `docs/SCOPE.md`: задача закрывает J1/J2/J3, остаётся внутри
|
||||
узкого owner-approved исключения (2.5D, не фотореализм/не свободная камера/
|
||||
не interior editor), не трогает единственную санкционированную поверхность
|
||||
actuation замков (`docs/SCOPE.md` «The lock invariant»).
|
||||
- Все вопросы владельцу (Q0–Q6) — продуктовые (видимое поведение, объём
|
||||
видимых изменений), ни один технический вопрос не был переадресован
|
||||
владельцу; все ответы получены и внесены в ТЗ без расширения скоупа.
|
||||
- ТЗ явно наследует, а не переопределяет Stage 1: проекция, `wallBodiesGeometry`,
|
||||
fingerprint без `_cfgEpoch`/HA-состояния, LRU-кэш 8 сцен, latched flat
|
||||
fallback, Labs-грамматика `iso`/`1.62.0`/`1.65.0` — все повторно
|
||||
использованы корректно, ни одно принятое решение Stage 1 не переписывается
|
||||
задним числом (историческая ADR-89 остаётся неприкосновенной, заводится
|
||||
новая `docs/adr/122-isometric-stage2-composition.md`).
|
||||
- Технические утверждения о существующем коде (10° gate, two-leaf window,
|
||||
static-open default при отсутствии contact, один регион света на источник,
|
||||
`wallBodiesGeometry` как единственный источник геометрии стен) подтверждены
|
||||
чтением реального кода, а не приняты на слово автора.
|
||||
- Скоуп/не-скоуп (§4/§5) корректно отсекает публичный rollout, второй флаг
|
||||
`iso2`, свободную камеру/#82, новые material-настройки, объёмные редакторы и
|
||||
`houseplan-space-card`, схемные поля высоты/подоконника, WebGL/Three.js — все
|
||||
типичные места, где скоуп мог бы незаметно расшириться, явно закрыты.
|
||||
- Зависимость от #124 обработана корректно: не как продуктовое решение
|
||||
Q0–Q6 (не переоткрывает их), а как отдельный DoR-блокер реализации (§10) с
|
||||
двумя явными путями снятия; ревью ТЗ по правилам `PROCESS.md` §10 разрешено
|
||||
вести, пока #124 открыт.
|
||||
- i18n/UX (§9): подтверждено отсутствием новых строк, README/CHANGELOG/
|
||||
USER-GUIDE не обещают публичную функцию — соответствует `User-Visible: no`
|
||||
контракту для всей будущей реализации.
|
||||
- Модель данных и совместимость (§8): подтверждено отсутствием новых
|
||||
схемных полей, ключей `localStorage` версий и compatibility-записей;
|
||||
откат (§15) не требует миграции и работает немедленно через
|
||||
`?hp-labs=-iso`/`off`, как и в Stage 1.
|
||||
- AC1–AC16 однозначны, у каждого указан тип доказательства (за вычетом Low-2)
|
||||
и план автотестов (§12) даёт конкретный, воспроизводимый по коду маршрут;
|
||||
§12.1 явно требует, чтобы каждый unit-мутант «умел падать» (удаление
|
||||
внешнего edge, сдвиг leaf с jamb, включение HA-состояния в cache key,
|
||||
дублирование floor symbol, лишний light layer).
|
||||
- Не найдено ни одного продуктового утверждения о поведении, которое не
|
||||
следует ни из канонических документов, ни из решений владельца Q0–Q6, ни
|
||||
из чтения существующего кода, и при этом не помечено как предположение;
|
||||
раздел §16 корректно отделяет свободно изменяемые технические детали
|
||||
(именование модулей, точные проценты высоты панелей, число SVG-корней) от
|
||||
зафиксированных владельцем решений.
|
||||
- Реестр `docs/specs/README.md` обновлён тем же коммитом, коммит несёт
|
||||
корректные трейлеры (`Issue: #122`, `User-Visible: no`) и не содержит
|
||||
изменений класса A — правило №1 соблюдено на этапе `spec`.
|
||||
|
||||
## Чего не проверял
|
||||
|
||||
- Не проверял реализуемость конкретной геометрической декомпозиции
|
||||
(`iso-openings.ts`/`iso-scene.ts`, точные проценты высоты панелей, число
|
||||
SVG-defs) — по правилам ТЗ (§16) это свободно изменяемое техническое
|
||||
предположение автора кода, не предмет ревью ТЗ.
|
||||
- Не проверял реальный performance-профиль `large-house-isometric-v1` и не
|
||||
прогонял #124 — на этапе `spec` это не требуется; факт открытости и
|
||||
статус #124 (`S1-new`) проверен через `gh issue view`, не исполнением
|
||||
бенчмарка.
|
||||
- Не запускал никаких автотестов, golden или смоков — на этапе `spec` это не
|
||||
требуется; существование референсных артефактов (`isometric-no-borders-dark`
|
||||
в `demo/golden/matrix.mjs`) проверено чтением файла, не исполнением.
|
||||
- Не проверял содержимое `docs/adr/089-isometric-stage1-renderer.md` построчно
|
||||
на полное покрытие всех констант камеры — доверился ссылке ТЗ и
|
||||
`docs/ISOMETRIC.md` как согласованному пересказу; расхождений при чтении
|
||||
обоих источников не обнаружено.
|
||||
- Не оценивал субъективную эстетическую адекватность референсов владельца
|
||||
(issue #89, комментарий с изображениями) — это продуктовое решение,
|
||||
принятое владельцем через Q2 и не подлежащее пересмотру ревьюером ТЗ.
|
||||
|
||||
## Вердикт
|
||||
|
||||
Зелёный. High: 0, Medium: 0. Две находки Low (отсутствие буквальной
|
||||
декларации `Touch editor: …` и три AC с типом доказательства вне буквального
|
||||
перечня §2.5) — не блокируют, оставлены автору на усмотрение с записью в этом
|
||||
документе; при желании их можно поправить в этом же цикле без возврата в
|
||||
`S3-spec`.
|
||||
@@ -1,226 +0,0 @@
|
||||
# SPEC-REVIEW-131-r1
|
||||
|
||||
- **Issue:** https://github.com/Matysh/houseplan-card/issues/131
|
||||
- **ТЗ под ревью:** `docs/specs/131-readonly-cold-start.md` (коммит `0164e65`)
|
||||
- **Роль:** ревьюер ТЗ (не автор), этап `S4-spec-review`
|
||||
- **Трек:** обычный (не `small`/`trivial`) — владелец явно принял D1/D2
|
||||
(оценка сложности 4/10, но затрагивает async boot, cache/navigation
|
||||
precedence, reload и warm continuity; лёгкий трек корректно не применён)
|
||||
- **Цикл:** r1/4
|
||||
|
||||
## Скоуп ревью
|
||||
|
||||
Проверялось соответствие ТЗ:
|
||||
|
||||
- `docs/SCOPE.md` — попадание в Core user jobs, отсутствие расширения скоупа,
|
||||
сохранение lock-инварианта и правила «никогда не удалять файл по догадке»
|
||||
(задача файлов не касается);
|
||||
- `PROCESS.md` §2.4, §2.5 (DoR), §7.1 (обязательные разделы), §12 (запреты);
|
||||
- `AGENTS.md` — классы файлов (класс A: `src/houseplan-card.ts`; класс B:
|
||||
тесты/demo; класс C: документация/changelog), ветка `issue/131-readonly-cold-start`,
|
||||
трейлеры;
|
||||
- `docs/TOUCH-SUPPORT.md` — View/kiosk как гарантированные touch-поверхности;
|
||||
- `docs/USER-GUIDE.ru.md` — терминология «пространство», «вкладка», «киоск»;
|
||||
- фактическому состоянию кода `src/houseplan-card.ts` — на предмет того, что
|
||||
технические утверждения ТЗ (диагноз причины, поведение hash/`LS_NAV`/warm
|
||||
viewport, no-op клика активной вкладки) не являются непроверенной догадкой,
|
||||
а описывают код, который действительно существует.
|
||||
|
||||
## Как проверялось
|
||||
|
||||
1. Прочитан весь тред issue #131: исходный отчёт с двумя скриншотами,
|
||||
аналитика S2 (таблица диагностики на `dev` SHA `0e69c4a18337`, два
|
||||
пространства `home`/`upstairs`), решение владельца о defaults D1–D3
|
||||
(https://github.com/Matysh/houseplan-card/issues/131#issuecomment-5287280361),
|
||||
комментарий «Взял» и комментарий «ТЗ готово».
|
||||
2. Сверены обязательные разделы ТЗ (§7.1 `PROCESS.md`) построчно — таблица ниже.
|
||||
3. Прочитан `setConfig()` (`src/houseplan-card.ts:2258-2321`): подтверждено, что
|
||||
`default_floor` и cache-приоритет (`hash → LS_NAV → default_floor → model[0]`)
|
||||
применяются здесь только когда есть валидный `LS_CFG`; без кэша `_space`
|
||||
остаётся дефолтным `'f1'` до серверного ответа — совпадает с §3 ТЗ.
|
||||
4. Прочитан `_loadFromServer()` (`src/houseplan-card.ts:3193-3316`): подтверждено
|
||||
дословно то, что описывает §3/§8 ТЗ —
|
||||
- `_adoptStructuralResponses()` (принятие config/layout/`can_write`) вызывается
|
||||
**до** трёх последовательных `await this.hass.connection.subscribeEvents(...)`
|
||||
(`houseplan_config_updated`, `houseplan_trail_updated`, `houseplan_layout_updated`);
|
||||
- выбор `_space` по hash/`LS_NAV`/`_norm`-fallback и `_cacheSnapshot()`
|
||||
находятся **после** этих трёх `await`, внутри того же `try`;
|
||||
- отклонённый `subscribeEvents` уходит во внешний `catch`, который при уже
|
||||
установленном `_serverCfg` вызывает `_scheduleLoadRetry(true)` — то есть
|
||||
ошибка необязательной подписки трактуется как отказ всей загрузки и
|
||||
провоцирует непрерывный retry. Это ровно то, что ТЗ называет в §3 п.4 и
|
||||
в риске §14.2.
|
||||
5. Прочитан `_warmAdoptViewport()` (`src/houseplan-card.ts:2451-2499`) и
|
||||
окружающие флаги `_hashApplied`/`_navApplied` (`:1521-1522`, `:2296-2299`,
|
||||
`:2466`, `:3266-3277`): подтверждено, что explicit `#space=` hash уже сегодня
|
||||
выигрывает у warm-viewport (`if (this._hashApplied || …) { this._warmVp = null; return; }`),
|
||||
а принятый warm viewport не сбрасывается менее точным `LS_NAV`/`default_floor`
|
||||
(`_navApplied = true` защищает ветку в `_loadFromServer`). Формулировка §7.2
|
||||
ТЗ об этом взаимодействии технически точна, а не придумана заново.
|
||||
6. Прочитан `_pickSpace()` (`src/houseplan-card.ts:1087-1097`): `if (id === this._space) return;`
|
||||
— клик по уже активной вкладке уже сегодня no-op. AC4 ТЗ («после исправления
|
||||
клик активной вкладки — no-op») не вводит новое поведение клика, а лишь
|
||||
требует, чтобы `_space` корректно совпадал с реальным выбором к моменту клика.
|
||||
7. Прочитана генерация id пространства (`src/houseplan-card.ts:12067`,
|
||||
`spaceId = 's' + Date.now().toString(36)`): реальные id никогда не выглядят
|
||||
как `f1`/`f2`, то есть легаси-дефолт `_space = 'f1'` (`:611`) — синтетический
|
||||
сентинел, а не потенциально валидный id; диагностическая таблица ТЗ с
|
||||
`home`/`upstairs` репрезентативна, коллизии не подразумевается.
|
||||
8. Проверено `docs/CONFIG-COMPATIBILITY.md` — ни `LS_CFG`, ни server-config
|
||||
schema там не упомянуты в связи с этим изменением; заявление ТЗ §9 «миграция
|
||||
не нужна» не противоречит канону.
|
||||
9. Проверено существующее browser-smoke покрытие: `demo/smoke_warm_remount.mjs`,
|
||||
`demo/smoke_kiosk.mjs`, `demo/smoke_warm_owners.mjs`, `demo/smoke_warm_dialogs.mjs`
|
||||
реально существуют — предположение §17 п.5 («smoke может расширить
|
||||
существующий WS/warm lifecycle сценарий») опирается на реальную
|
||||
инфраструктуру, а не на вымышленный файл.
|
||||
10. Проверена запись в `docs/specs/README.md` — строка на #131 добавлена в том
|
||||
же коммите `0164e65`, ссылка issue ↔ ТЗ двусторонняя.
|
||||
11. Сверена терминология с `docs/USER-GUIDE.ru.md` (§7 «Пространства», §17
|
||||
«Киоск-режим», обычный просмотр «не отдельная вкладка») — ТЗ использует
|
||||
«пространство», «вкладка», «киоск» ровно в этом значении, ничего не
|
||||
изобретает.
|
||||
12. Сверено с `docs/TOUCH-SUPPORT.md` — «View is fully supported/must be
|
||||
convenient and reliable» и «kiosk is the primary supported environment»:
|
||||
формулировка ТЗ §10 «View на touch блокирующий… в киоске требование ещё
|
||||
строже» дословно соответствует канону, а не собственная эскалация автора.
|
||||
|
||||
## Обязательные разделы (§7.1 PROCESS.md)
|
||||
|
||||
| Раздел | Есть | Комментарий |
|
||||
|---|---|---|
|
||||
| Сценарий (персона/поверхность/момент) | ✅ | §1 |
|
||||
| Что человек увидит до/после (без терминов реализации) | ✅ | §2, одной фразой каждое состояние |
|
||||
| Проблема | ✅ | §3, с подтверждённой диагностикой и таблицей на конкретном SHA |
|
||||
| Скоуп / не-скоуп | ✅ | §5 / §6 |
|
||||
| Контракт поведения | ✅ | §7 (инвариант, приоритет, mandatory/optional, деградация, reload/cache) |
|
||||
| UX / i18n / accessibility / touch | ✅ | §10 |
|
||||
| Модель данных и миграция | ✅ | §9 |
|
||||
| AC1…ACn с доказательством | ✅ | §11, 12 штук, каждый с типом (`unit`/`smoke`/«ревью кода»/`build`) |
|
||||
| План автотестов | ✅ | §12 |
|
||||
| Риски | ✅ | §14, 5 пунктов, каждый со ссылкой на закрывающий AC |
|
||||
| Откат | ✅ | §16 |
|
||||
| Release-артефакты | ✅ | §15 |
|
||||
|
||||
Все обязательные разделы присутствуют и содержательны. Дополнительно есть
|
||||
раздел §8 «Архитектурный контракт реализации» и §17 «Принятые технические
|
||||
предположения» — оба корректно отделены от продуктового контракта.
|
||||
|
||||
## Находки
|
||||
|
||||
Находок уровня **High** и **Medium** нет.
|
||||
|
||||
### Low-1 — плотная формулировка §7.2 про hash/warm-viewport взаимодействие
|
||||
|
||||
**Файл:** `docs/specs/131-readonly-cold-start.md:146-160`
|
||||
|
||||
Абзац описывает две ветки (без принятого warm viewport / с ним) одним плотным
|
||||
текстом: «применяется существующий порядок… Explicit hash по-прежнему
|
||||
выигрывает. Уже принятый валидный same-route warm viewport остаётся
|
||||
существующим continuity-исключением… и не сбрасывается менее точным
|
||||
saved/default значением». При беглом чтении можно ошибочно понять, что
|
||||
`explicit hash` всегда переопределяет уже принятый warm viewport — в
|
||||
реальности (проверено чтением `_warmAdoptViewport`, `src/houseplan-card.ts:2466`)
|
||||
hash блокирует **принятие** нового warm viewport, но не выбивает уже
|
||||
принятый в текущем цикле рендера; вторая фраза говорит о независимой ветке
|
||||
«when warm viewport already accepted», а не о конкуренции с первой веткой в
|
||||
рамках одного и того же кадра. Технически формулировка точна (я сверил её с
|
||||
кодом и противоречия не нашёл), но растянута до состояния, что имплементатор
|
||||
может перечитать её как конфликт правил.
|
||||
|
||||
**Почему не блокирует:** AC1/AC5/AC6 explicitly не расширяют матрицу на
|
||||
конкуренцию «hash vs уже принятый warm viewport» — этот случай прямо в
|
||||
не-скоупе (§6: «изменение выбора пространства… deep link… не входит»), то
|
||||
есть задача обязана лишь не сломать существующее поведение, а не
|
||||
переописывать его заново. Формулировка описывает уже существующий, а не
|
||||
новый контракт, и не создаёт риска неверной реализации, потому что §8 п.2
|
||||
и риск §14.1 уже требуют «одного resolver» и защищают его тем же AC1/AC5/AC6.
|
||||
|
||||
**Решение ревьюера:** Low, не блокирует. На усмотрение автора — можно
|
||||
перефразировать двумя явными пунктами («без warm viewport: …», «warm viewport
|
||||
уже принят: …») при следующей правке или оставить как есть.
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- Соответствие `docs/SCOPE.md`: задача закрывает J1 («план должен быть читаемым
|
||||
и полным сразу при открытии») и J6-грань «reload/reconnect/техническое
|
||||
перемонтирование не должны менять состав видимого плана» — регрессия внутри
|
||||
уже закрытых Core user jobs, не новая функциональность и не расширение
|
||||
скоупа; ни один пункт «Out of scope» не задет.
|
||||
- Гарантированный View/touch/kiosk-контракт (`docs/TOUCH-SUPPORT.md`) учтён
|
||||
верно и процитирован дословно, не переизобретён.
|
||||
- Владелец лично принял defaults D1–D3 и приоритет P1 (комментарий
|
||||
2026-08-14) — открытых продуктовых вопросов в финальной редакции ТЗ нет, и
|
||||
это корректно: вопросы (триггер cold start vs прав, поведение при
|
||||
нескольких пространствах, переживание reload) были заданы и закрыты на
|
||||
этапе аналитики диагностическим прогоном, а не додуманы автором ТЗ.
|
||||
Раздел §17 «Принятые технические предположения» отделяет свободно
|
||||
изменяемые технические решения (имя resolver'а, форма orchestration
|
||||
подписок, механизм retry, файл browser smoke) от продуктовых решений D1–D3,
|
||||
которые пересмотру не подлежат — ни одна догадка не выдана за факт без
|
||||
пометки.
|
||||
- Технический диагноз причины (порядок `_adoptStructuralResponses` → три
|
||||
`await subscribeEvents` → выбор пространства/`_cacheSnapshot`, отказ
|
||||
подписки уходит во внешний `catch` и триггерит `_scheduleLoadRetry(true)`)
|
||||
подтверждён построчным чтением `_loadFromServer()` — не голословное
|
||||
утверждение автора, а точное описание существующего кода.
|
||||
- Поведение hash/`LS_NAV`/warm-viewport и no-op клика активной вкладки
|
||||
(AC4) подтверждено чтением `setConfig`, `_warmAdoptViewport`, `_pickSpace`
|
||||
— расхождений с ТЗ не найдено (см. Low-1 только по ясности формулировки,
|
||||
не по фактической корректности).
|
||||
- AC1–AC12 однозначны, у каждого указан тип доказательства из допустимого по
|
||||
DoR перечня (`unit`/`smoke`/«ревью кода»/`build`); план автотестов (§12)
|
||||
даёт конкретный маршрут, включая явное требование покрыть матрицу
|
||||
valid/stale hash × `LS_NAV` × `default_floor` × 0/1/N пространств (AC1) и
|
||||
проверять «умеет падать» через фиксированный HA snapshot до/после no-op
|
||||
клика (AC4).
|
||||
- Не-скоуп (§6) корректно отсекает смежные соблазны: не менять backend
|
||||
permissions/websocket API, не добавлять новый toast/recovery overlay, не
|
||||
трогать порядок вкладок/deep-link/формат cache, не менять
|
||||
`houseplan-space-card`, не переписывать весь boot lifecycle — типичные
|
||||
места, где скоуп мог бы незаметно расшириться из-за близости к async boot.
|
||||
- Раздел §13 (Производительность и security) обоснованно закрывает вопрос
|
||||
«не ослабляет ли исправление read-only-границу»: подписка на запрещённые
|
||||
события не эмулируется, write API не вызывается — прямо отражает
|
||||
требование AC10.
|
||||
- Release-артефакты (§15) перечисляют реальные файлы:
|
||||
`docs/CHANGELOG.md`/`docs/CHANGELOG.ru.md` (существуют), `docs/ARCHITECTURE.md`
|
||||
(существует), `test/*.test.mjs` и `demo/smoke_*.mjs` (существующая
|
||||
инфраструктура, см. «Как проверялось» п.9), три bundle snapshot
|
||||
(существующая практика синхронизации по `AGENTS.md`).
|
||||
- Реестр `docs/specs/README.md` обновлён тем же коммитом, ссылка issue ↔ ТЗ
|
||||
двусторонняя (`PROCESS.md` §7.1).
|
||||
- Трейлеры коммита `0164e65` (`Issue: #131`, `User-Visible: no`) корректны:
|
||||
изменение — только документация ТЗ, продуктовый код не тронут, что явно
|
||||
подтверждено и в тексте самого ТЗ, и в комментарии «ТЗ готово».
|
||||
|
||||
## Чего не проверял
|
||||
|
||||
- Не проверял, что предложенная в §8 архитектурная декомпозиция (единый pure
|
||||
resolver cold precedence) реализуема без побочных эффектов на смежные поля
|
||||
`_hashApplied`/`_navApplied`/`_warmVpArmed` — по правилам ТЗ (§17 п.1–2) это
|
||||
свободно изменяемое техническое предположение автора кода, предмет
|
||||
код-ревью, а не ревью ТЗ.
|
||||
- Не запускал никаких автотестов и не собирал бандл — на этапе `spec` это не
|
||||
требуется; существование упомянутых тестовых файлов и smoke-инфраструктуры
|
||||
проверено чтением файловой системы, не исполнением.
|
||||
- Не проверял golden/скриншоты — ТЗ §12 явно и обоснованно заявляет их
|
||||
ненужность (новый визуал совпадает с уже принятым состоянием после клика);
|
||||
это будущий предмет пре-релизного гейта, не ревью ТЗ.
|
||||
- Не проверял поведение `houseplan-space-card` (статическая карточка) —
|
||||
явно вынесено в не-скоуп §6, и это верно: она получает пространство
|
||||
отдельным обязательным параметром, а не через выбор, который чинит эта
|
||||
задача.
|
||||
- Не проверял, действительно ли трёх-подписочный orchestration в текущем
|
||||
коде уже идемпотентен при повторном вызове `_loadFromServer` после отказа
|
||||
(авторитет `_unsubCfg`/`_unsubTrail`/`_unsubLayout`, AC7/AC11) —
|
||||
прочитал структуру `if (!this._unsubX) { this._unsubX = await … }`
|
||||
(`src/houseplan-card.ts:3232-3263`) и она выглядит согласованной с
|
||||
требованием, но полное покрытие гонок (двойной параллельный вызов
|
||||
`_loadFromServer`) — предмет код-ревью на реализованном коде, не ревью ТЗ.
|
||||
|
||||
## Вердикт
|
||||
|
||||
Зелёный. High: 0, Medium: 0. Одна находка Low (плотность формулировки §7.2
|
||||
про hash/warm-viewport) — не блокирует, технически формулировка проверена и
|
||||
корректна, оставлена автору на усмотрение с записью в этом документе.
|
||||
@@ -1,576 +0,0 @@
|
||||
# Issue #107 — переключение виртуального источника света «Всегда»
|
||||
|
||||
- **Issue:** https://github.com/Matysh/houseplan-card/issues/107
|
||||
- **Редакция:** первая редакция для независимого ревью; статус задачи определяется
|
||||
только метками issue
|
||||
- **Тип / приоритет:** feature + polish / P2
|
||||
- **Оценка:** пользовательская ценность 6/10; ценность для разработки 3/10;
|
||||
сложность и риск 7/10
|
||||
- **Область:** View-action, canonical light resolver, target preview, backend
|
||||
operational state, multi-card live-sync, i18n, compatibility и тесты
|
||||
- **Связано:** #65, #84, #88, #94, #97, `docs/SCOPE.md`,
|
||||
`docs/TOUCH-SUPPORT.md`, `docs/LIGHT.md`,
|
||||
`docs/CONFIG-COMPATIBILITY.md`
|
||||
|
||||
## 1. Сценарий и продуктовый контекст
|
||||
|
||||
**Персона:** home admin либо домочадец, который пользуется обычным View на
|
||||
настенной панели, телефоне или desktop dashboard. Право редактировать план для
|
||||
этого действия не требуется.
|
||||
|
||||
**Поверхность и момент:** на плане есть virtual marker, у которого роль источника
|
||||
света явно установлена в «Всегда», а действие по нажатию — «Переключить
|
||||
состояние». Пользователь нажимает marker, чтобы погасить или снова включить
|
||||
изображённый виртуальный светильник.
|
||||
|
||||
Задача обслуживает:
|
||||
|
||||
- **J1:** Glow, room fill и room light statistics показывают одно текущее
|
||||
пространственное состояние;
|
||||
- **J3:** очевидное безопасное действие выполняется прямо с плана;
|
||||
- гарантированный touch/View/kiosk-контракт: tap работает так же, как click, и
|
||||
результат не расходится между настенной панелью, телефоном и второй карточкой.
|
||||
|
||||
Это утверждённое владельцем узкое исключение к замороженной функциональности
|
||||
virtual devices. Оно не создаёт общего state engine для виртуальных маркеров и
|
||||
не меняет их роль placeholder в остальных комбинациях.
|
||||
|
||||
## 2. Что человек увидит до и после
|
||||
|
||||
**До:** диалог честно сообщает, что переключаемого состояния нет, а нажатие в
|
||||
View является тихим no-op. Passive `Always` source без контроллера всегда
|
||||
считается включённым по контракту #84.
|
||||
|
||||
**После:** в точной утверждённой комбинации preview показывает сам виртуальный
|
||||
источник, его текущее состояние и следующий эффект. Нажатие выключает его, а
|
||||
повторное включает. Одновременно меняются его Glow и все room-light consumers.
|
||||
Результат сразу видят другие карточки и он сохраняется после reload страницы и
|
||||
restart Home Assistant.
|
||||
|
||||
## 3. Подтверждённая причина текущего поведения
|
||||
|
||||
1. `resolvedLightSources()` создаёт для passive `Always` источник с identity
|
||||
`marker:<id>`, пустыми `stateEids`/`serviceEids` и default `on`.
|
||||
2. Если сохранены incoming controller links, #84 заменяет default на OR их HA
|
||||
driver entities. Ручного состояния у источника нет.
|
||||
3. `resolveToggleIntent()` ищет только HA service target. Virtual marker без
|
||||
actionable entity получает `no-actionable-entity`.
|
||||
4. `_clickDevice()` выполняет только HA service command и при его отсутствии
|
||||
намеренно ничего не делает.
|
||||
5. Hint в диалоге форматирует тот же resolved intent и поэтому показывает
|
||||
`marker.toggle_none_no_actionable_entity`.
|
||||
|
||||
Локальная ветка только в renderer не подходит: #84 закрепил
|
||||
`resolvedLightSources()` как authority для Glow, room fill, room state,
|
||||
`N из M`, room card и preview. Локальный browser state также нарушил бы принятое
|
||||
решение о reload/restart и multi-card synchronization.
|
||||
|
||||
## 4. Решения владельца
|
||||
|
||||
Владелец принял defaults Q1–Q3 14.08.2026. Каноническая запись:
|
||||
https://github.com/Matysh/houseplan-card/issues/107#issuecomment-5287719167
|
||||
|
||||
1. Состояние общее для всех карточек, live-синхронизируется, переживает reload
|
||||
страницы и restart Home Assistant. Источник без сохранённой записи начинает
|
||||
в `on`, сохраняя совместимость с #84. State является operational data и не
|
||||
входит в конфигурацию плана или export.
|
||||
2. Переключается canonical state источника, а не один SVG Glow. Вместе меняются
|
||||
Glow, room light fill, room statistics/card и preview. Геометрия, цвет,
|
||||
яркость и радиус не меняются.
|
||||
3. Пока действует точная тройка `binding=virtual` + `is_light=true` + effective
|
||||
`tap_action=toggle`, ручное состояние авторитетно; incoming controller links
|
||||
сохраняются, но не управляют этим источником. Когда тройка перестаёт
|
||||
действовать, ручная запись удаляется и немедленно возвращается обычная
|
||||
семантика #84. Повторное включение тройки начинает с `on`.
|
||||
|
||||
## 5. Точное условие исключения
|
||||
|
||||
Manual virtual-light action существует только для активного marker, у которого
|
||||
одновременно выполняется:
|
||||
|
||||
1. `marker.binding === 'virtual'`;
|
||||
2. `marker.is_light === true`;
|
||||
3. effective tap action равен `toggle`;
|
||||
4. marker не является удалённым tombstone (`removed !== true`).
|
||||
|
||||
Для virtual marker нет HA domain, который мог бы неявно спроецировать default
|
||||
`toggle`, поэтому сохранённый action token обязан быть `toggle`. Backend всё
|
||||
равно проверяет persisted config, а не доверяет утверждению frontend.
|
||||
|
||||
`hidden: true` временно убирает источник из визуальных consumers, но не стирает
|
||||
его ручное состояние: возврат видимости восстанавливает прежний `on/off`.
|
||||
|
||||
В точной тройке manual target имеет приоритет действия. Tap переключает только
|
||||
состояние собственного virtual source и не вызывает HA service даже при наличии
|
||||
сохранённых outgoing `controls`. Сами `controls` остаются lossless и продолжают
|
||||
участвовать в light graph по правилам #65/#84. После выхода из тройки обычный
|
||||
resolver #94 снова получает их без изменения.
|
||||
|
||||
Все прочие сочетания — virtual Auto/Never, non-virtual Always, другое действие,
|
||||
удалённый marker — сохраняют текущее поведение без нового state target.
|
||||
|
||||
## 6. Нормативная модель состояния
|
||||
|
||||
### 6.1. Значение по умолчанию
|
||||
|
||||
Отсутствующая запись означает `on`. Поэтому существующие passive `Always`
|
||||
источники не меняют вид после обновления, а первый tap переводит источник в
|
||||
`off`.
|
||||
|
||||
Хранилище может сохранять только множество выключенных marker IDs: отсутствие
|
||||
ID является canonical `on`. Это техническая форма, а не новый пользовательский
|
||||
режим. В ответах frontend получает revisioned snapshot, достаточный для
|
||||
однозначного восстановления `on/off`.
|
||||
|
||||
### 6.2. Один canonical consumer contract
|
||||
|
||||
Для eligible marker manual state подменяет только вычисление `source.on`.
|
||||
Identity, room ownership, position, `castsGlow`, geometry, manual/live colour,
|
||||
brightness и radius остаются теми же.
|
||||
|
||||
Один и тот же resolved source обязан дать одинаковый результат для:
|
||||
|
||||
- spatial Glow full card и kiosk;
|
||||
- light room fill;
|
||||
- room light state и `resolvedLightStats` / `N из M`;
|
||||
- room card и device presentation;
|
||||
- device-editor preview;
|
||||
- статического `houseplan-space-card`.
|
||||
|
||||
Нельзя добавлять manual-state ветку отдельно в SVG renderer, room card,
|
||||
preview или static card. Runtime snapshot является входом canonical light
|
||||
resolver, а его revision входит в cache invalidation. Один tap не должен ждать
|
||||
следующего HA state tick.
|
||||
|
||||
### 6.3. Controller semantics
|
||||
|
||||
- Для eligible source manual `on/off` выигрывает у incoming controllers.
|
||||
- Incoming `marker:<id>` links не удаляются, не становятся service targets и не
|
||||
меняют HA entities.
|
||||
- Контроллер продолжает выполнять собственное действие #94, но его HA state не
|
||||
меняет manual source, пока тройка активна.
|
||||
- После выхода target из тройки manual record очищается; incoming links снова
|
||||
определяют passive source по OR-контракту #84 без Save со стороны controller.
|
||||
- Остальные источники того же room продолжают давать независимые votes. Поэтому
|
||||
выключенный manual source не обязан погасить комнату, если в ней включён другой
|
||||
источник.
|
||||
|
||||
## 7. Backend storage и WebSocket contract
|
||||
|
||||
### 7.1. Отдельные operational data
|
||||
|
||||
State хранится в отдельном versioned Home Assistant `Store`, например
|
||||
`houseplan.virtual_lights`, а не в `Marker`, layout, Lovelace config,
|
||||
localStorage-authority или HA entity registry.
|
||||
|
||||
Минимальная логическая структура:
|
||||
|
||||
- monotonic runtime revision;
|
||||
- последняя согласованная config revision;
|
||||
- bounded set/map выключенных eligible marker IDs.
|
||||
|
||||
Точные Python class/key names можно изменить. Обязательны отсутствие записи =
|
||||
`on`, лимит не выше числа markers, отказ от произвольных строк вне live config и
|
||||
отдельность от export/import payload.
|
||||
|
||||
### 7.2. Initial snapshot
|
||||
|
||||
`houseplan/config/get` возвращает дополнительное optional поле с revisioned
|
||||
virtual-light snapshot. Оно читается вместе с той же config revision, по которой
|
||||
backend проверил eligibility.
|
||||
|
||||
Это поле является обязательной частью корректного нового frontend frame:
|
||||
сохранённый `off` не может сначала отрисоваться как `on` и исправиться только
|
||||
после необязательной подписки. Старый frontend игнорирует добавочное поле.
|
||||
|
||||
Поле optional на wire только для rolling/downgrade compatibility. Новый frontend
|
||||
при его отсутствии применяет legacy/default `on`; попытка toggle на старом
|
||||
backend завершается контролируемой ошибкой, без optimistic ложного состояния.
|
||||
|
||||
### 7.3. Atomic toggle command
|
||||
|
||||
Добавляется один authenticated WS command вида
|
||||
`houseplan/virtual_light/toggle`, принимающий только `marker_id`.
|
||||
|
||||
Backend под общим для config/state load-modify-save lock:
|
||||
|
||||
1. загружает текущую config revision и marker;
|
||||
2. повторно проверяет точное условие §5;
|
||||
3. читает текущее значение (`on`, если записи нет);
|
||||
4. атомарно инвертирует его и сохраняет state revision;
|
||||
5. возвращает `{marker_id, on, rev}`;
|
||||
6. после успешного save публикует `houseplan_virtual_light_updated` с тем же
|
||||
payload.
|
||||
|
||||
Клиент не присылает желаемый boolean и не выполняет read→set: две одновременные
|
||||
команды сериализуются как два toggle, а не теряют одну запись. Event не
|
||||
публикуется до durable save. Invalid/missing/ineligible marker получает
|
||||
стабильную ошибку `not_toggleable`; store и event при этом не меняются.
|
||||
|
||||
### 7.4. Права
|
||||
|
||||
Команда доступна любому аутентифицированному HA-пользователю, которому доступна
|
||||
карточка. `may_write` и опция admin-only защищают редактирование плана, но не
|
||||
View-action; иначе утверждённая household/kiosk persona увидела бы рабочий hint
|
||||
и получила no-op.
|
||||
|
||||
Это разрешение уже, чем HA service toggle: request содержит только marker
|
||||
ID, backend разрешает ровно утверждённую тройку и не вызывает HA service. Guest
|
||||
без authenticated HA session доступа не получает.
|
||||
|
||||
### 7.5. Config coherence и cleanup
|
||||
|
||||
Каждый backend writer конфигурации — обычный Save, import apply, optimize/undo,
|
||||
setup migration и другие пути замены config — согласует operational store с
|
||||
новой config revision до публикации завершённого config update:
|
||||
|
||||
- сохраняет `off` только для marker IDs, которые всё ещё eligible;
|
||||
- удаляет записи deleted/rebound/Auto/Never/non-toggle markers;
|
||||
- сохраняет state при rename, move, room/space reassignment, hidden/unhidden и
|
||||
несвязанных edits;
|
||||
- записывает согласованную config revision.
|
||||
|
||||
Из-за двух отдельных HA Store файлов crash между saves не может быть общей
|
||||
транзакцией. Fail-safe правило: если operational store видит неизвестный разрыв
|
||||
config revision (включая downgrade, старый writer или прерванный save), он
|
||||
сбрасывает manual-off overrides к безопасному compatibility default `on`, а не
|
||||
рискует воскресить старый `off` после повторного включения тройки. Следующий
|
||||
snapshot сохраняет согласованную revision.
|
||||
|
||||
Все config writers должны использовать один backend helper; список разрозненных
|
||||
ручных `state_store.async_save()` не допускается.
|
||||
|
||||
## 8. Frontend action и synchronization
|
||||
|
||||
### 8.1. Единый resolved intent
|
||||
|
||||
Authority #94 расширяется typed operational target, а не обходится ранним
|
||||
`if` непосредственно в `_clickDevice()` или диалоге.
|
||||
|
||||
Для eligible marker resolver возвращает:
|
||||
|
||||
- stable target identity `marker:<id>` и читаемое имя marker;
|
||||
- current `on/off` из принятого runtime snapshot;
|
||||
- next effect `turn-off` / `turn-on`;
|
||||
- executable operation `virtual-light-toggle`, отличную по типу от HA service
|
||||
command.
|
||||
|
||||
Formatter preview/confirmation получает эту же модель. Virtual target никогда
|
||||
не выдаётся за `entity_id` и никогда не попадает в `callService`.
|
||||
|
||||
### 8.2. Tap, click и confirmation
|
||||
|
||||
- Обычный tap/click отправляет atomic WS toggle и принимает только response/event
|
||||
с revision не старее локального.
|
||||
- Optimistic визуальный flip запрещён: transport/backend failure не должен
|
||||
показывать несохранённое состояние.
|
||||
- `tap_confirm: true` сохраняет существующий confirmation dialog. Перед
|
||||
выполнением eligibility/target re-resolve повторяется; текущий direction
|
||||
решает серверный atomic toggle.
|
||||
- Если marker перестал быть eligible до подтверждения или ответа, action не
|
||||
retargets на controls/HA entity. Пользователь получает существующий смысл
|
||||
«цель изменилась» либо локализованную контролируемую ошибку.
|
||||
- Ошибка WS использует локализованный toast и оставляет последний подтверждённый
|
||||
state. Page error и unhandled rejection недопустимы.
|
||||
|
||||
### 8.3. Live-sync и несколько карточек
|
||||
|
||||
Full card подписывается на `houseplan_virtual_light_updated` рядом с текущими
|
||||
House Plan events. `houseplan-space-card` получает тот же snapshot/event через
|
||||
module-level shared cache, чтобы N static cards не создавали N fetch/subscription.
|
||||
|
||||
Monotonic revision защищает от перестановки response и event. Применение более
|
||||
новой revision:
|
||||
|
||||
1. заменяет immutable runtime snapshot;
|
||||
2. инвалидирует light/presentation cache;
|
||||
3. обновляет local server-snapshot cache;
|
||||
4. просит render без structural config/device rebuild и без HA state mutation.
|
||||
|
||||
Missed event после disconnect исправляется следующим `config/get`. Подписка
|
||||
best-effort не заменяет initial snapshot и не является условием первого полного
|
||||
кадра.
|
||||
|
||||
### 8.4. Local cache
|
||||
|
||||
`LS_CFG` может хранить последний revisioned operational snapshot рядом с
|
||||
config/layout для мгновенного stale-while-revalidate кадра. Он не становится
|
||||
authority: server response всегда выигрывает по revision/config coherence.
|
||||
|
||||
Старый cache без поля означает default `on`. Corrupt/oversized/wrong-shape state
|
||||
игнорируется целиком, а не частично внедряется в resolver. Runtime event обновляет
|
||||
cache, чтобы reload не давал краткого обратного Glow flash.
|
||||
|
||||
## 9. UX редактора и i18n
|
||||
|
||||
В точной тройке прежний текст
|
||||
`marker.toggle_none_no_actionable_entity` не показывается. Вместо него preview
|
||||
содержит локализованные строки:
|
||||
|
||||
- target: виртуальный источник с именем marker, без вымышленного entity ID;
|
||||
- current state: «включён/выключен»;
|
||||
- next effect: «включить/выключить».
|
||||
|
||||
Новый несохранённый draft и draft, который только что вошёл в тройку, показывают
|
||||
initial `on → off`. Редактируемый сохранённый marker показывает server state.
|
||||
Cancel не меняет operational state; Save меняет eligibility и запускает cleanup
|
||||
только после принятого config write.
|
||||
|
||||
Нужны полноценные RU/EN keys для virtual target/state и ошибки toggle. Нельзя
|
||||
склеивать предложения или показывать backend error text как готовый перевод.
|
||||
Существующие HA entity/group/secure/missing hints #94 не меняются.
|
||||
|
||||
## 10. Accessibility и touch
|
||||
|
||||
Новых controls и focus order нет. Existing device marker, selector,
|
||||
confirmation и toast сохраняют текущие accessible surfaces.
|
||||
|
||||
Touch View и kiosk — блокирующие поверхности:
|
||||
|
||||
- один tap выполняет ровно один toggle;
|
||||
- pan, pinch, long-press и suppressed synthetic click не выполняют action;
|
||||
- confirmation остаётся доступным и не теряет focus contract;
|
||||
- live event не перехватывает focus и не создаёт screen-reader spam;
|
||||
- static card остаётся неинтерактивной, но показывает тот же state.
|
||||
|
||||
Touch editor остаётся best effort: новый control не добавляется, а существующий
|
||||
desktop-first preview должен оставаться читаемым без горизонтального overflow.
|
||||
|
||||
## 11. Lifecycle и edge cases
|
||||
|
||||
1. Existing eligible marker без state entry: `on`; первый tap → `off`.
|
||||
2. Повторный tap: `off` → `on`, off-entry может быть удалена.
|
||||
3. Reload/new browser/restart HA: последнее durable значение восстановлено.
|
||||
4. Две full cards и static card: одно событие приводит все к одной revision.
|
||||
5. Два одновременных tap: две server revisions, итог равен исходному состоянию.
|
||||
6. Response пришёл после более нового event: stale response игнорируется.
|
||||
7. Incoming controller включён, manual source выключен: source остаётся `off`.
|
||||
8. Manual source выключен, другой room source включён: room остаётся `on`, stats
|
||||
считают голоса раздельно.
|
||||
9. Outgoing controls есть: manual tap не вызывает их HA services; config
|
||||
сохраняется lossless.
|
||||
10. `Always → Auto/Never`: off-entry очищена, #84/#65 действует сразу.
|
||||
11. `toggle → info/run/more-info`: запись очищена, новое действие штатное.
|
||||
12. Virtual → HA binding/rebind: запись очищена и не приклеивается к новой цели.
|
||||
13. Delete/tombstone: запись очищена; restore/recreate начинает `on`.
|
||||
14. Rename/move/room/space/hidden: state сохраняется по stable marker ID.
|
||||
15. Full import сохраняет state только для совпавших всё ещё eligible IDs;
|
||||
импорт не приносит state из backup.
|
||||
16. Config revision gap/downgrade writer: conservative reset к `on`, без stale
|
||||
resurrection.
|
||||
17. Backend unavailable/command failure: confirmed state остаётся, generic no-op
|
||||
не маскирует ошибку.
|
||||
18. Old frontend + new backend: extra snapshot/event игнорируются, config не
|
||||
повреждается; marker ведёт себя по старому.
|
||||
19. New frontend + old backend: default `on`, команда не создаёт local false
|
||||
state и заканчивается контролируемой ошибкой.
|
||||
20. Auto/Never/non-virtual/missing marker request, подделанный вручную: backend
|
||||
`not_toggleable`, без save/event/service.
|
||||
|
||||
## 12. Модель данных, compatibility и import/export
|
||||
|
||||
`Marker`, `ServerConfig`, layout и Lovelace card config не получают новых полей.
|
||||
Backend `CONFIG_SCHEMA`, import schema и config revision не меняются. Миграция
|
||||
плана и materialization отсутствуют.
|
||||
|
||||
Создаётся отдельный optional operational Store version 1. Его отсутствие при
|
||||
upgrade/downgrade означает пустой off-set/default `on`. Старые integration и
|
||||
frontend его не читают и не уничтожают plan config. Config revision gap после
|
||||
старого writer обрабатывается fail-safe правилом §7.5.
|
||||
|
||||
Operational state намеренно не входит в:
|
||||
|
||||
- full/space export и import preview/apply;
|
||||
- marker JSON и compatibility passthrough;
|
||||
- Lovelace config/local editor draft;
|
||||
- HA entity/device registry.
|
||||
|
||||
Диагностика может показывать version/revision/count, но не обязана экспортировать
|
||||
полную map marker IDs. `docs/CONFIG-COMPATIBILITY.md` фиксирует wire field,
|
||||
отдельный Store, old/new matrix и reset-on-unknown-revision.
|
||||
|
||||
## 13. Не входит в задачу
|
||||
|
||||
- общее переключаемое состояние для virtual devices;
|
||||
- действие для virtual Auto/Never либо для `info`, `run`, `more-info`;
|
||||
- создание `input_boolean`, synthetic HA light/entity или helper;
|
||||
- управление цветом, brightness, radius, geometry или animation через tap;
|
||||
- запись state в marker config, layout или export backup;
|
||||
- изменение OR/links semantics #84 вне manual-authority исключения;
|
||||
- переключение HA entities из exact manual action;
|
||||
- история состояний, расписания, automations или restoration UI;
|
||||
- новый editor control «начальное состояние»;
|
||||
- polling, remote API либо отдельная general-purpose runtime-state platform.
|
||||
|
||||
## 14. Критерии приёмки
|
||||
|
||||
- **AC1 (`unit`):** eligibility matrix разрешает operational target только для
|
||||
active `virtual + is_light:true + explicit/effective toggle`; Auto, Never,
|
||||
non-virtual, other action и removed сохраняют прежний resolver #94.
|
||||
- **AC2 (`backend`):** новый eligible marker без записи возвращает `on`; atomic
|
||||
toggle сохраняет `off`, второй сохраняет `on`, увеличивает revision и
|
||||
публикует event только после успешного durable save.
|
||||
- **AC3 (`backend`):** два concurrent toggle одного marker сериализуются без
|
||||
lost update; response/event revisions монотонны, итог соответствует двум
|
||||
инверсиям.
|
||||
- **AC4 (`backend` + `ревью кода`):** non-admin authenticated connection может
|
||||
выполнить eligible toggle без `may_write`; forged missing/ineligible ID
|
||||
получает `not_toggleable`, не меняет store и не вызывает HA service.
|
||||
- **AC5 (`unit`):** manual `off` является `source.on=false` во всех canonical
|
||||
light consumers; incoming controller `on` его не перекрывает, а другой
|
||||
независимый room source продолжает влиять на room aggregate.
|
||||
- **AC6 (`unit` + `ревью кода`):** exact manual intent имеет typed marker target,
|
||||
current/next effect и operational operation; marker ID никогда не попадает в
|
||||
`hass.states` как entity и в `callService`/`entity_id`.
|
||||
- **AC7 (`unit` + `smoke`):** диалог exact-комбинации не показывает
|
||||
`toggle_none_no_actionable_entity`, показывает virtual target и current/next;
|
||||
остальные hints/confirmation #94 не изменились.
|
||||
- **AC8 (`backend`):** restart/recreation runtime data загружает сохранённый
|
||||
`off`; config/get возвращает согласованный revisioned snapshot, достаточный
|
||||
для первого `off` frame без ожидания event.
|
||||
- **AC9 (`backend`):** все config writers сохраняют state для rename/move/
|
||||
hidden/unrelated edits и очищают его при role/action/binding/delete change;
|
||||
неизвестный config revision gap сбрасывает overrides к `on`.
|
||||
- **AC10 (`smoke`):** tap/click в full View меняет Glow, room fill и room
|
||||
`N из M` вместе; повторный tap возвращает их. Touch pointer выполняет один
|
||||
toggle, pan/pinch/long-press не выполняют.
|
||||
- **AC11 (`smoke`):** две full cards и `houseplan-space-card` принимают один
|
||||
server event и показывают одинаковый state; stale response не откатывает
|
||||
более новую revision, reconnect/reload исправляет missed event.
|
||||
- **AC12 (`unit` + `smoke`):** outgoing controls exact marker не получают HA
|
||||
service от manual tap; после выхода из тройки прежний resolver #94 получает
|
||||
сохранённые controls без потери.
|
||||
- **AC13 (`unit` + `smoke`):** `tap_confirm` re-resolves stable marker target,
|
||||
выполняет server-current atomic toggle и безопасно отказывает, если eligibility
|
||||
изменилась до подтверждения.
|
||||
- **AC14 (`unit` + `ревью кода`):** runtime revision инвалидирует canonical
|
||||
light/presentation cache без HA tick, polling и structural config/device
|
||||
rebuild; N static cards разделяют cache/subscription.
|
||||
- **AC15 (`unit` + `build`):** RU/EN parity, malformed cache/store input и
|
||||
old/new optional field matrix покрыты; три bundle snapshot после build
|
||||
побайтно совпадают.
|
||||
- **AC16 (`ревью кода`):** оба changelog, RU user guide, README light recipe,
|
||||
LIGHT, ARCHITECTURE и CONFIG-COMPATIBILITY описывают точное исключение,
|
||||
persistence/permissions и отсутствие export.
|
||||
|
||||
## 15. План тестов и доказательств
|
||||
|
||||
### 15.1. Frontend unit
|
||||
|
||||
1. Eligibility/action matrix AC1/AC6/AC12.
|
||||
2. Canonical source matrix: default on, manual off/on, controller OR ignored,
|
||||
other room sources, hidden and exit from triple.
|
||||
3. Intent formatter: virtual target/current/next, no fake entity ID, no old
|
||||
no-actionable text, confirmation target stability.
|
||||
4. Runtime snapshot cache: revision ordering, event-before-response,
|
||||
reconnect snapshot and malformed local cache.
|
||||
5. Resolver-cache mutant: removing runtime revision from cache key must make a
|
||||
test fail because state would stay stale without HA tick.
|
||||
|
||||
### 15.2. Backend
|
||||
|
||||
1. Store absence/default, off/on persistence and runtime recreation.
|
||||
2. Atomic concurrency with controlled interleaving; save/event ordering.
|
||||
3. Admin and non-admin authenticated connection; invalid marker matrix.
|
||||
4. Config writer lifecycle for ordinary set, import apply, optimize/undo and
|
||||
setup migration path.
|
||||
5. Config revision gap, corrupt/oversized Store, bounded pruning and save
|
||||
failure without false event.
|
||||
6. Wire compatibility: optional snapshot, old client payload and no config
|
||||
schema/export changes.
|
||||
|
||||
### 15.3. Browser smoke
|
||||
|
||||
Targeted scenario builds one eligible virtual lamp, one incoming controller,
|
||||
one unrelated room source, two full cards and one static card. It checks
|
||||
AC7/AC10–AC13 using real click and touch pointer sequences, visible Glow/room
|
||||
presentation, WS call log, event revisions and zero HA service calls.
|
||||
|
||||
Reload starts from server `off` and proves the first committed candidate is
|
||||
already off. A disconnected card misses an event, reconnects and adopts the
|
||||
current snapshot. Confirmation race changes config before confirm and proves no
|
||||
retarget.
|
||||
|
||||
По действующему процессу smoke пишется вместе с реализацией, но в цикле
|
||||
реализации запускаются только typecheck, unit и build. Целевой smoke и полный
|
||||
browser smoke исполняются перед бетой; reviewer может запустить узкий сценарий
|
||||
для доказательства AC.
|
||||
|
||||
### 15.4. Golden и performance
|
||||
|
||||
Golden matrix получает точный on/off virtual-lamp fixture на существующей flat
|
||||
View сцене; новый художественный baseline не проектируется. Golden verify и
|
||||
обновление принятого baseline выполняются только на pre-beta gate.
|
||||
|
||||
Performance gate должен подтвердить отсутствие polling, дополнительного HA
|
||||
state subscription, per-source network call и полного config/device rebuild на
|
||||
event. Отдельный новый численный budget не вводится: операция пользовательская,
|
||||
state snapshot bounded числом markers, а resolver остаётся frame-cached.
|
||||
|
||||
## 16. Security и надёжность
|
||||
|
||||
Основной новый trust boundary — authenticated non-admin WS mutation. Он
|
||||
ограничен следующими правилами:
|
||||
|
||||
- marker выбирается только из server config;
|
||||
- eligibility вычисляет backend;
|
||||
- command не принимает state, entity ID, service/domain или config fragment;
|
||||
- payload и persisted set ограничены существующим marker limit;
|
||||
- одна команда делает одну atomic inversion;
|
||||
- никакой HA service/file/network action не выполняется;
|
||||
- failed save не создаёт success response/event;
|
||||
- revision gap деградирует в `on`, а не в stale manual state.
|
||||
|
||||
Lock/alarm invariant `docs/SCOPE.md` не затронут: virtual operational target не
|
||||
может резолвиться в secure entity или HA service.
|
||||
|
||||
## 17. Release-артефакты
|
||||
|
||||
Реализационный пользовательский коммит содержит:
|
||||
|
||||
- `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` со ссылкой на #107;
|
||||
- `docs/USER-GUIDE.ru.md` — рецепт точной тройки, persistence и отличие от
|
||||
controller link;
|
||||
- `README.md` — краткий EN recipe/contract;
|
||||
- `docs/LIGHT.md` — manual authority в canonical resolver;
|
||||
- `docs/ARCHITECTURE.md` — operational Store/WS/live-sync boundary;
|
||||
- `docs/CONFIG-COMPATIBILITY.md` — old/new matrix, config-revision coherence и
|
||||
export exclusion;
|
||||
- frontend unit, backend tests и browser scenario;
|
||||
- синхронные `dist/houseplan-card.js`,
|
||||
`custom_components/houseplan/frontend/houseplan-card.js` и
|
||||
`demo/srv/assets/houseplan-card.js`.
|
||||
|
||||
Новых migration UI, screenshot документации и отдельного security report не
|
||||
требуется. Перед бетой идут штатные golden verify, browser smoke и performance
|
||||
gate по release runbook.
|
||||
|
||||
## 18. Откат
|
||||
|
||||
Откат — обычный revert frontend/backend изменения, тестов, документации,
|
||||
changelog и bundle snapshot. Plan config/layout/import formats не меняются.
|
||||
|
||||
Отдельный operational Store после downgrade безопасно игнорируется; старая
|
||||
версия снова считает passive `Always` source включённым и не умеет его
|
||||
переключать. При последующем upgrade незнакомый config revision сбрасывает stale
|
||||
overrides к `on`. Ручное редактирование `.storage` пользователю не предлагается.
|
||||
|
||||
## 19. Принятые технические предположения — можно менять без пересмотра продукта
|
||||
|
||||
1. Отдельный Store и хранение только off IDs предпочтительны, но точный key и
|
||||
Python class names не являются продуктовым решением.
|
||||
2. Initial state может ехать дополнительным полем `config/get` либо другим
|
||||
атомарным snapshot endpoint, если первый frame, old/new compatibility и
|
||||
config-revision coherence полностью сохранены.
|
||||
3. Typed intent можно оформить union-командой либо отдельным operational action
|
||||
рядом с HA command; запрещены только две расходящиеся resolver-ветки.
|
||||
4. Full card может иметь собственную event subscription, а static cards — общую
|
||||
module-level; точная раскладка допустима при отсутствии N одинаковых fetch.
|
||||
5. Local cache хранит snapshot для continuity, но server revision остаётся
|
||||
authority и optimistic toggle не вводится.
|
||||
6. Config writer helper может консервативно сбросить все manual overrides при
|
||||
неизвестном revision gap вместо попытки угадать историю eligibility.
|
||||
7. Hidden marker сохраняет state, tombstone удаляет; это следует существующей
|
||||
границе visual hide против semantic delete.
|
||||
8. Full import не импортирует state, но может сохранить текущее значение
|
||||
совпавшего stable eligible marker ID после reconciliation.
|
||||
@@ -1,623 +0,0 @@
|
||||
# Issue #122 — Isometric Stage 2: hidden visual polish
|
||||
|
||||
- **Issue:** https://github.com/Matysh/houseplan-card/issues/122
|
||||
- **Predecessor:** #89, normative Stage 1 contract
|
||||
[`089-isometric-view-stage1.md`](089-isometric-view-stage1.md)
|
||||
- **Owner decisions:**
|
||||
https://github.com/Matysh/houseplan-card/issues/122#issuecomment-5286229767
|
||||
- **Visual references:**
|
||||
https://github.com/Matysh/houseplan-card/issues/89#issuecomment-5279077001
|
||||
- **Type / priority:** feature / P2
|
||||
- **Assessment:** user value 7/10; development value 6/10; complexity and
|
||||
risk 9/10
|
||||
- **Revision:** first revision for independent spec review; task status is
|
||||
defined only by the issue labels
|
||||
- **Related:** #124 (existing Iso ↔ Flat performance debt), #82 (camera
|
||||
animation, explicitly out of scope)
|
||||
|
||||
## 1. Scenario and product context
|
||||
|
||||
**Persona:** a home administrator who deliberately enables the hidden `iso`
|
||||
Labs experiment to evaluate it on a real plan. Household members and guests do
|
||||
not discover or enable Stage 2 through the product UI.
|
||||
|
||||
**Surface and moment:** the administrator opens ordinary View or kiosk on a
|
||||
desktop, phone or wall tablet, activates the existing `iso` Labs flag and
|
||||
switches the current space from Flat to the volumetric presentation.
|
||||
|
||||
**Before → after, without implementation terms:** today the hidden volumetric
|
||||
view shows technically correct but schematic white/grey wall slabs and flat
|
||||
opening symbols; after Stage 2 the same live home has restrained material
|
||||
depth, vertical openings, a low outside floor edge and soft grounding shadows,
|
||||
without losing any state, action or existing floor effect.
|
||||
|
||||
The task serves:
|
||||
|
||||
- **J1:** the live whole-home view becomes easier to read spatially;
|
||||
- **J2:** opening, lock and alert state stays located on the same plan;
|
||||
- **J3:** existing safe actions remain available at the same projected anchors.
|
||||
|
||||
This remains inside the owner-approved narrow exception in `docs/SCOPE.md`:
|
||||
deterministic 2.5D presentation of the canonical plan, not a second model,
|
||||
photorealistic renderer, free camera or interior editor. View is still the
|
||||
product; the experiment being hidden does not relax the View/kiosk touch
|
||||
contract.
|
||||
|
||||
## 2. Problem
|
||||
|
||||
Stage 1 established the hard parts of the renderer: one fixed orthographic
|
||||
projection, canonical wall geometry, complete opening cuts, screen-facing
|
||||
overlays, live floor layers, fallback, warm-remount and bounded geometry cache.
|
||||
Its deliberate visual limits are now the gap:
|
||||
|
||||
- wall tops and sides are flat solid colours with minimal depth cues;
|
||||
- the floor has no visible outside edge and the plan does not feel grounded;
|
||||
- doors, windows and gates keep their old floor-plane symbols inside a
|
||||
volumetric wall gap;
|
||||
- there is no ambient, contact or leaf shadow;
|
||||
- owner references and the accepted pseudo-3D direction are not yet represented.
|
||||
|
||||
A literal copy of the references would be wrong. They omit labels, live fills,
|
||||
decor, furniture, columns, Glow, sunlight and vacuum data, and they imply
|
||||
concrete/wood semantics that House Plan does not store. Stage 2 must adopt the
|
||||
references' language of depth while retaining the user's canonical content and
|
||||
the semantic meaning of every live layer.
|
||||
|
||||
## 3. Accepted owner decisions
|
||||
|
||||
The owner accepted defaults Q0–Q6 on 13 August 2026.
|
||||
|
||||
1. Priority is P2; Stage 2 is valuable but hidden and does not displace public
|
||||
P1 defects.
|
||||
2. Stage 2 evolves the existing `iso` flag. There is no second flag, public
|
||||
setting or rollout surface; Flat remains the initial presentation.
|
||||
3. The references guide wall/opening/edge material and depth only. They do not
|
||||
replace the plan image, room fills, labels, decor/backdrop, furniture,
|
||||
Glow/spill, sun, vacuum or canonical columns, and do not add inferred
|
||||
concrete/wood/terrace materials.
|
||||
4. In Iso, doors use one vertical leaf, gates use their existing pair of short
|
||||
leaves and windows use a light vertical insert. The old floor symbol is not
|
||||
duplicated while the volumetric representation is active. Existing contact,
|
||||
inversion, hinge/side, live state, lock card/badge and actions remain.
|
||||
5. There is no new decorative window-light beam on the floor. Windows may have
|
||||
a local material highlight; directional window light remains the existing
|
||||
live sunlight and room light remains Glow.
|
||||
6. One low outside floor edge and restrained ambient/contact/leaf shadows are
|
||||
included. There are no per-room steps or new terrace/porch entities.
|
||||
7. Unsupported decorative filters may disappear without losing structural
|
||||
volume, openings, live layers or actions. Flat fallback remains reserved for
|
||||
structural projection/topology failure.
|
||||
|
||||
## 4. Scope
|
||||
|
||||
Stage 2 includes:
|
||||
|
||||
1. evolution of the existing hidden `iso` presentation under the same Labs
|
||||
activation and expiry contract;
|
||||
2. theme-aware matte material for wall tops and visible sides, with a stable
|
||||
distinction between top, side and outline in light and dark themes;
|
||||
3. one low outside floor edge for every connected exterior component of the
|
||||
canonical room footprint, with no edge at internal room boundaries;
|
||||
4. a restrained ambient shadow for the complete footprint, contact shadow at
|
||||
physical walls and soft shadow for visible vertical leaves;
|
||||
5. vertical Iso presentation of doors, windows and gates derived only from the
|
||||
existing opening model and live `openingAmount()` semantics;
|
||||
6. exact preservation of opening gaps, jamb faces, lock markers, room fills,
|
||||
hover, Glow/spill, sunlight, plan image, decor, furniture, vacuum and all
|
||||
device/room overlays;
|
||||
7. deterministic behaviour for `show_borders`, `hide_openings`, themes,
|
||||
forced colours and missing decorative-filter support;
|
||||
8. fit/pan/zoom/warm-remount/fallback bounds that include structural floor and
|
||||
opening volume without clipping;
|
||||
9. unit, targeted browser smoke, golden and exact-SHA performance evidence;
|
||||
10. internal architecture, Iso and status documentation.
|
||||
|
||||
## 5. Out of scope
|
||||
|
||||
- public activation, settings, onboarding, announcement or documentation of
|
||||
Iso; public rollout requires a separate owner-approved issue;
|
||||
- a second `iso2` flag or an A/B selector between Stage 1 and Stage 2;
|
||||
- free rotation, perspective, user tilt, camera presets or #82 animation;
|
||||
- new wall, floor, door or window material settings;
|
||||
- inferred room types, concrete, wood, terrace, porch or floor finish;
|
||||
- a constant decorative beam from each window or a second light model;
|
||||
- geometric occlusion of device markers, labels, cards or popovers by walls;
|
||||
- volumetric Plan/Devices/Background editors;
|
||||
- volumetric `houseplan-space-card`;
|
||||
- opening height/sill/frame fields, schema changes or compatibility aliases;
|
||||
- 3D furniture, ceiling, roof, external-object shadows or weather lighting;
|
||||
- WebGL, Three.js, CSS `preserve-3d`, raster textures or a runtime dependency;
|
||||
- fixing or weakening the independent performance defect/budget in #124;
|
||||
- backend, import/export, HA registry, service calls or lock security changes.
|
||||
|
||||
## 6. Behaviour contract
|
||||
|
||||
### 6.1 Labs and surface boundary
|
||||
|
||||
- `iso` remains the only Labs id. Query/hash/storage grammar, Flat default,
|
||||
per-space preference, kiosk preference and `-iso`/`off` rollback remain the
|
||||
Stage 1 contract.
|
||||
- Stage 2 is available only while that registry entry is live. Its existing
|
||||
exclusive expiry at `1.65.0` is not extended by this issue; a `1.65.0-beta.*`
|
||||
build must fail closed unless a separate reviewed graduation decision exists.
|
||||
- Editors and `houseplan-space-card` stay Flat. Entering an editor, returning
|
||||
to View, changing space and warm-remount preserve the existing logical centre
|
||||
and projection preference.
|
||||
- With Labs inactive, no Stage 2 geometry, definitions, filters, capability
|
||||
work or DOM is created. Existing Flat DOM and pixels remain the reference.
|
||||
|
||||
### 6.2 Material language
|
||||
|
||||
- Walls remain low, neutral and matte. Top faces are lighter than visible side
|
||||
faces in both themes; an outline keeps adjacent faces distinguishable.
|
||||
- Material uses deterministic CSS/SVG tokens. A subtle bounded gradient or
|
||||
reusable low-amplitude treatment is allowed, but no raster texture, random
|
||||
noise, glossy highlight or floor-material replacement is allowed.
|
||||
- Theme or colour-scheme changes update presentation only and do not rebuild
|
||||
topology or change cache keys.
|
||||
- User room/border colours retain their existing Flat/editor meaning. Stage 2
|
||||
does not reinterpret them as physical material.
|
||||
- Materials do not encode HA state. All live state remains in the existing
|
||||
room/device/opening layers.
|
||||
|
||||
### 6.3 Canonical floor edge
|
||||
|
||||
The Stage 2 floor footprint is computed from the union of room floor polygons
|
||||
and their derived exterior masonry. It deliberately excludes detached
|
||||
partitions, columns, decor, background images and far markers from deciding the
|
||||
slab perimeter.
|
||||
|
||||
- Each connected exterior component receives one low vertical edge below the
|
||||
current floor plane.
|
||||
- Shared room edges and other internal boundaries never form a step or seam.
|
||||
- Nested-room/internal holes do not create an additional decorative step.
|
||||
- Two genuinely detached room components may each have an outside edge; they
|
||||
are not bridged by a bounding box.
|
||||
- A space without room geometry has no inferred floor edge.
|
||||
- The existing floor nodes remain the top surface. Stage 2 must not paint a new
|
||||
opaque floor over plan images, fills, Glow, sunlight or decor.
|
||||
|
||||
### 6.4 Shadows
|
||||
|
||||
There are three decorative depth cues:
|
||||
|
||||
1. one ambient shadow for the complete connected plan footprint;
|
||||
2. a restrained contact shadow where physical masonry meets the floor;
|
||||
3. a soft shadow belonging to each visible door/gate leaf.
|
||||
|
||||
They are presentation only:
|
||||
|
||||
- no shadow changes light visibility, room state, hit testing or HA action;
|
||||
- shadow opacity is bounded so room fills, Glow and sunlight remain readable;
|
||||
- no per-edge SVG filter is created; filter/definition count is O(1) per card,
|
||||
while geometry remains O(E + O) for wall edges and openings;
|
||||
- theme, hover and an HA-only device update do not reconstruct shadow geometry;
|
||||
- shadows from roofs, neighbouring wings, furniture or outdoor objects are not
|
||||
modelled;
|
||||
- reduced/unsupported decoration removes the shadow rather than substituting a
|
||||
hard, misleading shape.
|
||||
|
||||
### 6.5 Vertical openings
|
||||
|
||||
All opening placement and live meaning continues to come from existing
|
||||
`OpeningCfg`, `OpeningWallIndex`, wall cuts and `openingAmount()`.
|
||||
|
||||
**Door**
|
||||
|
||||
- one thin vertical leaf is hinged exactly at the owning jamb;
|
||||
- closed state lies in the wall opening; open amount follows the same 0–1 path
|
||||
and opening side/hinge choices as the current floor symbol;
|
||||
- no handle, hinge hardware or decorative panel is added.
|
||||
|
||||
**Gate**
|
||||
|
||||
- two equal vertical leaves meet at the opening centre when closed;
|
||||
- live amount preserves the current 0–10° outward contract, including the
|
||||
static-open default when no contact exists;
|
||||
- gate lock semantics remain identical to a door.
|
||||
|
||||
**Window**
|
||||
|
||||
- a closed window is a light neutral insert in the wall opening;
|
||||
- open state uses the existing two-leaf/casement direction and amount rather
|
||||
than inventing a new sash type;
|
||||
- the insert may have a local highlight but emits no floor beam and does not
|
||||
replace the existing sun layer.
|
||||
|
||||
For every type:
|
||||
|
||||
- leaf/panel height and thickness are fixed presentation ratios to Stage 1 wall
|
||||
height; no persisted setting is introduced;
|
||||
- jamb attachment error is at most the existing geometric epsilon and the
|
||||
projected visual gap at the hinge is at most 1 CSS px;
|
||||
- panels do not fill the masonry cut or bridge the full-height opening with a
|
||||
wall strip;
|
||||
- panels and their shadows are `aria-hidden`/pointer-inert. Existing View
|
||||
inertness, lock badge, card and secure action boundary remain the only
|
||||
interaction contract;
|
||||
- a contact-state update changes only the live leaf projection and cannot
|
||||
rebuild wall union, floor footprint or opening-wall topology.
|
||||
|
||||
### 6.6 Display settings
|
||||
|
||||
- `hide_openings: true` hides vertical panels and leaf shadows, while the wall
|
||||
cut, contact/lock references, Glow passage and sunlight behaviour remain as
|
||||
today.
|
||||
- `show_borders: false` preserves the Stage 1 no-volume contract: Stage 2 wall
|
||||
material, vertical panels, floor edge and all new shadows are absent. The
|
||||
existing floor-plane opening symbol remains available under its current
|
||||
`hide_openings` rule so openings do not become invisible floating gaps.
|
||||
- With borders visible, the vertical panel replaces rather than duplicates the
|
||||
old floor-plane symbol in Iso.
|
||||
- Flat and every editor keep their current symbols and settings behaviour.
|
||||
|
||||
### 6.7 Existing live layers and actions
|
||||
|
||||
The following invariants are exact, not best effort:
|
||||
|
||||
- the same resolved rooms, sources, opening state, markers and vacuum data are
|
||||
consumed in Flat and Iso;
|
||||
- room fills/hover, Glow/spill, sun, backdrop/decor/furniture, vacuum and
|
||||
screen-facing overlays retain their existing node/state/action contract;
|
||||
- Stage 2 creates no new HA call, websocket request, fetch, storage write or
|
||||
network resource;
|
||||
- device actions, opening cards and the lock invariant from `docs/SCOPE.md`
|
||||
remain unchanged;
|
||||
- decorative shadow may modulate final pixels but may not remove, replace or
|
||||
reclassify any live effect;
|
||||
- there is no second window-light, Glow or sun layer.
|
||||
|
||||
### 6.8 Accessibility, touch and graceful degradation
|
||||
|
||||
- View and kiosk remain fully supported on desktop and touch: pan, pinch,
|
||||
swipe, double-tap reset, orientation resize, space change and long-press
|
||||
kiosk settings are unchanged.
|
||||
- New volume is non-interactive and cannot steal pointer, focus or assistive
|
||||
technology navigation. Existing HTML devices, room cards and lock badges
|
||||
stay screen-facing and above it.
|
||||
- New geometry has no animation timeline. Live opening state may update, but
|
||||
no decorative continuous motion is introduced; `prefers-reduced-motion`
|
||||
therefore retains the Stage 1 transition contract.
|
||||
- In forced-colours/high-contrast mode, structural faces and outlines remain
|
||||
distinguishable without relying only on a gradient; decorative shadows and
|
||||
material nuance may be absent.
|
||||
- A detected unsupported filter/paint capability degrades to solid material
|
||||
and no decorative shadows. Geometry, vertical openings, floor edge, live
|
||||
layers and actions remain.
|
||||
- Canonical supported browsers must render full Stage 2 and pass performance.
|
||||
Fallback may not be forced in the benchmark merely to make the budget green.
|
||||
- A structural projection/topology exception uses the existing latched Flat
|
||||
fallback for `space|fingerprint`. Decorative-filter failure alone never
|
||||
changes the selected presentation to Flat.
|
||||
|
||||
## 7. Rendering and architecture contract
|
||||
|
||||
Stage 1 projection and canonical wall decisions remain accepted. Stage 2 may
|
||||
extend their scene result, but must not create a second coordinate system or
|
||||
replace `wallBodiesGeometry()`.
|
||||
|
||||
Required boundaries:
|
||||
|
||||
1. One structural Stage 2 scene is keyed by the complete geometry fingerprint:
|
||||
rooms, canonical masonry, openings and flips, wall/camera/edge height and an
|
||||
algorithm revision. HA state, theme, hover, day/night and filter capability
|
||||
are excluded.
|
||||
2. Cached opening structure stores jamb/basis/topology only. Live
|
||||
`openingAmount()` is applied after the structural cache, O(O), without a
|
||||
boolean wall operation.
|
||||
3. Floor-edge geometry derives from canonical room/exterior geometry, not from
|
||||
SVG DOM measurement, a background image or duplicated room inference.
|
||||
4. Structural complexity remains O(E + O). A constant number of shared SVG
|
||||
gradients/filters is allowed; per-face filters and data URLs are forbidden.
|
||||
5. Render order is explicit and testable:
|
||||
|
||||
```text
|
||||
stage background
|
||||
→ optional ambient shadow
|
||||
→ exterior floor edge
|
||||
→ existing projected floor SVG and all live floor layers
|
||||
→ optional contact/leaf shadows
|
||||
→ canonical wall sides/top and vertical opening panels
|
||||
→ existing screen-facing HTML overlays
|
||||
```
|
||||
|
||||
6. If implementation uses more than one absolutely positioned SVG, they share
|
||||
the same `viewBox` snapshot and projection; no layer computes fit, pan or
|
||||
zoom independently.
|
||||
7. `projectedFrame()` or its Stage 2 successor includes wall tops, opening tops
|
||||
and structural floor edge. Filter blur may overflow safely but must not
|
||||
expand logical content bounds or change zoom when decoration degrades.
|
||||
8. Flat fallback is independent of the Stage 2 cache and can render even when
|
||||
Stage 2 construction throws.
|
||||
9. Existing LRU cap remains eight structural scenes per card and does not grow
|
||||
on HA updates, theme changes, view toggles or filter fallback.
|
||||
10. The implementation records its composition/cache/fallback decisions in a
|
||||
new `docs/adr/122-isometric-stage2-composition.md`; the historical Stage 1
|
||||
ADR is not rewritten as if it had decided Stage 2.
|
||||
|
||||
Likely implementation files, subject to code review:
|
||||
|
||||
- `src/iso-projection.ts` for Stage 2 structural bounds only if required;
|
||||
- `src/iso-walls.ts` or a new pure `src/iso-scene.ts` for cached walls and floor
|
||||
edge;
|
||||
- a pure `src/iso-openings.ts` (or equivalent) for live vertical-panel
|
||||
projection;
|
||||
- `src/houseplan-card.ts` for orchestration and exact layer order;
|
||||
- `src/styles.ts` for theme/capability/forced-colour material;
|
||||
- `src/labs.ts` only for traceability metadata, without changing id/lifetime;
|
||||
- unit, smoke, golden/performance harness and internal docs named in §11–§13.
|
||||
|
||||
## 8. Model data, compatibility and migration
|
||||
|
||||
No persisted model changes.
|
||||
|
||||
- `space.openings[]`, room/wall geometry and display settings retain their
|
||||
current schema;
|
||||
- there are no door/window height, sill, material or Stage 2 fields;
|
||||
- no config version, backend validation, import/export or compatibility-registry
|
||||
entry changes;
|
||||
- current `houseplan_card_labs_v1` and `houseplan_card_view_v1` values remain
|
||||
readable and unchanged;
|
||||
- merely rendering Stage 2 writes no config/layout and does not rewrite local
|
||||
storage beyond the existing Stage 1 Labs/preference operations;
|
||||
- downgrade to Stage 1 or Flat requires no migration and loses no user data.
|
||||
|
||||
The `iso` registry `since: 1.62.0` and exclusive `expires: 1.65.0` values are
|
||||
unchanged. The metadata issue/summary may point to #122 for current diagnostics,
|
||||
provided internal docs retain the #89 predecessor link.
|
||||
|
||||
## 9. UX and i18n
|
||||
|
||||
There is no new public control or text. The existing hidden projection button,
|
||||
its 44×44 target, labels “Volumetric view” / “Flat view” and per-space preference
|
||||
remain unchanged.
|
||||
|
||||
- no new i18n keys;
|
||||
- no public settings/YAML option;
|
||||
- no README, user-guide or HACS promise;
|
||||
- no change to editor wording or opening settings;
|
||||
- no new focusable node or interaction path.
|
||||
|
||||
`docs/USER-GUIDE.ru.md` remains deliberately silent about hidden Iso. The
|
||||
implementation must nevertheless preserve its normative contracts for View,
|
||||
kiosk, openings, Glow, sunlight and lock safety.
|
||||
|
||||
## 10. Dependency and Definition of Ready
|
||||
|
||||
#124 records a reproducible failure of the existing Stage 1
|
||||
`large-house-isometric-v1` view-toggle budget. Stage 2 must not absorb that bug,
|
||||
hide it with decorative fallback or weaken its budget.
|
||||
|
||||
This specification may be reviewed while #124 is open. After a green spec
|
||||
review, #122 may carry `S5-ready`, but it also receives `blocked` and may not be
|
||||
claimed into `S6-in-progress` until one of these is true:
|
||||
|
||||
1. #124 is resolved with a green exact-SHA Full Performance comparison using at
|
||||
least seven samples and unchanged budgets; or
|
||||
2. the owner records a separate explicit arbitration that #122 may start with
|
||||
the known baseline debt.
|
||||
|
||||
Stage 2's own implementation must then pass the same unchanged profile on its
|
||||
exact SHA. A coincidentally green single local sample is not a substitute.
|
||||
|
||||
## 11. Acceptance criteria
|
||||
|
||||
- **AC1 (`unit` + `smoke` + code review):** the same `iso` Labs id exposes Stage
|
||||
2 while live; Flat remains default, expiry remains exclusive at `1.65.0`, and
|
||||
there is no second flag, public setting or config key.
|
||||
- **AC2 (`unit` + existing flat smoke + existing flat golden):** with Labs
|
||||
inactive, Flat/editors/static card do not create Stage 2 geometry, filters or
|
||||
DOM and retain their existing state/action/pixel contract.
|
||||
- **AC3 (`unit` + `golden`):** light and dark Iso show deterministic matte wall
|
||||
top/side/outline separation without raster textures, random output, inferred
|
||||
room material or per-face filters.
|
||||
- **AC4 (`unit` + `golden`):** every connected room footprint receives one low
|
||||
outside edge; shared/internal room edges and nested holes create no steps,
|
||||
detached components are not bridged, and detached partitions/columns do not
|
||||
expand the slab.
|
||||
- **AC5 (`unit` + `smoke` + `golden`):** door, window and gate vertical geometry
|
||||
matches existing jamb, side, flip and `openingAmount()` semantics for
|
||||
closed/open/no-contact/unavailable/inverted states; projected hinge gap is
|
||||
≤1 CSS px and the masonry opening remains full-height.
|
||||
- **AC6 (`smoke` + code review):** vertical panels are pointer/focus/ARIA inert;
|
||||
the existing lock badge/card and safe action result are unchanged and no new
|
||||
service or network call exists.
|
||||
- **AC7 (`unit` + `smoke` + `golden`):** `hide_openings` hides panels/leaf shadows
|
||||
but not cuts/state/light; `show_borders:false` preserves the Stage 1
|
||||
no-volume scene and existing floor symbols without Stage 2 edge/shadows.
|
||||
- **AC8 (`smoke` + `golden`):** room fills/hover, Glow/spill, sun,
|
||||
backdrop/decor/furniture, vacuum, markers, labels/cards and opening locks keep
|
||||
the same resolved state, source count, action outcome and layer order; no
|
||||
second window-light/Glow/sun layer appears.
|
||||
- **AC9 (`unit` + `smoke`):** HA-only state changes do not rebuild wall/floor
|
||||
topology; an opening contact update changes only live leaf projection; theme,
|
||||
hover and filter fallback do not grow the structural cache.
|
||||
- **AC10 (`smoke` + `golden`):** desktop View, touch View and kiosk retain
|
||||
pan/pinch/tap/long-press/swipe/orientation/space/warm-remount behaviour, use one
|
||||
projection snapshot and never expose volumetric editors.
|
||||
- **AC11 (`unit` + `smoke` + code review):** unsupported decoration and forced
|
||||
colours retain solid readable structure with no decorative shadows; a
|
||||
decorative failure stays Iso, while a structural failure uses the existing
|
||||
latched Flat fallback and emits no personal data.
|
||||
- **AC12 (`unit` + `smoke`):** fit/home and projection toggle include wall,
|
||||
opening and floor-edge structural bounds without clipping, preserve scalar
|
||||
zoom/logical floor centre and do not change when blur is disabled.
|
||||
- **AC13 (`performance`):** after #124's precondition, exact-SHA Full Performance
|
||||
for `large-house-isometric-v1` is green with ≥7 samples and unchanged budgets;
|
||||
Flat remains inside noise, filter/definition count is bounded and
|
||||
`isoGeometry` cache cap/growth remain 8/0.
|
||||
- **AC14 (`unit` + smoke + code review):** plan/backend/import/export schema,
|
||||
local-storage keys, HA actions, network traffic and i18n keys are unchanged;
|
||||
render-only use does not write config or layout.
|
||||
- **AC15 (`golden` + documentation review):** reviewed Stage 2 Iso baselines show
|
||||
materials, openings, edge, shadows and live-layer parity; all Flat and
|
||||
`isometric-no-borders-dark` baselines remain unchanged; internal docs describe
|
||||
the hidden experiment without public rollout claims.
|
||||
- **AC16 (`typecheck` + `unit` + `build`):** implementation-loop gates are green
|
||||
and all three generated bundle copies are byte-identical.
|
||||
|
||||
## 12. Automated test plan
|
||||
|
||||
### 12.1 Unit
|
||||
|
||||
1. Extend pure Iso geometry coverage with room union/exterior floor-edge cases:
|
||||
one room, shared rooms, nested room, two detached components, detached
|
||||
partition/column and empty space.
|
||||
2. Prove floor-edge geometry has no segment on an internal shared boundary and
|
||||
remains stable under room order/winding changes.
|
||||
3. Add pure vertical-opening fixtures for door/window/gate across amount 0/1,
|
||||
no contact, unavailable, invert and both flips. Assert exact jamb anchors,
|
||||
leaf count, wall-height bounds and stable output order.
|
||||
4. Prove an HA amount change reuses the structural basis and cannot mutate or
|
||||
grow the wall/floor cache.
|
||||
5. Verify structural fingerprint includes opening geometry/flips, wall/edge
|
||||
height and algorithm revision, but excludes HA state, theme, hover and
|
||||
filter capability.
|
||||
6. Verify layer/capability resolution: full decoration, unsupported filter,
|
||||
forced colours, `hide_openings` and `show_borders:false`.
|
||||
7. Source-contract tests assert no new config key, dependency, network call,
|
||||
public control or second light layer.
|
||||
|
||||
Tests must be capable of failing independently: removing one exterior edge,
|
||||
moving a leaf off its jamb, including HA state in the cache key, duplicating a
|
||||
floor symbol or adding an extra light layer must make the relevant test red.
|
||||
|
||||
### 12.2 Targeted browser smoke
|
||||
|
||||
Extend or add a production-bundle scenario with:
|
||||
|
||||
1. light and dark Stage 2 activation under existing `iso`;
|
||||
2. door/window/gate with live contact and lock references, including state
|
||||
changes without topology rebuild;
|
||||
3. visible borders, `hide_openings`, `show_borders:false` and return to visible
|
||||
borders;
|
||||
4. room fill + two Glow sources + sunlight + decor + vacuum + room/device
|
||||
actions in the same frame;
|
||||
5. Flat ↔ Iso, editor round-trip, space switch, resize/orientation and warm
|
||||
remount;
|
||||
6. touch View and kiosk gestures with pointer-inert Stage 2 layers;
|
||||
7. forced/unsupported-decoration mode and structural-failure Flat fallback;
|
||||
8. assertions for one light model, stable cache counts and no config/layout/
|
||||
network write.
|
||||
|
||||
Per the implementation-loop policy, browser smoke is authored with the code but
|
||||
run in the full pre-beta gate, not on every local iteration.
|
||||
|
||||
### 12.3 Golden
|
||||
|
||||
The existing matrix remains the base. Expected work:
|
||||
|
||||
- update reviewed Stage 2 baselines for geometry light/dark, live layers,
|
||||
touch kiosk and large warm-remount;
|
||||
- keep every Flat baseline and `isometric-no-borders-dark` pixel-identical;
|
||||
- add a focused light-theme and dark-theme opening/material scenario containing
|
||||
door, window, gate, floor edge and shadows;
|
||||
- add a detached-footprint/internal-boundary scenario if existing geometry
|
||||
fixtures cannot prove AC4 visually;
|
||||
- inspect live-effect readability rather than accepting lower contrast merely
|
||||
because the diff is expected.
|
||||
|
||||
Baselines are accepted only through `npm run golden:accept -- --reviewed` from a
|
||||
complete reviewed Linux CI artifact with the required `Release:` and
|
||||
`Baseline-Reviewed:` trailers. Stage 2's spec approval is not baseline approval.
|
||||
|
||||
### 12.4 Performance
|
||||
|
||||
Use the existing `large-house-isometric-v1` runner and unchanged budget file:
|
||||
|
||||
- at least seven samples on the exact candidate SHA;
|
||||
- Flat baseline comparison and Iso candidate comparison;
|
||||
- view toggle, first stable render, state update, space switch, pan/zoom,
|
||||
long tasks, heap, cache caps and growth;
|
||||
- a DOM metric or smoke assertion bounding shared material/filter definitions
|
||||
and vertical opening count to O(1) definitions + O(O) geometry.
|
||||
|
||||
A local run is diagnostic only. #124's evidence and Stage 2's own exact-SHA
|
||||
comparison are separate required results.
|
||||
|
||||
### 12.5 Backend
|
||||
|
||||
Backend is unchanged. No new backend test is required. Existing Linux Validate
|
||||
still runs the full project gate before beta.
|
||||
|
||||
## 13. Documentation and release artefacts
|
||||
|
||||
Stage 2 remains hidden, so its implementation commits use
|
||||
`User-Visible: no`. They do **not** add public changelog, README, HACS or
|
||||
user-guide promises. Public documentation belongs to a separate rollout issue.
|
||||
|
||||
Update with the implementation:
|
||||
|
||||
- `docs/ISOMETRIC.md` — Stage 2 material/opening/edge/layer/fallback contract;
|
||||
- `docs/ARCHITECTURE.md` — one cached structural scene and live-opening split;
|
||||
- `docs/adr/122-isometric-stage2-composition.md` — accepted composition,
|
||||
caching and degradation decisions;
|
||||
- `docs/STATUS.md` — current hidden Labs stage and release-cycle state;
|
||||
- `AGENTS.md` only if Labs authoring/lifetime rules themselves change (not
|
||||
expected).
|
||||
|
||||
Visual release artefacts:
|
||||
|
||||
- reviewed golden set from §12.3;
|
||||
- exact-SHA Validate and Full Performance evidence from §12.4;
|
||||
- no public screenshot or announcement;
|
||||
- no separate security report because there are no inputs, services or data
|
||||
changes; code review explicitly confirms the negative security contract.
|
||||
|
||||
The feature/material change must pass through a published beta before stable,
|
||||
even though it remains hidden.
|
||||
|
||||
## 14. Risks and mitigation
|
||||
|
||||
| Risk | Probability / impact | Mitigation |
|
||||
|---|---|---|
|
||||
| Vertical panel drifts from jamb or crosses masonry | medium / high | pure opening basis, ≤1 px smoke, focused golden |
|
||||
| Floor edge follows room seams or bridges detached plans | medium / high | canonical exterior union, permutation units, detached golden |
|
||||
| Shadows obscure Glow/sun or alter live meaning | medium / high | bounded shared filters, combined live-layer golden, no new light model |
|
||||
| Per-face filters or live topology rebuild break performance | high / high | O(1) definitions, structural/live split, unchanged exact-SHA budget |
|
||||
| #124 debt is hidden by Stage 2 fallback | high / high | explicit DoR blocker and benchmark full decoration |
|
||||
| `show_borders:false` leaves floating panels | medium / medium | retain Stage 1 floor symbols in the no-volume branch |
|
||||
| Touch/kiosk layer steals gestures | low / high | pointer-inert geometry and targeted touch/kiosk smoke |
|
||||
| Dark/forced-colour material becomes unreadable | medium / medium | outline-based distinction and solid fallback |
|
||||
| Filter exception unnecessarily drops to Flat | medium / medium | separate decorative capability from structural latch |
|
||||
| New cache key includes HA state and grows every tick | medium / high | basis/live split and cache-growth unit/performance assertion |
|
||||
| Internal feature leaks into public docs | low / medium | internal artefact list, no changelog/user-guide/README |
|
||||
| Stage 2 misses Labs expiry | medium / medium | unchanged exclusive 1.65 expiry and separate graduation decision |
|
||||
|
||||
## 15. Rollback
|
||||
|
||||
Immediate tester rollback needs no build: `?hp-labs=-iso` or
|
||||
`?hp-labs=off` makes Flat effective, and warm state cannot resurrect Iso.
|
||||
|
||||
Code rollback reverts the Stage 2 behaviour/artefact commit while retaining the
|
||||
accepted Stage 1 renderer. There is no data or schema migration to reverse.
|
||||
Existing Labs and view-preference storage remain valid. If only decorative
|
||||
filters fail, the supported runtime fallback removes them without disabling
|
||||
structural Stage 2; if Flat itself changes or fails, beta publication is blocked.
|
||||
|
||||
## 16. Technical assumptions — may change without product review
|
||||
|
||||
1. Recommended fixed ratios are: floor edge roughly 10–16% of wall height,
|
||||
door/gate panels roughly 85–100% and window panels roughly 45–70% of wall
|
||||
height. Exact constants are selected by reviewed golden output and do not
|
||||
become user settings.
|
||||
2. A new pure `iso-openings.ts`/`iso-scene.ts` split is preferred, but names and
|
||||
file boundaries may change while the structural/live-cache contract holds.
|
||||
3. Gradients and shadows should use shared SVG defs/CSS variables. Exact tokens,
|
||||
blur radii and alpha may change in review within the restrained material
|
||||
contract and performance budget.
|
||||
4. `show_borders:false` uses the existing floor symbols rather than floating
|
||||
vertical panels; this is the deterministic consequence of the accepted
|
||||
no-volume setting, not a new public mode.
|
||||
5. The ambient shadow may be rendered in a dedicated shared-projection SVG
|
||||
beneath the existing floor SVG. The number of SVG roots is not a product
|
||||
contract; the one-snapshot/layer-order invariant is.
|
||||
6. The Labs registry diagnostic may change its issue metadata from #89 to #122,
|
||||
but id, storage key, since and expiry do not change.
|
||||
7. Capability detection may use CSS/forced-colour media queries or a cached
|
||||
per-Document probe. It must not classify performance from viewport width or
|
||||
coarse pointer alone.
|
||||
8. #124 is a process/technical prerequisite, not Stage 2 product scope. Its fix
|
||||
may change the renderer internals this spec builds upon without reopening
|
||||
owner decisions Q0–Q6.
|
||||
|
||||
@@ -1,397 +0,0 @@
|
||||
# Issue #131 — полный первый кадр View у read-only-пользователя
|
||||
|
||||
- **Issue:** https://github.com/Matysh/houseplan-card/issues/131
|
||||
- **Редакция:** первая редакция для независимого ревью; статус задачи определяется
|
||||
только метками issue
|
||||
- **Тип / приоритет:** bug / P1
|
||||
- **Оценка:** пользовательская ценность 9/10; ценность для разработки 7/10;
|
||||
сложность и риск 4/10
|
||||
- **Область:** холодная загрузка `houseplan-card`, выбор пространства,
|
||||
read-only View/киоск, локальный config-cache, reload и warm remount
|
||||
- **Модель данных:** без изменений и миграции
|
||||
- **Связано:** #73, #93, `docs/SCOPE.md`, `docs/TOUCH-SUPPORT.md`,
|
||||
`docs/UX-MODES.md`, `docs/USER-GUIDE.ru.md`, `docs/ARCHITECTURE.md`
|
||||
|
||||
## 1. Сценарий и продуктовый контекст
|
||||
|
||||
**Персона:** домочадец без права редактирования плана либо пользователь
|
||||
настенной панели/киоска. Для него View — основной продуктовый режим, а не
|
||||
предпросмотр перед редактором.
|
||||
|
||||
**Поверхность и момент:** пользователь впервые открывает Lovelace-страницу после
|
||||
чистой установки, очистки локального состояния или на новом браузере. Сервер
|
||||
разрешает прочитать House Plan, но HA-сессия не разрешает одну или несколько
|
||||
подписок на служебные события интеграции.
|
||||
|
||||
Задача поддерживает:
|
||||
|
||||
- **J1:** план должен быть читаемым и полным сразу при открытии;
|
||||
- **J6:** reload, reconnect и техническое перемонтирование не должны менять
|
||||
смысл или состав видимого плана;
|
||||
- гарантированный View/touch/kiosk-контракт: телефон, планшет и настенная панель
|
||||
являются целевыми устройствами просмотра и управления.
|
||||
|
||||
## 2. Что человек увидит до и после
|
||||
|
||||
**До:** при первом открытии видна только часть плана, ни одно пространство не
|
||||
выделено, а нажатие его вкладки неожиданно «дорисовывает» пол, мебель, стены,
|
||||
свет и подписи состояний.
|
||||
|
||||
**После:** первый кадр уже совпадает с нормальным видом после нажатия вкладки;
|
||||
пространство выбрано, все его слои и состояния на месте, а нажатие активной
|
||||
вкладки ничего не исправляет.
|
||||
|
||||
## 3. Проблема и подтверждённая причина
|
||||
|
||||
`setConfig()` начинает без server-cache с legacy-значения `_space = 'f1'`.
|
||||
После ответов `houseplan/config/get` и `houseplan/layout/get` метод
|
||||
`_loadFromServer()` принимает конфигурацию через `_adoptStructuralResponses()`,
|
||||
но до выбора реального пространства последовательно ожидает три live-sync
|
||||
подписки.
|
||||
|
||||
Отказ первой `subscribeEvents()` попадает во внешний `catch` и пропускает весь
|
||||
оставшийся хвост успешной инициализации:
|
||||
|
||||
1. hash/saved/default/first precedence не применяется;
|
||||
2. `_cacheSnapshot()` не вызывается;
|
||||
3. zoom/viewport не восстанавливается по принятому пространству;
|
||||
4. ошибка ошибочно считается сбоем всей загрузки и запускает полный retry.
|
||||
|
||||
При этом `_loadOk` уже установлен и server config уже принят. `_spaceModel()`
|
||||
скрывает часть ошибки, подставляя первый model space вместо несуществующего
|
||||
`f1`, поэтому устройства и часть геометрии видны. Но `_curSpaceCfg` ищет точное
|
||||
совпадение и остаётся `undefined`; header также сравнивает точные ID. Из-за этого
|
||||
неактивна вкладка и отсутствуют зависящие от raw-space слои.
|
||||
|
||||
Диагностика на `dev` SHA `0e69c4a18337` с двумя пространствами `home` и
|
||||
`upstairs`:
|
||||
|
||||
| Сценарий | `_space` | model fallback | exact space | active tab |
|
||||
|---|---|---|---|---|
|
||||
| Cold start, подписка запрещена | `f1` | `home` | — | — |
|
||||
| После клика `home` | `home` | `home` | `home` | `home` |
|
||||
| Технический remount без config-cache | `f1` | `home` | — | — |
|
||||
| Новый экземпляр с валидным `LS_NAV`, без config-cache | `f1` | `home` | — | — |
|
||||
| Reload с валидным config-cache | `home` | `home` | `home` | `home` |
|
||||
|
||||
`can_write: false` без отказа подписок загружается правильно. Значит, условие
|
||||
дефекта — не запрет редактирования сам по себе, а ошибка необязательной подписки
|
||||
в обязательной последовательности cold start.
|
||||
|
||||
## 4. Решения владельца
|
||||
|
||||
Владелец принял defaults D1–D3 14.08.2026. Каноническая запись:
|
||||
https://github.com/Matysh/houseplan-card/issues/131#issuecomment-5287280361
|
||||
|
||||
1. Задача имеет приоритет P1 и идёт полным маршрутом без `small`/`trivial`.
|
||||
2. После принятия серверной конфигурации карточка выбирает ровно одно
|
||||
существующее пространство по действующему приоритету до пространственного
|
||||
рендера.
|
||||
3. Полнота initial snapshot не зависит от права записи, результата
|
||||
необязательных live-sync-подписок, числа пространств, наличия cache, reload
|
||||
или технического remount. Отказ подписки может отключить только последующую
|
||||
live-синхронизацию.
|
||||
|
||||
## 5. Скоуп
|
||||
|
||||
В задачу входят:
|
||||
|
||||
1. обязательное завершение server snapshot после успешных `config/get` и
|
||||
`layout/get`, даже если любая houseplan event subscription отклонена;
|
||||
2. выбор валидного пространства до первого пространственного кадра;
|
||||
3. cold start без `LS_CFG`, включая валидный и stale `LS_NAV`;
|
||||
4. одно и несколько пространств, валидные и stale `#space`/`default_floor`;
|
||||
5. сохранение принятого server snapshot в `LS_CFG` после успешной загрузки;
|
||||
6. reload и same-route warm remount;
|
||||
7. View и киоск при `can_write: false`; обычная admin-сессия остаётся той же;
|
||||
8. независимая best-effort установка config, trail и layout subscriptions;
|
||||
9. отсутствие полного load-retry только из-за отказа необязательной подписки;
|
||||
10. unit/browser regression coverage, архитектурная документация и два
|
||||
changelog.
|
||||
|
||||
## 6. Не входит в задачу
|
||||
|
||||
- выдача read-only-пользователю права редактировать план или подписываться на
|
||||
запрещённые HA events;
|
||||
- изменение backend permissions, websocket API или Home Assistant auth;
|
||||
- гарантированная live-синхронизация после явно запрещённой подписки;
|
||||
- новый warning, toast, recovery overlay или индикатор ограниченных прав;
|
||||
- изменение порядка вкладок, названий пространств, `default_floor`, deep link
|
||||
или формата локального cache/navigation;
|
||||
- изменение выбора пространства в `houseplan-space-card`, где пространство
|
||||
задаётся отдельным обязательным параметром;
|
||||
- изменение рендера пола, мебели, стен, Glow, устройств либо их состояний после
|
||||
того, как raw space уже выбран правильно;
|
||||
- изменение reconnect/asset-failure контракта #73 для обязательных данных;
|
||||
- schema/config migration, import/export и новые compatibility-поля;
|
||||
- отдельная оптимизация производительности или переработка всего boot lifecycle.
|
||||
|
||||
## 7. Контракт поведения
|
||||
|
||||
### 7.1. Инвариант выбранного пространства
|
||||
|
||||
Если принятая серверная конфигурация содержит хотя бы одно пространство, перед
|
||||
публикацией spatial candidate одновременно выполняются условия:
|
||||
|
||||
- `_space` равен ID существующего пространства;
|
||||
- model space и exact raw space описывают один и тот же ID;
|
||||
- ровно одна вкладка full card имеет active-состояние, кроме киоска, где header
|
||||
намеренно не рендерится;
|
||||
- все raw-space consumers получают один и тот же объект пространства;
|
||||
- несуществующее legacy/stale значение не остаётся скрытым за model fallback.
|
||||
|
||||
Если пространств нет, карточка сохраняет существующий empty/onboarding-контракт:
|
||||
она не обязана выдумывать ID и не падает.
|
||||
|
||||
### 7.2. Приоритет выбора
|
||||
|
||||
Для cold/reload без уже принятого валидного same-route warm viewport применяется
|
||||
существующий порядок, причём каждый кандидат обязан присутствовать в live model:
|
||||
|
||||
1. валидный `#space=<id>`;
|
||||
2. валидное сохранённое пространство `LS_NAV`;
|
||||
3. валидный `default_floor` карточки;
|
||||
4. первое пространство live config.
|
||||
|
||||
Невалидный кандидат пропускается, а не сохраняется как `_space`. Explicit hash
|
||||
по-прежнему выигрывает. Уже принятый валидный same-route warm viewport остаётся
|
||||
существующим continuity-исключением #73/#93 и не сбрасывается менее точным
|
||||
saved/default значением; при отсутствии его пространства в live config порядок
|
||||
выше применяется заново.
|
||||
|
||||
### 7.3. Обязательная и необязательная части загрузки
|
||||
|
||||
Успешный initial snapshot состоит из обязательных шагов:
|
||||
|
||||
1. получить config и layout;
|
||||
2. при структурном изменении подготовить обязательный backdrop;
|
||||
3. принять config/layout, revisions и `can_write`;
|
||||
4. выбрать валидное пространство;
|
||||
5. записать локальный snapshot;
|
||||
6. восстановить применимый viewport/zoom;
|
||||
7. опубликовать полный candidate frame и построить устройства.
|
||||
|
||||
Config/trail/layout event subscriptions являются best-effort live-sync. Ни одна
|
||||
из них не может отменить или задержать перечисленные гарантии после принятия
|
||||
server data.
|
||||
|
||||
### 7.4. Деградация live-sync
|
||||
|
||||
- Каждая подписка устанавливается независимо; отказ одной не запрещает попытки
|
||||
установить остальные.
|
||||
- Успешная подписка сохраняет текущий idempotent one-subscription-per-card
|
||||
контракт и штатно очищается при disconnect.
|
||||
- Неуспешная подписка остаётся доступной для повторной попытки при следующем
|
||||
обычном load/reconnect, но не запускает tight loop и не перечитывает весь
|
||||
snapshot только ради подписки.
|
||||
- Отказ подписки не показывает новый toast и не подменяет принятый config
|
||||
fallback-данными.
|
||||
- Ошибка обязательных config/layout calls, обязательного asset или последующей
|
||||
config reload сохраняет нынешний stale-while-revalidate/recovery-контракт и
|
||||
не маскируется как успех.
|
||||
|
||||
### 7.5. Reload, cache и warm remount
|
||||
|
||||
- После успешного server snapshot `LS_CFG` записывается независимо от результата
|
||||
подписок.
|
||||
- Сохранённый `LS_NAV` применяется после появления live model, даже если до
|
||||
server response не было config-cache, в котором можно было проверить ID.
|
||||
- Технический same-route remount сохраняет валидное пространство по текущему
|
||||
warm/navigation contract; отсутствие cache не возвращает `_space` к `f1`.
|
||||
- Валидный старый cache может дать мгновенный полный кадр, но не является
|
||||
условием корректности.
|
||||
- После исправления клик уже активной вкладки является no-op и не меняет состав
|
||||
сцены.
|
||||
|
||||
## 8. Архитектурный контракт реализации
|
||||
|
||||
Конкретные helper names являются техническим выбором автора, но обязательны
|
||||
следующие границы:
|
||||
|
||||
1. Нормализация пространства является частью принятия structural snapshot и
|
||||
выполняется до первого `await`, который относится только к live-sync.
|
||||
2. Один resolver владеет проверкой существования и cold precedence; setConfig,
|
||||
live load и warm path не должны получать расходящиеся копии правил.
|
||||
3. Model fallback не считается доказательством валидного exact space. После
|
||||
принятия непустого config invariant проверяется по точному ID.
|
||||
4. Подписки изолируют rejection по отдельности; rejected Promise не достигает
|
||||
outer catch обязательной загрузки и не создаёт unhandled rejection.
|
||||
5. Ошибка optional subscription не устанавливает recovery/error state полного
|
||||
кадра и не запускает `_scheduleLoadRetry(true)` сама по себе.
|
||||
6. Существующие `_unsubCfg`, `_unsubTrail`, `_unsubLayout` остаются authority
|
||||
идемпотентности: успешную подписку нельзя дублировать.
|
||||
7. Cache записывает только уже принятые server config/layout и revisions;
|
||||
optional subscription state в persistent data не добавляется.
|
||||
8. Device rebuild и continuity candidate читают уже нормализованное пространство;
|
||||
отдельной исправленной ветки рендера для read-only быть не должно.
|
||||
|
||||
Предполагаемые файлы реализации:
|
||||
|
||||
- `src/houseplan-card.ts`;
|
||||
- при полезном выделении pure resolver — небольшой frontend module;
|
||||
- соответствующий `test/*.test.mjs`;
|
||||
- `demo/smoke_readonly_cold_start.mjs` либо эквивалентный browser scenario;
|
||||
- `docs/ARCHITECTURE.md`, `docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md`.
|
||||
|
||||
## 9. Модель данных, compatibility и миграция
|
||||
|
||||
Форматы `CardConfig`, server config, layout, `LS_CFG`, `LS_NAV` и warm memo не
|
||||
меняются. Новых полей, schema version, backend validation и compatibility aliases
|
||||
нет.
|
||||
|
||||
Сохранённые планы не переписываются. Исправление вычисляется на каждом старте;
|
||||
конфигурации прежней версии остаются читаемыми новой и старой версиями. Прямая
|
||||
и обратная миграция не нужны.
|
||||
|
||||
## 10. UX, i18n, accessibility и touch
|
||||
|
||||
Новых controls, текстов, фокуса, клавиатурных команд или motion нет. Новые
|
||||
i18n-ключи en/ru не требуются.
|
||||
|
||||
Активная вкладка сохраняет существующую семантику кнопки и focus order.
|
||||
Исправление не должно программно переводить фокус и не должно объявлять
|
||||
служебную ошибку подписки через live region.
|
||||
|
||||
View на touch блокирующий: первый кадр телефона/планшета обязан быть полным без
|
||||
тапа по вкладке. В киоске требование ещё строже, потому что header скрыт и обход
|
||||
недоступен. Touch editor остаётся вне скоупа; право редактирования не меняется.
|
||||
|
||||
## 11. Критерии приёмки
|
||||
|
||||
- **AC1 (`unit`):** resolver cold selection покрывает матрицу valid/stale
|
||||
hash, `LS_NAV`, `default_floor`, legacy current ID, одного/нескольких/нулевого
|
||||
числа пространств и всегда возвращает существующий ID при непустом model.
|
||||
- **AC2 (`unit` + `ревью кода`):** после принятия config/layout exact space
|
||||
нормализован и cache/viewport finalization выполнены до optional subscription;
|
||||
rejection любой подписки не может пропустить эти шаги.
|
||||
- **AC3 (`smoke`):** read-only cold start без `LS_CFG`, с пространствами
|
||||
`home/upstairs`, `can_write: false` и rejected houseplan subscriptions сразу
|
||||
имеет `_space = home`, exact raw space `home`, одну active-вкладку и полный
|
||||
набор floor/decor/wall/Glow/device-state слоёв без клика и page error.
|
||||
- **AC4 (`smoke`):** fixed HA snapshot до клика активной вкладки и после её
|
||||
no-op клика имеет одинаковые проверяемые spatial layers; сцена не
|
||||
«дорисовывается».
|
||||
- **AC5 (`unit` + `smoke`):** valid/stale saved/default/hash precedence на
|
||||
нескольких пространствах выбирает ожидаемый live ID; stale `f1` никогда не
|
||||
остаётся exact selection, если такого пространства нет.
|
||||
- **AC6 (`smoke`):** после cold start с rejected subscriptions `LS_CFG`
|
||||
существует; reload и same-route warm remount без предварительного admin-cache
|
||||
сохраняют полный кадр и валидное пространство.
|
||||
- **AC7 (`unit` + `ревью кода`):** rejection config, trail или layout
|
||||
subscription изолирован: остальные подписки всё равно предпринимаются,
|
||||
успешные не дублируются, unhandled rejection и full-load retry storm нет.
|
||||
- **AC8 (`smoke`):** тот же cold сценарий с `kiosk: true` рендерит полный план
|
||||
при скрытом header; никакого пользовательского действия для восстановления
|
||||
не требуется.
|
||||
- **AC9 (`unit` + `ревью кода`):** обязательный `config/get`, `layout/get` или
|
||||
asset failure продолжает действующий stale-while-revalidate/recovery путь;
|
||||
optional и mandatory errors не смешиваются.
|
||||
- **AC10 (`ревью кода`):** исправление не выдаёт право записи, не вызывает
|
||||
write/service API, не показывает редакторы при `can_write: false` и не меняет
|
||||
backend/security boundary.
|
||||
- **AC11 (`unit` + `ревью кода`):** на успешную карточку остаётся не более одной
|
||||
подписки каждого типа; нормализация не добавляется в HA state render hot path.
|
||||
- **AC12 (`build` + `ревью кода`):** оба changelog и архитектурный документ
|
||||
описывают исправление, пользовательское руководство остаётся правдивым без
|
||||
нового контракта, а три bundle snapshot после build побайтно совпадают.
|
||||
|
||||
## 12. План автотестов и проверок
|
||||
|
||||
### Unit
|
||||
|
||||
1. Выделить или напрямую покрыть pure resolution matrix из AC1/AC5.
|
||||
2. Покрыть mandatory/optional orchestration контролируемыми fulfilled/rejected
|
||||
Promises: порядок finalization, независимость трёх подписок, idempotency и
|
||||
отсутствие retry от optional failure.
|
||||
3. Сохранить регрессию обязательного fetch/asset failure из AC9.
|
||||
|
||||
### Browser smoke
|
||||
|
||||
Новый узкий сценарий создаёт отдельную full card с пустым House Plan
|
||||
localStorage, non-admin hass, `can_write: false`, двумя пространствами с ID, не
|
||||
равными `f1`, и управляемыми subscription outcomes. Он проверяет AC3/AC4/AC6/AC8
|
||||
по внутреннему exact state и реальному shadow DOM, а не только по `_model`
|
||||
fallback.
|
||||
|
||||
По действующему решению владельца в цикле реализации запускаются только
|
||||
`typecheck`, unit и build. Smoke добавляется вместе с кодом, а его штатный
|
||||
прогон входит в pre-beta browser-smoke gate; независимый код-ревьюер вправе
|
||||
выполнить целевой сценарий для проверки AC.
|
||||
|
||||
### Golden и ручные изображения
|
||||
|
||||
Нового намеренного визуала нет: правильный результат уже совпадает с состоянием
|
||||
после клика и существующими View/golden. Новые baseline и их принятие не нужны.
|
||||
Скриншоты до/после уже приложены к issue и используются как диагностическая
|
||||
ссылка, не как новый эталон.
|
||||
|
||||
## 13. Производительность и security
|
||||
|
||||
Изменение выполняется один раз на structural load, а не на HA state tick.
|
||||
Дополнительного boolean geometry, DOM-слоя, polling или per-frame resolver нет.
|
||||
Количество успешных подписок не растёт. Performance benchmark и новый budget
|
||||
не нужны; штатный performance gate остаётся pre-beta проверкой.
|
||||
|
||||
Security boundary не ослабляется: read-only-пользователь только использует уже
|
||||
разрешённые read calls. Запрещённая подписка не эмулируется, backend permission
|
||||
не обходится, write API не вызывается. Отдельный security artifact не нужен.
|
||||
|
||||
## 14. Риски
|
||||
|
||||
1. **Регрессия precedence:** повторная нормализация может затереть valid hash или
|
||||
same-route warm viewport. Закрывается AC1/AC5/AC6.
|
||||
2. **Дубликаты подписок:** независимый retry может создать два listener после
|
||||
позднего успеха. Закрывается authority через `_unsub*` и AC7/AC11.
|
||||
3. **Ложный успех mandatory load:** слишком широкий `catch` может скрыть отказ
|
||||
config/layout/asset. Закрывается явной границей phases и AC9.
|
||||
4. **Partial live-sync:** read-only-сессия может не получать последующие внешние
|
||||
изменения. Это допустимая деградация запрещённой подписки, но initial snapshot
|
||||
обязан оставаться полным.
|
||||
5. **Cache leakage между browser tests:** сценарий должен изолировать и очищать
|
||||
House Plan keys, иначе валидный admin-cache маскирует регрессию.
|
||||
|
||||
## 15. Release-артефакты
|
||||
|
||||
Поскольку исправление пользовательски видимо, реализационный коммит содержит:
|
||||
|
||||
- `docs/CHANGELOG.md` — EN bug-fix bulletin со ссылкой на #131;
|
||||
- `docs/CHANGELOG.ru.md` — эквивалентный RU bulletin со ссылкой на #131;
|
||||
- `docs/ARCHITECTURE.md` — граница mandatory initial snapshot и best-effort
|
||||
live-sync subscriptions, плюс invariant exact space;
|
||||
- user guide — **без изменения**: он уже обещает правильный first space,
|
||||
read-only View и полноценный kiosk/touch; задача приводит код к этому тексту;
|
||||
- новый unit и browser regression scenario;
|
||||
- build-синхронизация `dist/houseplan-card.js`,
|
||||
`custom_components/houseplan/frontend/houseplan-card.js` и
|
||||
`demo/srv/assets/houseplan-card.js`.
|
||||
|
||||
Golden baseline, screenshots, migration, backend, performance и security
|
||||
артефакты не создаются по причинам из разделов 9, 12 и 13. Перед бетой идут
|
||||
штатные golden verify, полный browser smoke и performance gate по release runbook.
|
||||
|
||||
## 16. Откат
|
||||
|
||||
Откат — обычный revert frontend-изменения и синхронных документации/changelog/
|
||||
тестов/bundle snapshot. Данные и localStorage форматы не меняются, поэтому
|
||||
чистить cache или восстанавливать конфигурацию не требуется.
|
||||
|
||||
После отката у затронутой read-only-сессии вернётся прежний неполный cold frame;
|
||||
пользовательским временным обходом остаётся выбор пространства там, где header
|
||||
доступен. Feature flag и обратная миграция не нужны.
|
||||
|
||||
## 17. Принятые технические предположения — можно менять без пересмотра продукта
|
||||
|
||||
1. Предпочтительно выделить один pure resolver cold precedence, но точное имя и
|
||||
файл не являются продуктовым решением.
|
||||
2. Подписки можно устанавливать после mandatory finalization последовательно
|
||||
с локальными `try/catch` или общей best-effort orchestration; наблюдаемый
|
||||
контракт и AC важнее формы.
|
||||
3. Повторная попытка rejected subscription использует следующий штатный
|
||||
load/reconnect, без отдельного polling timer.
|
||||
4. Отказ optional subscription остаётся тихим: новый UX ограниченных live
|
||||
updates потребовал бы отдельного продуктового решения.
|
||||
5. Browser smoke может расширить существующий WS/warm lifecycle scenario вместо
|
||||
нового файла, если сохраняет изоляцию cache и все проверки AC.
|
||||
6. Valid same-route warm viewport трактуется как уже принятое пространство, а
|
||||
не как новый пятый cold persistence source; explicit valid hash сохраняет
|
||||
действующий приоритет.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Спецификации задач P1 и P2
|
||||
|
||||
Актуально на 2026-08-14.
|
||||
Актуально на 2026-08-12.
|
||||
|
||||
GitHub Issues и GitHub Projects (v2) остаются единственным каноническим backlog проекта. Этот каталог содержит развёрнутые ТЗ: каждое ТЗ ссылается на issue, а issue — на соответствующий файл. Статус, приоритет и факт завершения меняются только в GitHub.
|
||||
|
||||
@@ -45,7 +45,6 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
|
||||
| [#89](https://github.com/Matysh/houseplan-card/issues/89) Опциональный объёмный 2.5D/изометрический вид | [089-isometric-view.md](089-isometric-view.md) |
|
||||
| [#89](https://github.com/Matysh/houseplan-card/issues/89) Этап 1: объёмный вид за флагом Labs | [089-isometric-view-stage1.md](089-isometric-view-stage1.md) |
|
||||
| [#98](https://github.com/Matysh/houseplan-card/issues/98) Единая система пульсаций и активностей устройства | [098-device-pulse-system.md](098-device-pulse-system.md) |
|
||||
| [#131](https://github.com/Matysh/houseplan-card/issues/131) Полный первый кадр View у read-only-пользователя | [131-readonly-cold-start.md](131-readonly-cold-start.md) |
|
||||
|
||||
## P2
|
||||
|
||||
@@ -78,8 +77,6 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
|
||||
| [#90](https://github.com/Matysh/houseplan-card/issues/90) Управляемый бейдж со значением | [090-device-value-badge.md](090-device-value-badge.md) |
|
||||
| [#94](https://github.com/Matysh/houseplan-card/issues/94) Универсальное действие «Переключить состояние» | [094-universal-state-toggle.md](094-universal-state-toggle.md) |
|
||||
| [#101](https://github.com/Matysh/houseplan-card/issues/101) Плавный переход View ↔ редакторы | [101-view-editor-transition.md](101-view-editor-transition.md) |
|
||||
| [#107](https://github.com/Matysh/houseplan-card/issues/107) Переключение виртуального источника света «Всегда» | [107-virtual-light-toggle.md](107-virtual-light-toggle.md) |
|
||||
| [#122](https://github.com/Matysh/houseplan-card/issues/122) Изометрический режим Stage 2: скрытый режим и визуальная полировка | [122-isometric-stage2.md](122-isometric-stage2.md) |
|
||||
| [#123](https://github.com/Matysh/houseplan-card/issues/123) Split из вершины не меняет наружную геометрию стен | [123-corner-split-wall.md](123-corner-split-wall.md) |
|
||||
|
||||
## Правило актуализации
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.63.0",
|
||||
"version": "1.63.0-beta.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "houseplan-card",
|
||||
"version": "1.63.0",
|
||||
"version": "1.63.0-beta.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lit": "^3.1.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.63.0",
|
||||
"version": "1.63.0-beta.1",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// Реестр известных поломок (mutation gate), issue #85.
|
||||
//
|
||||
// node scripts/mutation-gate.mjs --list мутанты и кого они проверяют
|
||||
// node scripts/mutation-gate.mjs --check патчи применимы к текущему коду
|
||||
// node scripts/mutation-gate.mjs полный прогон: все мутанты
|
||||
// node scripts/mutation-gate.mjs --id=<mutant> один мутант
|
||||
// node scripts/mutation-gate.mjs --build-only применить и собрать, тест не гонять
|
||||
//
|
||||
// Код выхода: 0 — каждый тест поймал свою поломку, 1 — хотя бы один не поймал,
|
||||
// 2 — не смог проверить (патч не лёг, сборка упала).
|
||||
//
|
||||
// Зачем. Зелёный тест в этом проекте несколько раз означал «ничего не
|
||||
// проверено», и выяснялось это после того, как баг доезжал до владельца:
|
||||
// смок непрерывности не заметил удаления механизма, который защищает; golden,
|
||||
// заведённый под #71, был пуст — 1 177 тёплых пикселей против 107 119, и все
|
||||
// 1 177 были иконками. Общее у всех случаев: тест ни разу не проверяли на
|
||||
// способность падать. Этот гейт делает такую проверку регулярной.
|
||||
//
|
||||
// Каждый мутант — маленький патч продуктового исходника, воспроизводящий
|
||||
// известную поломку, и имя теста, который ОБЯЗАН на ней покраснеть. Прогон:
|
||||
// worktree → патч → сборка бандла → бандл в demo/srv/assets → тест. Тест,
|
||||
// оставшийся зелёным, — это провал гейта, а не успех теста.
|
||||
//
|
||||
// Прогон дорогой (пересборка бандла на мутанта), поэтому его место — перед
|
||||
// стабильным релизом (.github/workflows/mutation-gate.yml), не на каждой бете.
|
||||
// Дешёвая часть — «патчи применимы, guard-файлы существуют» — живёт в
|
||||
// test/mutation-gate.test.mjs и идёт с обычными юнитами: реестр, отставший от
|
||||
// кода, хуже отсутствующего, потому что выглядит защитой.
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import {
|
||||
cpSync, existsSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// --- реестр ---------------------------------------------------------------
|
||||
// `find` обязан встречаться в файле ровно один раз: патч, который ложится «куда
|
||||
// попало», проверяет не то, что объявлен проверять. Это контролирует --check.
|
||||
export const MUTANTS = [
|
||||
{
|
||||
id: 'continuity-long-resume-noop',
|
||||
guard: 'node demo/smoke_visual_continuity.mjs',
|
||||
because: 'смок обязан доказывать, что long-resume СРАБОТАЛ (токен ушёл вперёд), '
|
||||
+ 'а не что ничего не спрятали: в beta.7 он не заметил удаления всего механизма',
|
||||
patches: [{
|
||||
file: 'src/visual-continuity.ts',
|
||||
find: "if (!signal.long && (signal.kind === 'visible' || signal.kind === 'pageshow')) {",
|
||||
replace: "if ((signal.kind === 'visible' || signal.kind === 'pageshow')) {",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'opening-cut-degenerate',
|
||||
guard: 'node demo/smoke_glow.mjs',
|
||||
because: 'проём, выродившийся в точку, остаётся кладкой — свет перестаёт '
|
||||
+ 'проходить через дверь; смок обязан это увидеть по освещённому полу за проёмом',
|
||||
patches: [{
|
||||
file: 'src/houseplan-card.ts',
|
||||
find: 'cuts.push([o.rx - dx, o.ry - dy, o.rx + dx, o.ry + dy]);',
|
||||
replace: 'cuts.push([o.rx, o.ry, o.rx, o.ry]);',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'column-shadow-removed',
|
||||
guard: 'node demo/smoke_glow.mjs',
|
||||
because: 'физические тела выпали из окклюдеров — колонна перестаёт отбрасывать '
|
||||
+ 'тень; исторически смок теней был зелёным, пока тени физически не рисовались',
|
||||
patches: [{
|
||||
file: 'src/houseplan-card.ts',
|
||||
find: 'for (const body of physical) occluders.push(...polygonSegments(body));',
|
||||
replace: 'void physical;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'feather-20px',
|
||||
guard: 'node demo/smoke_glow.mjs',
|
||||
because: 'растушёвка 20 px вместо 2 размывает границу света на полкомнаты; '
|
||||
+ 'смок читает data-feather-px и обязан отвергнуть значение больше 3',
|
||||
patches: [{
|
||||
file: 'src/houseplan-card.ts',
|
||||
find: 'const GLOW_EDGE_FEATHER_PX = 2;',
|
||||
replace: 'const GLOW_EDGE_FEATHER_PX = 20;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'barrier-cache-never-invalidated',
|
||||
guard: 'node demo/smoke_openwall.mjs',
|
||||
because: 'кэш барьеров, который не инвалидируется по содержимому, — это свет '
|
||||
+ 'сквозь стену, которая уже существует; смок переключает виртуальную границу '
|
||||
+ 'и обязан увидеть смену освещённости соседней комнаты',
|
||||
patches: [{
|
||||
file: 'src/houseplan-card.ts',
|
||||
find: 'if (this._lightBarrierCache?.key === cacheKey) return this._lightBarrierCache.value;',
|
||||
replace: 'if (this._lightBarrierCache) return this._lightBarrierCache.value;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'golden-lamp-out-of-reach',
|
||||
guard: 'node demo/golden/run.mjs --mode=verify --scenario=lighting-opaque-glow-two-doorways-dark',
|
||||
because: 'сцена заведена как защита дверного света (#71) и однажды уже была '
|
||||
+ 'пустой: лампа стояла так, что пятно не доходило до стены. Уведённая лампа '
|
||||
+ 'обязана ронять семантический ассерт сцены, а не только пиксельный дифф',
|
||||
// Мутируется фикстура сцены, не продуктовый код: пустота сцены — свойство
|
||||
// фикстуры. Пересборка бандла всё равно нужна, путь тот же.
|
||||
patches: [{
|
||||
file: 'demo/golden/matrix.mjs',
|
||||
find: "layoutOverrides: { 'golden-light-one': { s: 'golden-lighting', x: 0.40, y: 0.48 } },",
|
||||
replace: "layoutOverrides: { 'golden-light-one': { s: 'golden-lighting', x: 0.06, y: 0.90 } },",
|
||||
}],
|
||||
},
|
||||
];
|
||||
|
||||
// --- механика ---------------------------------------------------------------
|
||||
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
|
||||
export function applyPatches(root, patches) {
|
||||
for (const patch of patches) {
|
||||
const path = join(root, patch.file);
|
||||
const source = readFileSync(path, 'utf8');
|
||||
const hits = source.split(patch.find).length - 1;
|
||||
if (hits !== 1) {
|
||||
throw new Error(`${patch.file}: якорь найден ${hits} раз(а), нужен ровно 1 — реестр отстал от кода`);
|
||||
}
|
||||
writeFileSync(path, source.replace(patch.find, patch.replace));
|
||||
}
|
||||
}
|
||||
|
||||
function sh(cmd, cwd, extraEnv = {}) {
|
||||
return spawnSync(cmd, {
|
||||
cwd, shell: true, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
|
||||
env: { ...process.env, ...extraEnv },
|
||||
});
|
||||
}
|
||||
|
||||
function makeWorktree() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'hp-mutant-'));
|
||||
const added = spawnSync('git', ['-C', repoRoot, 'worktree', 'add', '--detach', dir, 'HEAD'],
|
||||
{ encoding: 'utf8' });
|
||||
if (added.status !== 0) throw new Error(`git worktree add: ${added.stderr}`);
|
||||
// node_modules не копируется — символическая ссылка на настоящий. Установка
|
||||
// зависимостей на каждого мутанта превратила бы вечерний гейт в суточный.
|
||||
symlinkSync(join(repoRoot, 'node_modules'), join(dir, 'node_modules'), 'junction');
|
||||
return dir;
|
||||
}
|
||||
|
||||
function dropWorktree(dir) {
|
||||
spawnSync('git', ['-C', repoRoot, 'worktree', 'remove', '--force', dir], { encoding: 'utf8' });
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function buildBundle(dir) {
|
||||
// Только rollup, без tsc --noEmit: мутант имеет право быть нестрогим по
|
||||
// типам — он воспроизводит поломку, а не образцовый код.
|
||||
const built = sh('npx rollup -c', dir);
|
||||
if (built.status !== 0) {
|
||||
throw new Error(`сборка мутанта упала:\n${(built.stderr || built.stdout).slice(-2000)}`);
|
||||
}
|
||||
cpSync(join(dir, 'dist', 'houseplan-card.js'), join(dir, 'demo', 'srv', 'assets', 'houseplan-card.js'));
|
||||
}
|
||||
|
||||
function runMutant(mutant) {
|
||||
const dir = makeWorktree();
|
||||
try {
|
||||
applyPatches(dir, mutant.patches);
|
||||
buildBundle(dir);
|
||||
const guard = sh(mutant.guard, dir);
|
||||
if (guard.status === 0) {
|
||||
console.log(`FAIL ${mutant.id}: тест остался зелёным на сломанном коде`);
|
||||
console.log(` guard: ${mutant.guard}`);
|
||||
console.log(` ${mutant.because}`);
|
||||
return false;
|
||||
}
|
||||
console.log(`ok ${mutant.id}: тест покраснел, как обязан`);
|
||||
return true;
|
||||
} finally {
|
||||
dropWorktree(dir);
|
||||
}
|
||||
}
|
||||
|
||||
// Чистый прогон каждого guard ровно один раз: тест, красный и без мутанта,
|
||||
// «ловит» поломку тривиально и не доказывает ничего.
|
||||
function runCleanGuards(mutants) {
|
||||
const guards = [...new Set(mutants.map((m) => m.guard))];
|
||||
const dir = makeWorktree();
|
||||
try {
|
||||
buildBundle(dir);
|
||||
for (const guard of guards) {
|
||||
const result = sh(guard, dir);
|
||||
if (result.status !== 0) {
|
||||
console.log(`FAIL чистый прогон: ${guard} красный без мутанта`);
|
||||
console.log((result.stderr || result.stdout).slice(-1500));
|
||||
return false;
|
||||
}
|
||||
console.log(`ok чистый прогон: ${guard}`);
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
dropWorktree(dir);
|
||||
}
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
const idArg = argv.find((a) => a.startsWith('--id='))?.slice(5);
|
||||
const selected = idArg ? MUTANTS.filter((m) => m.id === idArg) : MUTANTS;
|
||||
if (idArg && !selected.length) {
|
||||
console.error(`мутант «${idArg}» не объявлен; --list покажет реестр`);
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (argv.includes('--list')) {
|
||||
for (const m of MUTANTS) console.log(`${m.id}\n guard: ${m.guard}\n ${m.because}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (argv.includes('--check')) {
|
||||
let stale = 0;
|
||||
for (const m of selected) {
|
||||
try {
|
||||
for (const patch of m.patches) {
|
||||
const source = readFileSync(join(repoRoot, patch.file), 'utf8');
|
||||
const hits = source.split(patch.find).length - 1;
|
||||
if (hits !== 1) throw new Error(`якорь найден ${hits} раз(а)`);
|
||||
}
|
||||
console.log(`ok ${m.id}`);
|
||||
} catch (error) {
|
||||
console.log(`FAIL ${m.id}: ${error.message}`);
|
||||
stale++;
|
||||
}
|
||||
}
|
||||
return stale ? 2 : 0;
|
||||
}
|
||||
|
||||
if (argv.includes('--build-only')) {
|
||||
for (const m of selected) {
|
||||
const dir = makeWorktree();
|
||||
try {
|
||||
applyPatches(dir, m.patches);
|
||||
buildBundle(dir);
|
||||
console.log(`ok ${m.id}: патч лёг, бандл собрался`);
|
||||
} catch (error) {
|
||||
console.log(`FAIL ${m.id}: ${error.message}`);
|
||||
return 2;
|
||||
} finally {
|
||||
dropWorktree(dir);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!runCleanGuards(selected)) return 2;
|
||||
let caught = 0;
|
||||
for (const m of selected) if (runMutant(m)) caught++;
|
||||
console.log(`\nпоймано ${caught} из ${selected.length}`);
|
||||
return caught === selected.length ? 0 : 1;
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
process.exit(main(process.argv.slice(2)));
|
||||
}
|
||||
@@ -67,12 +67,6 @@ const CLASS_C = [
|
||||
|
||||
const CHANGELOGS = ['docs/CHANGELOG.md', 'docs/CHANGELOG.ru.md'];
|
||||
|
||||
// Метки, при которых файла ТЗ в docs/specs/ быть не должно: на лёгком треке ТЗ
|
||||
// живёт в теле issue (§5), на коротком — там же, и ревью ТЗ вообще не проводится
|
||||
// (§5.1, issue #128). Офлайн эти случаи неотличимы от «ТЗ не написано», поэтому
|
||||
// проверка 3 краснеет только когда метки прочитаны.
|
||||
export const NO_SPEC_FILE = ['small', 'trivial'];
|
||||
|
||||
export const ALLOWED_STATUS = ['S5-ready', 'S6-in-progress', 'S7-code-review', 'S8-merged'];
|
||||
export const STRICT_STATUS = ['S5-ready', 'S6-in-progress', 'S7-code-review'];
|
||||
|
||||
@@ -241,12 +235,12 @@ export function checkSpecs(commits, specFiles, labelsOf = null) {
|
||||
if (labels === null) {
|
||||
out.push({
|
||||
level: 'warn', rule: 3, sha: c.short,
|
||||
msg: `класс A по ${t}, но ТЗ docs/specs/${nn}-*.md не найдено — допустимо при метке small или trivial`,
|
||||
msg: `класс A по ${t}, но ТЗ docs/specs/${nn}-*.md не найдено — допустимо только при метке small`,
|
||||
});
|
||||
} else if (!labels.some((l) => NO_SPEC_FILE.includes(l))) {
|
||||
} else if (!labels.includes('small')) {
|
||||
out.push({
|
||||
level: 'fail', rule: 3, sha: c.short,
|
||||
msg: `класс A по ${t}: ТЗ docs/specs/${nn}-*.md нет, и метки ${NO_SPEC_FILE.join(' / ')} на issue нет — код без ТЗ`,
|
||||
msg: `класс A по ${t}: ТЗ docs/specs/${nn}-*.md нет, и метки small на issue нет — код без ТЗ`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -289,22 +283,6 @@ export function commitsUnderRuleOne(commits) {
|
||||
);
|
||||
}
|
||||
|
||||
export function isStableTarget(targetRef) {
|
||||
return /^(?:refs\/heads\/)?main$/.test(targetRef ?? '');
|
||||
}
|
||||
|
||||
// При stable promotion диапазон main..candidate закономерно содержит коммиты,
|
||||
// уже выпущенные prerelease-тегом. Их issue к этому моменту обязаны быть закрыты
|
||||
// (§2.8), поэтому повторная online-проверка статуса дала бы ложный отказ. Новые
|
||||
// post-beta коммиты остаются в выборке и проверяются fail-closed как обычно.
|
||||
export function commitsNeedingIssueStatus(
|
||||
commits, { targetRef = '', isPublishedPrereleaseCommit = () => false } = {},
|
||||
) {
|
||||
const underRuleOne = commitsUnderRuleOne(commits);
|
||||
if (!isStableTarget(targetRef)) return underRuleOne;
|
||||
return underRuleOne.filter((commit) => !isPublishedPrereleaseCommit(commit.sha));
|
||||
}
|
||||
|
||||
// 8. статус issue. Fail closed: недоступный или закрытый issue — отказ, а не
|
||||
// пропуск. Гейт, который молчит при недоступном источнике правды, бесполезен.
|
||||
export function checkIssueStatuses(numbers, runner, { allowed = ALLOWED_STATUS } = {}) {
|
||||
@@ -398,7 +376,6 @@ function main(argv) {
|
||||
|
||||
const repo = value('repo', process.cwd());
|
||||
const allowed = flag('no-merged') ? STRICT_STATUS : ALLOWED_STATUS;
|
||||
const targetRef = value('target-ref', process.env.TARGET_REF ?? '');
|
||||
|
||||
let range = value('range');
|
||||
if (!range && flag('github-range')) {
|
||||
@@ -445,23 +422,7 @@ function main(argv) {
|
||||
// проверки 3. Второй запрос по тому же issue — лишний сетевой вызов.
|
||||
let labelsOf = null;
|
||||
if (flag('issues')) {
|
||||
const prereleaseTags = isStableTarget(targetRef)
|
||||
? git(['tag', '--list'], repo).split('\n').map((s) => s.trim()).filter((tag) =>
|
||||
/^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)-[0-9A-Za-z.-]+$/.test(tag))
|
||||
: [];
|
||||
const publishedCache = new Map();
|
||||
const isPublishedPrereleaseCommit = (sha) => {
|
||||
if (!publishedCache.has(sha)) {
|
||||
publishedCache.set(sha, prereleaseTags.some((tag) =>
|
||||
spawnSync('git', ['-C', repo, 'merge-base', '--is-ancestor', sha, `${tag}^{commit}`],
|
||||
{ encoding: 'utf8' }).status === 0));
|
||||
}
|
||||
return publishedCache.get(sha);
|
||||
};
|
||||
const statusCommits = commitsNeedingIssueStatus(commits, {
|
||||
targetRef, isPublishedPrereleaseCommit,
|
||||
});
|
||||
const numbers = [...new Set(statusCommits.flatMap((c) => c.issues).map((t) => t.slice(1)))];
|
||||
const numbers = [...new Set(commitsUnderRuleOne(commits).flatMap((c) => c.issues).map((t) => t.slice(1)))];
|
||||
const runner = ghRunner(process.env.HP_REPO ?? 'Matysh/houseplan-card', process.env.GH_BIN ?? 'gh');
|
||||
const cache = new Map();
|
||||
const cached = (nn) => {
|
||||
|
||||
@@ -9,12 +9,6 @@
|
||||
* so embedded cards paint immediately, then refresh from the server in the background.
|
||||
*/
|
||||
import { contentFingerprint } from './visual-continuity';
|
||||
import {
|
||||
adoptVirtualLightServerSnapshot,
|
||||
applyVirtualLightEvent,
|
||||
virtualLightSnapshot,
|
||||
type VirtualLightSnapshot,
|
||||
} from './virtual-light-state';
|
||||
|
||||
const LS_CFG = 'houseplan_card_cfg_v1';
|
||||
|
||||
@@ -25,21 +19,15 @@ export interface HpConfigSnapshot {
|
||||
layout: Record<string, any>;
|
||||
layoutRev: number;
|
||||
layoutFingerprint: string;
|
||||
virtualLights: VirtualLightSnapshot;
|
||||
}
|
||||
|
||||
let cache: HpConfigSnapshot | null = null;
|
||||
let inflight: Promise<HpConfigSnapshot> | null = null;
|
||||
let fetchGeneration = 0;
|
||||
let inflightGeneration = -1;
|
||||
let subscribedConnection: any = null;
|
||||
let subscriptionUnsubscribers: Array<() => void> = [];
|
||||
let subscribed = false;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
const keepUnsubscriber = (value: unknown, target: Array<() => void>): void => {
|
||||
if (typeof value === 'function') target.push(value as () => void);
|
||||
};
|
||||
|
||||
/** Instant, synchronous best-effort snapshot from the full card's localStorage cache. */
|
||||
export function cachedSnapshot(): HpConfigSnapshot | null {
|
||||
if (cache) return cache;
|
||||
@@ -54,7 +42,6 @@ export function cachedSnapshot(): HpConfigSnapshot | null {
|
||||
layout,
|
||||
layoutRev: c.layout_rev || 0,
|
||||
layoutFingerprint: c.layout_fingerprint || contentFingerprint(layout),
|
||||
virtualLights: virtualLightSnapshot(c.virtual_lights, c.rev || 0),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
@@ -68,70 +55,34 @@ async function fetchFresh(hass: any, generation: number): Promise<HpConfigSnapsh
|
||||
hass.callWS({ type: 'houseplan/config/get' }),
|
||||
hass.callWS({ type: 'houseplan/layout/get' }),
|
||||
]);
|
||||
const configRev = cfgResp?.rev ?? 0;
|
||||
const virtualLights = cache
|
||||
? adoptVirtualLightServerSnapshot(
|
||||
cache.virtualLights,
|
||||
cfgResp?.virtual_lights,
|
||||
configRev,
|
||||
!!cfgResp && 'virtual_lights' in cfgResp,
|
||||
)
|
||||
: virtualLightSnapshot(cfgResp?.virtual_lights, configRev);
|
||||
const snapshot: HpConfigSnapshot = {
|
||||
config: cfgResp?.config ?? null,
|
||||
rev: configRev,
|
||||
rev: cfgResp?.rev ?? 0,
|
||||
configFingerprint: contentFingerprint(cfgResp?.config ?? null),
|
||||
layout: layResp?.layout ?? {},
|
||||
layoutRev: layResp?.rev ?? 0,
|
||||
layoutFingerprint: contentFingerprint(layResp?.layout ?? {}),
|
||||
virtualLights,
|
||||
};
|
||||
// A force request may arrive while this fetch is in flight. Its older
|
||||
// response remains useful to its original caller, but must not repopulate
|
||||
// the shared cache after the newer generation invalidated it.
|
||||
if (generation === fetchGeneration) cache = snapshot;
|
||||
const connection = hass.connection;
|
||||
if (connection?.subscribeEvents && subscribedConnection !== connection) {
|
||||
for (const unsubscribe of subscriptionUnsubscribers) unsubscribe();
|
||||
subscriptionUnsubscribers = [];
|
||||
subscribedConnection = connection;
|
||||
if (!subscribed && hass.connection?.subscribeEvents) {
|
||||
subscribed = true;
|
||||
const invalidate = () => {
|
||||
cache = null; // invalidate; listeners reload
|
||||
fetchGeneration++;
|
||||
listeners.forEach((l) => l());
|
||||
};
|
||||
const pendingUnsubscribers: Array<() => void> = [];
|
||||
try {
|
||||
keepUnsubscriber(
|
||||
await connection.subscribeEvents(invalidate, 'houseplan_config_updated'),
|
||||
pendingUnsubscribers,
|
||||
);
|
||||
await hass.connection.subscribeEvents(invalidate, 'houseplan_config_updated');
|
||||
// Layout is separate state: dragging an icon on the full card writes only
|
||||
// the layout, so a static card on the same dashboard kept showing the old
|
||||
// position until the config changed or the page was reloaded — possibly
|
||||
// forever on a wall tablet (HP-1454-08).
|
||||
keepUnsubscriber(
|
||||
await connection.subscribeEvents(invalidate, 'houseplan_layout_updated'),
|
||||
pendingUnsubscribers,
|
||||
);
|
||||
keepUnsubscriber(await connection.subscribeEvents((event: any) => {
|
||||
if (cache) {
|
||||
const next = applyVirtualLightEvent(cache.virtualLights, event?.data);
|
||||
if (next === cache.virtualLights) return;
|
||||
cache = { ...cache, virtualLights: next };
|
||||
} else {
|
||||
fetchGeneration++;
|
||||
}
|
||||
listeners.forEach((listener) => listener());
|
||||
}, 'houseplan_virtual_light_updated'), pendingUnsubscribers);
|
||||
if (subscribedConnection === connection) {
|
||||
subscriptionUnsubscribers = pendingUnsubscribers;
|
||||
} else {
|
||||
for (const unsubscribe of pendingUnsubscribers) unsubscribe();
|
||||
}
|
||||
await hass.connection.subscribeEvents(invalidate, 'houseplan_layout_updated');
|
||||
} catch {
|
||||
for (const unsubscribe of pendingUnsubscribers) unsubscribe();
|
||||
if (subscribedConnection === connection) subscribedConnection = null;
|
||||
subscribed = false;
|
||||
}
|
||||
}
|
||||
return snapshot;
|
||||
|
||||
@@ -24,7 +24,6 @@ import type { DevItem } from './types';
|
||||
import { safeStoredColor } from './color';
|
||||
import { resolveDeviceValueBadge, type ResolvedValueBadge } from './device-value-badge';
|
||||
import { resolveDevicePulse, type ResolvedDevicePulse } from './device-pulse';
|
||||
import { isManualVirtualLightMarker } from './virtual-light-state';
|
||||
|
||||
export type PresentationSourceKind =
|
||||
| 'cover' | 'light' | 'controls' | 'device_role' | 'primary' | 'none';
|
||||
@@ -249,14 +248,9 @@ export function resolvePresentationSources(
|
||||
source.device.id === d.id && source.via !== 'controls'
|
||||
);
|
||||
const localOwned = localLights.filter((source) => source.via !== 'controls');
|
||||
const ownedLights = globalOwned.length ? globalOwned : localOwned;
|
||||
// In #107's exact manual mode, outgoing controls remain saved and active in
|
||||
// the plan graph, but they do not own this marker's face. Its icon/status is
|
||||
// the same canonical manual source used by Glow, room fill and statistics.
|
||||
const manualVirtualFace = isManualVirtualLightMarker(d.marker);
|
||||
const lights = manualVirtualFace ? [...ownedLights] : [
|
||||
const lights = [
|
||||
...localLights.filter((source) => source.via === 'controls'),
|
||||
...ownedLights,
|
||||
...(globalOwned.length ? globalOwned : localOwned),
|
||||
];
|
||||
if (markerRefs.size) {
|
||||
for (const source of globalLights) {
|
||||
@@ -283,8 +277,7 @@ export function resolvePresentationSources(
|
||||
sourceKind = 'cover';
|
||||
visualSources = [sourceOf(hass, cover, 'cover')];
|
||||
} else if (lights.length) {
|
||||
sourceKind = !manualVirtualFace && lights.some((source) => source.via === 'controls')
|
||||
? 'controls' : 'light';
|
||||
sourceKind = lights.some((source) => source.via === 'controls') ? 'controls' : 'light';
|
||||
visualSources = lights.map((source) => {
|
||||
const role = source.via === 'controls' ? 'control'
|
||||
: source.via === 'forced' ? 'forced_light' : 'light';
|
||||
|
||||
@@ -14,11 +14,6 @@ import {
|
||||
} from './devices';
|
||||
import { COVER_GUARDED_CLASSES, isControllable } from './logic';
|
||||
import type { DevItem } from './types';
|
||||
import {
|
||||
isManualVirtualLightMarker,
|
||||
virtualLightIsOn,
|
||||
type VirtualLightSnapshot,
|
||||
} from './virtual-light-state';
|
||||
|
||||
export type ToggleOrigin = 'explicit-toggle' | 'default-light' | 'legacy-cover';
|
||||
export type ToggleSemantics = 'power' | 'group-power' | 'cover' | 'valve';
|
||||
@@ -37,8 +32,7 @@ export type ToggleTargetVia =
|
||||
| 'binding'
|
||||
| 'device-role'
|
||||
| 'control-entity'
|
||||
| 'control-marker-driver'
|
||||
| 'virtual-light';
|
||||
| 'control-marker-driver';
|
||||
|
||||
export interface ResolvedToggleTarget {
|
||||
entityId: string;
|
||||
@@ -60,10 +54,6 @@ export interface ToggleCommand {
|
||||
data: { entity_id: string | string[] };
|
||||
}
|
||||
|
||||
export type ToggleOperation =
|
||||
| { kind: 'ha-service'; command: ToggleCommand }
|
||||
| { kind: 'virtual-light'; markerId: string };
|
||||
|
||||
export interface ResolvedToggleIntent {
|
||||
origin: ToggleOrigin;
|
||||
kind: 'single' | 'group' | 'none';
|
||||
@@ -74,8 +64,6 @@ export interface ResolvedToggleIntent {
|
||||
noneReason: ToggleNoneReason | null;
|
||||
nextEffect: ToggleNextEffect | null;
|
||||
command: ToggleCommand | null;
|
||||
/** Non-HA operational target. HA intents continue to expose `command`. */
|
||||
operation?: ToggleOperation | null;
|
||||
}
|
||||
|
||||
export interface ResolveToggleOptions {
|
||||
@@ -86,7 +74,6 @@ export interface ResolveToggleOptions {
|
||||
device: DevItem;
|
||||
/** Reuse a plan-wide light graph when the caller already has one. */
|
||||
lightSources?: readonly ResolvedLightSource<DevItem>[];
|
||||
virtualLights?: VirtualLightSnapshot | null;
|
||||
}
|
||||
|
||||
type PowerService = 'turn_on' | 'turn_off' | 'toggle';
|
||||
@@ -493,8 +480,7 @@ function resolveControls(options: ResolveToggleOptions): ResolvedToggleIntent {
|
||||
const refs = persistedExternalControls(
|
||||
device.marker?.binding, device.marker?.controls ?? device.controls, device.entities,
|
||||
);
|
||||
const sources = options.lightSources
|
||||
|| resolvedLightSources(hass, devices, null, options.virtualLights);
|
||||
const sources = options.lightSources || resolvedLightSources(hass, devices);
|
||||
const markerSources = new Map<string, ResolvedLightSource<DevItem>[]>();
|
||||
for (const source of sources) {
|
||||
if (!source.key.startsWith('marker:')) continue;
|
||||
@@ -607,30 +593,6 @@ export function resolveToggleIntent(options: ResolveToggleOptions): ResolvedTogg
|
||||
const origin = toggleOriginOf(device);
|
||||
if (!origin) return null;
|
||||
|
||||
// This operational target intentionally precedes `controls`: controls stay
|
||||
// persisted losslessly, but while the exact manual mode is active a tap
|
||||
// changes the marker's own server state and never calls an HA service.
|
||||
const marker = device.marker;
|
||||
if (origin === 'explicit-toggle' && isManualVirtualLightMarker(marker)) {
|
||||
const on = virtualLightIsOn(marker, options.virtualLights);
|
||||
return {
|
||||
origin,
|
||||
kind: 'single',
|
||||
semantics: 'power',
|
||||
targets: [{
|
||||
entityId: '',
|
||||
name: device.name,
|
||||
state: on ? 'on' : 'off',
|
||||
via: 'virtual-light',
|
||||
}],
|
||||
skippedTargets: [],
|
||||
noneReason: null,
|
||||
nextEffect: on ? 'turn-off' : 'turn-on',
|
||||
command: null,
|
||||
operation: { kind: 'virtual-light', markerId: marker!.id! },
|
||||
};
|
||||
}
|
||||
|
||||
if (origin === 'explicit-toggle') {
|
||||
const refs = persistedExternalControls(
|
||||
device.marker?.binding, device.marker?.controls ?? device.controls, device.entities,
|
||||
@@ -676,27 +638,6 @@ export function sameToggleCommandTargets(a: ToggleCommand | null, b: ToggleComma
|
||||
return left.length === right.length && left.every((entityId, index) => entityId === right[index]);
|
||||
}
|
||||
|
||||
export function toggleOperation(intent: ResolvedToggleIntent | null): ToggleOperation | null {
|
||||
if (!intent) return null;
|
||||
if (intent.operation) return intent.operation;
|
||||
return intent.command ? { kind: 'ha-service', command: intent.command } : null;
|
||||
}
|
||||
|
||||
/** Stable confirmation identity; direction is deliberately re-resolved later. */
|
||||
export function sameToggleOperationTargets(
|
||||
a: ResolvedToggleIntent | null,
|
||||
b: ResolvedToggleIntent | null,
|
||||
): boolean {
|
||||
const left = toggleOperation(a);
|
||||
const right = toggleOperation(b);
|
||||
if (!left || !right || left.kind !== right.kind) return false;
|
||||
if (left.kind === 'virtual-light' && right.kind === 'virtual-light') {
|
||||
return left.markerId === right.markerId;
|
||||
}
|
||||
return left.kind === 'ha-service' && right.kind === 'ha-service'
|
||||
&& sameToggleCommandTargets(left.command, right.command);
|
||||
}
|
||||
|
||||
export interface ToggleIntentFormatter {
|
||||
single: (target: ResolvedToggleTarget | SkippedToggleTarget) => string;
|
||||
group: (targets: readonly ResolvedToggleTarget[]) => string;
|
||||
|
||||
@@ -6,12 +6,6 @@ import { iconFor, iconFromDeviceClasses, DOMAIN_PRIORITY, FALLBACK_ICON, type Co
|
||||
import { averageLqi, isControllable } from './logic';
|
||||
import { isDevicePowerSwitch, isSemanticBinaryEntity } from './device-visual';
|
||||
import type { DevItem, Marker, ServerConfig } from './types';
|
||||
import {
|
||||
isManualVirtualLightMarker,
|
||||
virtualLightFingerprint,
|
||||
virtualLightIsOn,
|
||||
type VirtualLightSnapshot,
|
||||
} from './virtual-light-state';
|
||||
import {
|
||||
activeRegistryHass, fullRegistryHass, haRegistrySnapshot, isRegistryEntryEnabled,
|
||||
resolveHaBindingStatus, type HaRegistrySnapshot,
|
||||
@@ -328,7 +322,6 @@ const LIGHT_GRAPH_CACHE = new WeakMap<object, CachedLightGraph<any>>();
|
||||
interface CachedResolvedLightSources<D extends LightSourceDevice> {
|
||||
graphFingerprint: string;
|
||||
stateFingerprint: string;
|
||||
virtualLightFingerprint: string;
|
||||
registry: unknown;
|
||||
sources: ResolvedLightSource<D>[];
|
||||
}
|
||||
@@ -341,7 +334,6 @@ function lightGraphFingerprint(devices: readonly LightSourceDevice[]): string {
|
||||
[...device.entities].join(','), device.controls === undefined
|
||||
? '<runtime-undefined>' : [...device.controls].join(','),
|
||||
device.marker?.id || '', device.marker?.binding || '', device.marker?.is_light,
|
||||
(device.marker as any)?.tap_action || '', (device.marker as any)?.removed === true ? 1 : 0,
|
||||
device.marker?.light_entity || '', device.marker?.controls == null
|
||||
? '<persisted-null>' : [...device.marker.controls].join(','),
|
||||
].join('\u001f')).join('\u001e');
|
||||
@@ -459,22 +451,19 @@ export function resolvedLightSources<D extends LightSourceDevice>(
|
||||
hass: any,
|
||||
devices: readonly D[],
|
||||
room?: LightSourceRoom | null,
|
||||
virtualLights?: VirtualLightSnapshot | null,
|
||||
): ResolvedLightSource<D>[] {
|
||||
// Resolve the graph once per frame and scope by the source owner's precise
|
||||
// room binding. This preserves cross-room controllers while avoiding a full
|
||||
// O(devices + links) graph walk for every room consumer.
|
||||
if (room != null) {
|
||||
return resolvedLightSources(hass, devices, null, virtualLights).filter((source) =>
|
||||
return resolvedLightSources(hass, devices).filter((source) =>
|
||||
lightSourceBelongsToRoom(source.device, room));
|
||||
}
|
||||
const graphFingerprint = lightGraphFingerprint(devices);
|
||||
const stateFingerprint = lightStateFingerprint(hass, devices);
|
||||
const manualFingerprint = virtualLightFingerprint(virtualLights);
|
||||
const cached = RESOLVED_LIGHT_CACHE.get(devices as object) as CachedResolvedLightSources<D> | undefined;
|
||||
if (cached?.graphFingerprint === graphFingerprint
|
||||
&& cached.stateFingerprint === stateFingerprint
|
||||
&& cached.virtualLightFingerprint === manualFingerprint
|
||||
&& cached.registry === hass?.entities) return cached.sources;
|
||||
|
||||
type Candidate = ResolvedLightSource<D>;
|
||||
@@ -539,11 +528,6 @@ export function resolvedLightSources<D extends LightSourceDevice>(
|
||||
const markerId = source.key.slice('marker:'.length);
|
||||
const control = incoming.get(markerId);
|
||||
source.on = !control?.linked || [...control.drivers].some((eid) => hass.states?.[eid]?.state === 'on');
|
||||
// Manual persistent state overrides incoming controller links only while
|
||||
// the exact virtual + light + toggle eligibility triple remains active.
|
||||
if (isManualVirtualLightMarker(source.device.marker)) {
|
||||
source.on = virtualLightIsOn(source.device.marker, virtualLights);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -575,8 +559,7 @@ export function resolvedLightSources<D extends LightSourceDevice>(
|
||||
}
|
||||
const sources = [...byKey.values()];
|
||||
RESOLVED_LIGHT_CACHE.set(devices as object, {
|
||||
graphFingerprint, stateFingerprint, virtualLightFingerprint: manualFingerprint,
|
||||
registry: hass?.entities, sources,
|
||||
graphFingerprint, stateFingerprint, registry: hass?.entities, sources,
|
||||
});
|
||||
return sources;
|
||||
}
|
||||
|
||||
@@ -56,7 +56,6 @@ import {
|
||||
degradeWalls, rekeyWallsAfterMove,
|
||||
setWallThickness, setWallThicknessForRoom, cmToField, wallCmToUnits,
|
||||
wallEdgeBodies, wallBodiesGeometry, wallBodiesUnionPath,
|
||||
floorFootprintGeometry,
|
||||
innerContourForRoom, roomWallProfile, outsetContour,
|
||||
openingInnerFaceOffsetFromIndex, openingTunnelGeometriesFromIndex,
|
||||
openingWallIndex as buildOpeningWallIndex, applyWallThicknessToNewRoom,
|
||||
@@ -74,9 +73,6 @@ import {
|
||||
type OpenSpanEntry, type BoundaryTarget,
|
||||
} from './open-spans';
|
||||
import { ContentSigner } from './signing';
|
||||
import {
|
||||
resolveInitialSpace, settleBestEffort, type InitialSpaceSelection,
|
||||
} from './initial-load';
|
||||
import { mdiHomeCityOutline } from '@mdi/js';
|
||||
import {
|
||||
Affine, applyAffine, readVacTelemetry,
|
||||
@@ -103,19 +99,10 @@ import {
|
||||
} from './devices';
|
||||
import {
|
||||
formatToggleIntent, projectedTapAction, resolveToggleIntent,
|
||||
sameToggleOperationTargets, toggleCoverEntity, toggleIntentName, toggleOperation,
|
||||
sameToggleCommandTargets, toggleCoverEntity, toggleIntentName,
|
||||
type ResolvedToggleIntent, type ToggleNextEffect, type ToggleNoneReason,
|
||||
type ToggleSkipReason,
|
||||
} from './device-toggle';
|
||||
import {
|
||||
adoptVirtualLightServerSnapshot,
|
||||
applyVirtualLightEvent,
|
||||
reconcileVirtualLightSnapshot,
|
||||
virtualLightFingerprint,
|
||||
virtualLightSnapshot,
|
||||
virtualLightWire,
|
||||
type VirtualLightSnapshot,
|
||||
} from './virtual-light-state';
|
||||
import type {
|
||||
OpeningCfg,
|
||||
RoomCfg, RoomDraftCfg, PartitionCfg, WallColumnCfg,
|
||||
@@ -189,18 +176,12 @@ import {
|
||||
currentLabs, hashSpace, noteLabsRender, subscribeLabs, type LabsSnapshot,
|
||||
} from './labs';
|
||||
import {
|
||||
ISO_CAMERA, ISO_FLOOR_EDGE_HEIGHT, ISO_WALL_HEIGHT, isoFloorMatrixCss,
|
||||
projectPlanPoint, projectedFrame,
|
||||
ISO_CAMERA, ISO_WALL_HEIGHT, projectPlanPoint, projectedFrame,
|
||||
unprojectFloorPoint, type ScenePoint, type ViewRect,
|
||||
} from './iso-projection';
|
||||
import {
|
||||
buildIsoFloorGeometry, buildIsoWallGeometry, isoEffectiveView, isoGeometryFingerprint,
|
||||
type IsoFloorGeometry, type IsoWallGeometry,
|
||||
buildIsoWallGeometry, isoEffectiveView, isoGeometryFingerprint, type IsoWallGeometry,
|
||||
} from './iso-walls';
|
||||
import {
|
||||
buildIsoOpeningBasis, isoOpeningBounds, projectIsoOpening, resolveIsoDecoration,
|
||||
type IsoDecorationLayers, type IsoOpeningBasis, type IsoOpeningPanel,
|
||||
} from './iso-openings';
|
||||
import {
|
||||
acquireHaRegistries, activeRegistryHass, cacheHaBindingStatuses,
|
||||
fullRegistryHass, haRegistryBuildSignature, haRegistryDiagnostics, haRegistrySnapshot,
|
||||
@@ -227,7 +208,7 @@ import {
|
||||
} from './opening-placement';
|
||||
import { safeStoredColor } from './color';
|
||||
|
||||
const CARD_VERSION = '1.63.0';
|
||||
const CARD_VERSION = '1.63.0-beta.1';
|
||||
const DISPLAY_LABEL_KEYS: Record<DeviceDisplayMode, I18nKey> = {
|
||||
badge: 'display.badge',
|
||||
icon_ripple: 'display.icon_ripple',
|
||||
@@ -633,13 +614,8 @@ class HouseplanCard extends LitElement {
|
||||
private _cfgContentFingerprint = '';
|
||||
private _unsubCfg: (() => void) | null = null;
|
||||
private _unsubLayout: (() => void) | null = null;
|
||||
private _unsubVirtual: (() => void) | null = null;
|
||||
private _liveSyncAttempt: Promise<void> | null = null;
|
||||
private _liveSyncGeneration = 0;
|
||||
private _liveSyncConnection: any = null;
|
||||
private _layoutRev = 0;
|
||||
private _layoutContentFingerprint = '';
|
||||
private _virtualLights: VirtualLightSnapshot = virtualLightSnapshot(null);
|
||||
/** One-deep server snapshot; invalidated by the first later plan edit. */
|
||||
private _canOptimizeUndo = false;
|
||||
private _undoKind: 'optimize' | 'import' | null = null;
|
||||
@@ -1176,12 +1152,7 @@ class HouseplanCard extends LitElement {
|
||||
key: string;
|
||||
value: ReturnType<typeof wallBodiesUnionPath>;
|
||||
} | null = null;
|
||||
private _isoGeometryCache = new Map<string, {
|
||||
geometry: IsoWallGeometry;
|
||||
floor: IsoFloorGeometry;
|
||||
openings: readonly IsoOpeningBasis[];
|
||||
frame: Rect;
|
||||
}>();
|
||||
private _isoGeometryCache = new Map<string, { geometry: IsoWallGeometry; frame: Rect }>();
|
||||
private _isoFallback = new Set<string>();
|
||||
private _openingTunnelCache: {
|
||||
key: string;
|
||||
@@ -1581,7 +1552,6 @@ class HouseplanCard extends LitElement {
|
||||
private _capturedSnapshotLayout: Record<string, { x: number; y: number; s?: string; k?: number }> | null = null;
|
||||
private _capturedSnapshotActivity = '';
|
||||
private _capturedSnapshotConfigEpoch = -1;
|
||||
private _capturedSnapshotVirtual = '';
|
||||
private _lastValidStageSize: [number, number] | null = null;
|
||||
private _pendingRefitSize: [number, number] | null = null;
|
||||
private _refitRaf = 0;
|
||||
@@ -1872,7 +1842,6 @@ class HouseplanCard extends LitElement {
|
||||
if (!this._loadOk && this._serverCfg && this.hass) this._scheduleLoadRetry();
|
||||
// AUD-159B1-01: the placement is only knowable once we are IN the DOM.
|
||||
if (!this._warmSlot && this._config) this._warmAdopt();
|
||||
if (this._loadOk) this._ensureLiveSyncSubscriptions();
|
||||
// DEV-B703-03: one task later the element Lovelace replaced has detached
|
||||
// — only then is its open dialog ours to take over.
|
||||
if (this._warmVp && !this._warmRevivePending && this._warmReviveTimer === undefined) {
|
||||
@@ -1957,17 +1926,6 @@ class HouseplanCard extends LitElement {
|
||||
this._unsubLayout();
|
||||
this._unsubLayout = null;
|
||||
}
|
||||
if (this._unsubVirtual) {
|
||||
this._unsubVirtual();
|
||||
this._unsubVirtual = null;
|
||||
}
|
||||
if (this._unsubTrail) {
|
||||
this._unsubTrail();
|
||||
this._unsubTrail = undefined;
|
||||
}
|
||||
this._liveSyncGeneration++;
|
||||
this._liveSyncAttempt = null;
|
||||
this._liveSyncConnection = null;
|
||||
clearTimeout(this._layoutSyncTimer);
|
||||
clearTimeout(this._duplicateColumnTimer);
|
||||
for (const timer of this._glowFadeTimers.values()) clearTimeout(timer);
|
||||
@@ -2320,9 +2278,13 @@ class HouseplanCard extends LitElement {
|
||||
this._layout = c.layout || {};
|
||||
this._layoutRev = c.layout_rev || 0;
|
||||
this._layoutContentFingerprint = c.layout_fingerprint || contentFingerprint(this._layout);
|
||||
this._virtualLights = virtualLightSnapshot(c.virtual_lights, this._cfgRev);
|
||||
this._serverStorage = true;
|
||||
this._adoptInitialSpace(this._model);
|
||||
const hs = this._hashSpace();
|
||||
const nav = this._savedNav();
|
||||
if (hs && this._model.find((sp) => sp.id === hs)) { this._space = hs; this._hashApplied = true; }
|
||||
else if (nav?.space && this._model.find((sp) => sp.id === nav.space)) { this._space = nav.space; this._navApplied = true; }
|
||||
else if (config.default_floor) this._space = config.default_floor;
|
||||
else if (!this._model.find((sp) => sp.id === this._space)) this._space = this._model[0]?.id || this._space;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
@@ -2715,9 +2677,6 @@ class HouseplanCard extends LitElement {
|
||||
// keep the accepted identity paired with exactly what is cached.
|
||||
this._cfgContentFingerprint = contentFingerprint(this._serverCfg);
|
||||
this._layoutContentFingerprint = contentFingerprint(this._layout);
|
||||
this._virtualLights = reconcileVirtualLightSnapshot(
|
||||
this._virtualLights, this._serverCfg, this._cfgRev,
|
||||
);
|
||||
localStorage.setItem(LS_CFG, JSON.stringify({
|
||||
config: this._serverCfg,
|
||||
rev: this._cfgRev,
|
||||
@@ -2725,7 +2684,6 @@ class HouseplanCard extends LitElement {
|
||||
layout: this._layout,
|
||||
layout_rev: this._layoutRev,
|
||||
layout_fingerprint: this._layoutContentFingerprint,
|
||||
virtual_lights: virtualLightWire(this._virtualLights),
|
||||
}));
|
||||
} catch {
|
||||
/* ignore */
|
||||
@@ -2761,32 +2719,13 @@ class HouseplanCard extends LitElement {
|
||||
return !space?.bg?.href || this._signer.isReady(this.hass, space.bg.href);
|
||||
}
|
||||
|
||||
private _initialSpaceSelection(models: SpaceModel[]): InitialSpaceSelection {
|
||||
return resolveInitialSpace({
|
||||
spaceIds: models.map((space) => space.id),
|
||||
hashSpace: this._hashSpace(),
|
||||
acceptHash: !this._hashApplied,
|
||||
currentSpace: this._space,
|
||||
preserveCurrent: this._hashApplied || this._navApplied || this._warmVpArmed,
|
||||
savedSpace: this._savedNav()?.space,
|
||||
defaultSpace: this._config?.default_floor,
|
||||
});
|
||||
}
|
||||
|
||||
/** Install one exact raw-space authority before any spatial candidate paints. */
|
||||
private _adoptInitialSpace(models: SpaceModel[]): InitialSpaceSelection {
|
||||
const selection = this._initialSpaceSelection(models);
|
||||
if (!selection.id) return selection;
|
||||
this._space = selection.id;
|
||||
if (selection.source === 'hash') this._hashApplied = true;
|
||||
if (selection.source === 'saved') this._navApplied = true;
|
||||
return selection;
|
||||
}
|
||||
|
||||
private _candidateBackdrop(config: ServerConfig | null, spaceId = this._space): string {
|
||||
const models = spaceModels(config);
|
||||
const preferred = this._initialSpaceSelection(models).id
|
||||
|| (models.some((space) => space.id === spaceId) ? spaceId : models[0]?.id);
|
||||
const hashSpace = this._hashSpace();
|
||||
const savedSpace = this._savedNav()?.space || '';
|
||||
const preferred = !this._hashApplied && models.some((space) => space.id === hashSpace) ? hashSpace
|
||||
: !this._hashApplied && !this._navApplied && models.some((space) => space.id === savedSpace) ? savedSpace
|
||||
: models.some((space) => space.id === spaceId) ? spaceId : models[0]?.id;
|
||||
return models.find((space) => space.id === preferred)?.bg?.href || '';
|
||||
}
|
||||
|
||||
@@ -3205,19 +3144,6 @@ class HouseplanCard extends LitElement {
|
||||
this._cfgContentFingerprint = nextCfgFingerprint;
|
||||
}
|
||||
this._cfgRev = cfgResp?.rev ?? this._cfgRev;
|
||||
if (cfgResp && ('virtual_lights' in cfgResp || 'config' in cfgResp)) {
|
||||
const nextVirtualLights = adoptVirtualLightServerSnapshot(
|
||||
this._virtualLights,
|
||||
cfgResp.virtual_lights,
|
||||
this._cfgRev,
|
||||
'virtual_lights' in cfgResp,
|
||||
);
|
||||
if (virtualLightFingerprint(nextVirtualLights)
|
||||
!== virtualLightFingerprint(this._virtualLights)) {
|
||||
this._virtualLights = nextVirtualLights;
|
||||
this._capturedSnapshotVirtual = '';
|
||||
}
|
||||
}
|
||||
|
||||
let layoutChanged = false;
|
||||
if (layResp !== undefined || layoutOverride !== undefined) {
|
||||
@@ -3282,14 +3208,61 @@ class HouseplanCard extends LitElement {
|
||||
&& this._continuity.state === 'steady') {
|
||||
this._beginContinuityCandidate('structural-response', true);
|
||||
}
|
||||
this._loadOk = true;
|
||||
this._connectionWasLost = false;
|
||||
this._serverStorage = true;
|
||||
// absent can_write = older backend / demo stub → keep null (legacy admin fallback)
|
||||
if (typeof cfgResp?.can_write === 'boolean') this._serverCanWrite = cfgResp.can_write;
|
||||
this._canOptimizeUndo = !!(cfgResp?.can_optimize_undo || layResp?.can_optimize_undo);
|
||||
this._adoptStructuralResponses(cfgResp, layResp);
|
||||
this._adoptInitialSpace(this._model);
|
||||
this._resumePendingNavMode();
|
||||
this._adoptStructuralResponses(cfgResp, layResp);
|
||||
// live sync: the config was changed in another window → re-read it
|
||||
if (!this._unsubCfg) {
|
||||
this._unsubCfg = await this.hass.connection.subscribeEvents((ev: any) => {
|
||||
// Flush a pending local edit BEFORE adopting a remote revision:
|
||||
// otherwise the debounced write reads a config that this reload has
|
||||
// already replaced, and the user's edit vanishes (audit L2).
|
||||
const observedRev = Number(ev?.data?.rev ?? -1);
|
||||
if (observedRev !== this._cfgRev) this._reloadConfigOnly(false, observedRev);
|
||||
}, 'houseplan_config_updated');
|
||||
}
|
||||
// server-side trails are additive: an older backend without the WS
|
||||
// command just leaves the map empty and the card shows live-only trails
|
||||
this.hass.callWS({ type: 'houseplan/trail/get' })
|
||||
.then((r: any) => { this._vacSrvTrails = r?.trails || {}; this.requestUpdate(); })
|
||||
.catch(() => undefined);
|
||||
if (!this._unsubTrail) {
|
||||
this._unsubTrail = await this.hass.connection.subscribeEvents(async () => {
|
||||
try {
|
||||
const r: any = await this.hass.callWS({ type: 'houseplan/trail/get' });
|
||||
this._vacSrvTrails = r?.trails || {};
|
||||
this.requestUpdate();
|
||||
} catch { /* transient WS hiccup — the next event retries */ }
|
||||
}, 'houseplan_trail_updated');
|
||||
}
|
||||
if (!this._unsubLayout) {
|
||||
// Positions are separate state. The static card learned to follow them
|
||||
// in v1.46.0 and the full one did not, so two full cards side by side
|
||||
// stayed out of sync until a reload (HP-1460-03).
|
||||
this._unsubLayout = await this.hass.connection.subscribeEvents(
|
||||
(ev: any) => this._onLayoutEvent(Number(ev?.data?.rev ?? -1)),
|
||||
'houseplan_layout_updated',
|
||||
);
|
||||
}
|
||||
const hs = this._hashSpace();
|
||||
const nav = this._savedNav();
|
||||
if (!this._hashApplied && hs && this._model.find((s) => s.id === hs)) {
|
||||
this._space = hs;
|
||||
this._hashApplied = true;
|
||||
} else if (nav?.space && !this._navApplied && !this._hashApplied
|
||||
&& this._model.find((s) => s.id === nav.space)) {
|
||||
// the cached config might have been stale (no such space) — retry once
|
||||
// the live config is in
|
||||
this._space = nav.space;
|
||||
this._navApplied = true;
|
||||
} else if (this._norm && !this._model.find((s) => s.id === this._space)) {
|
||||
this._space = this._model[0]?.id || this._space;
|
||||
}
|
||||
this._cacheSnapshot();
|
||||
// DEV-B703-03: a warm re-mount already holds the exact viewport of the
|
||||
// instance that was thrown away; the centred restore here IS the
|
||||
@@ -3297,14 +3270,6 @@ class HouseplanCard extends LitElement {
|
||||
// another space) still needs it.
|
||||
if (this._warmVpArmed && this._space === this._warmVp?.space) this._warmVpArmed = false;
|
||||
else if (!hadViewport || this._space !== visibleSpace) this._restoreZoom();
|
||||
this._loadOk = true;
|
||||
// Trails and event subscriptions enrich an already complete snapshot.
|
||||
// A read-only HA session may reject these; that must never roll the
|
||||
// accepted config back into the mandatory load catch.
|
||||
void this.hass.callWS({ type: 'houseplan/trail/get' })
|
||||
.then((r: any) => { this._vacSrvTrails = r?.trails || {}; this.requestUpdate(); })
|
||||
.catch(() => undefined);
|
||||
this._ensureLiveSyncSubscriptions();
|
||||
} catch (e) {
|
||||
if (this._serverCfg) {
|
||||
// DEV-B703-02: this instance already RENDERS a valid config (the LS
|
||||
@@ -3338,94 +3303,6 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
/** Best-effort live sync starts only after the initial snapshot is usable. */
|
||||
private _ensureLiveSyncSubscriptions(): void {
|
||||
const connection = this.hass?.connection;
|
||||
if (!connection) return;
|
||||
if (connection !== this._liveSyncConnection) {
|
||||
this._unsubCfg?.();
|
||||
this._unsubCfg = null;
|
||||
this._unsubLayout?.();
|
||||
this._unsubLayout = null;
|
||||
this._unsubTrail?.();
|
||||
this._unsubTrail = undefined;
|
||||
this._unsubVirtual?.();
|
||||
this._unsubVirtual = null;
|
||||
this._liveSyncGeneration++;
|
||||
this._liveSyncAttempt = null;
|
||||
this._liveSyncConnection = connection;
|
||||
}
|
||||
if (this._liveSyncAttempt) return;
|
||||
const generation = this._liveSyncGeneration;
|
||||
const attempts: Array<() => Promise<void>> = [];
|
||||
const subscribe = (
|
||||
current: () => (() => void) | null | undefined,
|
||||
adopt: (unsubscribe: () => void) => void,
|
||||
event: string,
|
||||
callback: (event: any) => void | Promise<void>,
|
||||
): void => {
|
||||
if (current()) return;
|
||||
attempts.push(async () => {
|
||||
const unsubscribe = await connection.subscribeEvents(callback, event);
|
||||
const valid = generation === this._liveSyncGeneration
|
||||
&& this.isConnected && this.hass?.connection === connection && !current();
|
||||
if (valid) adopt(unsubscribe);
|
||||
else unsubscribe?.();
|
||||
});
|
||||
};
|
||||
|
||||
subscribe(
|
||||
() => this._unsubCfg,
|
||||
(unsubscribe) => { this._unsubCfg = unsubscribe; },
|
||||
'houseplan_config_updated',
|
||||
(ev: any) => {
|
||||
// Flush a pending local edit BEFORE adopting a remote revision:
|
||||
// otherwise the debounced write reads a config that this reload has
|
||||
// already replaced, and the user's edit vanishes (audit L2).
|
||||
const observedRev = Number(ev?.data?.rev ?? -1);
|
||||
if (observedRev !== this._cfgRev) void this._reloadConfigOnly(false, observedRev);
|
||||
},
|
||||
);
|
||||
subscribe(
|
||||
() => this._unsubTrail,
|
||||
(unsubscribe) => { this._unsubTrail = unsubscribe; },
|
||||
'houseplan_trail_updated',
|
||||
async () => {
|
||||
try {
|
||||
const r: any = await this.hass.callWS({ type: 'houseplan/trail/get' });
|
||||
this._vacSrvTrails = r?.trails || {};
|
||||
this.requestUpdate();
|
||||
} catch { /* transient WS hiccup — the next event retries */ }
|
||||
},
|
||||
);
|
||||
subscribe(
|
||||
() => this._unsubLayout,
|
||||
(unsubscribe) => { this._unsubLayout = unsubscribe; },
|
||||
'houseplan_layout_updated',
|
||||
(ev: any) => this._onLayoutEvent(Number(ev?.data?.rev ?? -1)),
|
||||
);
|
||||
subscribe(
|
||||
() => this._unsubVirtual,
|
||||
(unsubscribe) => { this._unsubVirtual = unsubscribe; },
|
||||
'houseplan_virtual_light_updated',
|
||||
(ev: any) => {
|
||||
const next = applyVirtualLightEvent(this._virtualLights, ev?.data);
|
||||
if (next === this._virtualLights) return;
|
||||
this._virtualLights = next;
|
||||
this._capturedSnapshotVirtual = '';
|
||||
this._cacheSnapshot();
|
||||
this.requestUpdate();
|
||||
},
|
||||
);
|
||||
|
||||
if (!attempts.length) return;
|
||||
const task = settleBestEffort(attempts).then(() => undefined);
|
||||
this._liveSyncAttempt = task;
|
||||
void task.finally(() => {
|
||||
if (this._liveSyncAttempt === task) this._liveSyncAttempt = null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopt the server config. Any pending local write is flushed first and, if a
|
||||
* write is still in flight, the reload is deferred — adopting a revision on
|
||||
@@ -3462,12 +3339,9 @@ class HouseplanCard extends LitElement {
|
||||
this._scheduleLoadRetry(true);
|
||||
return;
|
||||
}
|
||||
const visibleSpace = this._space;
|
||||
this._adoptStructuralResponses(resp);
|
||||
this._adoptInitialSpace(this._model);
|
||||
this._resumePendingNavMode();
|
||||
this._cacheSnapshot();
|
||||
if (this._space !== visibleSpace) this._restoreZoom();
|
||||
this._regSignature = '';
|
||||
this._maybeRebuildDevices();
|
||||
this.requestUpdate();
|
||||
@@ -3560,12 +3434,10 @@ class HouseplanCard extends LitElement {
|
||||
+ `${runtime.flashKind
|
||||
&& (runtime.expiresAt || runtime.flashTs + ACTIVITY_WINDOW_MS) > now ? 1 : 0}`)
|
||||
.join('|');
|
||||
const virtualFingerprint = virtualLightFingerprint(this._virtualLights);
|
||||
if (this._capturedSnapshotSequence === this._hassSequence
|
||||
&& this._capturedSnapshotDevices === this._devices
|
||||
&& this._capturedSnapshotLayout === this._layout
|
||||
&& this._capturedSnapshotConfigEpoch === this._cfgEpoch
|
||||
&& this._capturedSnapshotVirtual === virtualFingerprint
|
||||
&& this._capturedSnapshotActivity === activity) return;
|
||||
const planHass = this._planHass;
|
||||
const presentations = new Map<string, ResolvedDevicePresentation>();
|
||||
@@ -3614,9 +3486,7 @@ class HouseplanCard extends LitElement {
|
||||
));
|
||||
}
|
||||
}
|
||||
const planLightSources = resolvedLightSources(
|
||||
planHass, this._devices, null, this._virtualLights,
|
||||
);
|
||||
const planLightSources = resolvedLightSources(planHass, this._devices);
|
||||
for (const device of this._devices) {
|
||||
for (const showLqi of [false, true]) {
|
||||
presentations.set(presentationSnapshotKey(device.id, showLqi), resolveDevicePresentation(
|
||||
@@ -3672,7 +3542,6 @@ class HouseplanCard extends LitElement {
|
||||
this._capturedSnapshotLayout = this._layout;
|
||||
this._capturedSnapshotActivity = activity;
|
||||
this._capturedSnapshotConfigEpoch = this._cfgEpoch;
|
||||
this._capturedSnapshotVirtual = virtualFingerprint;
|
||||
if (!this._visibleDeviceSnapshot || this._continuity.state === 'steady') {
|
||||
this._visibleDeviceSnapshot = snapshot;
|
||||
this._candidateDeviceSnapshot = null;
|
||||
@@ -3793,11 +3662,10 @@ class HouseplanCard extends LitElement {
|
||||
this._continuityPaintToken = -1;
|
||||
}
|
||||
if (this._loading) return;
|
||||
// Re-read config and layout as one mandatory candidate. Optional live-sync
|
||||
// subscriptions are then retried independently for whichever channels are
|
||||
// still missing; their rejection cannot invalidate this snapshot.
|
||||
// `_loadFromServer` adopts each side by revision+fingerprint and preserves
|
||||
// equal references.
|
||||
// a subscribe lost mid-load leaves _loadOk=true without _unsubCfg — the
|
||||
// full load path repairs both (every subscribe in it is guarded)
|
||||
// Re-read config and layout as one candidate. `_loadFromServer` now adopts
|
||||
// each side by revision+fingerprint and preserves equal references.
|
||||
this._loadFromServer();
|
||||
};
|
||||
private _onConnLost = (): void => {
|
||||
@@ -4355,27 +4223,10 @@ class HouseplanCard extends LitElement {
|
||||
};
|
||||
if (action === 'toggle') {
|
||||
const initial = this._toggleIntent(actionDevice);
|
||||
if (!initial || !toggleOperation(initial)) return; // configured no-target is an intentional, quiet no-op
|
||||
if (!initial?.command) return; // configured no-target is an intentional, quiet no-op
|
||||
const execute = (intent: ResolvedToggleIntent): void => {
|
||||
const operation = toggleOperation(intent);
|
||||
if (!operation) return;
|
||||
if (operation.kind === 'virtual-light') {
|
||||
this.hass.callWS({
|
||||
type: 'houseplan/virtual_light/toggle',
|
||||
marker_id: operation.markerId,
|
||||
}).then((result: any) => {
|
||||
const next = applyVirtualLightEvent(this._virtualLights, result);
|
||||
if (next === this._virtualLights) return;
|
||||
this._virtualLights = next;
|
||||
this._capturedSnapshotVirtual = '';
|
||||
this._cacheSnapshot();
|
||||
this.requestUpdate();
|
||||
}).catch((e: any) => this._showToast(this._t(
|
||||
'toast.virtual_light_toggle_failed', { err: this._errText(e) },
|
||||
)));
|
||||
return;
|
||||
}
|
||||
const { command } = operation;
|
||||
const command = intent.command;
|
||||
if (!command) return;
|
||||
this.hass.callService(command.domain, command.service, command.data)
|
||||
.catch((e: any) => this._showToast(this._t('toast.error', { err: this._errText(e) })));
|
||||
};
|
||||
@@ -4386,7 +4237,7 @@ class HouseplanCard extends LitElement {
|
||||
exec: () => {
|
||||
const currentDevice = this._devices.find((item) => item.id === actionDevice.id);
|
||||
const current = currentDevice ? this._toggleIntent(currentDevice) : null;
|
||||
if (!current || !sameToggleOperationTargets(initial, current)) {
|
||||
if (!current?.command || !sameToggleCommandTargets(initial.command, current.command)) {
|
||||
this._showToast(this._t('toast.tap_target_changed'));
|
||||
return;
|
||||
}
|
||||
@@ -4567,31 +4418,21 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
/** The rectangle "fit to screen" fits — always the content (docs/CANVAS.md). */
|
||||
private _isoSource(): { key: string; build: () => {
|
||||
walls: any;
|
||||
floor: any;
|
||||
openings: readonly IsoOpeningBasis[];
|
||||
} } {
|
||||
private _isoSource(): { key: string; build: () => any } {
|
||||
const space = this._spaceModel();
|
||||
const walls = this._spaceWalls;
|
||||
const openCuts = this._openPairs().flatMap((pair) => pair.segs);
|
||||
const openings = this._openingsR.map((opening, sourceIndex) => ({
|
||||
id: String(opening.id || sourceIndex),
|
||||
sourceIndex,
|
||||
type: opening.type,
|
||||
x: opening.rx,
|
||||
y: opening.ry,
|
||||
const openings = (this._curSpaceCfg?.openings || []).map((opening: any) => ({
|
||||
x: Number(opening.x) * NORM_W,
|
||||
y: Number(opening.y) * NORM_W,
|
||||
angle: Number(opening.angle) || 0,
|
||||
length: opening.rlen > 0 ? opening.rlen : 0.9 * NORM_W,
|
||||
flipH: !!opening.flip_h,
|
||||
flipV: !!opening.flip_v,
|
||||
length: (Number(opening.length) > 0 ? Number(opening.length) : 0.9) * NORM_W,
|
||||
}));
|
||||
const key = `${space.id}|${isoGeometryFingerprint({
|
||||
rooms: space.rooms, walls, openCuts, openings,
|
||||
partitions: space.partitions, roomDrafts: space.room_drafts, columns: space.wall_columns,
|
||||
cellCm: this._cellCm, gridPitch: this._gridPitch, wallKeyPitch: this._wallKeyPitch,
|
||||
camera: ISO_CAMERA, wallHeight: ISO_WALL_HEIGHT,
|
||||
floorEdgeHeight: ISO_FLOOR_EDGE_HEIGHT, algorithm: 3,
|
||||
camera: ISO_CAMERA, wallHeight: ISO_WALL_HEIGHT, algorithm: 2,
|
||||
})}`;
|
||||
return {
|
||||
key,
|
||||
@@ -4602,32 +4443,9 @@ class HouseplanCard extends LitElement {
|
||||
space.rooms, walls, openCuts, openings,
|
||||
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, extras,
|
||||
)
|
||||
: null;
|
||||
if ((walls.length || extras.length) && !united)
|
||||
throw new Error('wall boolean geometry failed');
|
||||
const floor = united?.paperGeom ?? floorFootprintGeometry(
|
||||
space.rooms, walls, openCuts,
|
||||
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
|
||||
);
|
||||
if (!floor) throw new Error('floor boolean geometry failed');
|
||||
const openingIndex = united?.openingIndex || buildOpeningWallIndex(
|
||||
space.rooms, walls, openCuts,
|
||||
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
|
||||
);
|
||||
const openingBases = openings.map((opening) => {
|
||||
const faceFlipV = opening.type === 'gate' ? !opening.flipV : opening.flipV;
|
||||
const face = walls.length || opening.type === 'gate'
|
||||
? openingInnerFaceOffsetFromIndex(openingIndex, {
|
||||
x: opening.x,
|
||||
y: opening.y,
|
||||
angle: opening.angle,
|
||||
length: opening.length,
|
||||
flip_v: faceFlipV,
|
||||
})
|
||||
: { ox: 0, oy: 0, cm: 0, side: -1 as -1 | 1 };
|
||||
return buildIsoOpeningBasis({ ...opening, face });
|
||||
});
|
||||
return { walls: united?.geom || [], floor, openings: Object.freeze(openingBases) };
|
||||
: { geom: [] };
|
||||
if (!united) throw new Error('wall boolean geometry failed');
|
||||
return united.geom;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -4637,29 +4455,14 @@ class HouseplanCard extends LitElement {
|
||||
try { return this._isoSource().key; } catch { return `${this._space}|invalid`; }
|
||||
}
|
||||
|
||||
private _isoScene(): {
|
||||
key: string;
|
||||
geometry: IsoWallGeometry;
|
||||
floor: IsoFloorGeometry;
|
||||
openings: readonly IsoOpeningBasis[];
|
||||
frame: Rect;
|
||||
} {
|
||||
private _isoScene(): { key: string; geometry: IsoWallGeometry; frame: Rect } {
|
||||
const source = this._isoSource();
|
||||
const cached = this._isoGeometryCache.get(source.key);
|
||||
if (cached) return { key: source.key, ...cached };
|
||||
const flat = this._frameOf().rect;
|
||||
const structural = source.build();
|
||||
const openingFrame = isoOpeningBounds(structural.openings);
|
||||
const structuralFrame = openingFrame ? unionRect(flat, openingFrame) : flat;
|
||||
const frame = projectedFrame({
|
||||
rect: structuralFrame,
|
||||
wallHeight: ISO_WALL_HEIGHT,
|
||||
openingHeight: ISO_WALL_HEIGHT,
|
||||
floorDepth: ISO_FLOOR_EDGE_HEIGHT,
|
||||
});
|
||||
const geometry = buildIsoWallGeometry(structural.walls);
|
||||
const floor = buildIsoFloorGeometry(structural.floor, ISO_FLOOR_EDGE_HEIGHT);
|
||||
const value = { geometry, floor, openings: structural.openings, frame };
|
||||
const frame = projectedFrame({ rect: flat, wallHeight: ISO_WALL_HEIGHT });
|
||||
const geometry = buildIsoWallGeometry(source.build());
|
||||
const value = { geometry, frame };
|
||||
lruWrite(this._isoGeometryCache, source.key, value, 8);
|
||||
return { key: source.key, ...value };
|
||||
}
|
||||
@@ -4694,14 +4497,6 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
private _baseVb(): number[] {
|
||||
if (this._effectiveProjection() === 'iso') {
|
||||
// No-borders is the accepted Stage 1 no-volume scene. Stage 2 structure
|
||||
// is absent there, so its opening/floor-edge bounds must be absent too;
|
||||
// otherwise merely hiding volume reframes every live floor pixel.
|
||||
if (!this._spaceDisplayForRender().showBorders) {
|
||||
const flat = this._frameOf().rect;
|
||||
const frame = projectedFrame({ rect: flat, wallHeight: ISO_WALL_HEIGHT });
|
||||
return [frame.x, frame.y, frame.w, frame.h];
|
||||
}
|
||||
const frame = this._isoScene().frame;
|
||||
return [frame.x, frame.y, frame.w, frame.h];
|
||||
}
|
||||
@@ -10041,9 +9836,7 @@ class HouseplanCard extends LitElement {
|
||||
mode,
|
||||
mode === 'lqi' && room.area ? this._roomLqi(room.area) : null,
|
||||
mode === 'light'
|
||||
? resolvedLightState(resolvedLightSources(
|
||||
this._renderPlanHass, this._renderDevices, room, this._virtualLights,
|
||||
))
|
||||
? resolvedLightState(resolvedLightSources(this._renderPlanHass, this._renderDevices, room))
|
||||
: 'none',
|
||||
mode === 'temp' ? this._roomTemp(room) : null,
|
||||
disp.tempMin,
|
||||
@@ -10264,102 +10057,15 @@ class HouseplanCard extends LitElement {
|
||||
</g>` as unknown as TemplateResult;
|
||||
}
|
||||
|
||||
private _isoDecorationLayers(disp: SpaceDisplay): IsoDecorationLayers {
|
||||
const filtersSupported = typeof CSS === 'undefined'
|
||||
|| typeof CSS.supports !== 'function'
|
||||
|| CSS.supports('filter', 'blur(1px)');
|
||||
const forcedColors = typeof matchMedia === 'function'
|
||||
&& matchMedia('(forced-colors: active)').matches;
|
||||
return resolveIsoDecoration({
|
||||
showBorders: disp.showBorders,
|
||||
hideOpenings: disp.hideOpenings,
|
||||
filtersSupported,
|
||||
forcedColors,
|
||||
});
|
||||
}
|
||||
|
||||
private _isoOpeningPanels(layers: IsoDecorationLayers): IsoOpeningPanel[] {
|
||||
if (!layers.panels) return [];
|
||||
const scene = this._isoScene();
|
||||
const panels = scene.openings.flatMap((basis) => {
|
||||
const opening = this._openingsR[basis.sourceIndex];
|
||||
return opening ? projectIsoOpening(basis, this._openingAmt(opening)) : [];
|
||||
});
|
||||
return panels.sort((a, b) => a.depth - b.depth
|
||||
|| a.sourceIndex - b.sourceIndex || a.leaf - b.leaf);
|
||||
}
|
||||
|
||||
private _renderIsoDefs(
|
||||
layers: IsoDecorationLayers,
|
||||
root: 'underlay' | 'shadows' | 'walls',
|
||||
): TemplateResult {
|
||||
return svg`<defs>
|
||||
${root === 'walls' && layers.materialNuance ? svg`
|
||||
<linearGradient id="hp-iso-wall-side" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" class="iso-side-hi"></stop><stop offset="1" class="iso-side-lo"></stop>
|
||||
</linearGradient>
|
||||
<linearGradient id="hp-iso-wall-top" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" class="iso-top-hi"></stop><stop offset="1" class="iso-top-lo"></stop>
|
||||
</linearGradient>` : nothing}
|
||||
${root === 'underlay' && layers.shadows ? svg`
|
||||
<filter id="hp-iso-ambient-shadow" x="-12%" y="-12%" width="124%" height="130%">
|
||||
<feGaussianBlur stdDeviation="7"></feGaussianBlur>
|
||||
</filter>` : nothing}
|
||||
${root === 'shadows' && layers.shadows ? svg`
|
||||
<filter id="hp-iso-contact-shadow" x="-8%" y="-20%" width="116%" height="140%">
|
||||
<feGaussianBlur stdDeviation="2.5"></feGaussianBlur>
|
||||
</filter>
|
||||
<filter id="hp-iso-leaf-shadow" x="-12%" y="-30%" width="124%" height="160%">
|
||||
<feGaussianBlur stdDeviation="2"></feGaussianBlur>
|
||||
</filter>` : nothing}
|
||||
</defs>` as unknown as TemplateResult;
|
||||
}
|
||||
|
||||
private _renderIsoUnderlay(layers: IsoDecorationLayers): TemplateResult {
|
||||
if (!layers.structural) return svg`` as unknown as TemplateResult;
|
||||
const floor = this._isoScene().floor;
|
||||
return svg`<g class="iso-underlay" data-hp="iso-underlay" aria-hidden="true" pointer-events="none">
|
||||
${this._renderIsoDefs(layers, 'underlay')}
|
||||
${layers.shadows && floor.footprintPath
|
||||
? svg`<path class="iso-ambient-shadow" d=${floor.footprintPath} transform="translate(0 8)"></path>`
|
||||
: nothing}
|
||||
<g class="iso-floor-edge">${floor.sides.map((face) =>
|
||||
svg`<path class="iso-floor-side" d=${face.d} data-component=${face.component}
|
||||
data-edge=${face.edge}></path>`)}</g>
|
||||
</g>` as unknown as TemplateResult;
|
||||
}
|
||||
|
||||
private _renderIsoShadows(
|
||||
layers: IsoDecorationLayers,
|
||||
panels: readonly IsoOpeningPanel[],
|
||||
): TemplateResult {
|
||||
if (!layers.shadows) return svg`` as unknown as TemplateResult;
|
||||
const geometry = this._isoScene().geometry;
|
||||
return svg`<g class="iso-shadows" data-hp="iso-shadows" aria-hidden="true" pointer-events="none">
|
||||
${this._renderIsoDefs(layers, 'shadows')}
|
||||
<path class="iso-contact-shadow" d=${geometry.contactPath}></path>
|
||||
<g class="iso-leaf-shadows">${panels.map((panel) =>
|
||||
svg`<path class="iso-leaf-shadow" d=${panel.shadowD}
|
||||
data-id=${panel.id} data-leaf=${panel.leaf}></path>`)}</g>
|
||||
</g>` as unknown as TemplateResult;
|
||||
}
|
||||
|
||||
private _renderIsoWalls(
|
||||
layers: IsoDecorationLayers,
|
||||
panels: readonly IsoOpeningPanel[],
|
||||
): TemplateResult {
|
||||
private _renderIsoWalls(): TemplateResult {
|
||||
if (this._renderProjection !== 'iso') return svg`` as unknown as TemplateResult;
|
||||
if (!layers.structural) return svg`` as unknown as TemplateResult;
|
||||
const disp = this._spaceDisplayForRender();
|
||||
if (!disp.showBorders) return svg`` as unknown as TemplateResult;
|
||||
const scene = this._isoScene();
|
||||
return svg`<g class="iso-walls" data-hp="iso-walls" data-fingerprint=${scene.key}>
|
||||
${this._renderIsoDefs(layers, 'walls')}
|
||||
<g class="iso-wall-sides">${scene.geometry.sides.map((face) =>
|
||||
svg`<path class="iso-wall-side" d=${face.d} data-edge=${face.edge}></path>`)}</g>
|
||||
<path class="iso-wall-top" d=${scene.geometry.topPath} fill-rule="evenodd"></path>
|
||||
${layers.panels ? svg`<g class="iso-openings" data-hp="iso-openings"
|
||||
aria-hidden="true" pointer-events="none">${panels.map((panel) =>
|
||||
svg`<path class="iso-opening-panel iso-${panel.type}" d=${panel.d}
|
||||
data-id=${panel.id} data-kind=${panel.type} data-leaf=${panel.leaf}></path>`)}</g>` : nothing}
|
||||
</g>` as unknown as TemplateResult;
|
||||
}
|
||||
|
||||
@@ -13555,9 +13261,7 @@ class HouseplanCard extends LitElement {
|
||||
} = this._lightBarriers(space, polys, physical);
|
||||
// Resolve against the whole plan: a controller and its passive lamp may
|
||||
// legitimately live in different spaces. Ownership is filtered afterwards.
|
||||
const resolvedSources = resolvedLightSources(
|
||||
this._renderPlanHass, this._renderDevices, null, this._virtualLights,
|
||||
)
|
||||
const resolvedSources = resolvedLightSources(this._renderPlanHass, this._renderDevices)
|
||||
.filter((source) => source.device.space === space.id);
|
||||
const sourcesByDevice = new Map<string, typeof resolvedSources>();
|
||||
for (const source of resolvedSources) {
|
||||
@@ -14154,10 +13858,6 @@ class HouseplanCard extends LitElement {
|
||||
const devs = this._renderDevices.filter((d) => d.space === space.id && (!d.hidden || showGhosts));
|
||||
const deviceSnapshot = this._renderDeviceSnapshot;
|
||||
const disp = this._spaceDisplayForRender();
|
||||
// Stage 2 capability work is deliberately absent from Flat/Labs-off
|
||||
// renders. Decorative capability never enters the structural cache key.
|
||||
const isoLayers = iso ? this._isoDecorationLayers(disp) : null;
|
||||
const isoPanels = isoLayers ? this._isoOpeningPanels(isoLayers) : [];
|
||||
const roomFills = this._resolvedRoomFills(space, disp);
|
||||
const glowBase = this._resolvedGlowBase(space, disp, roomFills);
|
||||
const showLqi = disp.showLqi ?? this._config.show_signal ?? true;
|
||||
@@ -14314,18 +14014,8 @@ class HouseplanCard extends LitElement {
|
||||
<div class="zoomwrap ${this._slide ? 'slide-' + this._slide : ''}"
|
||||
?inert=${this._continuity.overlayBlocksInteraction || this._modeTransitionBusy}
|
||||
style="${transitionBrightness !== 1 ? `filter:brightness(${transitionBrightness.toFixed(3)})` : ''}">
|
||||
${iso && isoLayers?.structural ? svg`<svg class="iso-underlay-svg"
|
||||
viewBox="${view.x} ${view.y} ${view.w} ${view.h}"
|
||||
preserveAspectRatio="xMidYMid meet" aria-hidden="true" pointer-events="none">
|
||||
${this._renderIsoUnderlay(isoLayers)}
|
||||
</svg>` : nothing}
|
||||
<svg class=${isoLayers?.structural ? 'plan-svg' : nothing}
|
||||
viewBox=${isoLayers?.structural
|
||||
? `${view.x} ${view.y} ${view.w} ${view.h}`
|
||||
: `${floorView.x} ${floorView.y} ${floorView.w} ${floorView.h}`}
|
||||
preserveAspectRatio=${isoLayers?.structural || !iso ? 'xMidYMid meet' : 'none'}>
|
||||
<g class=${isoLayers?.structural ? 'iso-floor-scene' : nothing}
|
||||
transform=${isoLayers?.structural ? isoFloorMatrixCss() : nothing}>
|
||||
<svg viewBox="${floorView.x} ${floorView.y} ${floorView.w} ${floorView.h}"
|
||||
preserveAspectRatio=${iso ? 'none' : 'xMidYMid meet'}>
|
||||
${''/* THE PAPER IS THE ROOMS (docs/BACKDROP.md §3, owner
|
||||
2026-08-04). Opaque shapes stop the scene background —
|
||||
bg_color or the 'daynight' sky — from bleeding through the
|
||||
@@ -14517,11 +14207,7 @@ class HouseplanCard extends LitElement {
|
||||
preview deliberately paint AFTER real wall bodies. Their
|
||||
full centreline geometry remains visible for editing. */}
|
||||
${this._editing ? this._renderOpenWalls(disp) : nothing}
|
||||
${disp.hideOpenings && !this._markup
|
||||
? nothing
|
||||
: isoLayers && !isoLayers.floorSymbols
|
||||
? nothing
|
||||
: this._renderOpenings(disp)}
|
||||
${disp.hideOpenings && !this._markup ? nothing : this._renderOpenings(disp)}
|
||||
${this._renderWallThickUi()}
|
||||
${this._markup && this._tool === 'resize' ? this._renderResizeLayer(view) : nothing}
|
||||
${''/* editor chrome, not plan content: the backdrop frame sits on
|
||||
@@ -14530,16 +14216,10 @@ class HouseplanCard extends LitElement {
|
||||
backdrop editor, where rooms and devices are pointer-inert. */}
|
||||
${this._renderBackdropFrame(view)}
|
||||
${this._renderTextFrame(view)}
|
||||
</g>
|
||||
</svg>
|
||||
${iso && isoLayers?.structural ? svg`<svg class="iso-shadows-svg"
|
||||
viewBox="${view.x} ${view.y} ${view.w} ${view.h}"
|
||||
${iso ? svg`<svg class="iso-walls-svg" viewBox="${view.x} ${view.y} ${view.w} ${view.h}"
|
||||
preserveAspectRatio="xMidYMid meet" aria-hidden="true" pointer-events="none">
|
||||
${this._renderIsoShadows(isoLayers, isoPanels)}
|
||||
</svg>
|
||||
<svg class="iso-walls-svg" viewBox="${view.x} ${view.y} ${view.w} ${view.h}"
|
||||
preserveAspectRatio="xMidYMid meet" aria-hidden="true" pointer-events="none">
|
||||
${this._renderIsoWalls(isoLayers, isoPanels)}
|
||||
${this._renderIsoWalls()}
|
||||
</svg>` : nothing}
|
||||
${''/* docs/CANVAS.md §6: an icon is a percentage of the PLAN and
|
||||
scales with it when you zoom — the behaviour the card always
|
||||
@@ -14775,9 +14455,7 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
private _activitySnapshot(
|
||||
d: DevItem,
|
||||
planLightSources = resolvedLightSources(
|
||||
this._planHass, this._devices, null, this._virtualLights,
|
||||
),
|
||||
planLightSources = resolvedLightSources(this._planHass, this._devices),
|
||||
): { samples: EntityVisualSample[]; sourceKey: string } {
|
||||
const sources = resolvePresentationSources(
|
||||
this._planHass, d, this._devices, planLightSources, this._fullRegistryHass,
|
||||
@@ -14816,9 +14494,7 @@ class HouseplanCard extends LitElement {
|
||||
return snapshots;
|
||||
}
|
||||
const live = new Set<string>();
|
||||
const planLightSources = resolvedLightSources(
|
||||
this._planHass, this._devices, null, this._virtualLights,
|
||||
);
|
||||
const planLightSources = resolvedLightSources(this._planHass, this._devices);
|
||||
for (const d of this._devices) {
|
||||
if (d.hidden) continue;
|
||||
// Alarm/continuous semantics are resolved from the current samples and
|
||||
@@ -15856,9 +15532,7 @@ class HouseplanCard extends LitElement {
|
||||
if (l != null) rows.push(html`<span class="rlm"><ha-icon icon="mdi:zigbee"></ha-icon>${l}</span>`);
|
||||
}
|
||||
if (disp.labelLight) {
|
||||
const ls = resolvedLightStats(resolvedLightSources(
|
||||
this._renderPlanHass, this._renderDevices, r, this._virtualLights,
|
||||
));
|
||||
const ls = resolvedLightStats(resolvedLightSources(this._renderPlanHass, this._renderDevices, r));
|
||||
if (ls) {
|
||||
const txt = ls.on === 0
|
||||
? this._t('roomcard.light_off')
|
||||
@@ -16789,7 +16463,7 @@ class HouseplanCard extends LitElement {
|
||||
else if (['sensor', 'binary_sensor', 'number', 'select'].includes(dom))
|
||||
out.push({ eid, kind: 'value' });
|
||||
};
|
||||
for (const source of resolvedLightSources(h, this._devices, null, this._virtualLights)) {
|
||||
for (const source of resolvedLightSources(h, this._devices)) {
|
||||
if (source.device.id !== d.id) continue;
|
||||
for (const eid of [...source.serviceEids, ...source.stateEids]) push(eid);
|
||||
}
|
||||
@@ -16994,7 +16668,6 @@ class HouseplanCard extends LitElement {
|
||||
registryHass: this._fullRegistryHass,
|
||||
devices,
|
||||
device,
|
||||
virtualLights: this._virtualLights,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17025,9 +16698,6 @@ class HouseplanCard extends LitElement {
|
||||
this._t((`marker.toggle_skip_${value.replace('-', '_')}`) as any);
|
||||
return formatToggleIntent(intent, {
|
||||
single: (target) => {
|
||||
if ('via' in target && target.via === 'virtual-light') {
|
||||
return this._t('marker.virtual_light_target', { name: target.name });
|
||||
}
|
||||
const entityId = target.entityId || ('ref' in target ? target.ref : '');
|
||||
const name = target.name || entityId;
|
||||
return this._t('marker.toggle_hint_single', { name, id: entityId });
|
||||
@@ -17036,16 +16706,10 @@ class HouseplanCard extends LitElement {
|
||||
count: targets.length,
|
||||
names: targets.map((target) => `${target.name} (${target.entityId})`).join(', '),
|
||||
}),
|
||||
currentNext: (target, next) => target.via === 'virtual-light'
|
||||
? this._t('marker.virtual_light_current', {
|
||||
state: this._t(target.state === 'on'
|
||||
? 'marker.virtual_light_state_on' : 'marker.virtual_light_state_off'),
|
||||
effect: effect(next),
|
||||
})
|
||||
: this._t('marker.toggle_hint_current', {
|
||||
state: this._toggleStateText(target.entityId, target.state),
|
||||
effect: effect(next),
|
||||
}),
|
||||
currentNext: (target, next) => this._t('marker.toggle_hint_current', {
|
||||
state: this._toggleStateText(target.entityId, target.state),
|
||||
effect: effect(next),
|
||||
}),
|
||||
groupCurrentNext: (targets, next) => this._t('marker.toggle_hint_group_current', {
|
||||
on: targets.filter((target) => target.state === 'on').length,
|
||||
count: targets.length,
|
||||
@@ -17122,7 +16786,7 @@ class HouseplanCard extends LitElement {
|
||||
const preview = { ...device, hidden: false };
|
||||
return selectSpatialGlowSource(resolvedLightSources(this._planHass, [
|
||||
...this._devices.filter((item) => item.id !== preview.id), preview,
|
||||
], null, this._virtualLights).filter((source) => source.device.id === preview.id));
|
||||
]).filter((source) => source.device.id === preview.id));
|
||||
}
|
||||
|
||||
private _markerAutoHasSpatialSource(d: NonNullable<HouseplanCard['_markerDialog']>): boolean {
|
||||
|
||||
@@ -627,10 +627,6 @@
|
||||
"marker.tap_confirm": "Ask for confirmation",
|
||||
"marker.tap_confirm_tip": "Show a confirmation dialog before acting — a guard against accidental taps.",
|
||||
"marker.toggle_hint_single": "Target: {name} ({id}).",
|
||||
"marker.virtual_light_target": "Virtual light: {name}.",
|
||||
"marker.virtual_light_current": "Manual state: {state} → {effect}.",
|
||||
"marker.virtual_light_state_on": "on",
|
||||
"marker.virtual_light_state_off": "off",
|
||||
"marker.toggle_hint_group": "Will toggle {count} source(s): {names}.",
|
||||
"marker.toggle_hint_current": "Now: {state} → {effect}.",
|
||||
"marker.toggle_hint_group_current": "Currently on: {on} of {count} → {effect}.",
|
||||
@@ -663,7 +659,6 @@
|
||||
"toast.run_target_missing": "Run target not found — check the device settings",
|
||||
"toast.run_target_required": "Pick an automation, script or scene",
|
||||
"toast.tap_target_changed": "The action target changed. Try again.",
|
||||
"toast.virtual_light_toggle_failed": "Could not toggle the virtual light: {err}",
|
||||
"toast.value_badge_source_required": "Choose a value for the badge",
|
||||
"btn.run": "Run",
|
||||
"vac.section": "Robot vacuum: live position",
|
||||
|
||||
@@ -627,10 +627,6 @@
|
||||
"marker.tap_confirm": "Спрашивать подтверждение",
|
||||
"marker.tap_confirm_tip": "Перед выполнением показать диалог подтверждения — защита от случайных нажатий.",
|
||||
"marker.toggle_hint_single": "Цель: {name} ({id}).",
|
||||
"marker.virtual_light_target": "Виртуальный свет: {name}.",
|
||||
"marker.virtual_light_current": "Ручное состояние: {state} → {effect}.",
|
||||
"marker.virtual_light_state_on": "включён",
|
||||
"marker.virtual_light_state_off": "выключен",
|
||||
"marker.toggle_hint_group": "Переключатся источники ({count}): {names}.",
|
||||
"marker.toggle_hint_current": "Сейчас: {state} → {effect}.",
|
||||
"marker.toggle_hint_group_current": "Сейчас включено {on} из {count} → {effect}.",
|
||||
@@ -663,7 +659,6 @@
|
||||
"toast.run_target_missing": "Цель запуска не найдена — проверьте настройки устройства",
|
||||
"toast.run_target_required": "Выберите автоматизацию, скрипт или сцену",
|
||||
"toast.tap_target_changed": "Цель действия изменилась. Повторите попытку.",
|
||||
"toast.virtual_light_toggle_failed": "Не удалось переключить виртуальный свет: {err}",
|
||||
"toast.value_badge_source_required": "Выберите значение для бейджа",
|
||||
"btn.run": "Выполнить",
|
||||
"vac.section": "Робот-пылесос: живая позиция",
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
export type InitialSpaceSource = 'hash' | 'current' | 'saved' | 'default' | 'first' | 'none';
|
||||
|
||||
export interface InitialSpaceSelectionInput {
|
||||
spaceIds: readonly string[];
|
||||
hashSpace?: string | null;
|
||||
acceptHash?: boolean;
|
||||
currentSpace?: string | null;
|
||||
preserveCurrent?: boolean;
|
||||
savedSpace?: string | null;
|
||||
defaultSpace?: string | null;
|
||||
}
|
||||
|
||||
export interface InitialSpaceSelection {
|
||||
id: string | null;
|
||||
source: InitialSpaceSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the one exact space that may back a spatial frame.
|
||||
*
|
||||
* A same-route warm/hash/nav selection has already recorded newer intent and
|
||||
* may be preserved by the caller. Otherwise the public cold-start precedence
|
||||
* is hash -> saved -> default -> first. Every candidate is checked against the
|
||||
* live model; the legacy `f1` field is never accepted merely because it is the
|
||||
* class initializer.
|
||||
*/
|
||||
export function resolveInitialSpace(input: InitialSpaceSelectionInput): InitialSpaceSelection {
|
||||
const ids = new Set(input.spaceIds.filter((id) => !!id));
|
||||
if (!ids.size) return { id: null, source: 'none' };
|
||||
|
||||
const candidates: Array<[InitialSpaceSource, string | null | undefined]> = [
|
||||
['hash', input.acceptHash === false ? null : input.hashSpace],
|
||||
['current', input.preserveCurrent ? input.currentSpace : null],
|
||||
['saved', input.savedSpace],
|
||||
['default', input.defaultSpace],
|
||||
['first', input.spaceIds[0]],
|
||||
];
|
||||
for (const [source, id] of candidates) {
|
||||
if (id && ids.has(id)) return { id, source };
|
||||
}
|
||||
return { id: null, source: 'none' };
|
||||
}
|
||||
|
||||
/** Start every optional operation and wait only for their individual result. */
|
||||
export function settleBestEffort<T>(
|
||||
attempts: ReadonlyArray<() => Promise<T>>,
|
||||
): Promise<PromiseSettledResult<T>[]> {
|
||||
return Promise.allSettled(attempts.map((attempt) => Promise.resolve().then(attempt)));
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
import {
|
||||
ISO_CAMERA, ISO_WALL_HEIGHT, projectPlanPoint,
|
||||
type IsoCamera, type PlanPoint, type ScenePoint,
|
||||
} from './iso-projection';
|
||||
|
||||
export type IsoOpeningType = 'door' | 'window' | 'gate';
|
||||
|
||||
export interface IsoOpeningFace {
|
||||
ox: number;
|
||||
oy: number;
|
||||
side: -1 | 1;
|
||||
}
|
||||
|
||||
export interface IsoOpeningInput {
|
||||
id: string;
|
||||
sourceIndex: number;
|
||||
type: IsoOpeningType;
|
||||
x: number;
|
||||
y: number;
|
||||
angle: number;
|
||||
length: number;
|
||||
flipH: boolean;
|
||||
flipV: boolean;
|
||||
face: IsoOpeningFace;
|
||||
}
|
||||
|
||||
export interface IsoOpeningLeafBasis {
|
||||
leaf: number;
|
||||
hinge: PlanPoint;
|
||||
closedVector: PlanPoint;
|
||||
quarterVector: PlanPoint;
|
||||
turnDeg: number;
|
||||
bottom: number;
|
||||
top: number;
|
||||
}
|
||||
|
||||
/** Immutable jamb/axis topology stored in the structural Iso LRU. */
|
||||
export interface IsoOpeningBasis {
|
||||
id: string;
|
||||
sourceIndex: number;
|
||||
type: IsoOpeningType;
|
||||
leaves: readonly IsoOpeningLeafBasis[];
|
||||
}
|
||||
|
||||
export interface IsoOpeningPanel {
|
||||
id: string;
|
||||
sourceIndex: number;
|
||||
type: IsoOpeningType;
|
||||
leaf: number;
|
||||
d: string;
|
||||
shadowD: string;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
export interface IsoOpeningBounds { x: number; y: number; w: number; h: number; }
|
||||
|
||||
const finite = (values: readonly number[]): boolean => values.every(Number.isFinite);
|
||||
|
||||
function add(a: PlanPoint, b: PlanPoint): PlanPoint {
|
||||
return [a[0] + b[0], a[1] + b[1]];
|
||||
}
|
||||
|
||||
function transformVector(
|
||||
point: PlanPoint, angleDeg: number, sx: number, sy: number,
|
||||
): PlanPoint {
|
||||
const x = point[0] * sx, y = point[1] * sy;
|
||||
const angle = angleDeg * Math.PI / 180;
|
||||
return [x * Math.cos(angle) - y * Math.sin(angle),
|
||||
x * Math.sin(angle) + y * Math.cos(angle)];
|
||||
}
|
||||
|
||||
function leafBasis(
|
||||
input: IsoOpeningInput,
|
||||
leaf: number,
|
||||
localHinge: PlanPoint,
|
||||
localVector: PlanPoint,
|
||||
turnDeg: number,
|
||||
bottom: number,
|
||||
top: number,
|
||||
): IsoOpeningLeafBasis {
|
||||
const sx = input.flipH ? -1 : 1;
|
||||
const sy = input.flipV ? -1 : 1;
|
||||
const origin: PlanPoint = [input.x + input.face.ox, input.y + input.face.oy];
|
||||
const hinge = add(origin, transformVector(localHinge, input.angle, sx, sy));
|
||||
const closedVector = transformVector(localVector, input.angle, sx, sy);
|
||||
const quarterVector = transformVector(
|
||||
[-localVector[1], localVector[0]], input.angle, sx, sy,
|
||||
);
|
||||
return { leaf, hinge, closedVector, quarterVector, turnDeg, bottom, top };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build only stable opening topology. The transform is algebraically the same
|
||||
* as the floor symbol's translate/rotate/flip nesting, including the gate's
|
||||
* 0..10 degree exterior-face convention.
|
||||
*/
|
||||
export function buildIsoOpeningBasis(
|
||||
input: IsoOpeningInput,
|
||||
wallHeight = ISO_WALL_HEIGHT,
|
||||
): IsoOpeningBasis {
|
||||
if (!finite([
|
||||
input.x, input.y, input.angle, input.length,
|
||||
input.face.ox, input.face.oy, wallHeight,
|
||||
]) || !(input.length > 0) || !(wallHeight > 0)) {
|
||||
throw new Error('invalid isometric opening input');
|
||||
}
|
||||
const half = input.length / 2;
|
||||
let leaves: IsoOpeningLeafBasis[];
|
||||
if (input.type === 'gate') {
|
||||
const sy = input.flipV ? -1 : 1;
|
||||
const turn = input.face.side * sy * 10;
|
||||
leaves = [
|
||||
leafBasis(input, 0, [-half, 0], [half, 0], turn, 0, wallHeight * 0.88),
|
||||
leafBasis(input, 1, [half, 0], [-half, 0], -turn, 0, wallHeight * 0.88),
|
||||
];
|
||||
} else if (input.type === 'window') {
|
||||
leaves = [
|
||||
leafBasis(input, 0, [-half, 0], [half, 0], -90, wallHeight * 0.27, wallHeight * 0.78),
|
||||
leafBasis(input, 1, [half, 0], [-half, 0], 90, wallHeight * 0.27, wallHeight * 0.78),
|
||||
];
|
||||
} else {
|
||||
leaves = [leafBasis(input, 0, [-half, 0], [input.length, 0], -90, 0, wallHeight * 0.92)];
|
||||
}
|
||||
return {
|
||||
id: input.id,
|
||||
sourceIndex: input.sourceIndex,
|
||||
type: input.type,
|
||||
leaves: Object.freeze(leaves.map((leaf) => Object.freeze(leaf))),
|
||||
};
|
||||
}
|
||||
|
||||
const pointText = (point: ScenePoint): string =>
|
||||
`${Number(point[0].toFixed(4))} ${Number(point[1].toFixed(4))}`;
|
||||
|
||||
function liveTip(leaf: IsoOpeningLeafBasis, amount: number): PlanPoint {
|
||||
const angle = leaf.turnDeg * amount * Math.PI / 180;
|
||||
return [
|
||||
leaf.hinge[0] + leaf.closedVector[0] * Math.cos(angle)
|
||||
+ leaf.quarterVector[0] * Math.sin(angle),
|
||||
leaf.hinge[1] + leaf.closedVector[1] * Math.cos(angle)
|
||||
+ leaf.quarterVector[1] * Math.sin(angle),
|
||||
];
|
||||
}
|
||||
|
||||
/** Apply live state after the structural cache: O(leaves), no topology work. */
|
||||
export function projectIsoOpening(
|
||||
basis: IsoOpeningBasis,
|
||||
amount: number,
|
||||
camera: IsoCamera = ISO_CAMERA,
|
||||
): IsoOpeningPanel[] {
|
||||
const liveAmount = Math.max(0, Math.min(1, Number.isFinite(amount) ? amount : 0));
|
||||
return basis.leaves.map((leaf) => {
|
||||
const tip = liveTip(leaf, liveAmount);
|
||||
const floorHinge = projectPlanPoint(leaf.hinge, leaf.bottom, camera);
|
||||
const floorTip = projectPlanPoint(tip, leaf.bottom, camera);
|
||||
const topTip = projectPlanPoint(tip, leaf.top, camera);
|
||||
const topHinge = projectPlanPoint(leaf.hinge, leaf.top, camera);
|
||||
return {
|
||||
id: basis.id,
|
||||
sourceIndex: basis.sourceIndex,
|
||||
type: basis.type,
|
||||
leaf: leaf.leaf,
|
||||
d: `M ${pointText(floorHinge)} L ${pointText(floorTip)} L ${pointText(topTip)} L ${pointText(topHinge)} Z`,
|
||||
shadowD: `M ${pointText(projectPlanPoint(leaf.hinge, 0, camera))} L ${pointText(projectPlanPoint(tip, 0, camera))}`,
|
||||
depth: Math.max(floorHinge[1], floorTip[1]),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** State-independent plan envelope for fit/home; blur is intentionally absent. */
|
||||
export function isoOpeningBounds(
|
||||
bases: readonly IsoOpeningBasis[],
|
||||
): IsoOpeningBounds | null {
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const basis of bases) {
|
||||
for (const leaf of basis.leaves) {
|
||||
const radius = Math.hypot(leaf.closedVector[0], leaf.closedVector[1]);
|
||||
minX = Math.min(minX, leaf.hinge[0] - radius);
|
||||
minY = Math.min(minY, leaf.hinge[1] - radius);
|
||||
maxX = Math.max(maxX, leaf.hinge[0] + radius);
|
||||
maxY = Math.max(maxY, leaf.hinge[1] + radius);
|
||||
}
|
||||
}
|
||||
return finite([minX, minY, maxX, maxY])
|
||||
? { x: minX, y: minY, w: maxX - minX, h: maxY - minY }
|
||||
: null;
|
||||
}
|
||||
|
||||
export interface IsoDecorationInput {
|
||||
showBorders: boolean;
|
||||
hideOpenings: boolean;
|
||||
filtersSupported: boolean;
|
||||
forcedColors: boolean;
|
||||
}
|
||||
|
||||
export interface IsoDecorationLayers {
|
||||
structural: boolean;
|
||||
panels: boolean;
|
||||
shadows: boolean;
|
||||
materialNuance: boolean;
|
||||
floorSymbols: boolean;
|
||||
}
|
||||
|
||||
/** Display/capability policy is pure so decoration can never trigger Flat fallback. */
|
||||
export function resolveIsoDecoration(input: IsoDecorationInput): IsoDecorationLayers {
|
||||
const structural = !!input.showBorders;
|
||||
return {
|
||||
structural,
|
||||
panels: structural && !input.hideOpenings,
|
||||
shadows: structural && input.filtersSupported && !input.forcedColors,
|
||||
materialNuance: structural && !input.forcedColors,
|
||||
floorSymbols: !input.hideOpenings && !structural,
|
||||
};
|
||||
}
|
||||
@@ -14,8 +14,6 @@ export interface ViewRect { x: number; y: number; w: number; h: number; }
|
||||
export interface IsoFrameInput {
|
||||
rect: ViewRect;
|
||||
wallHeight: number;
|
||||
openingHeight?: number;
|
||||
floorDepth?: number;
|
||||
}
|
||||
|
||||
export const ISO_CAMERA: Readonly<IsoCamera> = Object.freeze({
|
||||
@@ -27,7 +25,6 @@ export const ISO_CAMERA: Readonly<IsoCamera> = Object.freeze({
|
||||
});
|
||||
|
||||
export const ISO_WALL_HEIGHT = 64;
|
||||
export const ISO_FLOOR_EDGE_HEIGHT = 10;
|
||||
|
||||
function finiteCamera(camera: IsoCamera): boolean {
|
||||
return [camera.rotDeg, camera.tiltDeg, camera.xyScale, camera.zScale,
|
||||
@@ -100,21 +97,17 @@ export function projectedFrame(
|
||||
input: IsoFrameInput, camera: IsoCamera = ISO_CAMERA,
|
||||
): ViewRect {
|
||||
const { rect, wallHeight } = input;
|
||||
const openingHeight = input.openingHeight ?? wallHeight;
|
||||
const floorDepth = input.floorDepth ?? 0;
|
||||
if (!(rect.w >= 0) || !(rect.h >= 0) || !Number.isFinite(wallHeight)
|
||||
|| !Number.isFinite(openingHeight) || !Number.isFinite(floorDepth)
|
||||
|| wallHeight < 0 || openingHeight < 0 || floorDepth < 0)
|
||||
if (!(rect.w >= 0) || !(rect.h >= 0) || !Number.isFinite(wallHeight))
|
||||
throw new Error('invalid isometric frame');
|
||||
const corners: PlanPoint[] = [
|
||||
[rect.x, rect.y], [rect.x + rect.w, rect.y],
|
||||
[rect.x + rect.w, rect.y + rect.h], [rect.x, rect.y + rect.h],
|
||||
];
|
||||
const top = Math.max(wallHeight, openingHeight);
|
||||
const points = corners.flatMap((point) => [
|
||||
projectPlanPoint(point, -floorDepth, camera), projectPlanPoint(point, top, camera),
|
||||
projectPlanPoint(point, 0, camera), projectPlanPoint(point, wallHeight, camera),
|
||||
]);
|
||||
const xs = points.map((point) => point[0]), ys = points.map((point) => point[1]);
|
||||
const x = Math.min(...xs), y = Math.min(...ys);
|
||||
return { x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y };
|
||||
}
|
||||
|
||||
|
||||
@@ -14,22 +14,6 @@ export interface IsoWallFace {
|
||||
export interface IsoWallGeometry {
|
||||
topPath: string;
|
||||
sides: readonly IsoWallFace[];
|
||||
contactPath: string;
|
||||
edgeCount: number;
|
||||
}
|
||||
|
||||
export interface IsoFloorFace {
|
||||
d: string;
|
||||
depth: number;
|
||||
component: number;
|
||||
edge: number;
|
||||
planEdge: readonly [PlanPoint, PlanPoint];
|
||||
}
|
||||
|
||||
export interface IsoFloorGeometry {
|
||||
footprintPath: string;
|
||||
sides: readonly IsoFloorFace[];
|
||||
componentCount: number;
|
||||
edgeCount: number;
|
||||
}
|
||||
|
||||
@@ -69,19 +53,6 @@ function normalizedRing(raw: any, hole: boolean): PlanPoint[] {
|
||||
return (signedArea(points) > 0) === wantPositive ? points : [...points].reverse();
|
||||
}
|
||||
|
||||
function canonicalRing(raw: any, hole: boolean): PlanPoint[] {
|
||||
const points = normalizedRing(raw, hole);
|
||||
if (!points.length) return points;
|
||||
let start = 0;
|
||||
for (let index = 1; index < points.length; index++) {
|
||||
if (points[index][0] < points[start][0]
|
||||
|| (points[index][0] === points[start][0] && points[index][1] < points[start][1])) {
|
||||
start = index;
|
||||
}
|
||||
}
|
||||
return [...points.slice(start), ...points.slice(0, start)];
|
||||
}
|
||||
|
||||
function topRingPath(points: readonly PlanPoint[], camera: IsoCamera, height: number): string {
|
||||
return points.length
|
||||
? `M ${points.map((point) => pointText(projectPlanPoint(point, height, camera))).join(' L ')} Z`
|
||||
@@ -104,7 +75,6 @@ export function buildIsoWallGeometry(
|
||||
if (!Number.isFinite(height) || height < 0) throw new Error('invalid wall height');
|
||||
const tops: string[] = [];
|
||||
const sides: IsoWallFace[] = [];
|
||||
const contacts: string[] = [];
|
||||
let edgeCount = 0;
|
||||
for (let polygon = 0; polygon < (geometry || []).length; polygon++) {
|
||||
const source = geometry[polygon];
|
||||
@@ -115,10 +85,9 @@ export function buildIsoWallGeometry(
|
||||
edgeCount += points.length;
|
||||
for (let edge = 0; edge < points.length; edge++) {
|
||||
const a = points[edge], b = points[(edge + 1) % points.length];
|
||||
if (visibleNormalY(a, b, camera) <= 1e-9) continue;
|
||||
const floorA = projectPlanPoint(a, 0, camera);
|
||||
const floorB = projectPlanPoint(b, 0, camera);
|
||||
contacts.push(`M ${pointText(floorA)} L ${pointText(floorB)}`);
|
||||
if (visibleNormalY(a, b, camera) <= 1e-9) continue;
|
||||
const topB = projectPlanPoint(b, height, camera);
|
||||
const topA = projectPlanPoint(a, height, camera);
|
||||
sides.push({
|
||||
@@ -130,62 +99,7 @@ export function buildIsoWallGeometry(
|
||||
}
|
||||
sides.sort((a, b) => a.depth - b.depth || a.polygon - b.polygon
|
||||
|| a.ring - b.ring || a.edge - b.edge);
|
||||
return { topPath: tops.join(' '), sides, contactPath: contacts.join(' '), edgeCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* Project the already-unioned canonical room/exterior footprint. Only outer
|
||||
* rings receive a low edge; holes and nested rooms therefore cannot become
|
||||
* decorative steps. Polygon order and ring start points are canonicalised.
|
||||
*/
|
||||
export function buildIsoFloorGeometry(
|
||||
geometry: any,
|
||||
edgeHeight: number,
|
||||
camera: IsoCamera = ISO_CAMERA,
|
||||
): IsoFloorGeometry {
|
||||
if (!Number.isFinite(edgeHeight) || edgeHeight < 0) throw new Error('invalid floor edge height');
|
||||
const components = (geometry || []).map((source: any) => {
|
||||
const rings = (source || []).map((ring: any, index: number) => canonicalRing(ring, index > 0))
|
||||
.filter((ring: PlanPoint[]) => ring.length >= 3 && Math.abs(signedArea(ring)) >= 1e-9);
|
||||
return rings;
|
||||
}).filter((rings: PlanPoint[][]) => rings.length && signedArea(rings[0]) > 0);
|
||||
components.sort((a: PlanPoint[][], b: PlanPoint[][]) => {
|
||||
const aa = a[0][0], bb = b[0][0];
|
||||
return aa[0] - bb[0] || aa[1] - bb[1]
|
||||
|| Math.abs(signedArea(b[0])) - Math.abs(signedArea(a[0]));
|
||||
});
|
||||
|
||||
const footprints: string[] = [];
|
||||
const sides: IsoFloorFace[] = [];
|
||||
let edgeCount = 0;
|
||||
for (let component = 0; component < components.length; component++) {
|
||||
const rings = components[component];
|
||||
for (const ring of rings) footprints.push(topRingPath(ring, camera, 0));
|
||||
const outer = rings[0];
|
||||
edgeCount += outer.length;
|
||||
for (let edge = 0; edge < outer.length; edge++) {
|
||||
const a = outer[edge], b = outer[(edge + 1) % outer.length];
|
||||
if (visibleNormalY(a, b, camera) <= 1e-9) continue;
|
||||
const floorA = projectPlanPoint(a, 0, camera);
|
||||
const floorB = projectPlanPoint(b, 0, camera);
|
||||
const lowB = projectPlanPoint(b, -edgeHeight, camera);
|
||||
const lowA = projectPlanPoint(a, -edgeHeight, camera);
|
||||
sides.push({
|
||||
d: `M ${pointText(floorA)} L ${pointText(floorB)} L ${pointText(lowB)} L ${pointText(lowA)} Z`,
|
||||
depth: Math.max(lowA[1], lowB[1]),
|
||||
component,
|
||||
edge,
|
||||
planEdge: [a, b],
|
||||
});
|
||||
}
|
||||
}
|
||||
sides.sort((a, b) => a.depth - b.depth || a.component - b.component || a.edge - b.edge);
|
||||
return {
|
||||
footprintPath: footprints.join(' '),
|
||||
sides,
|
||||
componentCount: components.length,
|
||||
edgeCount,
|
||||
};
|
||||
return { topPath: tops.join(' '), sides, edgeCount };
|
||||
}
|
||||
|
||||
function mixHash(hash: number, value: number): number {
|
||||
|
||||
@@ -93,7 +93,6 @@ class HouseplanSpaceCard extends LitElement {
|
||||
private _capturedSnapshotSequence = -1;
|
||||
private _capturedSnapshotDevices: DevItem[] | null = null;
|
||||
private _capturedSnapshotActivity = '';
|
||||
private _capturedSnapshotVirtual = '';
|
||||
private _activityRuntime = new Map<string, FiniteActivityRuntime>();
|
||||
private _reducedMotion = false;
|
||||
private _motionMedia?: MediaQueryList;
|
||||
@@ -305,9 +304,7 @@ class HouseplanSpaceCard extends LitElement {
|
||||
return;
|
||||
}
|
||||
const live = new Set<string>();
|
||||
const planLightSources = resolvedLightSources(
|
||||
planHass, devices, null, this._snap?.virtualLights,
|
||||
);
|
||||
const planLightSources = resolvedLightSources(planHass, devices);
|
||||
for (const device of devices) {
|
||||
if (device.hidden) continue;
|
||||
// Finite edge history belongs only to the one mode that can present it.
|
||||
@@ -357,12 +354,9 @@ class HouseplanSpaceCard extends LitElement {
|
||||
.map(([id, runtime]) => `${id}:${runtime.gen}:${runtime.flashTs}:`
|
||||
+ `${runtime.flashKind && (runtime.expiresAt || runtime.flashTs + 3300) > now ? 1 : 0}`)
|
||||
.join('|');
|
||||
const virtualFingerprint = this._snap?.virtualLights
|
||||
? `${this._snap.virtualLights.configRev}:${this._snap.virtualLights.rev}` : '';
|
||||
if (this._capturedSnapshotSequence === this._hassSequence
|
||||
&& this._capturedSnapshotDevices === this._devices
|
||||
&& this._capturedSnapshotActivity === activity
|
||||
&& this._capturedSnapshotVirtual === virtualFingerprint) return;
|
||||
&& this._capturedSnapshotActivity === activity) return;
|
||||
const planHass = activeRegistryHass(this.hass, haRegistrySnapshot(this.hass));
|
||||
const presentations = new Map<string, ReturnType<typeof resolveDevicePresentation>>();
|
||||
const entityIds = new Set<string>(['sun.sun']);
|
||||
@@ -388,9 +382,7 @@ class HouseplanSpaceCard extends LitElement {
|
||||
if (opening.contact) entityIds.add(opening.contact);
|
||||
if (opening.lock) entityIds.add(opening.lock);
|
||||
}
|
||||
const planLightSources = resolvedLightSources(
|
||||
planHass, this._devices, null, this._snap?.virtualLights,
|
||||
);
|
||||
const planLightSources = resolvedLightSources(planHass, this._devices);
|
||||
for (const device of this._devices) {
|
||||
for (const showLqi of [false, true]) {
|
||||
presentations.set(presentationSnapshotKey(device.id, showLqi), resolveDevicePresentation(
|
||||
@@ -415,7 +407,6 @@ class HouseplanSpaceCard extends LitElement {
|
||||
this._capturedSnapshotSequence = this._hassSequence;
|
||||
this._capturedSnapshotDevices = this._devices;
|
||||
this._capturedSnapshotActivity = activity;
|
||||
this._capturedSnapshotVirtual = virtualFingerprint;
|
||||
if (!this._visibleDeviceSnapshot || this._continuity.state === 'steady') {
|
||||
this._visibleDeviceSnapshot = snapshot;
|
||||
this._candidateDeviceSnapshot = null;
|
||||
@@ -463,7 +454,6 @@ class HouseplanSpaceCard extends LitElement {
|
||||
snap?.configFingerprint || contentFingerprint(snap?.config),
|
||||
snap?.layoutRev || 0,
|
||||
snap?.layoutFingerprint || contentFingerprint(snap?.layout),
|
||||
snap?.virtualLights ? `${snap.virtualLights.configRev}:${snap.virtualLights.rev}` : '',
|
||||
this._config?.space || '',
|
||||
this._stageWidth,
|
||||
this.hass?.themes?.darkMode ?? this.hass?.themes?.default_theme ?? '',
|
||||
@@ -587,8 +577,6 @@ class HouseplanSpaceCard extends LitElement {
|
||||
|| this._snap.configFingerprint !== snap.configFingerprint;
|
||||
const layoutChanged = !this._snap
|
||||
|| this._snap.layoutFingerprint !== snap.layoutFingerprint;
|
||||
const virtualLightsChanged = !this._snap
|
||||
|| this._snap.virtualLights !== snap.virtualLights;
|
||||
if (configChanged && !await this._signer.prepareImage(
|
||||
this.hass, this._candidateBackdrop(snap.config),
|
||||
)) {
|
||||
@@ -603,7 +591,7 @@ class HouseplanSpaceCard extends LitElement {
|
||||
&& this._continuity.state === 'steady') {
|
||||
this._beginContinuityCandidate('structural-response', true);
|
||||
}
|
||||
if (configChanged || layoutChanged || virtualLightsChanged) {
|
||||
if (configChanged || layoutChanged) {
|
||||
this._snap = snap;
|
||||
} else if (this._snap) {
|
||||
// Revision-only echoes are metadata, not a new visual candidate.
|
||||
@@ -612,7 +600,6 @@ class HouseplanSpaceCard extends LitElement {
|
||||
}
|
||||
if (configChanged) this._continuity.note('config-candidate', { configRev: snap.rev });
|
||||
if (layoutChanged) this._continuity.note('layout-candidate', { layoutRev: snap.layoutRev });
|
||||
if (virtualLightsChanged) this._capturedSnapshotSequence = -1;
|
||||
this._loadedOnce = true;
|
||||
this._connectionWasLost = false;
|
||||
this._continuityDataReady = true;
|
||||
@@ -733,7 +720,6 @@ class HouseplanSpaceCard extends LitElement {
|
||||
presentations: deviceSnapshot?.presentations,
|
||||
activityRuntime: this._activityRuntime,
|
||||
reducedMotion: this._reducedMotion,
|
||||
virtualLights: this._snap?.virtualLights,
|
||||
liveStates: this._config.live_states !== false,
|
||||
showTemperature: this._config.show_temperature !== false,
|
||||
showSignal: this._config.show_signal !== false,
|
||||
|
||||
@@ -26,7 +26,6 @@ import { presentationSnapshotKey } from './render-device-snapshot';
|
||||
import { deviceFaceStyle, renderDeviceFace } from './device-face';
|
||||
import { valueBadgeTitle } from './device-value-badge';
|
||||
import { contentFingerprint } from './visual-continuity';
|
||||
import type { VirtualLightSnapshot } from './virtual-light-state';
|
||||
import {
|
||||
spaceModels, roomCenter, defaultPositions, markerPos, labelPos, spaceFrame, iconCqw, NORM_W,
|
||||
GRID_STEP_N, GRID_PITCH,
|
||||
@@ -76,7 +75,6 @@ export interface StaticRenderOpts {
|
||||
showTemperature?: boolean;
|
||||
showSignal?: boolean;
|
||||
reducedMotion?: boolean;
|
||||
virtualLights?: VirtualLightSnapshot | null;
|
||||
/**
|
||||
* Resolve a stored content url to what the DOM may actually request — the
|
||||
* plan lives behind `requires_auth`, so it needs an `authSig` signature.
|
||||
@@ -193,7 +191,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
fill,
|
||||
fill === 'lqi' && room.area ? areaLqi(planHass, spaceDevs, room.area) : null,
|
||||
fill === 'light'
|
||||
? resolvedLightState(resolvedLightSources(planHass, spaceDevs, room, o.virtualLights))
|
||||
? resolvedLightState(resolvedLightSources(planHass, spaceDevs, room))
|
||||
: 'none',
|
||||
fill === 'temp' && room.area ? areaTemp(planHass, spaceDevs, room.area) : null,
|
||||
disp.tempMin,
|
||||
@@ -262,7 +260,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
})
|
||||
: [];
|
||||
|
||||
const planLightSources = resolvedLightSources(planHass, devs, null, o.virtualLights);
|
||||
const planLightSources = resolvedLightSources(planHass, devs);
|
||||
const markers = devs.map((d) => {
|
||||
const p = markerPos(d, o.layout, o.cfg, defPos, space);
|
||||
const left = ((p.x - vb[0]) / vb[2]) * 100;
|
||||
|
||||
@@ -469,97 +469,25 @@ export const cardStyles = css`
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
.plan-svg { z-index: 1; }
|
||||
.iso-underlay-svg { z-index: 0; overflow: visible; }
|
||||
.iso-shadows-svg { z-index: 3; overflow: visible; }
|
||||
.iso-walls-svg {
|
||||
z-index: 4;
|
||||
overflow: visible;
|
||||
}
|
||||
.iso-underlay-svg,
|
||||
.iso-shadows-svg,
|
||||
.iso-walls-svg,
|
||||
.iso-underlay,
|
||||
.iso-shadows,
|
||||
.iso-walls,
|
||||
.iso-openings {
|
||||
pointer-events: none;
|
||||
}
|
||||
.iso-side-hi { stop-color: #b9bdbe; }
|
||||
.iso-side-lo { stop-color: #969c9f; }
|
||||
.iso-top-hi { stop-color: #fafaf7; }
|
||||
.iso-top-lo { stop-color: #e2e4e2; }
|
||||
.iso-wall-side {
|
||||
fill: url(#hp-iso-wall-side) #a8acae;
|
||||
fill: #a8acae;
|
||||
stroke: #92989b;
|
||||
stroke-width: 0.7;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
.iso-wall-top {
|
||||
fill: url(#hp-iso-wall-top) #f3f3f1;
|
||||
fill: #f3f3f1;
|
||||
stroke: #d7d9d8;
|
||||
stroke-width: 0.8;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
.iso-floor-side {
|
||||
fill: #858b8d;
|
||||
stroke: #71787b;
|
||||
stroke-width: 0.7;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
.iso-opening-panel {
|
||||
fill: #d7d9d7;
|
||||
fill-opacity: 0.96;
|
||||
stroke: #7f878b;
|
||||
stroke-width: 0.9;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
.iso-opening-panel.iso-window {
|
||||
fill: #dfeff4;
|
||||
fill-opacity: 0.72;
|
||||
stroke: #8aa7b1;
|
||||
}
|
||||
.iso-ambient-shadow {
|
||||
fill: rgba(15, 21, 25, 0.22);
|
||||
filter: url(#hp-iso-ambient-shadow);
|
||||
}
|
||||
.iso-contact-shadow {
|
||||
fill: none;
|
||||
stroke: rgba(22, 28, 31, 0.25);
|
||||
stroke-width: 3;
|
||||
filter: url(#hp-iso-contact-shadow);
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
.iso-leaf-shadow {
|
||||
fill: none;
|
||||
stroke: rgba(18, 23, 27, 0.24);
|
||||
stroke-width: 4;
|
||||
filter: url(#hp-iso-leaf-shadow);
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.iso-side-hi { stop-color: #4c555a; }
|
||||
.iso-side-lo { stop-color: #343c40; }
|
||||
.iso-top-hi { stop-color: #687176; }
|
||||
.iso-top-lo { stop-color: #50585d; }
|
||||
.iso-wall-side { stroke: #30373b; }
|
||||
.iso-wall-top { stroke: #7b858a; }
|
||||
.iso-floor-side { fill: #2d3438; stroke: #20272a; }
|
||||
.iso-opening-panel { fill: #626b70; stroke: #899399; }
|
||||
.iso-opening-panel.iso-window { fill: #75919b; stroke: #abc6ce; }
|
||||
.iso-ambient-shadow { fill: rgba(0, 0, 0, 0.34); }
|
||||
.iso-contact-shadow, .iso-leaf-shadow { stroke: rgba(0, 0, 0, 0.38); }
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
.iso-wall-side, .iso-wall-top, .iso-floor-side, .iso-opening-panel {
|
||||
fill: Canvas;
|
||||
stroke: CanvasText;
|
||||
forced-color-adjust: auto;
|
||||
}
|
||||
.iso-ambient-shadow, .iso-contact-shadow, .iso-leaf-shadow { display: none; }
|
||||
}
|
||||
@supports not (filter: blur(1px)) {
|
||||
.iso-ambient-shadow, .iso-contact-shadow, .iso-leaf-shadow { display: none; }
|
||||
.iso-wall-side { fill: #3f474c; stroke: #333a3e; }
|
||||
.iso-wall-top { fill: #596166; stroke: #747d82; }
|
||||
}
|
||||
/* Opaque plan paper (owner 2026-08-03): the scene bg_color / daynight sky
|
||||
shows ONLY around the plan, never through it. The colour is the
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
/** Runtime-only persistent state for manually toggled virtual lights (#107). */
|
||||
export interface VirtualLightSnapshot {
|
||||
rev: number;
|
||||
configRev: number;
|
||||
off: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
export interface VirtualLightWireSnapshot {
|
||||
rev?: unknown;
|
||||
config_rev?: unknown;
|
||||
off?: unknown;
|
||||
}
|
||||
|
||||
export interface VirtualLightEvent {
|
||||
marker_id?: unknown;
|
||||
on?: unknown;
|
||||
rev?: unknown;
|
||||
}
|
||||
|
||||
const nonNegative = (value: unknown, fallback = 0): number => {
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback;
|
||||
};
|
||||
|
||||
const normalizedSnapshot = (
|
||||
raw: VirtualLightWireSnapshot | null | undefined,
|
||||
configRev: number,
|
||||
): { snapshot: VirtualLightSnapshot; valid: boolean } => {
|
||||
const fallback: VirtualLightSnapshot = {
|
||||
rev: 0,
|
||||
configRev: nonNegative(configRev),
|
||||
off: new Set(),
|
||||
};
|
||||
if (raw == null) return { snapshot: fallback, valid: false };
|
||||
const off = raw.off;
|
||||
if (!Number.isInteger(raw.rev) || Number(raw.rev) < 0
|
||||
|| !Number.isInteger(raw.config_rev) || Number(raw.config_rev) < 0
|
||||
|| !Array.isArray(off)
|
||||
|| off.some((id) => typeof id !== 'string' || !id)) {
|
||||
return { snapshot: fallback, valid: false };
|
||||
}
|
||||
return {
|
||||
snapshot: {
|
||||
rev: Number(raw.rev),
|
||||
configRev: Number(raw.config_rev),
|
||||
off: new Set(off as string[]),
|
||||
},
|
||||
valid: true,
|
||||
};
|
||||
};
|
||||
|
||||
export function virtualLightSnapshot(
|
||||
raw: VirtualLightWireSnapshot | null | undefined,
|
||||
configRev = 0,
|
||||
): VirtualLightSnapshot {
|
||||
return normalizedSnapshot(raw, configRev).snapshot;
|
||||
}
|
||||
|
||||
/** Adopt a config/get result without letting an older response undo a live event. */
|
||||
export function adoptVirtualLightServerSnapshot(
|
||||
current: VirtualLightSnapshot,
|
||||
raw: VirtualLightWireSnapshot | null | undefined,
|
||||
configRev: number,
|
||||
fieldPresent: boolean,
|
||||
): VirtualLightSnapshot {
|
||||
const incoming = normalizedSnapshot(raw, configRev);
|
||||
if (!fieldPresent || !incoming.valid || incoming.snapshot.configRev !== configRev) {
|
||||
return virtualLightSnapshot(null, configRev);
|
||||
}
|
||||
if (current.configRev === configRev && incoming.snapshot.rev < current.rev) return current;
|
||||
return incoming.snapshot;
|
||||
}
|
||||
|
||||
export function virtualLightWire(snapshot: VirtualLightSnapshot): Required<VirtualLightWireSnapshot> {
|
||||
return {
|
||||
rev: snapshot.rev,
|
||||
config_rev: snapshot.configRev,
|
||||
off: [...snapshot.off].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
export function virtualLightFingerprint(snapshot: VirtualLightSnapshot | null | undefined): string {
|
||||
if (!snapshot) return '0:0:';
|
||||
return `${snapshot.configRev}:${snapshot.rev}:${[...snapshot.off].sort().join(',')}`;
|
||||
}
|
||||
|
||||
export function isManualVirtualLightMarker(marker: any): boolean {
|
||||
return !!marker
|
||||
&& typeof marker.id === 'string'
|
||||
&& !!marker.id
|
||||
&& marker.binding === 'virtual'
|
||||
&& marker.is_light === true
|
||||
&& marker.tap_action === 'toggle'
|
||||
&& marker.removed !== true;
|
||||
}
|
||||
|
||||
export function virtualLightIsOn(
|
||||
marker: any,
|
||||
snapshot: VirtualLightSnapshot | null | undefined,
|
||||
): boolean {
|
||||
return !isManualVirtualLightMarker(marker) || !snapshot?.off.has(marker.id);
|
||||
}
|
||||
|
||||
/** Apply an event/reply only when it advances the current durable revision. */
|
||||
export function applyVirtualLightEvent(
|
||||
current: VirtualLightSnapshot,
|
||||
event: VirtualLightEvent | null | undefined,
|
||||
): VirtualLightSnapshot {
|
||||
const markerId = typeof event?.marker_id === 'string' ? event.marker_id : '';
|
||||
const rev = nonNegative(event?.rev, -1);
|
||||
if (!markerId || typeof event?.on !== 'boolean' || rev <= current.rev) return current;
|
||||
const off = new Set(current.off);
|
||||
if (event.on) off.delete(markerId);
|
||||
else off.add(markerId);
|
||||
return { ...current, rev, off };
|
||||
}
|
||||
|
||||
/** Carry state across a configuration revision accepted by this client. */
|
||||
export function reconcileVirtualLightSnapshot(
|
||||
current: VirtualLightSnapshot,
|
||||
config: any,
|
||||
configRev: number,
|
||||
): VirtualLightSnapshot {
|
||||
if (current.configRev === configRev) return current;
|
||||
const eligible = new Set(
|
||||
(Array.isArray(config?.markers) ? config.markers : [])
|
||||
.filter(isManualVirtualLightMarker)
|
||||
.map((marker: any) => marker.id),
|
||||
);
|
||||
return {
|
||||
...current,
|
||||
configRev,
|
||||
off: new Set([...current.off].filter((id) => eligible.has(id))),
|
||||
};
|
||||
}
|
||||
@@ -1313,34 +1313,6 @@ function exteriorEnvelopeGeometry(
|
||||
return { centre, shell: shell || [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical Stage floor footprint: room union plus derived exterior masonry.
|
||||
* Independent partitions/columns are deliberately not accepted here, so they
|
||||
* can never enlarge the slab perimeter. Null is a boolean failure; an empty
|
||||
* array is a valid space without room geometry.
|
||||
*/
|
||||
export function floorFootprintGeometry(
|
||||
rooms: any[],
|
||||
walls: WallEntry[] | null | undefined,
|
||||
openCuts: number[][],
|
||||
pitch: number,
|
||||
cellCm: number,
|
||||
gridPitch: number,
|
||||
coordScale = 1,
|
||||
): any | null {
|
||||
try {
|
||||
const exterior = exteriorEnvelopeGeometry(
|
||||
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
|
||||
);
|
||||
if (!exterior) return [];
|
||||
return exterior.shell?.length
|
||||
? union(exterior.centre, exterior.shell)
|
||||
: exterior.centre;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function polyclipToPathD(geom: any): string {
|
||||
if (!geom) return '';
|
||||
let d = '';
|
||||
@@ -1503,7 +1475,7 @@ export function wallBodiesGeometry(
|
||||
gridPitch: number,
|
||||
coordScale = 1,
|
||||
extraBodies: number[][][] = [],
|
||||
): { geom: any; paperGeom: any; depthUnits: number; openingIndex: OpeningWallIndex | null } | null {
|
||||
): { geom: any; paperGeom: any; depthUnits: number } | null {
|
||||
if (!walls?.length && !extraBodies.length) return null;
|
||||
const roomRings: { outset: number[][]; inset: number[][] | null }[] = [];
|
||||
let maxDepth = 0;
|
||||
@@ -1611,7 +1583,7 @@ export function wallBodiesGeometry(
|
||||
if (extra.length < 3) continue;
|
||||
body = body ? union(body, closedRing(extra) as any) : [closedRing(extra)];
|
||||
}
|
||||
return { geom: body || [], paperGeom, depthUnits: maxDepth, openingIndex };
|
||||
return { geom: body || [], paperGeom, depthUnits: maxDepth };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -81,50 +81,6 @@ test('passive source and its controller share the plan-wide derived state', () =
|
||||
assert.equal(switchPresentation.visualSources[0].sample.status, 'working');
|
||||
});
|
||||
|
||||
test('issue 107 manual virtual source owns its face despite saved outgoing controls', () => {
|
||||
const h = hass({
|
||||
'light.ceiling': state('light.ceiling', 'on'),
|
||||
});
|
||||
const lamp = device({
|
||||
id: 'manual-lamp',
|
||||
name: 'Manual lamp',
|
||||
entities: [],
|
||||
primary: null,
|
||||
bindingKind: 'virtual',
|
||||
bindingRef: 'manual-lamp',
|
||||
marker: {
|
||||
id: 'manual-lamp',
|
||||
binding: 'virtual',
|
||||
is_light: true,
|
||||
tap_action: 'toggle',
|
||||
controls: ['light.ceiling'],
|
||||
},
|
||||
});
|
||||
const offState = { rev: 2, configRev: 1, off: new Set(['manual-lamp']) };
|
||||
const offSources = resolvedLightSources(h, [lamp], null, offState);
|
||||
const off = resolveDevicePresentation(h, lamp, {
|
||||
...options,
|
||||
lightDevices: [lamp],
|
||||
lightSources: offSources,
|
||||
});
|
||||
assert.equal(off.sourceKind, 'light');
|
||||
assert.deepEqual(off.visualSources.map((source) => source.eid), ['marker:manual-lamp']);
|
||||
assert.equal(off.visual.status, 'neutral');
|
||||
assert.ok(!off.classes.includes('on'));
|
||||
|
||||
const onSources = resolvedLightSources(
|
||||
h, [lamp], null, { rev: 3, configRev: 1, off: new Set() },
|
||||
);
|
||||
const on = resolveDevicePresentation(h, lamp, {
|
||||
...options,
|
||||
lightDevices: [lamp],
|
||||
lightSources: onSources,
|
||||
});
|
||||
assert.equal(on.sourceKind, 'light');
|
||||
assert.equal(on.visual.status, 'working');
|
||||
assert.ok(on.classes.includes('on'));
|
||||
});
|
||||
|
||||
test('passive sensor source keeps its normal scalar value and never probes marker ids', () => {
|
||||
const hits = [];
|
||||
const h = hass(new Proxy({
|
||||
|
||||
@@ -4,8 +4,6 @@ import {
|
||||
projectedTapAction,
|
||||
resolveToggleIntent,
|
||||
sameToggleCommandTargets,
|
||||
sameToggleOperationTargets,
|
||||
toggleOperation,
|
||||
toggleCommandEntityIds,
|
||||
toggleCoverEntity,
|
||||
toggleOriginOf,
|
||||
@@ -70,41 +68,6 @@ test('virtual marker without controls is a saved, quiet no-op', () => {
|
||||
assert.equal(intent.command, null);
|
||||
});
|
||||
|
||||
test('issue 107: exact manual virtual light wins over saved HA controls', () => {
|
||||
const d = device({
|
||||
marker: {
|
||||
id: 'marker', binding: 'virtual', is_light: true, tap_action: 'toggle',
|
||||
controls: ['light.saved'],
|
||||
},
|
||||
controls: ['light.saved'],
|
||||
});
|
||||
const h = hass({ 'light.saved': state('light.saved', 'on') });
|
||||
const on = resolveToggleIntent({
|
||||
hass: h, devices: [d], device: d,
|
||||
virtualLights: { rev: 4, configRev: 9, off: new Set() },
|
||||
});
|
||||
assert.equal(on.command, null, 'manual mode must not call the saved HA target');
|
||||
assert.deepEqual(toggleOperation(on), { kind: 'virtual-light', markerId: 'marker' });
|
||||
assert.equal(on.targets[0].via, 'virtual-light');
|
||||
assert.equal(on.targets[0].state, 'on');
|
||||
assert.equal(on.nextEffect, 'turn-off');
|
||||
|
||||
const off = resolveToggleIntent({
|
||||
hass: h, devices: [d], device: d,
|
||||
virtualLights: { rev: 5, configRev: 9, off: new Set(['marker']) },
|
||||
});
|
||||
assert.equal(off.targets[0].state, 'off');
|
||||
assert.equal(off.nextEffect, 'turn-on');
|
||||
assert.equal(sameToggleOperationTargets(on, off), true, 'direction may change, target may not');
|
||||
|
||||
const resumed = resolveToggleIntent({
|
||||
hass: h,
|
||||
devices: [{ ...d, marker: { ...d.marker, is_light: false } }],
|
||||
device: { ...d, marker: { ...d.marker, is_light: false } },
|
||||
});
|
||||
assert.equal(toggleOperation(resumed).kind, 'ha-service', 'saved controls resume outside the triple');
|
||||
});
|
||||
|
||||
test('exact entity binding never retargets to a controllable sibling', () => {
|
||||
const h = hass({
|
||||
'sensor.room': state('sensor.room', '21'),
|
||||
|
||||
@@ -1113,36 +1113,6 @@ test('issue 84: Always without an own entity is a constant passive source', () =
|
||||
assert.deepEqual(resolvedLightStats([source]), { on: 1, total: 1 });
|
||||
});
|
||||
|
||||
test('issue 107: manual virtual state is canonical and invalidates the light cache', () => {
|
||||
const target = {
|
||||
id: 'dumb', area: 'bedroom', entities: [],
|
||||
marker: {
|
||||
id: 'dumb', binding: 'virtual', is_light: true, tap_action: 'toggle', room_id: 'bed',
|
||||
},
|
||||
};
|
||||
const controller = {
|
||||
id: 'controller', area: 'hall', primary: 'switch.wall', entities: ['switch.wall'],
|
||||
marker: {
|
||||
id: 'controller', binding: 'entity:switch.wall', controls: ['marker:dumb'],
|
||||
},
|
||||
};
|
||||
const devices = [controller, target];
|
||||
const hass = { states: { 'switch.wall': { state: 'on' } } };
|
||||
const onSnapshot = { rev: 1, configRev: 3, off: new Set() };
|
||||
const offSnapshot = { rev: 2, configRev: 3, off: new Set(['dumb']) };
|
||||
|
||||
const first = resolvedLightSources(hass, devices, { id: 'bed', area: null }, onSnapshot);
|
||||
assert.equal(first[0].on, true);
|
||||
const second = resolvedLightSources(hass, devices, { id: 'bed', area: null }, offSnapshot);
|
||||
assert.equal(second[0].on, false, 'manual off overrides an incoming on controller');
|
||||
assert.notEqual(second, first, 'operational revision participates in resolver caching');
|
||||
|
||||
const legacy = resolvedLightSources(hass, [{
|
||||
...target, marker: { ...target.marker, tap_action: 'info' },
|
||||
}, controller], { id: 'bed', area: null }, offSnapshot);
|
||||
assert.equal(legacy[0].on, true, 'leaving the exact triple restores issue #84 semantics');
|
||||
});
|
||||
|
||||
test('issue 84: passive target state is OR of controller drivers and remains target-owned', () => {
|
||||
const target = {
|
||||
id: 'dumb', area: 'bedroom', entities: [],
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { resolveInitialSpace, settleBestEffort } from '../test-build/initial-load.js';
|
||||
|
||||
test('initial space follows hash, saved, default and first live precedence', () => {
|
||||
const base = { spaceIds: ['home', 'upstairs'] };
|
||||
assert.deepEqual(resolveInitialSpace({
|
||||
...base, hashSpace: 'upstairs', savedSpace: 'home', defaultSpace: 'home',
|
||||
}), { id: 'upstairs', source: 'hash' });
|
||||
assert.deepEqual(resolveInitialSpace({
|
||||
...base, hashSpace: 'stale', savedSpace: 'upstairs', defaultSpace: 'home',
|
||||
}), { id: 'upstairs', source: 'saved' });
|
||||
assert.deepEqual(resolveInitialSpace({
|
||||
...base, hashSpace: 'stale', savedSpace: 'gone', defaultSpace: 'upstairs',
|
||||
}), { id: 'upstairs', source: 'default' });
|
||||
assert.deepEqual(resolveInitialSpace({
|
||||
...base, hashSpace: 'stale', savedSpace: 'gone', defaultSpace: 'missing',
|
||||
}), { id: 'home', source: 'first' });
|
||||
});
|
||||
|
||||
test('legacy current id is not a cold-start persistence source', () => {
|
||||
assert.deepEqual(resolveInitialSpace({
|
||||
spaceIds: ['home', 'f1'], currentSpace: 'f1', preserveCurrent: false,
|
||||
}), { id: 'home', source: 'first' });
|
||||
assert.deepEqual(resolveInitialSpace({
|
||||
spaceIds: ['home', 'upstairs'], currentSpace: 'f1', preserveCurrent: true,
|
||||
}), { id: 'home', source: 'first' });
|
||||
});
|
||||
|
||||
test('a new explicit hash wins once, then adopted navigation is preserved', () => {
|
||||
assert.deepEqual(resolveInitialSpace({
|
||||
spaceIds: ['home', 'upstairs'], currentSpace: 'upstairs', preserveCurrent: true,
|
||||
savedSpace: 'home', defaultSpace: 'home',
|
||||
}), { id: 'upstairs', source: 'current' });
|
||||
assert.deepEqual(resolveInitialSpace({
|
||||
spaceIds: ['home', 'upstairs'], hashSpace: 'home', currentSpace: 'upstairs',
|
||||
preserveCurrent: true, savedSpace: 'upstairs',
|
||||
}), { id: 'home', source: 'hash' });
|
||||
assert.deepEqual(resolveInitialSpace({
|
||||
spaceIds: ['home', 'upstairs'], hashSpace: 'home', acceptHash: false,
|
||||
currentSpace: 'upstairs', preserveCurrent: true, savedSpace: 'home',
|
||||
}), { id: 'upstairs', source: 'current' });
|
||||
});
|
||||
|
||||
test('empty model has no invented selection', () => {
|
||||
assert.deepEqual(resolveInitialSpace({
|
||||
spaceIds: [], hashSpace: 'home', savedSpace: 'home', defaultSpace: 'home',
|
||||
}), { id: null, source: 'none' });
|
||||
});
|
||||
|
||||
test('best-effort optional work starts every attempt and contains rejections', async () => {
|
||||
const calls = [];
|
||||
const results = await settleBestEffort([
|
||||
async () => { calls.push('config'); throw new Error('unauthorized'); },
|
||||
() => { calls.push('trail'); throw new Error('sync rejection'); },
|
||||
async () => { calls.push('layout'); return 'subscribed'; },
|
||||
]);
|
||||
assert.deepEqual(calls, ['config', 'trail', 'layout']);
|
||||
assert.deepEqual(results.map((result) => result.status), ['rejected', 'rejected', 'fulfilled']);
|
||||
assert.equal(results[2].status === 'fulfilled' ? results[2].value : null, 'subscribed');
|
||||
});
|
||||
@@ -1,87 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
buildIsoOpeningBasis, isoOpeningBounds, projectIsoOpening, resolveIsoDecoration,
|
||||
} from '../test-build/iso-openings.js';
|
||||
import { openingAmount } from '../test-build/logic.js';
|
||||
import { projectPlanPoint } from '../test-build/iso-projection.js';
|
||||
|
||||
const opening = (patch = {}) => ({
|
||||
id: 'op-1', sourceIndex: 0, type: 'door', x: 100, y: 80,
|
||||
angle: 0, length: 60, flipH: false, flipV: false,
|
||||
face: { ox: 0, oy: 5, side: 1 }, ...patch,
|
||||
});
|
||||
|
||||
test('door basis is immutable and live amount preserves the exact jamb anchor', () => {
|
||||
const basis = buildIsoOpeningBasis(opening());
|
||||
assert.equal(Object.isFrozen(basis.leaves), true);
|
||||
const closed = projectIsoOpening(basis, 0)[0];
|
||||
const open = projectIsoOpening(basis, 1)[0];
|
||||
const hinge = projectPlanPoint(basis.leaves[0].hinge, basis.leaves[0].bottom)
|
||||
.map((value) => Number(value.toFixed(4))).join(' ');
|
||||
assert.match(closed.d, new RegExp(`^M ${hinge.replace('.', '\\.')}`));
|
||||
assert.match(open.d, new RegExp(`^M ${hinge.replace('.', '\\.')}`));
|
||||
assert.notEqual(open.d, closed.d);
|
||||
assert.equal(basis.leaves[0].top, 64 * 0.92);
|
||||
});
|
||||
|
||||
test('window and gate retain two leaves, fixed height bounds and gate 10 degree turn', () => {
|
||||
const windowBasis = buildIsoOpeningBasis(opening({ type: 'window' }));
|
||||
const gateBasis = buildIsoOpeningBasis(opening({ type: 'gate', flipV: true }));
|
||||
assert.equal(windowBasis.leaves.length, 2);
|
||||
assert.equal(windowBasis.leaves.every((leaf) => leaf.bottom === 64 * 0.27
|
||||
&& leaf.top === 64 * 0.78), true);
|
||||
assert.deepEqual(gateBasis.leaves.map((leaf) => Math.abs(leaf.turnDeg)), [10, 10]);
|
||||
assert.equal(gateBasis.leaves.every((leaf) => leaf.top === 64 * 0.88), true);
|
||||
assert.equal(projectIsoOpening(gateBasis, 1).length, 2);
|
||||
});
|
||||
|
||||
test('state-independent opening bounds contain closed and open leaf tips', () => {
|
||||
const basis = buildIsoOpeningBasis(opening());
|
||||
const bounds = isoOpeningBounds([basis]);
|
||||
assert.ok(bounds);
|
||||
for (const amount of [0, 0.5, 1]) {
|
||||
const panel = projectIsoOpening(basis, amount)[0];
|
||||
assert.match(panel.d, /^M /);
|
||||
}
|
||||
const leaf = basis.leaves[0];
|
||||
assert.ok(bounds.x <= leaf.hinge[0] - 60 && bounds.x + bounds.w >= leaf.hinge[0] + 60);
|
||||
assert.ok(bounds.y <= leaf.hinge[1] - 60 && bounds.y + bounds.h >= leaf.hinge[1] + 60);
|
||||
assert.equal(isoOpeningBounds([]), null);
|
||||
});
|
||||
|
||||
test('flips move structural jamb/basis while HA state changes only projected leaves', () => {
|
||||
const normal = buildIsoOpeningBasis(opening());
|
||||
const flipped = buildIsoOpeningBasis(opening({ flipH: true, flipV: true }));
|
||||
assert.notDeepEqual(flipped.leaves.map((leaf) => leaf.hinge),
|
||||
normal.leaves.map((leaf) => leaf.hinge));
|
||||
const basisSnapshot = structuredClone(normal);
|
||||
const noContact = projectIsoOpening(normal, openingAmount('door', null));
|
||||
const unavailable = projectIsoOpening(normal, openingAmount('door', 'unavailable'));
|
||||
const closed = projectIsoOpening(normal, openingAmount('door', 'off'));
|
||||
const inverted = projectIsoOpening(normal, openingAmount('door', 'off', true));
|
||||
assert.deepEqual(unavailable, noContact);
|
||||
assert.notDeepEqual(closed, inverted);
|
||||
assert.deepEqual(normal, basisSnapshot);
|
||||
});
|
||||
|
||||
test('decoration degradation never removes structure or creates floating panels', () => {
|
||||
assert.deepEqual(resolveIsoDecoration({
|
||||
showBorders: true, hideOpenings: false, filtersSupported: true, forcedColors: false,
|
||||
}), {
|
||||
structural: true, panels: true, shadows: true, materialNuance: true, floorSymbols: false,
|
||||
});
|
||||
assert.deepEqual(resolveIsoDecoration({
|
||||
showBorders: true, hideOpenings: false, filtersSupported: false, forcedColors: false,
|
||||
}), {
|
||||
structural: true, panels: true, shadows: false, materialNuance: true, floorSymbols: false,
|
||||
});
|
||||
assert.deepEqual(resolveIsoDecoration({
|
||||
showBorders: false, hideOpenings: false, filtersSupported: true, forcedColors: false,
|
||||
}), {
|
||||
structural: false, panels: false, shadows: false, materialNuance: false, floorSymbols: true,
|
||||
});
|
||||
assert.equal(resolveIsoDecoration({
|
||||
showBorders: true, hideOpenings: true, filtersSupported: true, forcedColors: false,
|
||||
}).panels, false);
|
||||
});
|
||||
@@ -46,17 +46,6 @@ test('projected frame includes raised wall tops and is view-state independent',
|
||||
assert.deepEqual(projectedFrame({ rect, wallHeight: ISO_WALL_HEIGHT }), frame);
|
||||
});
|
||||
|
||||
test('Stage 2 frame includes opening tops and the structural floor edge, not blur', () => {
|
||||
const rect = { x: 100, y: 200, w: 400, h: 300 };
|
||||
const stage1 = projectedFrame({ rect, wallHeight: ISO_WALL_HEIGHT });
|
||||
const stage2 = projectedFrame({
|
||||
rect, wallHeight: ISO_WALL_HEIGHT, openingHeight: ISO_WALL_HEIGHT + 5, floorDepth: 10,
|
||||
});
|
||||
assert.ok(stage2.y < stage1.y);
|
||||
assert.ok(stage2.h > stage1.h);
|
||||
assert.deepEqual(Object.keys(stage2).sort(), ['h', 'w', 'x', 'y']);
|
||||
});
|
||||
|
||||
test('client coordinates map through the current scene view', () => {
|
||||
const scene = clientToScenePoint([250, 175], { left: 50, top: 25, width: 400, height: 300 },
|
||||
{ x: 100, y: 200, w: 800, h: 600 });
|
||||
@@ -69,3 +58,4 @@ test('degenerate cameras and frames throw instead of mixing projections', () =>
|
||||
assert.throws(() => clientToScenePoint([0, 0], { left: 0, top: 0, width: 0, height: 1 },
|
||||
{ x: 0, y: 0, w: 1, h: 1 }));
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
buildIsoFloorGeometry, buildIsoWallGeometry, isoEffectiveView, isoGeometryFingerprint,
|
||||
buildIsoWallGeometry, isoEffectiveView, isoGeometryFingerprint,
|
||||
} from '../test-build/iso-walls.js';
|
||||
import { floorFootprintGeometry } from '../test-build/wall-thickness.js';
|
||||
|
||||
const closed = (points) => [...points, points[0]];
|
||||
const square = closed([[0, 0], [100, 0], [100, 100], [0, 100]]);
|
||||
@@ -15,49 +14,9 @@ test('one top and only O(E) visible sides are built deterministically', () => {
|
||||
assert.equal(first.edgeCount, 4);
|
||||
assert.ok(first.sides.length > 0 && first.sides.length <= first.edgeCount);
|
||||
assert.match(first.topPath, /^M /);
|
||||
assert.match(first.contactPath, /^M /);
|
||||
assert.equal(first.sides.every((face) => /^M .* Z$/.test(face.d)), true);
|
||||
});
|
||||
|
||||
test('floor edge follows outer components without internal or nested steps', () => {
|
||||
const second = closed([[160, 0], [220, 0], [220, 60], [160, 60]]);
|
||||
const hole = closed([[30, 30], [30, 70], [70, 70], [70, 30]]);
|
||||
const floor = buildIsoFloorGeometry([[square, hole], [second]], 10);
|
||||
assert.equal(floor.componentCount, 2);
|
||||
assert.equal(floor.edgeCount, 8);
|
||||
assert.equal(floor.sides.every((face) => face.planEdge.every((point) => (
|
||||
!(point[0] >= 30 && point[0] <= 70 && point[1] >= 30 && point[1] <= 70)
|
||||
))), true);
|
||||
assert.ok(floor.sides.length > 0 && floor.sides.length <= floor.edgeCount);
|
||||
});
|
||||
|
||||
test('canonical adjacent room union has no edge on its shared boundary', () => {
|
||||
const rooms = [
|
||||
{ id: 'left', poly: [[0, 0], [100, 0], [100, 100], [0, 100]] },
|
||||
{ id: 'right', poly: [[100, 0], [200, 0], [200, 100], [100, 100]] },
|
||||
];
|
||||
const geometry = floorFootprintGeometry(rooms, [], [], 20, 250, 40, 1);
|
||||
const floor = buildIsoFloorGeometry(geometry, 10);
|
||||
assert.equal(floor.componentCount, 1);
|
||||
assert.equal(floor.edgeCount, 4);
|
||||
assert.equal(floor.sides.some((face) => face.planEdge.every((point) => point[0] === 100)), false);
|
||||
});
|
||||
|
||||
test('floor edge output is stable under component order, winding and ring start', () => {
|
||||
const a = closed([[0, 0], [80, 0], [80, 60], [0, 60]]);
|
||||
const b = closed([[160, 20], [210, 20], [210, 80], [160, 80]]);
|
||||
const rotatedA = closed([[80, 60], [80, 0], [0, 0], [0, 60]]);
|
||||
const rotatedB = closed([[210, 80], [210, 20], [160, 20], [160, 80]]);
|
||||
assert.deepEqual(buildIsoFloorGeometry([[rotatedB], [rotatedA]], 10),
|
||||
buildIsoFloorGeometry([[a], [b]], 10));
|
||||
});
|
||||
|
||||
test('empty room footprint emits no inferred slab', () => {
|
||||
assert.deepEqual(buildIsoFloorGeometry([], 10), {
|
||||
footprintPath: '', sides: [], componentCount: 0, edgeCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('holes preserve an evenodd top and add visible inner/jamb edges', () => {
|
||||
const hole = closed([[40, 40], [40, 60], [60, 60], [60, 40]]);
|
||||
const geometry = buildIsoWallGeometry([[square, hole]]);
|
||||
|
||||
@@ -4,8 +4,6 @@ import { readFileSync } from 'node:fs';
|
||||
|
||||
const card = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
|
||||
const styles = readFileSync(new URL('../src/styles.ts', import.meta.url), 'utf8');
|
||||
const openings = readFileSync(new URL('../src/iso-openings.ts', import.meta.url), 'utf8');
|
||||
const packageJson = readFileSync(new URL('../package.json', import.meta.url), 'utf8');
|
||||
const spaceCard = readFileSync(new URL('../src/space-card.ts', import.meta.url), 'utf8');
|
||||
const spaceRender = readFileSync(new URL('../src/space-render.ts', import.meta.url), 'utf8');
|
||||
|
||||
@@ -17,16 +15,12 @@ test('Labs iso is presentation-only and absent from the secondary space card', (
|
||||
assert.doesNotMatch(spaceRender, /hp-labs|iso-walls|projection-toggle|view\.volumetric/);
|
||||
});
|
||||
|
||||
test('Stage 2 uses inert shared-projection SVG geometry without a second light model', () => {
|
||||
assert.match(card, /<svg class="iso-underlay-svg"/);
|
||||
assert.match(card, /<svg class="iso-shadows-svg"/);
|
||||
test('renderer uses SVG geometry without a CSS 3D context or new window light', () => {
|
||||
assert.match(card, /<svg class="iso-walls-svg"/);
|
||||
assert.match(card, /class="iso-wall-top"/);
|
||||
assert.match(card, /class="iso-wall-side"/);
|
||||
assert.match(card, /class="iso-opening-panel iso-\$\{panel\.type\}"/);
|
||||
assert.match(card, /aria-hidden="true" pointer-events="none"/);
|
||||
assert.doesNotMatch(styles, /perspective\s*:|preserve-3d|rotateX\(|rotateZ\(/);
|
||||
assert.doesNotMatch(card, /window-light|iso-window-light|iso-glow|iso-sun/);
|
||||
assert.doesNotMatch(card, /iso-window|window-light|vertical-door/);
|
||||
});
|
||||
|
||||
test('all current floor/live layers remain in the one main scene', () => {
|
||||
@@ -37,24 +31,3 @@ test('all current floor/live layers remain in the one main scene', () => {
|
||||
assert.match(card, /const point = this\._scenePoint\(\[pos\.x, pos\.y\]\)/);
|
||||
assert.match(card, /const point = this\._scenePoint\(\[p\.x, p\.y\]\)/);
|
||||
});
|
||||
|
||||
test('structural cache includes opening flips and excludes live HA amount', () => {
|
||||
assert.match(card, /flipH: !!opening\.flip_h/);
|
||||
assert.match(card, /flipV: !!opening\.flip_v/);
|
||||
assert.match(card, /floorEdgeHeight: ISO_FLOOR_EDGE_HEIGHT, algorithm: 3/);
|
||||
const source = card.slice(card.indexOf('private _isoSource()'), card.indexOf('private _isoSceneKey()'));
|
||||
assert.doesNotMatch(source, /_openingAmt|openingAmount|\.hass|matchMedia|CSS\.supports|theme|hover/);
|
||||
assert.match(card, /projectIsoOpening\(basis, this\._openingAmt\(opening\)\)/);
|
||||
assert.match(card, /isoLayers && !isoLayers\.floorSymbols/);
|
||||
assert.match(card, /if \(!this\._spaceDisplayForRender\(\)\.showBorders\)/);
|
||||
assert.match(card, /projectedFrame\(\{ rect: flat, wallHeight: ISO_WALL_HEIGHT \}\)/);
|
||||
assert.match(card, /viewBox=\$\{isoLayers\?\.structural[\s\S]*?: `\$\{floorView\.x\}/);
|
||||
assert.match(card, /preserveAspectRatio=\$\{isoLayers\?\.structural \|\| !iso \? 'xMidYMid meet' : 'none'\}/);
|
||||
assert.match(card, /transform=\$\{isoLayers\?\.structural \? isoFloorMatrixCss\(\) : nothing\}/);
|
||||
});
|
||||
|
||||
test('Stage 2 adds no schema, dependency, storage, network or HA action surface', () => {
|
||||
assert.doesNotMatch(openings, /localStorage|fetch\(|XMLHttpRequest|WebSocket|callService|config|schema/i);
|
||||
assert.doesNotMatch(packageJson, /three|babylon|webgl/i);
|
||||
assert.doesNotMatch(card, /iso2|isometric_stage|stage2_enabled/);
|
||||
});
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { MUTANTS, applyPatches } from '../scripts/mutation-gate.mjs';
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
|
||||
// Дешёвая половина гейта, идёт с обычными юнитами на каждом прогоне. Полный
|
||||
// прогон с пересборкой бандла на мутанта — предрелизный, он в
|
||||
// .github/workflows/mutation-gate.yml.
|
||||
//
|
||||
// Реестр, отставший от кода, хуже отсутствующего: он выглядит защитой. Поэтому
|
||||
// дрейф якорей ловится здесь, а не при редком полном прогоне.
|
||||
|
||||
test('every mutant patch anchors exactly once in the current source', () => {
|
||||
for (const mutant of MUTANTS) {
|
||||
for (const patch of mutant.patches) {
|
||||
const path = join(repoRoot, patch.file);
|
||||
assert.ok(existsSync(path), `${mutant.id}: файла ${patch.file} больше нет`);
|
||||
const source = readFileSync(path, 'utf8');
|
||||
const hits = source.split(patch.find).length - 1;
|
||||
assert.equal(hits, 1,
|
||||
`${mutant.id}: якорь в ${patch.file} найден ${hits} раз(а) — реестр отстал от кода`);
|
||||
assert.notEqual(patch.find, patch.replace, `${mutant.id}: патч ничего не меняет`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('every guard command points at a file that exists', () => {
|
||||
for (const mutant of MUTANTS) {
|
||||
const script = mutant.guard.split(' ').find((part) => part.endsWith('.mjs'));
|
||||
assert.ok(script, `${mutant.id}: guard не называет исполняемый файл`);
|
||||
assert.ok(existsSync(join(repoRoot, script)),
|
||||
`${mutant.id}: guard-файла ${script} не существует`);
|
||||
}
|
||||
});
|
||||
|
||||
test('every mutant explains itself', () => {
|
||||
const ids = new Set();
|
||||
for (const mutant of MUTANTS) {
|
||||
assert.ok(mutant.because && mutant.because.length > 40,
|
||||
`${mutant.id}: без объяснения мутант превратится в карго-культ`);
|
||||
assert.ok(!ids.has(mutant.id), `дубль id: ${mutant.id}`);
|
||||
ids.add(mutant.id);
|
||||
}
|
||||
assert.ok(MUTANTS.length >= 6, 'стартовый набор — шесть мутантов по дырам из #85');
|
||||
});
|
||||
|
||||
test('applyPatches rewrites the anchor and refuses a stale one', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'hp-mg-'));
|
||||
try {
|
||||
mkdirSync(join(dir, 'src'), { recursive: true });
|
||||
writeFileSync(join(dir, 'src', 'a.ts'), 'const KEEP = 1;\nconst FEATHER = 2;\n');
|
||||
|
||||
applyPatches(dir, [{ file: 'src/a.ts', find: 'const FEATHER = 2;', replace: 'const FEATHER = 20;' }]);
|
||||
assert.match(readFileSync(join(dir, 'src', 'a.ts'), 'utf8'), /FEATHER = 20/);
|
||||
|
||||
// Якоря нет — отказ, а не тихий пропуск: патч «в никуда» выглядит защитой.
|
||||
assert.throws(
|
||||
() => applyPatches(dir, [{ file: 'src/a.ts', find: 'no such anchor', replace: 'x' }]),
|
||||
/0 раз/,
|
||||
);
|
||||
|
||||
// Якорь двоится — тоже отказ: патч лёг бы «куда попало».
|
||||
writeFileSync(join(dir, 'src', 'a.ts'), 'twice\ntwice\n');
|
||||
assert.throws(
|
||||
() => applyPatches(dir, [{ file: 'src/a.ts', find: 'twice', replace: 'x' }]),
|
||||
/2 раз/,
|
||||
);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
checkReviewDocLimit,
|
||||
checkSpecs,
|
||||
classify,
|
||||
commitsNeedingIssueStatus,
|
||||
commitsUnderRuleOne,
|
||||
evaluateCommit,
|
||||
makeCommit,
|
||||
@@ -135,9 +134,8 @@ test('a class A commit without a spec warns offline and fails with labels', () =
|
||||
assert.equal(offline[0].level, 'warn');
|
||||
assert.equal(offline[0].rule, 3);
|
||||
|
||||
// С метками: small и trivial оправдывают отсутствие файла, их отсутствие — нет.
|
||||
// С метками: small оправдывает отсутствие файла, его отсутствие — нет.
|
||||
assert.deepEqual(checkSpecs([c], [], () => ['small', 'S5-ready']), []);
|
||||
assert.deepEqual(checkSpecs([c], [], () => ['trivial', 'S5-ready']), []);
|
||||
const strict = checkSpecs([c], [], () => ['S5-ready']);
|
||||
assert.equal(strict.length, 1);
|
||||
assert.equal(strict[0].level, 'fail');
|
||||
@@ -171,36 +169,6 @@ test('only class A/B commits are held to the issue status', () => {
|
||||
assert.deepEqual(commitsUnderRuleOne([reviewDoc]), []);
|
||||
});
|
||||
|
||||
test('stable promotion skips status recheck only for commits already published in a prerelease', () => {
|
||||
const published = makeCommit({
|
||||
sha: 'a'.repeat(40), subject: 'Fix shipped in beta', body: 'Issue: #123', files: ['src/a.ts'],
|
||||
});
|
||||
const postBeta = makeCommit({
|
||||
sha: 'b'.repeat(40), subject: 'New promotion work', body: 'Issue: #130', files: ['scripts/a.mjs'],
|
||||
});
|
||||
const publishedShas = new Set([published.sha]);
|
||||
assert.deepEqual(
|
||||
commitsNeedingIssueStatus([published, postBeta], {
|
||||
targetRef: 'refs/heads/main',
|
||||
isPublishedPrereleaseCommit: (sha) => publishedShas.has(sha),
|
||||
}).map((commit) => commit.sha),
|
||||
[postBeta.sha],
|
||||
);
|
||||
assert.deepEqual(
|
||||
rules(checkIssueStatuses(['130'], () => ({
|
||||
ok: true, json: { state: 'CLOSED', labels: [] },
|
||||
}))),
|
||||
[8],
|
||||
);
|
||||
assert.deepEqual(
|
||||
commitsNeedingIssueStatus([published, postBeta], {
|
||||
targetRef: 'refs/heads/dev',
|
||||
isPublishedPrereleaseCommit: (sha) => publishedShas.has(sha),
|
||||
}).map((commit) => commit.sha),
|
||||
[published.sha, postBeta.sha],
|
||||
);
|
||||
});
|
||||
|
||||
test('issue status check is fail closed when the source of truth is unreachable', () => {
|
||||
// AC3: недоступный gh должен давать отказ, а не молчаливый пропуск.
|
||||
const broken = () => ({ ok: false, error: 'gh: could not resolve to a Repository' });
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
adoptVirtualLightServerSnapshot,
|
||||
applyVirtualLightEvent,
|
||||
reconcileVirtualLightSnapshot,
|
||||
virtualLightSnapshot,
|
||||
virtualLightWire,
|
||||
} from '../test-build/virtual-light-state.js';
|
||||
|
||||
test('issue 107 runtime wire data is normalized and missing data defaults on', () => {
|
||||
const missing = virtualLightSnapshot(undefined, 7);
|
||||
assert.deepEqual(virtualLightWire(missing), { rev: 0, config_rev: 7, off: [] });
|
||||
const parsed = virtualLightSnapshot({
|
||||
rev: 4, config_rev: 7, off: ['b', 'a', 'a'],
|
||||
});
|
||||
assert.deepEqual(virtualLightWire(parsed), { rev: 4, config_rev: 7, off: ['a', 'b'] });
|
||||
const corrupt = virtualLightSnapshot({
|
||||
rev: 4, config_rev: 7, off: ['valid', '', 1],
|
||||
}, 7);
|
||||
assert.deepEqual(virtualLightWire(corrupt), { rev: 0, config_rev: 7, off: [] });
|
||||
});
|
||||
|
||||
test('issue 107 events are monotonic and never optimistic', () => {
|
||||
const initial = virtualLightSnapshot({ rev: 2, config_rev: 7, off: [] });
|
||||
assert.equal(applyVirtualLightEvent(initial, { marker_id: 'lamp', on: false, rev: 2 }), initial);
|
||||
const off = applyVirtualLightEvent(initial, { marker_id: 'lamp', on: false, rev: 3 });
|
||||
assert.deepEqual(virtualLightWire(off), { rev: 3, config_rev: 7, off: ['lamp'] });
|
||||
const on = applyVirtualLightEvent(off, { marker_id: 'lamp', on: true, rev: 4 });
|
||||
assert.deepEqual(virtualLightWire(on), { rev: 4, config_rev: 7, off: [] });
|
||||
});
|
||||
|
||||
test('issue 107 event-before-response ordering never rolls state back', () => {
|
||||
const afterEvent = virtualLightSnapshot({ rev: 5, config_rev: 7, off: ['lamp'] });
|
||||
const staleResponse = adoptVirtualLightServerSnapshot(
|
||||
afterEvent,
|
||||
{ rev: 4, config_rev: 7, off: [] },
|
||||
7,
|
||||
true,
|
||||
);
|
||||
assert.equal(staleResponse, afterEvent);
|
||||
|
||||
const currentResponse = adoptVirtualLightServerSnapshot(
|
||||
afterEvent,
|
||||
{ rev: 5, config_rev: 7, off: ['lamp'] },
|
||||
7,
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(virtualLightWire(currentResponse), {
|
||||
rev: 5, config_rev: 7, off: ['lamp'],
|
||||
});
|
||||
|
||||
const oldBackend = adoptVirtualLightServerSnapshot(afterEvent, undefined, 7, false);
|
||||
assert.deepEqual(virtualLightWire(oldBackend), { rev: 0, config_rev: 7, off: [] });
|
||||
});
|
||||
|
||||
test('issue 107 known config transitions preserve only still-eligible ids', () => {
|
||||
const current = virtualLightSnapshot({ rev: 5, config_rev: 10, off: ['keep', 'drop'] });
|
||||
const next = reconcileVirtualLightSnapshot(current, {
|
||||
markers: [
|
||||
{ id: 'keep', binding: 'virtual', is_light: true, tap_action: 'toggle', hidden: true },
|
||||
{ id: 'drop', binding: 'virtual', is_light: false, tap_action: 'toggle' },
|
||||
],
|
||||
}, 11);
|
||||
assert.deepEqual(virtualLightWire(next), { rev: 5, config_rev: 11, off: ['keep'] });
|
||||
});
|
||||
@@ -6,8 +6,7 @@ import {
|
||||
setWallThickness, setWallThicknessForRoom, applyWallThicknessToNewRoom,
|
||||
drawWallPreviewD, DRAW_WALL_DEFAULT_CM, clampWallCm, cmToField, fieldToCm,
|
||||
wallCmToUnits, insetContour, inwardNormal, edgeKinds, wallEdgeBodies,
|
||||
wallBodyRings, wallBodiesGeometry, wallBodiesUnionPath, floorFootprintGeometry,
|
||||
innerContourForRoom,
|
||||
wallBodyRings, wallBodiesGeometry, wallBodiesUnionPath, innerContourForRoom,
|
||||
paperRoomShapesWithWalls, WALL_MIN_CM, WALL_MAX_CM, MITRE_LIMIT,
|
||||
atomicPolyForRoom, insetOffsetsForRoom, wallIntervals, materializeWallIntervals,
|
||||
normalizeWallIntervals,
|
||||
@@ -41,17 +40,6 @@ const geometryBounds = (geom) => {
|
||||
];
|
||||
};
|
||||
|
||||
test('Stage floor footprint excludes detached independent physical bodies', () => {
|
||||
const rooms = [{ id: 'room', poly: [[0, 0], [100, 0], [100, 100], [0, 100]] }];
|
||||
const detached = [[[200, 20], [220, 20], [220, 80], [200, 80]]];
|
||||
const footprint = floorFootprintGeometry(rooms, [], [], 20, 250, 40, 1);
|
||||
const withBody = wallBodiesGeometry(rooms, [], [], [], 20, 250, 40, 1, detached);
|
||||
assert.ok(footprint && withBody);
|
||||
assert.deepEqual(geometryBounds(footprint), [0, 0, 100, 100]);
|
||||
assert.deepEqual(geometryBounds(withBody.paperGeom), [0, 0, 100, 100]);
|
||||
assert.deepEqual(geometryBounds(withBody.geom), [200, 20, 220, 80]);
|
||||
});
|
||||
|
||||
const geometryDifferenceArea = (a, b) => geometryArea(difference(a, b));
|
||||
|
||||
function cornerSplitFixture({
|
||||
|
||||
@@ -27,25 +27,6 @@ async def test_setup_creates_runtime_data(hass: HomeAssistant) -> None:
|
||||
assert entry.runtime_data is not None
|
||||
assert entry.runtime_data.store is not None
|
||||
assert entry.runtime_data.config_store is not None
|
||||
assert entry.runtime_data.virtual_light_store is not None
|
||||
|
||||
|
||||
async def test_setup_tolerates_unreadable_virtual_light_state(
|
||||
hass: HomeAssistant, monkeypatch
|
||||
) -> None:
|
||||
"""Operational light state must not take the whole integration offline."""
|
||||
from custom_components.houseplan.store import HouseplanStore
|
||||
|
||||
real_load = HouseplanStore.async_load
|
||||
|
||||
async def failing_load(self):
|
||||
if self.key == "houseplan.virtual_lights":
|
||||
raise OSError("corrupt operational store")
|
||||
return await real_load(self)
|
||||
|
||||
monkeypatch.setattr(HouseplanStore, "async_load", failing_load)
|
||||
entry = await _setup(hass)
|
||||
assert entry.state.value == "loaded"
|
||||
|
||||
|
||||
async def test_unload(hass: HomeAssistant) -> None:
|
||||
|
||||