Compare commits

..
Author SHA1 Message Date
claude[bot] 3d6cea455d docs: review document for #43
Issue: #43
User-Visible: no
2026-09-02 00:05:33 +00:00
Sergey Matyunin 0e0ca3d4dc test: cover help and feedback browser flows (#43)
Issue: #43
User-Visible: no
2026-09-02 02:52:24 +03:00
claude[bot] 40d63080bd docs: review document for #43
Issue: #43
User-Visible: no
2026-09-01 23:42:03 +00:00
Sergey Matyunin ffddc38376 fix: satisfy validation gates for support reports (#43)
Issue: #43
User-Visible: no
2026-09-02 02:28:38 +03:00
Sergey Matyunin 1ba5363038 Merge origin/dev into issue/43-help-feedback
Issue: #43
User-Visible: no
2026-09-02 02:18:45 +03:00
Sergey Matyunin 1e2e0fa61c feat: add private help and feedback reports (#43)
Issue: #43
User-Visible: yes
2026-09-02 02:17:35 +03:00
claude[bot] 7490575799 docs: review document for #43
Issue: #43
User-Visible: no
2026-09-01 22:42:11 +00:00
Codex 3ce5bc0ea2 docs: require the trusted-proxy switch for the rate-limit source (#43)
User-Visible: no
Issue: #43
2026-09-02 01:34:58 +03:00
claude[bot] 321bd9691b docs: review document for #43
Issue: #43
User-Visible: no
2026-09-01 22:32:15 +00:00
Codex cd8d01aced docs: pin the rate-limit source and the webhook recipe (#43)
User-Visible: no
Issue: #43
2026-09-02 01:23:41 +03:00
claude[bot] 55b51a0270 docs: review document for #43
Issue: #43
User-Visible: no
2026-09-01 22:21:09 +00:00
Codex c43bf84789 feat(relay): deliver through the maintainer's Home Assistant webhook (#43)
User-Visible: no
Issue: #43
2026-09-02 01:03:57 +03:00
Codex c7efcf1699 fix(relay): pin the rate-limit source to the proxy-supplied address (#43)
User-Visible: no
Issue: #43
2026-09-02 00:24:41 +03:00
claude[bot] 319b25c281 docs: review document for #411
Issue: #411
User-Visible: no
2026-09-01 21:16:15 +00:00
Codex 6b10cd52af docs: switch the support sink to a maintainer channel (#43)
User-Visible: no
Issue: #43
2026-09-02 00:12:41 +03:00
Codex 0010281952 feat(relay): receive support reports on the project stand (#43)
User-Visible: no
Issue: #43
2026-09-02 00:12:41 +03:00
Sergey Matyunin 9d5c0357b0 test: keep grid scale static fixture coherent
Issue: #411
User-Visible: no
2026-09-02 00:08:02 +03:00
Matysh 2d143b570f ci: stop truncating the numbers a failing smoke printed
smoke_grid_scale_invariance сообщает, НАСКОЛЬКО разошлись кадры: строка
pixel-diffs с changed, maxDelta и meanDelta по каждой паре. Введена в #302
ровно затем, чтобы падение не было голым boolean.

В логе прогона её не видно: шаг шарда печатает tail -20, а диагностика идёт до
вердикта и срезается. На #411 это и вышло — в логе осталось только «expected
true, got false», а числа, по которым видно, превышение порога это или
расхождение слоя, пришлось бы искать в артефакте.

Строки достаются адресно, перед хвостом лога. Они и есть разница между
«чинить» и «гадать».

Issue: #411
User-Visible: no
2026-09-01 23:52:36 +03:00
claude[bot] cf7f47db27 docs: review document for #43
Issue: #43
User-Visible: no
2026-09-01 20:45:50 +00:00
Sergey Matyunin e25aa302d4 docs: address support spec review
Issue: #43
User-Visible: no
2026-09-01 23:41:24 +03:00
claude[bot]andSergey Matyunin 4559b5cd74 docs: review document for #43
Issue: #43
User-Visible: no
2026-09-01 23:41:24 +03:00
Sergey Matyunin 00b6845058 docs: rewrite support feedback spec
Issue: #43
User-Visible: no
2026-09-01 23:41:24 +03:00
Matysh 4fa670e873 docs: accept deterministic documentation screenshots
Accept the canonical Linux capture after rasterisation flags and whole-pixel clipping changed every frame.

Issue: #410
User-Visible: no
2026-09-01 23:36:05 +03:00
Matysh ca0b318e5e ci: make the stability measurement a gate, not a printout
Замер сделал свою работу — теперь он остаётся как проверка. Если кадр снова
начнёт зависеть от времени, шаг упадёт, а не напечатает число в лог. Стоит
перед съёмкой набора: публиковать артефакт, снятый недетерминированной
съёмкой, смысла нет.

Issue: #410
User-Visible: no
2026-09-01 23:20:09 +03:00
Matysh ed700c8c8e fix: crop the documentation frames on whole pixels
Замер снял главное: три снимка подряд в одном состоянии страницы совпадают
побайтово у всех десяти сценариев. Значит рендер детерминирован, а плавает то,
что приходит на вход съёмке.

Обрезка считается из живого DOM через getBoundingClientRect и приходит
дробной. Дробная обрезка заставляет Chromium ресемплить кадр — и тогда сдвиг
раскладки на десятую пикселя переписывает границы всех элементов на единицы
уровней. Ровно эта подпись в #410: 76 пикселей, максимум 2 уровня, alpha не
тронута, всё на сглаженных границах полей.

Рамка расширяется наружу, а не округляется к ближайшему: обрезка обязана
содержать цель целиком.

Issue: #410
User-Visible: no
2026-09-01 23:13:59 +03:00
Matysh d945b03208 test: measure whether the frame drifts inside one page state (#410)
Флаги растеризации убрали один кадр из трёх, но device-editor и device-info
плавают по-прежнему. Дальше гадать нельзя: нужен ответ, плавает ли кадр внутри
одного состояния страницы или разница копится между подготовками сценария.

Режим --stability=N делает N снимков подряд без единой правки состояния и
сравнивает их попиксельно в самой странице — тем же приёмом, что у golden.
Печатает число различающихся пикселей, максимум по RGB, задета ли alpha и
bbox.

Ветка временная.

Issue: #410
User-Visible: no
2026-09-01 23:09:05 +03:00
Matysh 823fc316b2 fix: pin rasterisation for the documentation capture
Съёмка скриншотов документации запускалась вообще без флагов детерминизма,
тогда как golden имел их с самого начала. Отсюда и плавающие кадры: включённое
субпиксельное сглаживание даёт разный результат от прогона к прогону, а
дельта — единицы уровней в RGB на сглаженных границах при неизменной alpha —
это его подпись, а не изменение продукта.

Измерено до починки: два прогона канонического workflow на одном и том же
dev SHA 184e0098, одном Chromium 151.0.7922.34 и одном oxipng 10.2.0 дали три
разошедшихся кадра из десяти — 06-device-editor, 08-room-card, 09-device-info.

Взяты те же три флага, что у golden: --disable-lcd-text снимает субпиксельное
сглаживание, --font-render-hinting=none — зависимость от хинтинга,
--force-color-profile=srgb фиксирует профиль. Добавлен reducedMotion: 'reduce'
и два кадра ожидания перед съёмкой: animations: 'disabled' гасит анимации, но
не гарантирует, что запланированный ре-рендер успел лечь в композитор.

Байтовый контракт приёмки не ослаблен ни в одном месте — чинится источник
шума, а не проверка.

Правка меняет capture.mjs, поэтому captureScriptSha256 в манифесте протух и
check-docs красный до пересъёмки. Пересъёмка неизбежна и по существу: с
выключенным субпиксельным сглаживанием переписываются все десять кадров сразу,
то есть приёмка идёт через --no-witnesses --reason.

Issue: #410
User-Visible: no
2026-09-01 22:59:17 +03:00
Matysh 184e009806 ci: print frame hashes so two capture runs can be compared
Чтобы измерить недетерминированность съёмки, нужен текст, который видно с
экрана: артефакт для этого не годится — его надо скачать и распаковать. Шаг
печатает sha256 каждого кадра, и два прогона одного SHA сравниваются
построчно.

Сначала измерение, потом починка (#410).

Issue: #410
User-Visible: no
2026-09-01 22:52:00 +03:00
claude[bot] 36e1b7f9c2 docs: review document for #405
Issue: #405
User-Visible: no
2026-09-01 19:48:07 +00:00
Sergey Matyunin 24af34ccba docs: refresh #405 screenshot source fingerprint
Проверка (CI) / Классификация изменённых файлов (push) Successful in 27s
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 1m10s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 40s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Skipped
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 17m35s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Canonical run 33549400366; the 76-pixel raster flake is tracked in #410.

Issue: #405
User-Visible: no
2026-09-01 22:36:15 +03:00
Sergey Matyunin b4809a12ff fix: await draft deletion through the card facade (#405)
Issue: #405
User-Visible: no
2026-09-01 22:23:57 +03:00
claude[bot] e926ea453e docs: review document for #405
Issue: #405
User-Visible: no
2026-09-01 19:20:46 +00:00
Sergey Matyunin d5c93d4e28 docs: revise #405 spec after review
Issue: #405
User-Visible: no
2026-09-01 22:14:26 +03:00
claude[bot]andSergey Matyunin bf40e26ed0 docs: review document for #405
Issue: #405
User-Visible: no
2026-09-01 22:12:14 +03:00
CodexandSergey Matyunin 2c6d937d46 docs: specify draft delete awaiting and witness floor (#405)
User-Visible: no
Issue: #405
2026-09-01 22:12:13 +03:00
claude[bot] 9c9a22354b docs: review document for #406
Проверка (CI) / Классификация изменённых файлов (push) Successful in 22s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 39s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 1m0s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 24s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 25s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 16m42s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 6m24s
Issue: #406
User-Visible: no
2026-09-01 16:32:25 +00:00
claude[bot] 77a22efc78 docs: review document for #406
Issue: #406
User-Visible: no
2026-09-01 16:32:25 +00:00
Sergey Matyuninandclaude[bot] 269d828dd4 chore: refresh docs source fingerprint
Docs screenshots run 33530447151; all 10 canonical PNGs matched byte-for-byte and only the source fingerprint changed.

Issue: #406
User-Visible: no
2026-09-01 16:32:25 +00:00
Sergey Matyuninandclaude[bot] 816b1476f0 fix: preserve the ordinary dialog path
Issue: #406
User-Visible: no
2026-09-01 16:32:25 +00:00
Sergey Matyuninandclaude[bot] 372d838ec2 fix: close beta 2 polish gaps
Issue: #406
User-Visible: yes
2026-09-01 16:32:25 +00:00
claude[bot] 8273af59ca docs: review document for #406
Issue: #406
User-Visible: no
2026-09-01 16:32:25 +00:00
Sergey Matyuninandclaude[bot] 7806ab7182 docs: document confirm shell tradeoff
Issue: #406
User-Visible: no
2026-09-01 16:32:25 +00:00
claude[bot] da82ab9199 docs: review document for #406
Issue: #406
User-Visible: no
2026-09-01 16:32:25 +00:00
Sergey Matyuninandclaude[bot] c3bb62a3b6 docs: resolve beta2 polish review
Issue: #406
User-Visible: no
2026-09-01 16:32:25 +00:00
claude[bot] bd780c6013 docs: review document for #406
Issue: #406
User-Visible: no
2026-09-01 16:32:25 +00:00
claude[bot] b3f22b33e4 docs: review document for #406
Issue: #406
User-Visible: no
2026-09-01 16:32:25 +00:00
Sergey Matyuninandclaude[bot] aad1eeaa10 docs: revise beta2 polish spec
Issue: #406
User-Visible: no
2026-09-01 16:32:25 +00:00
Codexandclaude[bot] 15ff625964 docs: specify the beta.2 polish batch (#406)
User-Visible: no
Issue: #406
2026-09-01 16:32:25 +00:00
Matysh 8ac8f0105a fix: let the product build the marker-dialog fixture
Прогон показал, что дописать binding и bindingMode было мало: у объявленного
типа больше сорока обязательных полей, и рендер упал на следующем
недостающем — теперь на name внутри _markerDraft. Гоняться за типом руками
бессмысленно.

Смок открывает диалог штатным _openMarkerDialog() и переопределяет три поля.
Это заодно и доказательство, что дефекта поведения нет: продукт своим же
путём собирает объект, на котором рендер не падает.

Issue: #404
User-Visible: no
2026-09-01 19:19:05 +03:00
Matysh 2903374b72 fix: the exception guard reads its counter after delivery, not before
Гард «uncaught exception внутри карточки» жил в demo/serve.mjs с 2026-07-27 и
не срабатывал ни разу в самом частом случае. Счётчик читался синхронно, а
Playwright доставляет pageerror асинхронно по CDP: если исключение возникло
после последнего обращения смока к странице, счётчик к моменту проверки
нулевой, а browser.close() уносит недоставленное событие. В логе это видно
дословно — EXC печатается после результата и до OK.

finish() теперь делает round-trip по открытым страницам перед чтением
счётчика. Страницы регистрируются там, где создаются: ссылок на них у
finish(browser, out) нет, а менять сигнатуру нельзя — так её зовут 205
смоков.

Medium-1 жёлтого ревью ТЗ закрыт расширением, а не оговоркой. Страницы,
созданные смоком после launch(), регистрация в launchInternal не покрывает:
smoke_zoom_flash печатал своё EXC2 мимо счётчика, три страницы
smoke_svg_sandbox не имели слушателя вовсе. Документировать слепую зону в
задаче, которая существует ради устранения слепой зоны, значит закрыть issue,
оставив дефект. Наружу отдана одна функция watchPage(page): подписка и
регистрация неразделимы, иначе появится страница, чьи исключения считаются, а
доставки не ждёт никто.

Разрыв оказался шире, чем в ревью: проверка по всему набору нашла ещё два
файла со своей подпиской — smoke_cold_view_toggle и smoke_cold_view_vacuum.
Они не слепая зона, их страница приходит из launchColdView и уже
зарегистрирована, а свой счётчик они превращают в отдельное утверждение.
Поэтому инвариант сформулирован как «ни одна страница не создаётся мимо
гарда» и закреплён по всему набору, а не по двум названным файлам.

reportPageErrors() из #407 стал асинхронным: второй читатель счётчика обязан
ждать доставку так же, как finish(). Пять смоков получили await.

Фикстура smoke_danger_confirmation приведена к объявленному типу: без binding
и bindingMode _bindingHasHaPage падал на undefined.split(':') — два
исключения, которых гард не видел. Дефекта поведения нет, все 15 мест в src/,
создающих диалог, binding пишут; врала фикстура.

Два отступления от ТЗ, каждое по измеренной причине. Пробы лежат в
demo/guard/, а не demo/fixtures/: последний входит в корпус sourceFingerprint,
и каждый файл там объявил бы устаревшими бандл, скриншот-индекс и
golden-индекс — пробы же не касаются ни одного пикселя. Поведение
доказывается в job со браузером, а не в npm test: job «Фронтенд» браузеры не
ставит, и тест молча скипался бы — тот самый тихий успех, против которого вся
задача.

Issue: #404
User-Visible: no
2026-09-01 19:08:07 +03:00
claude[bot] b04387ce91 docs: review document for #403
Issue: #403
User-Visible: no
2026-09-01 15:08:22 +00:00
Sergey Matyuninandclaude[bot] 2274e6eb84 docs: refresh canonical screenshots
Docs screenshots run 33521193808; all 10 pairs were visually reviewed. The explicit no-witnesses reason is recorded in screenshots.json because the prior rasterization baseline is no longer reproducible.

Issue: #403
User-Visible: no
2026-09-01 15:08:22 +00:00
Sergey Matyuninandclaude[bot] 355b0516c7 fix: preserve area relocation state
Issue: #403
User-Visible: yes
2026-09-01 15:08:22 +00:00
claude[bot] 09613ce322 docs: review document for #403
Issue: #403
User-Visible: no
2026-09-01 15:08:22 +00:00
Sergey Matyuninandclaude[bot] 98d6edb7b2 docs: revise area relocation safety spec
Issue: #403
User-Visible: no
2026-09-01 15:08:22 +00:00
claude[bot] 64f48e7ffd docs: review document for #403
Issue: #403
User-Visible: no
2026-09-01 15:08:22 +00:00
Codexandclaude[bot] 8e7c927264 docs: specify area relocation safety (#403)
User-Visible: no
Issue: #403
2026-09-01 15:08:21 +00:00
Matysh 8119c523fa fix: the screenshot witness floor comes from the set, not from survivors
Тот же дефект, что #408 у golden, и в моём же коде из #401. Порог свидетелей
считался от числа кадров, уцелевших на диске: rm docs/images/*.png плюс
объявить все десять через --expect-change — уцелевших ноль, порог ноль,
причины никто не спрашивает, а в манифесте остаётся {"witnesses":0,"floor":0}
без единого слова о произошедшем. Щель была описана в комментарии над самой
функцией и оставлена открытой.

Порог теперь от набора сценариев, свидетели — из тех, с кем есть что
сравнить. Разделение принципиальное: удаление кадров лишает доказательств, но
не должно снижать планку. Первичная съёмка идёт через --no-witnesses
--reason, как теперь и в golden.

Отдельного параметра размера, как в golden, здесь не нужно, и это не
небрежность: `ids` и есть набор — docs-accept.mjs передаёт DOC_SCREENSHOTS, а
verifyDocsCandidate до того отказывает, если набор сцен в кандидате не совпал
с ожидаемым. Пустой ids — отказ, а не ноль.

Попутно Low из #405: повторная приёмка неизменённого набора затирала
acceptance.declared пустым списком. След приёмки отвечает на вопрос «когда
эти пиксели приняли и что тогда объявляли», а обновление отпечатка пикселей
не меняет — значит и стирать ответ не должно. Прежний след сохраняется и
помечается lastWriteWasFingerprintOnly.

Заодно отказ по свидетелям теперь возвращает сами числа: вызывающий печатает
свой вердикт, и сочинять их заново ему не из чего.

Issue: #409
User-Visible: no
2026-09-01 17:54:48 +03:00
Matysh cd0ecf4db2 fix: the witness floor comes from the matrix, not from surviving baselines
Порог свидетелей считался от числа сцен со статусом не missing-baseline, то
есть от эталонов, уцелевших на диске. Обход в одну команду: git rm
demo/golden/baselines/*.png — все сцены становятся missing-baseline, порог
обращается в ноль, свидетелей никто не требует, и чужая съёмка всей матрицы
принимается без единого следа причины в манифесте. Отказ
goldenAcceptanceRefusal этого не ловит: он требует объявить каждую новую сцену
в --expect-new, а объявить их все ничто не мешает.

Прежняя редакция объясняла ноль тем, что первичная съёмка свидетелей иметь не
может. Верно по факту и неверно по выводу: невозможность доказать среду не
отменяет требования, она требует сказать это вслух. Теперь и первичная съёмка
идёт через --no-witnesses --reason, а причина уезжает в манифест эталонов.

Размер матрицы стал обязательным параметром, а не выводится из отчёта: у
частичного прогона (run.mjs --only=…) results короче матрицы, и порог просел
бы молча — тот же дефект в другой одежде. Отсутствие параметра — отказ.

Формула не менялась: она общая с docsWitnessFloor и обязана такой остаться.
Менялся источник счётчика. На обычной приёмке ничего не меняется: при 143
эталонах порог был и остался 10.

Issue: #408
User-Visible: no
2026-09-01 17:25:54 +03:00
Matysh 601e68e52d fix: six smokes never asked whether the card threw
Счётчик исключений внутри карточки живёт в demo/serve.mjs, и читала его одна
функция — finish(). Шесть смоков её не вызывали вовсе: у трёх своя развязка
(`if (!ok) process.exit(1)`), у двух throw из try/finally, у
smoke_entry_stale ни того ни другого. Необработанное исключение во время этих
шести проходило незамеченным всегда — в лог печаталось EXC, а прогон
оставался зелёным.

smoke_entry_stale был хуже остальных: он складывал неудачи в _failures через
check/checkAll, но их никто не печатал и код возврата не выставлял. То есть
смок не мог провалиться в принципе — ровно паттерн «печатали булевы значения
и всегда выходили нулём», который шапка serve.mjs описывает как исправленный
в 2026-07-27.

Добавлен reportPageErrors(): тот же вердикт, что у finish(), для смоков со
своей логикой выхода. Каждый из шести теперь вердикт запрашивает, а
smoke_entry_stale получил finish() и вместе с ним настоящий код возврата.

Вердикт обязан ОСТАНАВЛИВАТЬ, а не только помечать. Первый заход выставлял
process.exitCode, и отрицательный прогон напечатал «FAILED: 1 uncaught
exception(s)» и следом «OK deep-link: …»: код был верным, вывод
противоречивым, а читают вывод.

Доказано отрицательным прогоном, а не рассуждением: на ветке
experiment/407-negative smoke_deeplink получил намеренное исключение внутри
карточки, шард 2/3 упал с exit code 1, в логе FAIL и FAILED без строки
успеха. Ветка удалена.

Гейт против повторения — test/smoke-harness-contract.test.mjs: он падает,
если смок не запрашивает вердикт или запрашивает, не останавливаясь. На
origin/dev до починки он находил ровно шесть файлов, после — ноль.

Issue: #407
User-Visible: no
2026-09-01 17:19:41 +03:00
claude[bot] 2fae84af58 docs: review document for #402
Проверка (CI) / Классификация изменённых файлов (push) Successful in 27s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 47s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 23s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 18s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 1m10s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 4m16s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 6m14s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Issue: #402
User-Visible: no
2026-08-31 14:14:43 +00:00
Codex ccd870ddf7 build: refresh bundle trees and the doc capture for #402
User-Visible: no
Issue: #402
2026-08-31 16:47:53 +03:00
Codex 00b6f41233 fix: the danger confirmation lives outside render()'s branches (#402)
hp-confirm sat at the end of a chain of early returns, so in onboarding
(«no spaces yet»), in the fixed-floor states and without a space it did
not exist at all: the trash button next to a saved plan was dead and the
promise hung forever, because the decision event had no source in the
DOM. An already open dialog vanished the moment the card slipped into
one of those branches, leaving the caller waiting for a resolution that
could never come. Before #32 a browser confirm() worked there.

render() is now a wrapper: it takes the body — the old chain, unchanged,
as _renderBody — and renders the confirmation beside it. That fixes the
class rather than the instance: a branch added later cannot lose the
dialog again. noChange and nothing are passed through untouched, since
neither may be wrapped in a template; in those states _confirmDanger
refuses the request outright instead of leaving it pending, which is the
honest answer while the card is not on screen and the user has pressed
nothing.

_tapConfirm and _vacCalConfirm deliberately stay where they are. They
share the same final branch, but they have no promise (a synchronous
exec, a dialog closed by hp-close), so the defect cannot occur there,
and their entry points require a drawn plan.

Proven by a separate smoke rather than an addition to
smoke_danger_confirmation: that file keeps deliberately incomplete
dialog fixtures open, and the extra re-renders this change needs make
them throw. The new smoke runs under touch emulation, because
TOUCH-SUPPORT § Safety floor forbids bypassing a destructive
confirmation and the broken branch pierced that floor on finger as
surely as on mouse. Reverting the wrapper reddens it.

User-Visible: yes
Issue: #402
2026-08-31 16:47:05 +03:00
claude[bot] 931e49e3d6 docs: review document for #402
Issue: #402
User-Visible: no
2026-08-31 13:28:51 +00:00
Codex 11959e4c85 docs: #402 spec revision 2 per SPEC-REVIEW-402-r1
User-Visible: no
Issue: #402
2026-08-31 16:22:50 +03:00
claude[bot] 9b29df28db docs: review document for #402
Issue: #402
User-Visible: no
2026-08-31 13:19:34 +00:00
Codex d94db87ee7 docs: specify the branch-independent danger confirmation (#402)
User-Visible: no
Issue: #402
2026-08-31 16:08:52 +03:00
Matysh 4794183114 build: prepare v1.70.0-beta.2 candidate
Package HA Area marker relocation, unified dangerous confirmations, camera and marker-history fixes, beta polish and backend gate hardening.

Issue: #32
Issue: #126
Issue: #396
Issue: #397
Issue: #398
Issue: #399
Issue: #400
User-Visible: yes
2026-08-31 15:03:22 +03:00
claude[bot] bc076d3f50 docs: review document for #126
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 5m43s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 45s
Проверка (CI) / Классификация изменённых файлов (push) Successful in 21s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 1m16s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 38s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 43s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 6m40s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Issue: #126
User-Visible: no
2026-08-31 09:51:46 +00:00
Matysh 26cb00db24 docs: refresh canonical screenshots for area relocation
Issue: #126
User-Visible: no
2026-08-31 10:21:53 +03:00
Matysh d5268086c2 fix: harden HA area relocation regressions
Issue: #126
User-Visible: no
2026-08-31 10:16:46 +03:00
claude[bot]andMatysh 61dd7ccb89 docs: review document for #126
Issue: #126
User-Visible: no
2026-08-31 09:45:53 +03:00
Matysh b319fc9548 docs: refresh canonical screenshots
Issue: #126
User-Visible: no
2026-08-31 09:45:53 +03:00
Matysh 633cb20e59 fix: follow Home Assistant Area marker moves
Issue: #126
User-Visible: yes
2026-08-31 09:45:53 +03:00
claude[bot]andMatysh c8ef4f19e4 docs: review document for #126
Issue: #126
User-Visible: no
2026-08-31 09:45:52 +03:00
Matysh cdd9588ca6 docs: address HA area relocation review
Issue: #126
User-Visible: no
2026-08-31 09:45:52 +03:00
claude[bot]andMatysh bce0ae97b2 docs: review document for #126
Issue: #126
User-Visible: no
2026-08-31 09:45:52 +03:00
Matysh 59546662e6 docs: update HA area relocation contract
Issue: #126
User-Visible: no
2026-08-31 09:45:52 +03:00
Sergey MatyuninandMatysh 84f9901ca0 docs: specify HA area marker relocation
Issue: #126
User-Visible: no
2026-08-31 09:45:52 +03:00
Matysh 5c8cb58e1f ci: prove the screenshot environment instead of trusting the place
Правило приёмки скриншотов было про место: снимать только в CI. Обоснование
измерено — съёмка в другом окружении переписывает файлы без содержательных
изменений, в #231 два из девяти на 7–8 байт, набор с беты все девять. Но
держалось правило на комментарии, а не на механизме: кандидат проверялся на
самосогласованность и принимался целиком, ни разу не сравниваясь с тем, что
лежит в репозитории.

Цена видна на #390: правка типов, которая физически не может сдвинуть
пиксель, потребовала прогона workflow, а затем правки одиннадцати полей
манифеста руками.

Теперь правило про доказательство, и оно то же, что у golden с #334: среда
доказана, если каждый кадр, который менять не собирались, совпал с
закоммиченным байт-в-байт. Расхождение растеризации спрятать нельзя — оно
задевает все кадры с текстом сразу. Снимать можно где угодно, включая WSL;
принять получится только оттуда, где кадры воспроизводятся, и перестанет
получаться в тот день, когда обновятся шрифты.

Остальное следует из того же правила: намерение объявляется
--expect-change, необъявленное расхождение останавливает приёмку,
объявленное без расхождения — тоже (ложная декларация обесценивает список),
заменяются ровно объявленные файлы, а тотальная перерисовка требует
--no-witnesses --reason, и причина уезжает в манифест.

Частый случай закрылся сам: ничего не объявлено, все кадры совпали —
принимается один манифест, руками ничего писать не надо.

Проверено шестью сквозными прогонами на подделанном артефакте, не только
юнитами: идентичный кандидат, необъявленное расхождение, объявленное,
молчаливая декларация, тотальная перерисовка без причины и с ней.

Issue: #401
User-Visible: no
2026-08-31 09:40:52 +03:00
claude[bot] 4683a493cc docs: review document for #400
Issue: #400
User-Visible: no
2026-08-31 05:31:46 +00:00
claude[bot] 6a0bb985e7 docs: review document for #400
Issue: #400
User-Visible: no
2026-08-31 05:22:03 +00:00
Codex bd6722ccd2 build: refresh bundle trees and the doc capture for #400
User-Visible: no
Issue: #400
2026-08-31 08:14:55 +03:00
Codex d153b20a1d fix: make the handle paint order a named decision and mutate it (#400)
CODE-REVIEW-400-r1 Medium: the registered mutant edited a comment, not
the order — it could not reproduce the regression AC1 exists to catch.
That is the same defect class this issue is fixing elsewhere, in my own
guard.

The order is now HANDLE_PAINT_ORDER, a named constant, because it IS the
hit priority rather than an accident of where the blocks sit in the
template. The mutant flips that constant, so it reproduces exactly the
behaviour the audit found.

Also: smoke_furniture picked the SE corner as handles[3], an index that
silently depended on the old paint order — CI shard 3 went red on four
checks. It now selects by role (corner handles, third of four), which is
what the test actually means.

User-Visible: no
Issue: #400
2026-08-31 08:14:55 +03:00
claude[bot] f07e3c54ff docs: review document for #400
Issue: #400
User-Visible: no
2026-08-31 05:06:43 +00:00
Codex 73ecfbd1d7 build: refresh bundle trees and the doc capture for #400
User-Visible: no
Issue: #400
2026-08-31 07:57:02 +03:00
Codex fe3b85c06b fix: the corner handle wins on small furniture, guides exclude the real drag (#400)
(1) Corner and edge handles carry the same hit radius (1.8 % of the
view), so on furniture narrower than 4·hr — a 40 cm cabinet — the two
circles overlap and whichever is painted last takes the tap. Edges were
painted last. Corners are now, because a side handle scales one axis
while a corner scales both, and the object is small exactly when
proportional resize matters most. The visible beads are unchanged.

The audit called this 'proportional resize becomes unavailable'; the
measurement says otherwise and the spec records the correction: the
corner centre lies outside the edge circle, so the corner was reachable
— its area was halved, not lost. A polish, not a bug, and worth fixing
because it is one line of ordering.

(2) Alignment guides in the devices mode excluded the dragged marker by
_drag, which has been null there since #74 moved device dragging into
_deviceDrag. So the marker being moved was among its own candidates.
Nothing looked wrong because a point always matches itself within
tolerance — the guide was drawn from the marker to itself, visually
identical to an honest one, and the smoke asserted only guides() >= 1.
The smoke now compares the candidate lists with and without the drag and
demands exactly one removed entry.

(3) The 38 settings-help strings stay in the initial chunk, and that is
now a recorded decision rather than an oversight: measured 2 654 B gzip,
0.9 % of the ceiling, against splitting a synchronous dictionary in two,
a second request on first hint, and a second source for the key type
derived from en.json (#391). docs/ARCHITECTURE.md says so, with the
number that would justify revisiting it.

Both mutants run by hand: reverting the paint order reddens the 40 cm
probe while the 160 cm one stays green; restoring _drag reddens the
guides smoke.

User-Visible: yes
Issue: #400
2026-08-31 07:56:27 +03:00
claude[bot] 532743b01c docs: review document for #400
Issue: #400
User-Visible: no
2026-08-31 04:45:04 +00:00
Codex 60c211c82a docs: specify the beta polish batch (#400)
User-Visible: no
Issue: #400
2026-08-31 07:36:44 +03:00
claude[bot] 6bf39ee9a7 docs: review document for #399
Проверка (CI) / Классификация изменённых файлов (push) Successful in 20s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 33s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 15s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 44s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 17s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 4m55s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 5m59s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Issue: #399
User-Visible: no
2026-08-31 01:54:28 +00:00
Codex 5c4d8cba9e test: prove AC5 by running the scanner, not the predicate (#399)
CODE-REVIEW-399-r1 High: the test named after AC5 called
installsPythonDeps on string literals and never executed the directory
walk it was supposed to protect. The reviewer showed what that costs:
restoring the old hardcoded pair of real names and dropping a third
workflow with unpinned installs into .github/workflows left all ten
checks green — the exact scenario AC5 describes went undetected.

The walk is now a function taking the directory, so the test can run it
for real: it builds a temporary directory with three files (a pinned
installer, a workflow that installs nothing, and a rogue one) and
asserts on what the scanner returns. Reverting the walk to a list of two
real names now reddens this test, verified by hand.

The mutant is sharpened accordingly: it substitutes the two-name list
instead of a one-name list. The old form failed on an unrelated
assertion about directory size, so it proved nothing about the scan
itself — while the two-name form is indistinguishable from correct code
on today's tree, which is what makes it the likely regression.

User-Visible: no
Issue: #399
2026-08-31 04:48:41 +03:00
claude[bot] 8b2a146c6c docs: review document for #399
Issue: #399
User-Visible: no
2026-08-31 01:44:20 +00:00
Codex 98028a3093 ci: the backend gate now checks exactly what it promises (#399)
Three claims a green backend used to make, each slightly wider than the
truth — and #392 happened in exactly that gap.

The frontend pin said 20260826.1 next to homeassistant==2026.8.3, whose
package_constraints.txt requires 20260729.7: a combination that exists
in no HA release. It was never derived from anything — someone once
picked it. It is now taken from the constraints, the source is named in
the file, and a test holds both numbers together so raising HA cannot
quietly desync them.

ruff's include declared three trees while CI linted one. Narrowed the
declaration rather than widening CI: the debt in scripts/ and
tests_backend/ (56 findings, mostly E402/I001, plus 7 B023 and 5 B017)
has its own cost and its own decisions, and belongs in its own task, not
in a visibility fix. test/lint-scope.test.mjs now compares the two, so
they can only move together.

The pin check skipped a workflow when it found neither the package name
nor the requirements path — and both vanish together the moment someone
returns to Defaulting to user installation because normal site-packages is not writeable, i.e. the gate switched itself off
under precisely the change it exists to catch. It now walks the whole
.github/workflows directory and decides per file by a positive sign: if
a file installs python packages, it must install them from the pins
file. Verified by dropping a rogue workflow into the directory — it
reddens without touching any list.

Three mutants registered and each run by hand.

User-Visible: no
Issue: #399
2026-08-31 04:35:56 +03:00
claude[bot] cc44b28f3b docs: review document for #399
Issue: #399
User-Visible: no
2026-08-31 01:31:41 +00:00
Codex bb635298c1 docs: #399 spec revision 3 per SPEC-REVIEW-399-r2
User-Visible: no
Issue: #399
2026-08-31 04:26:14 +03:00
claude[bot] 6befe4168f docs: review document for #399
Issue: #399
User-Visible: no
2026-08-31 01:23:19 +00:00
Codex 6d229c66f1 docs: #399 spec revision 2 per SPEC-REVIEW-399-r1
User-Visible: no
Issue: #399
2026-08-31 04:16:18 +03:00
claude[bot] 627a5359c3 docs: review document for #399
Issue: #399
User-Visible: no
2026-08-31 01:14:10 +00:00
Codex a1d7e0da55 docs: specify the backend gate honesty fixes (#399)
User-Visible: no
Issue: #399
2026-08-31 04:06:40 +03:00
claude[bot] f7fb3369b7 docs: review document for #398
Issue: #398
User-Visible: no
2026-08-31 01:05:33 +00:00
Codex d9b6766362 test: the sys.modules guard now sees the write, not its spelling (#398)
The guard introduced by #394 matched the literal
sys.modules['custom_components... and therefore never looked at
pure_imports.py, which writes through a variable — the third instance of
the #389 class walked straight past the check created for it.

The guard now inspects the write itself and decides by the key: a whole
literal or the literal head of an f-string is safe unless it starts with
custom_components (that is how tests register homeassistant.*, hp_pure.*
and houseplan.trails); anything else — a variable, a concatenation,
setdefault/update — counts as a violation whenever the file is able to
name the package at all, i.e. contains a custom_components. literal. A
file that never names the package cannot poison it through a variable,
so restoring a snapshot stays legal.

load_pure now removes what it registered. Removing its own name is not
enough: relative imports pull neighbours in, so junction_limits leaves
wall_segment_model and coordinate_canonicalization behind. It removes
the whole custom_components difference accumulated during exec_module,
in a finally, and a repeated call still works.

pure_imports.py is a named exemption of the static guard precisely
because that guard cannot see the cleanup — so the cleanup is proven by
an executable test instead, and the mutant pure-imports-stops-cleaning
reddens it. Both mutants were run by hand.

User-Visible: no
Issue: #398
2026-08-31 03:56:01 +03:00
claude[bot] 2b3cabe7e0 docs: review document for #398
Issue: #398
User-Visible: no
2026-08-31 00:47:06 +00:00
Codex 9fde407e4e docs: #398 spec revision 3 per SPEC-REVIEW-398-r2
User-Visible: no
Issue: #398
2026-08-31 03:40:36 +03:00
claude[bot] 692e3b72f4 docs: review document for #398
Issue: #398
User-Visible: no
2026-08-31 00:38:50 +00:00
Codex 480d202fa5 docs: #398 spec revision 2 per SPEC-REVIEW-398-r1
User-Visible: no
Issue: #398
2026-08-31 03:30:10 +03:00
claude[bot] a4b686a7fe docs: review document for #398
Issue: #398
User-Visible: no
2026-08-31 00:26:38 +00:00
Codex 69dd09a7c9 docs: specify the sys.modules guard scope fix (#398)
User-Visible: no
Issue: #398
2026-08-31 03:17:55 +03:00
claude[bot] 037a19d093 docs: review document for #397
Issue: #397
User-Visible: no
2026-08-31 00:15:10 +00:00
Codex f4c66bed3f test: prove AC3 on the reconnect path, with a probe that can fail (#397)
CODE-REVIEW-397-r1 Medium: AC3 named the second reader of the same
value — _loadFromServer via _adoptStructuralResponses — and nothing
exercised it. Adding the scenario turned out to be less mechanical than
it looked, and both obstacles are worth recording:

The reconnect path reads BOTH answers, and a differing config clears the
history for its own reason (configChanged). The fake server now echoes
the config the card already holds, so the check answers the layout
question it claims to answer.

The first version of the probe used a round 0.42, which canonicalization
leaves untouched — the check passed with and without the fix, i.e. for
the wrong reason. The probe now starts from a non-canonical position
(0.024999999999999942, which snaps to 0.025), and the scenario runs
immediately after the write, before any reload can align the two sides.

Verified by removing the fix: five checks red, now including
reconnectKeepsHistory and deleteEchoKeepsHistory. Both were green in the
weaker version — which is exactly what the reviewer's Medium was about.

User-Visible: no
Issue: #397
2026-08-31 03:03:59 +03:00
claude[bot] b17f416740 docs: review document for #397
Issue: #397
User-Visible: no
2026-08-30 23:55:36 +00:00
Codex 08d5612265 build: refresh bundle trees and the doc capture for #397
User-Visible: no
Issue: #397
2026-08-31 02:37:18 +03:00
Codex d87cc29804 fix: the card keeps the position it sent, so its echo is not foreign (#397)
B3: _persistDevicePlacement sent canonicalizePosition(...) to the server
and left the raw value in _layout, then recorded the fingerprint over
that raw snapshot. Canonicalization is not identity — it snaps to the
lattice — so 39 of 115 pixel-derived coordinates differ, and the next
_reloadLayoutOnly or _adoptStructuralResponses saw its own write as a
remote edit: history cleared, _layout replaced. The old _persistLayout
wrote the canonical value back; the per-device path introduced by #74
lost that line.

M1: the smoke that was supposed to prove AC10 assigned
serverLayout = structuredClone(c._layout) right before the reload —
erasing by hand the very divergence it existed to catch, so it could not
fail. The fake WS already stores what went over the wire; the
assignment is gone and the check now reddens on the unfixed code
(verified: three checks red without the fix, including this one).

Also proven, because the fix touches their neighbourhood: the echo of a
DELETE keeps the history (the branch removes a key rather than replacing
a value), and an in-flight write still wins the merge against a server
answer holding the old position.

One existing assertion was loosened deliberately: undo now restores a
position that may differ from the raw one by the lattice snap (<1e-9 of
the plan). That is the point of the fix — local and server agree — so the
equality is stated to that precision, with the snap size pinned
separately so a real drift would still fail.

User-Visible: yes
Issue: #397
2026-08-31 02:36:55 +03:00
claude[bot] f4fcb1fac0 docs: review document for #397
Issue: #397
User-Visible: no
2026-08-30 23:25:38 +00:00
Codex 6edcde012a docs: #397 spec revision 2 per SPEC-REVIEW-397-r1
User-Visible: no
Issue: #397
2026-08-31 02:21:20 +03:00
claude[bot] 38b48ee0aa docs: review document for #397
Issue: #397
User-Visible: no
2026-08-30 23:19:33 +00:00
Codex 83692e7959 docs: #397 spec — correct the fingerprint line number
User-Visible: no
Issue: #397
2026-08-31 02:11:19 +03:00
Codex 9fcbb64633 docs: specify the device position echo fix (#397)
User-Visible: no
Issue: #397
2026-08-31 02:10:49 +03:00
claude[bot] a25de7383f docs: review document for #396
Issue: #396
User-Visible: no
2026-08-30 23:00:18 +00:00
Codex f3620f2b27 build: refresh bundle trees and the doc capture after the dev rebase (#396)
User-Visible: no
Issue: #396
2026-08-31 01:40:05 +03:00
claude[bot]andCodex f8cf18a25a docs: review document for #396
Issue: #396
User-Visible: no
2026-08-31 01:37:12 +03:00
Codex d6a2607692 build: refresh bundle trees and the doc capture for #396
User-Visible: no
Issue: #396
2026-08-31 01:37:12 +03:00
Codex 8f485c0b00 fix: the camera keeps the zoom you see and the point you hold (#396)
Three findings of the v1.70.0-beta.1 audit, all on the transition path
added by #82, all of the same shape — the new path did not inherit a
property the old one had.

B1: persisting the zoom moved into _settleCameraTransition only, and a
cancellation never settles. Touching the plan mid-flight — the literal
scenario of the issue — froze the shown frame and threw it away; before
which kind it is: the user one (_stagePointerDown) persists the frame
that stays on screen, the eleven structural ones keep writing nothing.
The distinction is now also written down in spec #82 §13, which had one
line for both.

B2: the anchor was read from the presented (lagging) frame while the
zoom accumulated from the target, so a six-notch trackpad series walked
the point under the cursor 17 px away — against §10's own promise. Both
now come from the same state. Spec §10 said to use the presented frame
and to keep the anchor within 0.5 px; those two are incompatible, and
the paragraph is corrected rather than left as a trap.

M2: the feather freeze keyed on the two gesture flags, which an
animated transition does not set, so every tween frame rebuilt the blur
region. It keys on 'the camera is still' now.

Guards: unit tests pin the anchor at 1e-9 across 8/16/33 ms series and
prove zoom accumulation is untouched; the smoke checks the shown zoom is
the stored one, that a structural cancellation stores nothing, and that
the anchor holds; three mutants (cancel-loses-zoom, anchor-from-
presented, feather-thaws) were run by hand and each reddens.

User-Visible: yes
Issue: #396
2026-08-31 01:36:59 +03:00
claude[bot]andCodex 3421065286 docs: review document for #396
Issue: #396
User-Visible: no
2026-08-31 01:36:53 +03:00
Codex 371f2e9869 docs: #396 spec revision 3 per SPEC-REVIEW-396-r2
User-Visible: no
Issue: #396
2026-08-31 01:36:53 +03:00
claude[bot]andCodex 334dcf2bf8 docs: review document for #396
Issue: #396
User-Visible: no
2026-08-31 01:36:53 +03:00
Codex 7cf22cb54f docs: #396 spec revision 2 per SPEC-REVIEW-396-r1
User-Visible: no
Issue: #396
2026-08-31 01:36:53 +03:00
claude[bot]andCodex 4132e03642 docs: review document for #396
Issue: #396
User-Visible: no
2026-08-31 01:36:53 +03:00
Codex 54f9efb798 docs: specify the camera transition fixes (#396)
User-Visible: no
Issue: #396
2026-08-31 01:36:53 +03:00
claude[bot] cc6bbdf24c docs: review document for #32
Issue: #32
User-Visible: no
2026-08-30 22:28:41 +00:00
Matysh 2f021046aa Update remaining dangerous-action smokes
User-Visible: no
Issue: #32
2026-08-31 01:18:00 +03:00
claude[bot] 2b3d5613f2 docs: review document for #32
Issue: #32
User-Visible: no
2026-08-30 22:06:44 +00:00
Matysh f3ac0ac061 Update free-walls smoke for shared confirmation
User-Visible: no
Issue: #32
2026-08-31 00:59:37 +03:00
Matysh 7b363caabc Accept canonical documentation screenshots
Canonical run: https://github.com/Matysh/houseplan-card/actions/runs/33337407834

User-Visible: no
Issue: #32
2026-08-31 00:50:44 +03:00
Matysh 751769908a Update destructive action smokes
User-Visible: no
Issue: #32
2026-08-31 00:49:56 +03:00
Matysh c91ac74e19 Implement unified dangerous-action confirmation
User-Visible: yes
Issue: #32
2026-08-31 00:46:54 +03:00
claude[bot] 2782f3b1f3 docs: review document for #32
Issue: #32
User-Visible: no
2026-08-30 21:29:56 +00:00
Matysh 4ba1b2730f docs: budget unified confirmation surface
User-Visible: no
Issue: #32
2026-08-31 00:24:32 +03:00
claude[bot] a6b5161f07 docs: review document for #32
Issue: #32
User-Visible: no
2026-08-30 21:23:04 +00:00
Matysh 206bcd1ae7 docs: refresh danger confirmation spec
User-Visible: no
Issue: #32
2026-08-31 00:17:19 +03:00
Matysh 8955c02e3a build: prepare v1.70.0-beta.1 candidate
Package smooth plan navigation, device position history, visible discovery filters, furniture transforms, localized backend errors and the reviewed fixes since v1.69.0.

Issue: #33
Issue: #44
Issue: #74
Issue: #82
Issue: #86
Issue: #381
Issue: #383
Issue: #384
Issue: #385
Issue: #391
User-Visible: yes
2026-08-30 22:52:02 +03:00
Codex 7e27bc7922 fix: type-check against python 3.14, as the runner does (#42)
The new typing step failed on its first CI run — not on our code:
mypy parses the sources of the installed homeassistant, and after #392
that is HA 2026.8.3 on python 3.14, which uses 3.14-only syntax
(parenthesis-free `except`). With python_version = 3.13 mypy stopped at
a syntax error in someone else's file before reaching a single module of
ours, which is exactly the silent-nothing the gate exists to prevent —
except loud.

ruff keeps target-version py313 deliberately: it lints OUR sources and a
lower target only withholds newer-syntax suggestions, while mypy has to
read the dependency tree the runner actually installs.

User-Visible: no
Issue: #42
2026-08-30 22:05:33 +03:00
Codex dddbbe5522 ci: make the strict typing gate actually run (#42)
r6 Medium: AC4 was measurable only on a developer's machine — no
workflow invoked mypy, so a typing regression in any of the six
allowlist modules reached dev unnoticed while the issue claimed
measurable backend quality. Coverage and lint had continuous gates;
typing had a text comparison of a committed list.

The backend job now runs mypy right after ruff, from the same pinned
dependency file (mypy==2.3.1 — an unpinned checker would redden on code
that never changed). The step derives its module list from the
pyproject.toml strict allowlist instead of duplicating it, because a
drifted duplicate is a green step checking the wrong modules, and it
refuses an empty list rather than passing silently.

Guarded twice: a contract test pins all three facts (pinned checker,
a step that really invokes it, list read from pyproject) and the new
typing-gate-stops-running mutant reddens when the invocation is
neutered.

User-Visible: no
Issue: #42
2026-08-30 22:00:52 +03:00
claude[bot] 01fbb77f3b docs: review document for #42
Issue: #42
User-Visible: no
2026-08-30 18:52:32 +00:00
Codex 972e708a71 build: refresh bundle trees and the doc capture after the #392 rebase (#42)
User-Visible: no
Issue: #42
2026-08-30 21:34:32 +03:00
Codex 569867487c fix: adopt the #392 dependency file as the single source (#42)
Rebase resolution: dev's #392 introduced tests_backend/requirements.txt
(python 3.14, phcc 0.13.357, homeassistant 2026.8.3) — exactly the
single-source-of-pins AC of this issue, so the duplicate
requirements_test.txt is dropped and both workflows keep installing from
the #392 file; ruff is added there for the lint step. The backend
reuse key no longer names the dead file (tests_backend/ as a root
already covers the new one); ARCHITECTURE.md points at the real path.

User-Visible: no
Issue: #42
2026-08-30 21:34:32 +03:00
Codex 9f50778df5 test: close the r4 mediums — structural tuple scan and reuse-key inputs (#42)
M1: the AC5 scanner parses the (field, code, message) literal tuple in
validation.py structurally instead of naming the two known codes — a
third tuple entry with an unregistered code now fails the registry test
(verified with an injected invalid_ghost_entity_mutant_probe), and a
tuple whose string count is not a multiple of three refuses instead of
guessing.
M2: the backend reuse key now includes its direct job inputs introduced
by this issue — scripts/backend-coverage-baseline.txt (the threshold the
comparison step reads), requirements_test.txt (the pip source) and
pyproject.toml (ruff/mypy config) — verified: the key changes when the
baseline changes and is restored byte-for-byte with the file.

User-Visible: no
Issue: #42
2026-08-30 21:34:32 +03:00
Codex 26d45cf405 build: refresh bundle trees and the doc capture after the dev rebase (#42)
Rebased onto dev with #82 (camera transitions), #74 (marker undo) and
the re-accepted goldens; bundle trees are rebuilt from the rebased
sources and the screenshot capture is retaken on this HEAD — manifest
AND all PNGs committed together.

User-Visible: no
Issue: #42
2026-08-30 21:34:32 +03:00
Codex fa1aa7a877 test: record the real backend coverage baseline (#42)
87.2% line coverage, taken from the first fully green backend CI job of
this branch (run 33321192996, coverage.xml line-rate 0.8716) — replaces
the 80.0 placeholder as promised before the verdict. The gate refuses
any run below baseline minus 0.1.

User-Visible: no
Issue: #42
2026-08-30 21:34:32 +03:00
Codex f7ae00019c test: the jamb-margin harness assert reads the JSON details (#42)
The harness test still parsed the legacy 'space=... opening=...
margin_cm=...' string; #42 replaced that message with structured JSON
details (the client localizes from the code and reads the fields). The
assert now parses the payload and checks the same three facts.

User-Visible: no
Issue: #42
2026-08-30 21:34:32 +03:00
Codex 778da930cb fix: the quality test no longer stubs packages in sys.modules (#42)
Same defect class as #389: _const() planted bare ModuleType stand-ins
for custom_components(.houseplan) and never removed them, so the HA
harness running later in the same pytest process saw a package without
async_setup — 85 test_ha_* failures with 'No setup or config entry
setup function defined'. const.py imports nothing, so the loader needs
no package context at all: load it by file path under a standalone
module name and leave sys.modules untouched (verified: no
custom_components* keys after _const()).

User-Visible: no
Issue: #42
2026-08-30 21:34:32 +03:00
claude[bot]andCodex 92cf0ab17c docs: review document for #42
Issue: #42
User-Visible: no
2026-08-30 21:34:32 +03:00
Codex 512ab7f279 docs: commit the recaptured screenshot images, not only the manifest (#42)
The previous commit refreshed docs/images/screenshots.json but left the
re-rendered PNGs out of the index — CI compared the committed manifest
against the committed (stale) images and failed on every hash.

User-Visible: no
Issue: #42
2026-08-30 21:34:32 +03:00
Codex 6582a06e17 build: refresh bundle trees after the dev rebase (#42)
Bundle trees are rebuilt from the rebased sources (the pre-rebase build
commit was dropped during the rebase and the trees recreated), and the
doc capture is retaken on the rebased HEAD.

User-Visible: no
Issue: #42
2026-08-30 21:34:32 +03:00
Codex c9dd3401fe fix: pin the last py3.13-compatible test harness and refresh the doc capture (#42)
pytest-homeassistant-custom-component 0.13.317+ require python >=3.14
while the CI runner is 3.13: 0.13.316 is the newest installable release
and resolves to the same homeassistant==2026.2.3 the previously green
unpinned pip line produced. voluptuous is unpinned again — homeassistant
pins 0.15.2 itself and a 0.16.0 pin deadlocks the resolver.
The screenshot capture is refreshed after the any-gate source fix of
e8243d1e, which changed src/ without recapturing (the standing rule:
every src commit needs capture + check-docs).

User-Visible: no
Issue: #42
2026-08-30 21:34:32 +03:00
claude[bot]andCodex fa20e37330 docs: review document for #42
Issue: #42
User-Visible: no
2026-08-30 21:34:32 +03:00
Codex 09adfd73a0 fix: CI-compatible dependency pins and an any-free JSON branch (#42)
pytest-homeassistant-custom-component transitively pins the pytest
family and the homeassistant version — re-pinning them deadlocked the
resolver (the sandbox pip index is py3.10-bound and suggested stale
versions). The structured-details branch no longer copies the legacy
(item: any) annotation the new-code any gate rightly rejects.

User-Visible: no
Issue: #42
2026-08-30 21:34:32 +03:00
Codex d274e38d57 test: mutation-gate accepts python guard files (#42)
User-Visible: no
Issue: #42
2026-08-30 21:34:32 +03:00
Codex 10cdba8435 test: the JSON-details contract counts both branches (#42)
User-Visible: no
Issue: #42
2026-08-30 21:34:32 +03:00
Codex 323b7803e0 feat: measurable backend engineering quality — stage 1 (#42)
Tooling: requirements_test.txt becomes the single source of backend CI
dependencies; pyproject.toml configures ruff (E/F/B/I, E501 excluded by
decision) and mypy strict for a grow-only allowlist of six pure modules
(junction_limits annotated to pass). The 42 substantive ruff findings
are fixed — the B023 loop-variable closures bind their variables as
parameter defaults instead of hiding behind noqa, and every remaining
noqa carries a reason (guarded by a test).

Errors: const.ERROR_CODES / ERROR_CODE_FAMILIES formalise the stable
contract; the scanner test proves every emitted code across BOTH paths
(send_error literals; class attrs, literal and variable-passed
MarkerControlError codes, f-string families) is registered and has a
localized message — 22 missing backup.error.* keys added in all four
languages. invalid_passage_fields / invalid_partition_opening_jamb_margin
ship structured JSON details (legacy format read-compat for one beta),
and _errText renders code-first: unknown codes localize, raw English
messages go to the console.

CI: the backend job lints with ruff, refuses a silently skipped HA
harness (import + collect threshold), measures branch coverage over
pure+harness, fails below the committed baseline and uploads
coverage.xml. quality_scale: docs-troubleshooting/examples honestly
done, test-coverage/strict-typing carry staged progress.

User-Visible: yes
Issue: #42
2026-08-30 21:34:31 +03:00
Codex 066cf44c2f fix: count spec and code review documents apart (#395)
Проверка (CI) / Классификация изменённых файлов (push) Successful in 20s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 52s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 55s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 20s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 19s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 9m52s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 1h5m50s
The p.7 limit bucketed every review document of an issue together, so a
task that honestly passed both stages was refused for having passed
them: #42 has 4 SPEC-REVIEW plus 3 CODE-REVIEW documents — 4 and 3
rounds per stage, both inside the budget — and its already-published
GREEN r5 verdict could not publish its own artefact for three runs in a
row, blocking the merge each time.

The counter is now keyed by stage and issue, and the refusal names the
stage. The threshold itself is unchanged: seven documents of one kind
still fail, and the comment above the constant already said what the
number means — the round budget of ONE stage.

User-Visible: no
Issue: #395
2026-08-30 21:34:19 +03:00
claude[bot] e2a681b543 docs: review document for #391
Issue: #391
User-Visible: no
2026-08-30 17:13:01 +00:00
claude[bot] 2518abd961 docs: review document for #391
Issue: #391
User-Visible: no
2026-08-30 17:13:01 +00:00
Matyshandclaude[bot] 31cb2827bf chore: refresh docs source fingerprint (#391)
Record the unchanged screenshot set against the typed editor runtime source after verifying the capture output and preserving the reviewed PNG bytes.

Issue: #391
User-Visible: no
2026-08-30 17:13:01 +00:00
Matyshandclaude[bot] a577fde799 refactor: type editor i18n keys (#391)
Remove legacy any casts from device inbox, marker, and geometry preflight translation calls. Refresh the moved preflight mutation anchor.

Issue: #391
User-Visible: no
2026-08-30 17:13:01 +00:00
Matysh 8dfb0a9e36 test: put the package stub in one place, under an honest condition
Чистые тесты подменяли custom_components и custom_components.houseplan
пустышками и не убирали их никогда. В CI это не стреляло только потому, что
настоящий пакет успевал импортироваться из файла, который идёт раньше по
алфавиту: условие «если ещё не импортирован» оказывалось ложным. То есть
корректность HA-харнесса держалась на именах файлов в каталоге, и хватило бы
переименования, чтобы получить #389 заново.

Подмена переехала в conftest и стала условной по единственному честному
признаку: есть Home Assistant — работаем с настоящим пакетом и не подменяем
ничего; нет — HA-тесты и так пропущены, ломать нечего.

Первым заходом я делал подмену обратимой прямо в тестах, контекстным
менеджером. Замер показал, что так теряется работоспособность
test_wall_segment_model в песочнице: он импортирует пакет обычным способом и
жил как раз за счёт чужой пустышки. Развилка в conftest сохраняет оба
окружения и убирает зависимость от порядка файлов.

Проверено в обе стороны: в песочнице 240 passed, а в эмуляции «HA есть»
объект пакета после прогона тот же, что был до.

Issue: #394
User-Visible: no
2026-08-30 20:11:31 +03:00
Matysh a3dcee5241 ci: move the harness to the current Home Assistant
Замер по AC5 #392 изменил решение. Я собирался заморозить набор на том, что
резолвилось (HA 2026.2.3, февральский), и вынести обновление в отдельную
задачу «на потом» — потому что шесть месяцев дрейфа API вслепую в dev не
отправляют.

Проверил на ветке experiment/392-py314: Python 3.14, phcc 0.13.357,
homeassistant 2026.8.3 — `450 passed, 2 skipped`. Ровно то же, что на старом
наборе, ни одного падения. Обновление оказалось бесплатным, и держать гейт на
февральском HA после такого замера нельзя.

Взята 0.13.357, а не последняя: она последняя, которая пинует стабильный
2026.8.3, дальше пинуются беты. Гейт на бете невоспроизводим — бету могут
перевыпустить под тем же номером.

pytest не закреплён намеренно: phcc задаёт совместимый диапазон сам, жёсткий
пин с ним конфликтует (ResolutionImpossible на 9.0.0, проверено).

Issue: #392
User-Visible: no
2026-08-30 20:00:17 +03:00
Matysh eb5aa2a0f8 ci: pin the HA harness and stop tests from editing sys.path
Два независимых хвоста из разбора #389, оба про то, что «зелёный» значит не
то, что читается.

#392. Оба места, где поднимается HA-харнесс, ставили зависимости без единой
версии. Python при этом закреплён на 3.13, а pytest-homeassistant-custom-
component с 0.13.348 требует 3.14 — резолвер молча уезжал на 0.13.316, а та
тянет homeassistant 2026.2.3. Интеграция полгода проверялась против
февральского HA, и состав окружения мог измениться без нашего коммита.
Версии закреплены в tests_backend/requirements.txt ровно те, что резолвились
30.08; шаг печатает установленное в лог. Это остановка дрейфа, а не
обновление: переход на 3.14 и свежий phcc — отдельная задача с отдельным
измерением.

#393. test_trails.py клал каталог пакета в sys.path при коллекции — на всю
сессию, включая HA-харнесс. Модули интеграции становились импортируемыми ещё
и как модули верхнего уровня, то есть один файл мог оказаться в sys.modules
дважды. Вставка при этом не работала ни на что: TrailBook берётся чтением
текста и exec среза, а не импортом. Убрана вместе с мёртвым spec.

Гейт статический, по исходникам: он ловит намерение, а рантайм поймал бы
последствие и только при сегодняшнем порядке тестов.

Issue: #392
User-Visible: no
2026-08-30 19:49:37 +03:00
claude[bot] 162c8a3bef docs: review document for #391
Issue: #391
User-Visible: no
2026-08-30 16:44:22 +00:00
Matysh a47ec97a04 docs: self-review document for #390
Саморевью по указанию владельца, с оговоркой о независимости. Одна находка
Low на себя: утверждение «эмит тот же» в сообщении cbf5cc1b неверно —
побайтовая сверка нормализованных сборок показала расхождение в одном месте.
Поведение при этом тождественно, разбор в документе.

Отдельно разобрано, почему восемь разошедшихся golden-сцен не из этого
коммита.

Issue: #390
User-Visible: no
2026-08-30 19:29:13 +03:00
claude[bot] b2074c103c docs: review document for #82
Issue: #82
User-Visible: no
2026-08-30 16:11:36 +00:00
claude[bot] a015f31376 docs: review document for #82
Issue: #82
User-Visible: no
2026-08-30 16:11:35 +00:00
Matyshandclaude[bot] f2d620b5d5 test: accept inherited dev golden updates
The same eight changes are present on origin/dev and are unrelated to the smooth camera implementation. Review confirmed the catalog actions, toolbar undo/redo controls and dialog help/status icons are the intended current dev UI. Accept only those named Linux candidates; keep 139 existing scenarios unchanged.

Issue: #82
User-Visible: no
Release: v1.70.0-beta.1
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/33319326145
2026-08-30 16:11:35 +00:00
Matyshandclaude[bot] fd301b6b08 docs: accept settled camera captures
Canonical capture: https://github.com/Matysh/houseplan-card/actions/runs/33319234994

Issue: #82
User-Visible: no
2026-08-30 16:11:35 +00:00
Matyshandclaude[bot] 6379334b21 test: settle animated camera checks
Wait for camera transitions in legacy smoke and golden scenarios, and render the far-object hint state even when fitting is a camera no-op.

Issue: #82
User-Visible: no
2026-08-30 16:11:35 +00:00
Matyshandclaude[bot] ac625833f2 docs: accept rebased smooth zoom captures
Canonical capture: https://github.com/Matysh/houseplan-card/actions/runs/33318316300

Issue: #82
User-Visible: no
2026-08-30 16:11:35 +00:00
Matyshandclaude[bot] 511b1bb980 docs: accept settled smooth zoom captures
Canonical capture: https://github.com/Matysh/houseplan-card/actions/runs/33318010989

Issue: #82
User-Visible: no
2026-08-30 16:11:35 +00:00
Matyshandclaude[bot] 23b94b2e61 test: wait for settled camera in docs capture
Issue: #82
User-Visible: no
2026-08-30 16:11:35 +00:00
Matyshandclaude[bot] 7d50628672 fix: preserve structural adoption guard
Issue: #82
User-Visible: no
2026-08-30 16:11:35 +00:00
Matyshandclaude[bot] 4a202a0379 docs: refresh smooth zoom screenshots
Canonical capture: https://github.com/Matysh/houseplan-card/actions/runs/33317712876

Issue: #82
User-Visible: no
2026-08-30 16:11:35 +00:00
Matyshandclaude[bot] e2464cbb48 feat: add smooth viewport zoom transitions
Issue: #82
User-Visible: yes
2026-08-30 16:11:35 +00:00
claude[bot] ad10803a29 docs: review document for #82
Issue: #82
User-Visible: no
2026-08-30 16:11:35 +00:00
Matyshandclaude[bot] bb79ece0b3 docs: actualize smooth zoom specification
Issue: #82
User-Visible: no
2026-08-30 16:11:35 +00:00
Sergey Matyuninandclaude[bot] 2cf82d9156 docs: specify smooth viewport zoom
Issue: #82
User-Visible: no
2026-08-30 16:11:35 +00:00
Matysh e98f869826 ci: name the right issue in the range summary
Живой прогон #2157 показал огрех формулировки: база диапазона (#388)
представилась заголовком «База классификации (#387)». Текст верный, ссылка
чужая — читатель уходит не в тот issue разбираться, почему диапазон такой.

Заголовок теперь следует режиму, а не общей ветке кода. Закреплено тестом на
оба режима и обе формулировки.

Issue: #388
User-Visible: no
2026-08-30 19:05:24 +03:00
Matysh e39cd3dfa1 docs: refresh the screenshot fingerprint after the typing fix
Правка типов в #390 пересобрала бандл, а в него вшит отпечаток исходников —
скриншот-индекс стал формально протухшим, хотя ни один пиксель измениться не
мог: аннотации типов стираются, эмит тот же.

Это не рассуждение, а измерение. Канонический прогон «Скриншоты
документации» #130 (workflow_dispatch, ref=dev) снял кадры пином и вынес
вердикт:

  --- изменившихся PNG: 0
  --- Chromium: было «151.0.7922.34», стало «151.0.7922.34»
  --- oxipng: было «oxipng 10.2.0», стало «oxipng 10.2.0»
  ВЕРДИКТ: ничего не изменилось, принимать нечего.

Единственное, что изменилось в артефакте, — screenshots.json. Здесь ровно
он и обновлён: sourceFingerprint и десять sourceSha256 сцен. Хеши картинок,
версия браузера и версия упаковщика не тронуты — проверено полем за полем.

Issue: #390
User-Visible: no
2026-08-30 18:42:41 +03:00
claude[bot] 4e69078f90 docs: review document for #389
Issue: #389
User-Visible: no
2026-08-30 15:33:51 +00:00
Matysh 32c70ecd19 fix: stop the schema dump from poisoning the HA harness
Красный backend на dev — это два независимых дефекта, и ни один не был виден
в диффе.

Первый, 85 падений. scripts/dump-config-schema.py подменяет
custom_components и custom_components.houseplan пустышками, чтобы прочитать
схему без Home Assistant, и оставляет их в sys.modules навсегда. Вызывает его
в том числе pytest: tests_backend/test_config_schema_manifest.py идёт первым
по алфавиту. Дальше HA просил у загрузчика custom_components.houseplan,
получал пустышку без async_setup и отказывался поднимать интеграцию — «No
setup or config entry setup function defined». Каждый тест харнесса падал на
_setup с «assert False», и ни один не намекал на причину: подмена работает
для подмодулей, потому что __path__ у пустышки настоящий.

Подмена не убрана — без неё скрипт не выполнит свою задачу. Она стала
обратимой: sys.modules снимается до и возвращается после, включая отсутствие
ключа. Обратимость закреплена тестом.

Второй, 1 падение. test_furniture_flip_flags_survive_coordinate_
canonicalization_unchanged требовал CONFIG_SCHEMA(result) == result, но схема
на минимальном конфиге достраивает markers и settings и приводит целые к
float — тождества там нет и не было. Проверяется теперь неподвижная точка:
повторная валидация не меняет канонический вид, а флаги её переживают.

Диагностика шла через CI: в песочнице HA-харнесс не поднять, поэтому
временная ветка experiment/389-diag печатала, что именно видит загрузчик.
Она показала модуль без __file__ и без единого атрибута — namespace-подобную
пустышку, — и это вывело на подмену.

Issue: #389
User-Visible: no
2026-08-30 18:31:42 +03:00
claude[bot] 12165aae46 docs: review document for #390
Issue: #390
User-Visible: no
2026-08-30 14:47:55 +00:00
Matysh cbf5cc1baa fix: type the five any that slipped past the gate
Пять мест, найденных при разборе #388: они проехали мимо гейта #342, пока
диапазоны были узкими, и он их больше не покажет.

Кэш климата (houseplan-card.ts) сравнивается только по ссылке, значение не
читается ни разу — поэтому hass стал `unknown`: он и запрещает случайно
воспользоваться содержимым, и не врёт про форму объекта, которую HA нам не
обещает. Правила и маркеры типизированы по-настоящему, их типы известны.

Реестр HA (houseplan-editor-runtime.ts) описан минимальной структурной
формой: код читает ровно два поля и оба защищённо, так что форма честнее
`any` — она говорит, на что код опирается.

Ключ i18n `device_inbox.reason_excluded_integration` не нуждался в приведении
вовсе: он есть в словаре. Шаблонный ключ приведён к `I18nKey`, как уже
сделано в этом файле строкой 8013 — это утверждение о пространстве ключей, а
не отключение проверок; `as any` заодно снимал контроль и со второго
аргумента.

Поведение не меняется: аннотации типов стираются, эмит тот же. Бандл
пересобран, потому что в него вшит отпечаток исходников.

Issue: #390
User-Visible: no
2026-08-30 17:36:23 +03:00
Matysh 66db451280 ci: prove the range by judgement, not by a green verdict
Правка #388 уронила dev, и виновата подмена предиката. Я взял доказательством
`conclusion=success`, то есть оправдательный вердикт. Гейтам диапазона нужен
другой факт: судили ли этот коммит вообще. Упавший прогон коммит судил —
вердикт вынесен, автор его видел; переоткрывать такой коммит диапазоном не
надо. Не судил только отменённый.

Цена ошибки была наглядной. Backend на dev красный несколько дней по своей
причине (test_ha_import_export), успешных прогонов нет вовсе, поэтому база
уезжала на десятки коммитов назад. На 83d646c — docs-коммите — гейт
«новый код не добавляет any» предъявил 5 чужих находок из e4e1e370 и
8d431d6d и уронил frontend. Ровно тот сценарий обвинения невиновного, ради
которого делался #386, только устроенный мной.

Теперь `judgedShas` считает судимыми завершённые прогоны с любым вердиктом,
кроме cancelled, а `greenShas` остаётся для классификации (#387): там вопрос
другой — доказано ли, что тяжёлые гейты на этом дереве ПРОШЛИ. Два вопроса,
два предиката, и путать их дорого.

Запрос к API стал `status=completed` — надмножество, нужный предикат
применяет скрипт.

Issue: #388
User-Visible: no
2026-08-30 17:11:53 +03:00
Codex 3ae3ec0f48 docs: #42 spec revision 6 — final r4 fix by owner arbitration
User-Visible: no
Issue: #42
2026-08-30 17:04:34 +03:00
claude[bot] 83d646c51e docs: review document for #74
Проверка (CI) / Классификация изменённых файлов (push) Successful in 25s
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Skipped
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 52s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Skipped
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 1m59s
Issue: #74
User-Visible: no
2026-08-30 13:54:24 +00:00
Matysh 6d73d6e909 docs: refresh device editor screenshots
Issue: #74
User-Visible: no
2026-08-30 16:44:57 +03:00
Matysh 2e30daa399 test: include device history icons in demo harness
Issue: #74
User-Visible: no
2026-08-30 16:39:06 +03:00
claude[bot] 4b5c9f2382 docs: review document for #74
Issue: #74
User-Visible: no
2026-08-30 13:38:16 +00:00
Matysh a6b0850a0c test: update device alignment guide fixture
Issue: #74
User-Visible: no
2026-08-30 16:24:48 +03:00
Matyshandclaude[bot] fc63f3bf36 test: align device drag regression smokes
Issue: #74
User-Visible: no
2026-08-30 13:16:27 +00:00
Matyshandclaude[bot] bdf81fad58 feat: add device position undo history
Issue: #74
User-Visible: yes
2026-08-30 13:16:27 +00:00
Matyshandclaude[bot] 857537c620 docs: apply review notes for device position undo
Issue: #74
User-Visible: no
2026-08-30 13:16:27 +00:00
claude[bot] 861cb0ec94 docs: review document for #74
Issue: #74
User-Visible: no
2026-08-30 13:16:27 +00:00
Matyshandclaude[bot] df92f8d448 docs: actualize device position undo specification
Issue: #74
User-Visible: no
2026-08-30 13:16:27 +00:00
Sergey Matyuninandclaude[bot] f049d3b1eb docs: specify device position undo
Issue: #74
User-Visible: no
2026-08-30 13:16:27 +00:00
claude[bot] f0e0542c64 docs: review document for #42
Issue: #42
User-Visible: no
2026-08-30 13:03:44 +00:00
Codex 84008a0a8f docs: #42 spec revision 5 per SPEC-REVIEW-42-r3
User-Visible: no
Issue: #42
2026-08-30 15:55:26 +03:00
Matysh 65339f63ad ci: judge the push range from the last proven-green ancestor
#387 закрыл классификацию — какие job запускать. Здесь остаток того же
дефекта: гейты, которые судят сам диапазон коммитов. Провенанс, процессный
гейт и «новый код не добавляет any» брали диапазон от головы предыдущего
пуша, а concurrency отменяет прогон предыдущего пуша штатно. Тогда его
коммиты не судит никто: свой прогон отменён, а следующий пуш сравнивает уже
с ними. Окно не закрывается никогда.

Уязвим был прямой пуш в dev — основной режим конвейера. На ветках дефекта
нет: no-new-any там всегда считает от merge-base, а resolveValidationRange
подменяет осиротевший before на origin/dev (#315).

База стала последним предком с успешно завершённым Validate. Фолбэк, когда
такого нет, сознательно оставлен прежним — before, но с пометкой в summary
«диапазон недоказуем». Расширять диапазон здесь нельзя: гейт, который сам
красит прогон, лишил бы следующий пуш зелёного предка и запер dev в
красноте навсегда. Фолбэк обязан не зависеть от собственного успеха гейта.
Дыра сужается с «всегда, когда прогон предыдущего пуша отменён» до «когда
во всём окне обхода нет ни одного успешного прогона».

Находки no-new-any теперь называют коммит, добавивший строку: диапазон стал
шире, и без имени источника сообщение обвиняло бы того, кто пушнул
следующим, — ровно то, что чинили в #386 для golden.

Issue: #388
User-Visible: no
2026-08-30 15:55:08 +03:00
claude[bot] 6542d190ea docs: review document for #42
Issue: #42
User-Visible: no
2026-08-30 12:52:23 +00:00
Codex 5bdde98b50 docs: #42 spec revision 4 per SPEC-REVIEW-42-r2
User-Visible: no
Issue: #42
2026-08-30 15:46:41 +03:00
claude[bot] 6ee53e6171 docs: review document for #42
Issue: #42
User-Visible: no
2026-08-30 12:45:18 +00:00
Codex 0ed83c36f0 docs: #42 spec revision 3 per SPEC-REVIEW-42-r1
User-Visible: no
Issue: #42
2026-08-30 15:40:40 +03:00
claude[bot] e270bd7137 docs: review document for #42
Issue: #42
User-Visible: no
2026-08-30 12:39:05 +00:00
Matysh 50851dc3f9 ci: classify against the last proven-green ancestor
Диапазон классификации брался от `github.event.before` — головы предыдущего
пуша. Это допущение «до этого уже проверено», и оно неверно ровно тогда,
когда прогон предыдущего пуша не завершился. А не завершается он штатно:
concurrency отменяет его следующим пушем.

На #86 (r5) это дало ложный зелёный: push 04da7eb1 тронул dist/** и
frontend/**, его прогон отменили через три минуты; следующий push fa146fb1
тронул только docs/images/**, классификация сравнила эти два коммита и
выставила frontend=false. Job «Фронтенд», а за ней golden, smoke и backend
оказались skipped — прогон при этом success. Маркеры переиспользования эти
гейты тоже не подтверждали: `Cache not found` по всем четырём.

Теперь база — самый новый предок HEAD, для которого Validate ДЕЙСТВИТЕЛЬНО
завершился успешно; если такого нет, диапазон расширяется до merge-base с
dev, то есть до всего вклада ветки. Работает по индукции: цепочка узких
диффов покрывает всё изменённое с последней настоящей проверки, а одно
незавершённое звено теперь расширяет диапазон, а не сужает.

Недоступность API не роняет job: пустой ответ опускает базу до merge-base,
то есть в сторону большего объёма проверок.

Защита от force-push (#347) сохранена: механизм, из-за которого merge-base
врал на переписанной истории, до конца не разобран, и снимать защиту, не
объяснив её, — способ получить #347 второй раз.

Issue: #387
User-Visible: no
2026-08-30 15:35:56 +03:00
Codex 582d673a2d docs: #42 spec revision 2 — mechanism + baseline stage, measured facts
User-Visible: no
Issue: #42
2026-08-30 15:28:52 +03:00
claude[bot] f7ac00f895 docs: review document for #86
Проверка (CI) / Классификация изменённых файлов (push) Successful in 28s
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 39s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 48s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Skipped
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 5m39s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Issue: #86
User-Visible: no
2026-08-30 12:23:19 +00:00
Matysh fa146fb133 docs: accept rebased settings help screenshots
Refresh the canonical documentation frames after rebasing #86 onto the
current dev branch and visually reviewing all ten scenarios.

Issue: #86
User-Visible: no
2026-08-30 14:55:54 +03:00
Matysh 04da7eb18f build: refresh rebased settings help bundles
Rebuild both canonical bundle trees after rebasing #86 onto the merged
discovery-filter changes.

Issue: #86
User-Visible: no
Release: v1.70.0-beta.1
2026-08-30 14:53:00 +03:00
claude[bot]andMatysh 00a7d7d43d docs: review document for #86
Issue: #86
User-Visible: no
2026-08-30 14:49:33 +03:00
Matysh cd9bd54801 test: accept 200 percent help baselines
Accept the two reviewed Linux DPR 2 help frames and refresh the two
documentation images changed by the rebased dev bundle.

Issue: #86
User-Visible: no
Release: v1.70.0-beta.1
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/33308795628
2026-08-30 14:49:33 +03:00
Matysh 981b6b25e8 test: add reviewed 200 percent help goldens
Capture the general-settings help surface at 390 CSS pixels and DPR 2 in
both themes, assert its viewport contract, and teach the golden runner to
select a renderer scale per scenario.

Issue: #86
User-Visible: no
2026-08-30 14:49:27 +03:00
claude[bot]andMatysh cd7c53cd97 docs: review document for #86
Issue: #86
User-Visible: no
2026-08-30 14:49:19 +03:00
Matysh 74ec0199fa test: prove settings help at 200 percent zoom
Exercise the effective Chromium renderer contract for 200% browser zoom and
assert that the trigger and tooltip stay visible, the dialog does not gain
horizontal overflow, and opening help leaves stage geometry unchanged.

Issue: #86
User-Visible: no
2026-08-30 14:49:19 +03:00
claude[bot]andMatysh 6dcf1e4345 docs: review document for #86
Issue: #86
User-Visible: no
2026-08-30 14:49:18 +03:00
Matysh 8e4fa54477 docs: accept rebased settings help screenshots
Accept the complete Linux Chromium documentation set captured after rebasing
change also replaces the uncompressed dev set with the pinned oxipng output.

Issue: #86
User-Visible: no
Release: v1.70.0-beta.1
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/33306281294
2026-08-30 14:49:18 +03:00
Matysh 244271835f test: cover cold onboarding settings help
Exercise the real lazy onboarding runtime, verify all five space help controls,
and prove that opening help neither mutates the draft nor eagerly loads the
editor runtime.

Issue: #86
User-Visible: no
2026-08-30 14:49:08 +03:00
claude[bot]andMatysh e5f773ce76 docs: review document for #86
Issue: #86
User-Visible: no
2026-08-30 14:49:08 +03:00
Matysh 26304c8ce2 test: accept reviewed settings help goldens
Accept the six intentional settings-help layout changes from the complete Linux candidate and the two pending furniture-transform scenarios already merged for #383. The remaining 137 baselines stay unchanged.

Issue: #86
User-Visible: no
Release: v1.70.0-beta.1
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/33305056892
2026-08-30 14:49:08 +03:00
Matysh 4e0f84200a fix: satisfy no-new-any gate for settings help
Use the typed i18n key directly for the show-hidden device catalog label and regenerate bundles.

Issue: #86
User-Visible: no
2026-08-30 14:49:08 +03:00
Matysh 30c15a070b feat: add critical settings help content
Add the agreed Party 1 help controls to general, space, marker and device-catalog settings in all four locales, including cold onboarding parity and regression coverage.

Issue: #86
User-Visible: yes
2026-08-30 14:48:56 +03:00
Matysh 802b2dc71b docs: actualize settings help specification
Refresh Party 1 against the current four-locale UI and replace the retired Boundary affordance with zero-thickness wall semantics.

Issue: #86
User-Visible: no
2026-08-30 14:48:37 +03:00
Sergey MatyuninandMatysh 22f22980fc docs: specify critical settings help content
Issue: #86
User-Visible: no
2026-08-30 14:48:37 +03:00
claude[bot] 2b0ac0c091 docs: review document for #44
Issue: #44
User-Visible: no
2026-08-30 11:42:51 +00:00
claude[bot]andCodex 5c027a9f9b docs: review document for #44
Issue: #44
User-Visible: no
2026-08-30 14:30:43 +03:00
Codex e4e1e3705f fix: close CODE-REVIEW-44-r1 M1-M2
M1: the room-climate cache keys on the STORED exclusion array reference
(stable across renders; _excluded builds a fresh Set per call), so
saving new Discovery filters recomputes climate immediately instead of
waiting for an unrelated hass tick.
M2: a device without a platform-bearing entity takes its integration
name from the identifier domain — the excluded reason can no longer
render a raw {integration} placeholder (if the exclusion matched,
at least one of the two names exists).

User-Visible: no
Issue: #44
2026-08-30 14:29:19 +03:00
Codex 34778d81e4 build: refresh bundle trees for #44
User-Visible: no
Issue: #44
2026-08-30 14:06:15 +03:00
Codex 8d431d6ddf feat: discovery filters become visible, explained and previewable (#44)
Light grouping and the excluded-integrations list move from hidden keys
to a Discovery-filters section on the catalog's Available tab: a toggle
(unset = on, the legacy behaviour), searchable integration chips with a
Restore-recommended reset (defaults stored as key absence), and
appear/disappear counters computed by diffing the REAL
seedHiddenBindings/buildDevices outputs — no second copy of the filter.
Saving writes settings once over the ordinary expected_rev path. Every
excluded candidate now names its integration in the catalog. Room
climate follows the same user exclusions through the single
effectiveExcludedIntegrations resolver (spec H2); explicit climate
opt-in stays stronger. The field registry passports both keys as
current supported settings.

User-Visible: yes
Issue: #44
2026-08-30 14:03:35 +03:00
claude[bot] d14cf769d5 docs: review document for #44
Issue: #44
User-Visible: no
2026-08-30 10:51:30 +00:00
Codex 44aa6968f7 docs: #44 spec revision 4 per SPEC-REVIEW-44-r2
User-Visible: no
Issue: #44
2026-08-30 13:48:10 +03:00
claude[bot] 6e6747dc09 docs: review document for #44
Issue: #44
User-Visible: no
2026-08-30 10:45:00 +00:00
Codex ba56876361 docs: #44 spec revision 3 per SPEC-REVIEW-44-r1
User-Visible: no
Issue: #44
2026-08-30 13:39:23 +03:00
claude[bot] df1dbccfdb docs: review document for #44
Issue: #44
User-Visible: no
2026-08-30 10:38:17 +00:00
Codex 3d4c509088 docs: #44 spec revision 2 — discovery filters UI, preview, excluded-integration reason
User-Visible: no
Issue: #44
2026-08-30 13:29:57 +03:00
claude[bot] 8819e390c9 docs: review document for #33
Проверка (CI) / Классификация изменённых файлов (push) Successful in 28s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 1m16s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 1m24s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 25s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 16s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 8m17s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 10m20s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Issue: #33
User-Visible: no
2026-08-30 10:09:14 +00:00
Codex 15fa33b9d9 fix: close CODE-REVIEW-33-r1 M1-M3
M1: the AC7 no-import test scans the whole src tree for the CURRENT dump
name (the old assertion checked the pre-rename string; proven by
execution — a planted fetch now turns it red).
M2: deduplicate the #33 paragraphs in both changelogs and ARCHITECTURE.
M3: the auditor's exit-3 statuses match the spec contract exactly
(migrate-*/deprecated-read); decision-required is live behaviour
awaiting #44, drop-on-validation is the backend's own job.

User-Visible: no
Issue: #33
2026-08-30 13:04:04 +03:00
claude[bot] a7e531787e docs: review document for #33
Issue: #33
User-Visible: no
2026-08-30 09:59:51 +00:00
Codex 572cf92896 build: refresh bundle trees for #33
User-Visible: no
Issue: #33
2026-08-30 12:47:07 +03:00
Codex f43187218f fix: the schema dump must not match the HACS *manifest.json glob (#33)
repo-hygiene caught it: HACS globs *manifest.json over the whole clone
and rejects a repository with two. The dump is scripts/config-schema.json.

User-Visible: no
Issue: #33
2026-08-30 12:45:17 +03:00
Codex 867f476289 feat: schema manifest, parity allow-list and lifecycle fixtures (#33)
The Voluptuous schema is now dumped into a deterministic committed
manifest (265 leaf paths); a pytest fails on drift. A parity test
compares manifest enums with the exported frontend const lists through
a machine-readable allow-list that also refuses to rot. The field
registry gains passports (allow-extra / lovelace-card), enforcedBy
citations for mechanisms that already shipped, and passports for the
v1.68-v1.69 fields; a completeness test bans dead decisions. Lifecycle
fixtures (oldest / current / future) prove lossless loading, and the
config auditor gains the 0/3/2 exit-code contract.

User-Visible: yes
Issue: #33
2026-08-30 12:42:31 +03:00
Codex 24681ee26f feat: schema manifest, parity allow-list and lifecycle fixtures (#33)
The Voluptuous schema is now dumped into a deterministic committed
manifest (265 leaf paths); a pytest fails on drift. A parity test
compares manifest enums with the exported frontend const lists through
a machine-readable allow-list that also refuses to rot. The field
registry gains passports (allow-extra / lovelace-card), enforcedBy
citations for mechanisms that already shipped, and passports for the
v1.68-v1.69 fields; a completeness test bans dead decisions. Lifecycle
fixtures (oldest / current / future) prove lossless loading, and the
config auditor gains the 0/3/2 exit-code contract.

User-Visible: yes
Issue: #33
2026-08-30 12:42:31 +03:00
claude[bot] 1e0e7ccf21 docs: review document for #33
Issue: #33
User-Visible: no
2026-08-30 09:33:39 +00:00
Codex 1c1f9d8ea4 docs: #33 spec revision 3 per SPEC-REVIEW-33-r1
User-Visible: no
Issue: #33
2026-08-30 12:29:11 +03:00
claude[bot] 3653bbf807 docs: review document for #33
Issue: #33
User-Visible: no
2026-08-30 09:26:20 +00:00
Codex 8335191bff docs: #33 spec revision 2 — generated schema manifest, parity allow-list, lifecycle fixtures
User-Visible: no
Issue: #33
2026-08-30 12:17:14 +03:00
claude[bot] e5cef16ad7 docs: review document for #383
Issue: #383
User-Visible: no
2026-08-30 09:14:52 +00:00
Sergey Matyunin b1b1e7439b docs: refresh screenshot fingerprint
Issue: #383
User-Visible: no
2026-08-30 12:00:28 +03:00
claude[bot]andSergey Matyunin 0d5d2896a6 docs: review document for #383
Issue: #383
User-Visible: no
2026-08-30 11:56:35 +03:00
Sergey Matyunin 686557c5cf feat: add smooth furniture transforms
Issue: #383
User-Visible: yes
2026-08-30 11:56:35 +03:00
claude[bot]andSergey Matyunin 4a83bd0540 docs: review document for #383
Issue: #383
User-Visible: no
2026-08-30 11:54:51 +03:00
Sergey Matyunin 2103b86c17 docs: specify furniture transforms
Issue: #383
User-Visible: no
2026-08-30 11:54:51 +03:00
claude[bot] 95115de265 docs: review document for #385
Issue: #385
User-Visible: no
2026-08-30 08:54:32 +00:00
Codex e561e5fc5d fix: drop the unreachable virtual-radio guard, keep honest evidence (#385 r2-M1)
An already-checked radio input fires no change event, so the same-binding
guard on the virtual branch was dead code and its smoke assert vacuous.
Removed both; a comment documents why the reset there is always
legitimate. The candidate-list guard (the reachable path) stays tested.

User-Visible: no
Issue: #385
2026-08-30 11:44:57 +03:00
claude[bot] c872c93516 docs: review document for #385
Issue: #385
User-Visible: no
2026-08-30 08:41:44 +00:00
claude[bot] 2e77fe09a5 docs: review document for #385
Issue: #385
User-Visible: no
2026-08-30 08:24:40 +00:00
Codexandclaude[bot] 223f499e37 fix: audit-lows batch from the v1.69.0 audit (#385)
(a) a same-binding click in the marker dialog is a no-op: the value
source and badge reset only on an actual change of binding (#378 §1.6) —
both the candidate list and the virtual radio.
(b) rewriteMarkerControlReferences no longer plants value_badge /
value_source keys as undefined on markers that never had them.
(v) the expensive release diff proof (2 git-show per src file) runs only
for commits the SAME shared predicate classifies as release — both
disjuncts, including the Release: trailer.
(g) the paired neutralisation formats in space export are documented in
place and pinned by a combined badge+value_source pytest.

User-Visible: yes
Issue: #385
2026-08-30 08:24:40 +00:00
Matysh fbbea47545 ci: name the commit that actually broke golden
Упавший тяжёлый гейт не пишет маркер переиспользования — и правильно, иначе
починка осталась бы незамеченной. Но следствие в том, что следующий коммит
гонит ту же job на тех же входах, падает так же, и письмо «Run failed»
называет его. 29 августа так был назван 0f7b6f5, документ ревью, который не
может изменить ни одного пикселя: сцену без эталона добавил dbbe94ae.

Теперь падение оставляет второй маркер — с тем же ключом, что у маркера
успеха. Совпадение ключа доказывает равенство входов, поэтому повторное
падение может честно сказать «красная с такого-то SHA, этот коммит её не
ронял», а первое — «причина здесь». Само падение по-прежнему не кэшируется:
job прогоняется всегда, меняется только формулировка в notice и summary.

Первопричина записывается только на первом падении: иначе SHA съехал бы на
свидетеля и сообщение перевернулось бы смыслом.

Issue: #386
User-Visible: no
2026-08-30 11:23:35 +03:00
claude[bot] 75d11d7c2e docs: review document for #384
Issue: #384
User-Visible: no
2026-08-30 08:10:21 +00:00
Codex 4f28bf4f7f build: refresh bundle trees for #384
User-Visible: no
Issue: #384
2026-08-30 10:53:58 +03:00
Codex 1613339463 fix: hidden architecture no longer widens the fit: house frame (#384)
Wall bodies, extras and zero walls vote in the tight frame only while
show_borders renders them — mirroring the hideOpenings guard for opening
symbols. needsCanonicalWallGeometry returns to its pre-#373 form: the
union is no longer forced for extras-only plans just for the frame.

User-Visible: yes
Issue: #384
2026-08-30 10:53:14 +03:00
claude[bot] 3bb732c803 docs: review document for #385
Issue: #385
User-Visible: no
2026-08-30 07:48:57 +00:00
claude[bot] e442a7c8d3 docs: review document for #381
Issue: #381
User-Visible: no
2026-08-30 07:48:16 +00:00
Sergey Matyuninandclaude[bot] c1f2d36267 test: register no-op tap review evidence
Issue: #381
User-Visible: no
2026-08-30 07:48:16 +00:00
Sergey Matyuninandclaude[bot] ea7803230a feat: add no-op marker tap action
Issue: #381
User-Visible: yes
2026-08-30 07:48:16 +00:00
claude[bot] 20cd3241c0 docs: review document for #381
Issue: #381
User-Visible: no
2026-08-30 07:48:16 +00:00
Sergey Matyuninandclaude[bot] 212606c3e4 docs: add risks and rollback for no-op tap
Issue: #381
User-Visible: no
2026-08-30 07:48:16 +00:00
claude[bot] f7e607f867 docs: review document for #381
Issue: #381
User-Visible: no
2026-08-30 07:48:16 +00:00
Sergey Matyuninandclaude[bot] cfb8ce401d docs: specify no-op marker tap action
Issue: #381
User-Visible: no
2026-08-30 07:48:16 +00:00
claude[bot] 083038f25f docs: review document for #384
Issue: #384
User-Visible: no
2026-08-30 07:46:03 +00:00
Codex 687b296639 docs: #385 spec revision 2 per SPEC-REVIEW-385-r1
User-Visible: no
Issue: #385
2026-08-30 10:44:28 +03:00
claude[bot] 1f95fba270 docs: review document for #385
Issue: #385
User-Visible: no
2026-08-30 07:41:48 +00:00
claude[bot] 3aac9bd268 docs: review document for #384
Issue: #384
User-Visible: no
2026-08-30 07:38:20 +00:00
Codex c28b2f2d41 docs: specify #385 audit-lows batch
User-Visible: no
Issue: #385
2026-08-30 10:32:43 +03:00
Sergey Matyunin 9c162c0926 fix: preload each measured editor card
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (blend) (push) Failing after 33m7s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (large-house) (push) Failing after 38m37s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (isometric) (push) Failing after 47m35s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (overlay) (push) Failing after 57m39s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (space-default) (push) Failing after 21m38s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (plan-snap) (push) Failing after 33m1s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (space-glow) (push) Failing after 24m36s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 7m31s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 1m6s
Проверка (CI) / Классификация изменённых файлов (push) Successful in 22s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 54s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 20s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 16s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 9m57s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Issue: #380
User-Visible: no
2026-08-30 00:55:01 +03:00
Sergey Matyunin 285688de47 fix: support stable performance baselines
Issue: #380
User-Visible: no
2026-08-30 00:45:31 +03:00
Sergey Matyunin d68d912f83 Document stable ancestry reconciliation
Record the main-only workflow mirror ancestry repair that makes the v1.69.0 promotion a true fast-forward; no product or bundle bytes change.

Issue: #379
User-Visible: no
2026-08-30 00:28:44 +03:00
Sergey Matyunin c4eded3d04 Merge main ancestry into dev for v1.69.0
Reconcile main-only workflow mirror commits before the stable fast-forward; conflicts retain the already validated dev versions and the resulting tree is unchanged.

Issue: #379
User-Visible: no
2026-08-30 00:27:46 +03:00
Sergey Matyunin 816bc77020 Release v1.69.0
Promote the published v1.69.0 beta line without new product behaviour: stable version fields, synchronized generated bundles, bilingual changelogs, release notes and status metadata only.

Issue: #379
Release: v1.69.0
User-Visible: yes
2026-08-30 00:19:03 +03:00
Sergey Matyunin b2470df5a7 Fix stable promotion version-source gate
Allow only mechanically proven version-declaration changes in the three canonical source files while keeping every other release source diff fail-closed.

Issue: #379
User-Visible: no
2026-08-30 00:05:41 +03:00
Sergey Matyunin b5863be168 Release v1.69.0-beta.5 candidate
Prepared the reviewed S8 work for the v1.69.0-beta.5 prerelease, refreshed the bilingual changelogs and release notes, and rebuilt the synchronized frontend bundles.

Issue: #373
Issue: #375
Issue: #376
Issue: #377
Issue: #378
User-Visible: yes
2026-08-29 23:14:59 +03:00
Sergey Matyunin 5f6ee65768 test: accept explicit value source golden
Reviewed by Claude in CODE-REVIEW-378-r2 against the complete Linux candidate. Exactly the new 42 percent value-face frame is accepted; 142 existing baselines retain their reviewed hashes.

Issue: #378
User-Visible: no
Release: v1.69.0-beta.5
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/33271690654
2026-08-29 23:07:07 +03:00
claude[bot] 0f7b6f5550 docs: review document for #378
Issue: #378
User-Visible: no
2026-08-29 20:04:28 +00:00
Sergey Matyunin e524f07dde Refresh docs screenshots for golden matrix
Captured by https://github.com/Matysh/houseplan-card/actions/runs/33271699236

Issue: #378
User-Visible: no
2026-08-29 22:47:05 +03:00
Sergey Matyunin dbbe94ae08 Add golden proof for explicit value source
Issue: #378
User-Visible: no
2026-08-29 22:45:23 +03:00
claude[bot] 795325ee08 docs: review document for #378
Issue: #378
User-Visible: no
2026-08-29 19:39:28 +00:00
Sergey Matyunin 552b78a134 Refresh documentation screenshots after rebase
Captured by https://github.com/Matysh/houseplan-card/actions/runs/33270679280

Issue: #378
User-Visible: no
2026-08-29 22:24:15 +03:00
Sergey Matyunin 591f8f6ac9 Add selectable value face sources
Issue: #378
User-Visible: yes
2026-08-29 22:20:18 +03:00
claude[bot]andSergey Matyunin ad9c634901 docs: review document for #378
Issue: #378
User-Visible: no
2026-08-29 22:17:11 +03:00
Sergey Matyunin a851c8f958 docs: specify selectable value face source
Issue: #378
User-Visible: no
2026-08-29 22:17:11 +03:00
claude[bot] 6e1d93648a docs: review document for #376
Проверка (CI) / Классификация изменённых файлов (push) Successful in 33s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 56s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 53s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 17s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 21s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 7m45s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 11m19s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Issue: #376
User-Visible: no
2026-08-29 19:12:15 +00:00
claude[bot]andCodex ae89f841c4 docs: review document for #376
Issue: #376
User-Visible: no
2026-08-29 21:55:13 +03:00
claude[bot]andCodex 2775a7032a docs: review document for #376
Issue: #376
User-Visible: no
2026-08-29 21:55:13 +03:00
Codex 2e32cbcb38 fix: small honesty batch from the beta.4 audit (#376)
(а) title: null gets the same compact frame as title: '' — YAML 'title:'
with no value parses as null, the owner's decision makes them synonyms.
(б) the guides state that room labels are inert in the Background editor.
(г) furniture strokes skip the flat-camera compensation in the labs iso
projection, tracking ordinary decor there.
(д) TESTING.md notes the light_pools opt-in for static room cards.
(е) the space-card dispose gate mirrors the strict === true render gate.

User-Visible: yes
Issue: #376
2026-08-29 21:54:29 +03:00
claude[bot] 8fda392734 docs: review document for #377
Issue: #377
User-Visible: no
2026-08-29 18:50:55 +00:00
claude[bot]andCodex 82d710f160 docs: review document for #377
Issue: #377
User-Visible: no
2026-08-29 21:41:15 +03:00
Codex 40ba222ac3 build: refresh bundle trees for #377; align the color-picker contract
User-Visible: no
Issue: #377
2026-08-29 21:39:55 +03:00
Codex 1b1c1c1d99 test: pin the persist debounce window in the #377 smoke
User-Visible: no
Issue: #377
2026-08-29 21:39:09 +03:00
Codex 10a1a26612 feat: persist the Background editor's default style with the plan (#377)
settings.decor_default_style (all fields optional, validated) seeds
_decorStyle once from the first config that arrives; every UI change of
the session default flows through one runtime method with a 1s debounce
and the ordinary serialized expected_rev write path. The built-in default
is stored as the absence of the key; a partial or garbage key falls back
per-field. decorStyleFromSettings/decorStyleToSettings are the single
snake_case<->camelCase conversion point.

User-Visible: yes
Issue: #377
2026-08-29 21:39:08 +03:00
claude[bot] 720200e3df docs: review document for #373
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Skipped
Проверка (CI) / Классификация изменённых файлов (push) Successful in 26s
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 44s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 44s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 7m28s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Issue: #373
User-Visible: no
2026-08-29 18:33:49 +00:00
Sergey Matyunin 41c09ea6a2 docs: accept rebased canonical screenshots
Issue: #373
User-Visible: no
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/33268042941
2026-08-29 21:23:50 +03:00
Sergey Matyunin f56d9a76a8 build: drop rebased orphan locale chunks
Issue: #373
User-Visible: no
2026-08-29 21:17:31 +03:00
claude[bot]andSergey Matyunin 7ebead5df8 docs: review document for #373
Issue: #373
User-Visible: no
2026-08-29 21:15:28 +03:00
Sergey Matyunin 0d33691fc4 feat: add tight house framing to space card
Issue: #373
User-Visible: yes
2026-08-29 21:15:21 +03:00
claude[bot]andSergey Matyunin a143a8e63c docs: review document for #373
Issue: #373
User-Visible: no
2026-08-29 21:13:28 +03:00
Sergey Matyunin 4a343814b7 docs: specify tight house framing for space card
Issue: #373
User-Visible: no
2026-08-29 21:13:28 +03:00
claude[bot] 51196c6576 docs: review document for #375
Issue: #375
User-Visible: no
2026-08-29 18:09:53 +00:00
Codexandclaude[bot] 05a81403bd build: refresh bundle trees for #375
User-Visible: no
Issue: #375
2026-08-29 18:09:53 +00:00
Codexandclaude[bot] 187c90ba69 fix: give static Glow the full card's cache hierarchy (#375)
V6a: pass the stable devices array to resolvedLightSources — the WeakMap
cache is keyed by array identity, a spread guaranteed a miss on every
render in BOTH cards (full-card regression since beta.4).
V6b: the static wall geometry now carries the same non-enumerable
sourceFingerprint tag the full card attaches, so buildLightBarrierScene
takes the fast recutWallBodiesGeometry path on door state changes.
V6c: the static barrier-scene cache is an LRU of 8 per space (parity
with _lightBarrierPool) — a flipping door reuses both of its scenes.
V6d: enabledClip is cached by geometry fingerprint + disabled-room set,
with the full card's bbox prefilter for decor bodies.

User-Visible: yes
Issue: #375
2026-08-29 18:09:53 +00:00
claude[bot] 92f1d3cbbf docs: review document for #377
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Классификация изменённых файлов (push) Successful in 22s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 40s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 1m40s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 24s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 22s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 13m8s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 6m38s
Issue: #377
User-Visible: no
2026-08-29 18:03:04 +00:00
Codex 6a21727177 docs: #377 spec revision 2 per SPEC-REVIEW-377-r1
User-Visible: no
Issue: #377
2026-08-29 21:00:00 +03:00
claude[bot] 97bb0eceea docs: review document for #377
Issue: #377
User-Visible: no
2026-08-29 17:58:51 +00:00
claude[bot] b300586f80 docs: review document for #376
Issue: #376
User-Visible: no
2026-08-29 17:53:15 +00:00
claude[bot] 44929293bd docs: review document for #375
Issue: #375
User-Visible: no
2026-08-29 17:51:23 +00:00
Codex e2e4cec133 docs: specify decor default style persistence (#377)
User-Visible: no
Issue: #377
2026-08-29 20:48:46 +03:00
claude[bot] 9e60ade8df docs: review document for #376
Issue: #376
User-Visible: no
2026-08-29 17:44:39 +00:00
claude[bot] 7722b3c8c6 docs: review document for #375
Issue: #375
User-Visible: no
2026-08-29 17:43:19 +00:00
Sergey Matyunin 4eede5bc59 build: prepare v1.69.0-beta.4 candidate
Package standalone-card Glow, French localisation, safer Background editing and the reviewed fixes since beta.3.

Issue: #39
Issue: #360
Issue: #361
Issue: #362
Issue: #363
Issue: #366
Issue: #368
Issue: #369
Issue: #371
Issue: #372
Issue: #374
User-Visible: yes
2026-08-29 15:28:57 +03:00
claude[bot] 67109efdd3 docs: review document for #374
Issue: #374
User-Visible: no
2026-08-29 12:22:23 +00:00
Sergey Matyunin f5a49718a0 test: separate Glow performance contracts
Issue: #374
User-Visible: no
2026-08-29 15:03:12 +03:00
Sergey Matyunin 6e3d08eda7 docs: add static Glow smoke commands
Issue: #374
User-Visible: no
2026-08-29 14:55:02 +03:00
Sergey Matyunin aa3ad429e0 docs: refresh screenshot provenance
Issue: #374
User-Visible: no
2026-08-29 14:54:05 +03:00
Sergey Matyunin 9e9e32847e refactor: type Glow scene boundaries
Issue: #374
User-Visible: no
2026-08-29 14:52:19 +03:00
Sergey Matyunin 6d838d7750 docs: accept canonical screenshots
Issue: #374
User-Visible: no
2026-08-29 14:49:08 +03:00
Sergey Matyunin 0dfc74244b feat: add opt-in Glow to space card
Issue: #374
User-Visible: yes
2026-08-29 14:46:52 +03:00
claude[bot] 8914190c56 docs: review document for #374
Issue: #374
User-Visible: no
2026-08-29 11:14:50 +00:00
Sergey Matyunin c4f3798612 docs: specify opt-in Glow for space card
Issue: #374
User-Visible: no
2026-08-29 14:08:15 +03:00
claude[bot] 8a65e0bfcd docs: review document for #371
Issue: #371
User-Visible: no
2026-08-29 10:52:27 +00:00
claude[bot]andCodex 1cd84dd07b docs: review document for #371
Issue: #371
User-Visible: no
2026-08-29 13:42:09 +03:00
Codex 4c4563cdbe docs: refresh screenshot fingerprint after rebase onto dev (#371)
User-Visible: no
Issue: #371
2026-08-29 13:41:58 +03:00
Codex c7dfd712b8 test: keep the fr chunk emitted in the #371 mutant (wrong dictionary via en)
The loadGerman-swap mutant tree-shook src/i18n/fr.ts, so the manifest
emit guard failed the mutant BUILD instead of the guard smoke. The
mutant now keeps import('./fr') alive and returns the English
dictionary, which only demo/smoke_french_locale.mjs can catch.

User-Visible: no
Issue: #371
2026-08-29 13:39:22 +03:00
Codex 7f3e67bce0 test: pin the fr entry to the fr dictionary via a live-bundle mutant (#371)
The entry-removed variant died at build time (tree-shaking drops the fr
chunk and the manifest emit guard refuses the bundle) — an infrastructure
failure, not a red guard. The mutant now swaps the fr entry to the German
loader instead: everything builds, parity units stay green, and only the
French smoke catches the wrong dictionary.

Issue: #371
User-Visible: no
2026-08-29 13:39:22 +03:00
Codex 89fa8ca588 feat: French localization — community contribution by @OUARZA (#371)
The complete French dictionary contributed in #371 (1026 keys, zero empty
values, zero placeholder mismatches) lands as the second lazy locale on the
fr.ts + one static entry. The contributor's snapshot predated this week's
keys, so the 121 additions (device inbox #29, backdrop guard #39, junction
limits #331, lazy-editor toasts #353/#354, furniture #159) are translated
in this commit and flagged in the issue for the author's review; 21 stale
pre-#62 keys are dropped; key order follows en.json. The HA integration
translations file ships as contributed (full parity).

Wiring: loadFrench + __HOUSEPLAN_FR_RETRY_ASSET__ with the same 1/1
replacement guarantee as German; locale roles and localeRoots generalise to
(de|fr); the stale-entry fallback panel (#353) gains its French branch —
the r1 reviewer caught that this second hardcoded language list would have
silently degraded French to English on a cached entry. French profiles
(fr, fr-FR, fr-CA, fr-BE, fr-CH) select automatically.

Proofs: the registry-driven parity suite covers fr by construction
(1128/1128 keys); French analogues of both German-personal tests (product
glossary + non-translation scan with a reviewed equal-to-English
allow-list of 22 legitimate homographs); smoke_french_locale — fr-CA
profile commits French from the real bundle, one lazy chunk per page,
initial graph free of fr; smoke_entry_stale gains the French run; a
registry mutant drops the fr entry and is killed by the smoke. Initial
view: +0.5 KB (registry entry + fallback branch); the 23 KB dictionary is
lazy.

Issue: #371
User-Visible: yes
2026-08-29 13:39:21 +03:00
claude[bot] fbf2c1bd41 docs: review document for #372
Issue: #372
User-Visible: no
2026-08-29 10:34:59 +00:00
Sergey Matyunin 2bb7dfca5f test: cover compact space card surfaces
Issue: #372
User-Visible: no
2026-08-29 13:25:42 +03:00
Sergey Matyunin 27a90dfd68 docs: accept issue 372 screenshots
Issue: #372
User-Visible: no
2026-08-29 13:24:33 +03:00
Sergey Matyunin 3c733c390c feat: compact empty-title space card
Issue: #372
User-Visible: yes
2026-08-29 13:18:57 +03:00
claude[bot]andSergey Matyunin 6392179013 docs: review document for #372
Issue: #372
User-Visible: no
2026-08-29 13:16:04 +03:00
Sergey Matyunin d696541aba docs: specify compact empty-title space card
Issue: #372
User-Visible: no
2026-08-29 13:16:04 +03:00
claude[bot] eadd69644c docs: review document for #371
Issue: #371
User-Visible: no
2026-08-29 10:13:09 +00:00
claude[bot] dd591a6c77 docs: review document for #371
Issue: #371
User-Visible: no
2026-08-29 10:07:45 +00:00
Claude 89950e9944 docs: сказать, что компактная карточка не строит пулы света
Внешний пользователь завёл #370 как баг: в houseplan-space-card нет теней от
стен, хотя в houseplan-card они есть. Разбор показал, что кода это не касается —
ограничение намеренное и записано в src/space-render.ts:353 («the compact card
intentionally has no live radial pools»). visibilityPolygon из
src/light-visibility.ts импортируют ровно два файла, houseplan-card.ts и
houseplan-editor-runtime.ts; space-render.ts не импортирует его вовсе. Пулов нет,
а тень существует только как форма пула — затенять нечего.

Но претензия справедлива, просто адресована не туда. Единственная запись о
намеренности жила в комментарии исходника, которого пользователь видеть не может,
а руководство обещало обратное: «маркеры используют те же состояния, значения,
тревоги и эффекты, что полный план» — про свет ни слова. В LIGHT.md про
компактную карточку тоже не было ничего. Человек полез в код именно потому, что
документация молчала, и сам корректно предположил, что это может быть намеренно.

Теперь сказано в трёх местах: оба руководства и LIGHT.md, с причиной — пулы это
самая дорогая часть отрисовки, у неё свой перф-воркфлоу и бюджеты, и компактная
карточка платит за дешевизну именно ими.

Issue: #370
User-Visible: no
2026-08-29 13:02:41 +03:00
claude[bot] 06fbfcbc90 docs: review document for #363
Issue: #363
User-Visible: no
2026-08-29 10:01:16 +00:00
Sergey Matyunin e5ecf39977 chore: preserve changelog mode after rebase
Issue: #363
User-Visible: no
2026-08-29 12:47:14 +03:00
Sergey Matyunin 5ba0d9e1b7 test: accept reviewed device shortcut frames
Issue: #363
User-Visible: no
Release: v1.69.0-beta.3
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/33245527914
2026-08-29 12:47:03 +03:00
Sergey Matyunin 3068a97cff test: align device catalog evidence with shortcut
Issue: #363
User-Visible: no
2026-08-29 12:47:03 +03:00
Sergey Matyunin e8916c7131 feat: restore direct device add shortcut
Issue: #363
User-Visible: yes
2026-08-29 12:47:02 +03:00
claude[bot] 914bb4ede7 docs: review document for #368
Проверка (CI) / Классификация изменённых файлов (push) Successful in 19s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 42s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 49s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 18s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 17s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 8m45s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 11m48s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Issue: #368
User-Visible: no
2026-08-29 09:36:31 +00:00
Codexandclaude[bot] 0c74b2fc4f docs: name the expected_rev requirement for external writers (#368)
#340/#356 made expected_rev mandatory for config/set and layout/set over a
non-empty store — an honest protection the release notes sold only as a
stale-tab guard. A third-party script writing plans directly cannot infer
from "protects from stale tabs" that it must now read the revision first.

Both changelogs gain an explicit breaking-for-external-writers entry with
the read-then-write recipe; ARCHITECTURE.md's WS contract section extends
the #340 paragraph with the cycle external clients must follow (get rev →
send expected_rev → on conflict re-read and retry); and both conflict
messages now carry the actionable hint for scripts — "include expected_rev
from houseplan/config/get / layout/get" — alongside the tab-oriented
"reload" advice. The backend tests pin only the "revision is required"
substring and stay untouched.

Issue: #368
User-Visible: yes
2026-08-29 09:36:31 +00:00
Claude 0935a4d099 ci: рекалибровать бюджет initial view и предупреждать до стены, а не после
Запас ушёл с 26 КБ до 8.3 КБ за сутки. По документам код-ревью видно, что это не
диффузное расползание, а один шаг плюс обычная работа:

  #317 256127 · #318 256091 · #341 256046 · #354 257212 · #159 256828
  #357 271143  <- +14 КБ за один заход
  #20 271455 · #361 272848 · #359 272469 · #360 273697  <- текущий факт

Шаг на #357 — plan-art мебели: 44 top-view символа в eager-графе, которые платит
каждый план, включая планы без единого предмета мебели.

Потолок 282000 -> 300000. Правило #352 сохранено: 273697 x 1.10 = 301067, то
есть 300000 остаётся внутри надбавки ~10% над измеренным фактом. Запас
возвращается к 26.3 КБ — примерно к тому, что было до #357.

Запись честная и в комментарии сказана прямо: рекалибровка ничего не ускоряет и
ничего не чинит. Она фиксирует новую норму и возвращает гейту способность красить
того, кто вырастил бандл, а не того, кто пушнул последним. Настоящий рычаг —
ленивый граф, варианты 1 и 2 из #367.

Добавлено предупреждение: пока запас меньше 15000 Б, гейт печатает ::warning:: и
строку в summary. Прежняя редакция полагалась на то, что человек заметит тренд в
выводе; за сутки его не заметил никто, потому что каждая отдельная строка
выглядела нормально. Текст предупреждения называет лечение — иначе следующий
читатель поднимет потолок ещё раз и назовёт это решением.

Тест закрепляет обе стороны: надбавка не больше 10% и не меньше 5% (меньше —
возврат лотереи «красит последний коммит»), тревога срабатывает строго ниже
порога, превышение описывается как превышение. Три мутанта проверены руками,
один добавлен в реестр.

Issue: #367
User-Visible: no
2026-08-29 12:32:01 +03:00
Claude 88dc61dd40 ci: синхронизировать process.yml с dev
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (blend) (push) Failing after 26m23s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (large-house) (push) Failing after 38m27s
Проверка (CI) / Документация: гайды, ченджлоги, скриншот-индекс (push) Successful in 32s
Проверка (CI) / Процесс: process.yml идентичен в main и dev (push) Successful in 1m2s
Проверка (CI) / Провенанс коммитов: трейлеры и эталоны (push) Successful in 1m14s
Проверка (CI) / Процессный гейт: диапазон, трейлеры, статусы issue (push) Failing after 1m23s
Проверка (CI) / Классификация изменённых файлов (push) Successful in 1m12s
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Skipped
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 56s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Skipped
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (overlay) (push) Failing after 44m51s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (isometric) (push) Failing after 45m32s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (plan-snap) (push) Failing after 29m56s
Конвейер исполняет версию из ветки по умолчанию, поэтому файл обязан совпадать
в main и dev побайтово. Содержательная правка сделана в dev (#365), здесь копия.

Issue: #365
User-Visible: no
2026-08-29 10:38:57 +03:00
Claude d2f4f94159 ci: синхронизировать process.yml с dev
Конвейер исполняет версию из ветки по умолчанию, поэтому файл обязан совпадать
в main и dev побайтово — это проверяет шаг предполётных проверок в Validate.
Содержательная правка сделана в dev (#364), здесь только копия.

Issue: #364
User-Visible: no
2026-08-29 10:07:07 +03:00
Claude 22dc15c602 ci: синхронизировать process.yml с dev
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (blend) (push) Failing after 19m18s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (large-house) (push) Failing after 33m45s
Проверка (CI) / Документация: гайды, ченджлоги, скриншот-индекс (push) Failing after 30s
Проверка (CI) / Процесс: process.yml идентичен в main и dev (push) Successful in 1m0s
Проверка (CI) / Провенанс коммитов: трейлеры и эталоны (push) Successful in 1m14s
Проверка (CI) / Процессный гейт: диапазон, трейлеры, статусы issue (push) Failing after 1m25s
Проверка (CI) / Классификация изменённых файлов (push) Successful in 56s
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Skipped
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 1m7s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Skipped
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (isometric) (push) Failing after 42m58s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (overlay) (push) Failing after 43m43s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (plan-snap) (push) Failing after 27m8s
Конвейер исполняет версию из ветки по умолчанию, поэтому файл обязан совпадать
в main и dev побайтово — это проверяет шаг `process.yml идентичен в main и dev`
в Validate. Содержательная правка сделана в dev (#343), здесь только копия.

Issue: #343
User-Visible: no
2026-08-28 02:15:36 +03:00
Codex 55155b0d4f ci: mutation-gate workflow runs four shards with a warm test-build (#332)
Mirror of the dev-side change: the scheduled run executes this file from
main while checking out the registry from dev, so the shard matrix must
live here too.

Issue: #332
User-Visible: no
2026-08-28 01:34:07 +03:00
Codex 2d1e69b0c1 ci: the performance-workflow contract test follows the renamed labels
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 1m40s
Проверка (CI) / Процесс: process.yml идентичен в main и dev (push) Successful in 1m36s
Проверка (CI) / Документация: гайды, ченджлоги, скриншот-индекс (push) Failing after 1m59s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (plan-snap) (push) Failing after 7m33s
Проверка (CI) / Провенанс коммитов: трейлеры и эталоны (push) Failing after 2m13s
Проверка (CI) / Классификация изменённых файлов (push) Successful in 1m24s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (overlay) (push) Failing after 21m28s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (large-house) (push) Failing after 21m40s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (isometric) (push) Failing after 21m46s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (blend) (push) Failing after 22m15s
Проверка (CI) / Процессный гейт: диапазон, трейлеры, статусы issue (push) Failing after 5m45s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 33s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 34s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Successful in 12m36s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 12m27s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Failing after 18m19s
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Failing after 20m56s
Проверка (CI) / Golden-кадры против принятых эталонов (push) Failing after 14m27s
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Failing after 11m40s
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Failing after 28m52s
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
The test pins the literal workflow name and the release-gate label; both
moved to the Russian names of #327.

Issue: #327
User-Visible: no
(cherry picked from commit 6a3ac52258)
2026-08-27 18:50:04 +03:00
Codex 7d29ca388b ci: every workflow and job carries a human-readable Russian name
Owner decision (chat, 2026-08-27): the running check's name must say what it
does, in Russian. Scripts locate workflows by file name (release-gate.mjs ->
validate.yml), so display names are free; job ids and needs are untouched.
The same content is cherry-picked to main because release workflows execute
from the default branch and process.yml must stay identical in main and dev.

Issue: #327
User-Visible: no
(cherry picked from commit 1a8b480355)
2026-08-27 18:46:56 +03:00
467 changed files with 74369 additions and 17881 deletions
+22
View File
@@ -9,6 +9,12 @@
# локально через `npm run docs:accept -- --reviewed --from=<распакованный>`.
# Та же конструкция, что у golden-эталонов, и по той же причине: картинки
# попадают в репозиторий через явное решение, а не через бота.
#
# Снимать здесь больше не обязанность, а удобство (#401). Приёмка проверяет не
# место съёмки, а её воспроизводимость: каждый кадр, не объявленный изменённым,
# должен совпасть с закоммиченным байт-в-байт. Эта джоба потому и удобна, что
# среда у неё та же, в которой снят закоммиченный набор, — но принять получится
# из любой, где кадры воспроизводятся, и не получится ни из одной, где нет.
name: Скриншоты документации
on:
@@ -79,6 +85,22 @@ jobs:
# браузер: тот же Chromium и десять изменившихся картинок означают, что
# изменился продукт (или что-то не так), другой Chromium — ожидаемую
# разницу рендеринга.
# Хеши печатаются в лог, а не только уезжают в артефакт (#410): чтобы
# сравнить два прогона одного и того же SHA, нужен текст, который видно с
# экрана. Именно так измеряется недетерминированность съёмки — и её
# отсутствие после починки.
# Съёмка обязана быть воспроизводимой, и это проверяется, а не
# предполагается (#410): три снимка на сценарий в одном процессе, без
# правки состояния между ними, обязаны совпасть побайтово. Шаг падает,
# если кадр снова начнёт зависеть от времени.
#
# Проверка стоит ПЕРЕД съёмкой набора: смысла публиковать артефакт,
# снятый недетерминированной съёмкой, нет.
- name: "Кадр не плавает внутри одного состояния (#410)"
run: node demo/docs/capture.mjs --stability=3
- name: Хеши кадров
run: |
for f in docs/images/*.png; do sha256sum "$f"; done
- name: Вердикт
run: |
# Поле манифеста «до» — из закоммиченного состояния, «после» — из
+7 -2
View File
@@ -61,10 +61,15 @@ jobs:
- uses: actions/setup-python@v7
with:
python-version: '3.13'
python-version: '3.14'
- name: Установить backend test dependencies
run: pip install pytest voluptuous pytest-homeassistant-custom-component home-assistant-frontend
run: |
# Точные версии — в tests_backend/requirements.txt (#392): без них
# резолвер выбирает набор сам, и «зелёный backend» значит разное
# в разные дни.
pip install -r tests_backend/requirements.txt
pip list --format=columns | grep -Ei 'homeassistant|voluptuous|^pytest '
- name: Кэш браузеров Playwright
id: pw
+21 -1
View File
@@ -27,7 +27,7 @@ jobs:
name: Бенчмарки рендера и геометрии
# Every profile keeps base and candidate sequential on one hosted runner.
# Independent profile pairs run in parallel: cross-profile timing is never
# compared, while serialising all five pairs cannot fit the job timeout.
# compared, while serialising all profile pairs cannot fit the job timeout.
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
@@ -39,6 +39,8 @@ jobs:
- plan-snap
- blend
- overlay
- space-default
- space-glow
steps:
- name: Check out candidate
uses: actions/checkout@v7
@@ -200,6 +202,18 @@ jobs:
cp ../artifacts/performance/overlay-candidate.json ../artifacts/performance/overlay-baseline.json
fi
;;
space-default)
npm run benchmark:glow -- --profile=large-space-card-default-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/space-default-baseline.json
npm run benchmark:glow -- --profile=large-space-card-default-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/space-default-candidate.json
;;
space-glow)
npm run benchmark:glow -- --profile=large-space-card-glow-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/space-glow-baseline.json
npm run benchmark:glow -- --profile=large-space-card-glow-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/space-glow-candidate.json
if ! grep -q "light_pools" ../baseline/src/space-card.ts; then
echo "Base predates opt-in static Glow; bootstrap relative baseline, keep absolute gate"
cp ../artifacts/performance/space-glow-candidate.json ../artifacts/performance/space-glow-baseline.json
fi
;;
*)
echo "::error::Unknown performance profile: $PROFILE"
exit 1
@@ -227,6 +241,12 @@ jobs:
overlay)
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-glow-overlay.json --baseline=../artifacts/performance/overlay-baseline.json --candidate=../artifacts/performance/overlay-candidate.json --output=../artifacts/performance/overlay-comparison.json
;;
space-default)
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-space-card-default.json --baseline=../artifacts/performance/space-default-baseline.json --candidate=../artifacts/performance/space-default-candidate.json --output=../artifacts/performance/space-default-comparison.json
;;
space-glow)
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-space-card-glow.json --baseline=../artifacts/performance/space-glow-baseline.json --candidate=../artifacts/performance/space-glow-candidate.json --output=../artifacts/performance/space-glow-comparison.json
;;
esac
- name: Upload full performance report
+211 -30
View File
@@ -32,6 +32,13 @@ jobs:
preflight:
name: "Предполётные проверки: документация, провенанс, процесс"
runs-on: ubuntu-latest
# `actions: read` — чтение списка прогонов Validate (#388), `issues: read` —
# проверка 8 процессного гейта. Права перечислены явно, потому что job
# обращается к API сверх содержимого репозитория.
permissions:
contents: read
actions: read
issues: read
steps:
# `blob:none` при полной истории (#345): этой job нужны сообщения
# коммитов, трейлеры и ИМЕНА изменённых файлов, а не содержимое старых
@@ -69,12 +76,38 @@ jobs:
exit 1
fi
# Оба гейта ниже судят САМ диапазон коммитов, а не объём проверок, и до
# #388 брали его от головы предыдущего пуша. Прогон предыдущего пуша
# штатно отменяется следующим (concurrency), и тогда его коммиты не судит
# никто: свой прогон отменён, а следующий пуш сравнивает уже с ними.
#
# База — последний предок с УСПЕШНО завершённым Validate. Фолбэк остаётся
# прежним `before`: расширять диапазон здесь нельзя, иначе гейт, который
# сам красит прогон, лишает следующий пуш зелёного предка и запирает dev
# в красноте навсегда.
- name: "База диапазона: последний доказанно зелёный предок"
id: range
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
env:
GH_TOKEN: ${{ github.token }}
HEAD_SHA: ${{ github.sha }}
REPO: ${{ github.repository }}
FALLBACK: ${{ github.event.before }}
run: |
# `status=completed`, а не `success`: гейтам диапазона нужен факт
# «коммит судили», а не «вердикт был оправдательный». Упавший прогон
# коммит судил; отменённый — нет, его отсеивает judgedShas (#388).
gh api -X GET "repos/$REPO/actions/workflows/validate.yml/runs" \
-f branch=dev -f status=completed -F per_page=100 \
> /tmp/validate-runs.json || echo '{}' > /tmp/validate-runs.json
node scripts/classify-base.mjs --head="$HEAD_SHA" --mode=range \
--fallback="$FALLBACK" --runs=/tmp/validate-runs.json
- name: "Провенанс коммитов: трейлеры и эталоны"
id: provenance
continue-on-error: true
env:
EVENT_NAME: ${{ github.event_name }}
BEFORE_SHA: ${{ github.event.before }}
BEFORE_SHA: ${{ steps.range.outputs.base || github.event.before }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.sha }}
DEVELOPMENT_BRANCH: dev
@@ -92,7 +125,7 @@ jobs:
continue-on-error: true
env:
EVENT_NAME: ${{ github.event_name }}
BEFORE_SHA: ${{ github.event.before }}
BEFORE_SHA: ${{ steps.range.outputs.base || github.event.before }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.sha }}
DEVELOPMENT_BRANCH: dev
@@ -140,20 +173,65 @@ jobs:
changes:
name: Классификация изменённых файлов
runs-on: ubuntu-latest
# Чтение списка прогонов Validate — единственное, что этой job нужно сверх
# содержимого репозитория (#387).
permissions:
contents: read
actions: read
outputs:
frontend: ${{ steps.classify.outputs.frontend }}
backend: ${{ steps.classify.outputs.backend }}
integration: ${{ steps.classify.outputs.integration }}
base: ${{ steps.base.outputs.base }}
# Разные вещи под разными именами намеренно: `base` — до какого коммита
# классифицировать файлы ветки (#387), `range_base` — от какого коммита
# судить сам диапазон на dev (#388). Общее имя означало бы, что
# потребитель однажды молча возьмёт чужую базу.
range_base: ${{ steps.base.outputs.range_base }}
steps:
# `git diff --name-only` содержимого файлов не читает вовсе, поэтому
# блобы истории этой job не нужны ни на одном шаге (#345).
- uses: actions/checkout@v7
with: { fetch-depth: 0, filter: 'blob:none' }
# База диапазона — самый новый предок с УСПЕШНО завершённым Validate
# (#387). Прежде бралась голова предыдущего пуша (`github.event.before`),
# то есть допущение «до этого всё проверено». Concurrency отменяет прогон
# предыдущего пуша штатно, и на #86 (r5) это дало зелёный статус ветки
# без единого исполненного тяжёлого гейта.
- id: base
if: github.event_name != 'pull_request'
env:
GH_TOKEN: ${{ github.token }}
HEAD_SHA: ${{ github.sha }}
BRANCH: ${{ github.ref_name }}
REPO: ${{ github.repository }}
REF: ${{ github.ref }}
FALLBACK: ${{ github.event.before }}
run: |
git fetch -q origin dev
# Недоступность API — не отказ гейта: пустой ответ уводит базу в
# сторону БОЛЬШЕГО объёма проверок, а не меньшего.
# Один запрос на оба режима: `completed` — надмножество `success`,
# а нужный предикат применяет скрипт (#387 — успех, #388 — судимость).
gh api -X GET "repos/$REPO/actions/workflows/validate.yml/runs" \
-f branch="$BRANCH" -f status=completed -F per_page=100 \
> /tmp/validate-runs.json || echo '{}' > /tmp/validate-runs.json
if [ "$REF" = "refs/heads/dev" ]; then
# На dev классифицировать нечего (всё true), но база диапазона
# нужна гейту «новый код не добавляет any» в job frontend (#388).
node scripts/classify-base.mjs --head="$HEAD_SHA" --mode=range \
--name=range_base --fallback="$FALLBACK" --runs=/tmp/validate-runs.json
exit 0
fi
merge_base=$(git merge-base origin/dev "$HEAD_SHA" || git rev-parse "$HEAD_SHA~1")
node scripts/classify-base.mjs --head="$HEAD_SHA" \
--merge-base="$merge_base" --runs=/tmp/validate-runs.json
- id: classify
env:
EVENT_NAME: ${{ github.event_name }}
BEFORE_SHA: ${{ github.event.before }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
GREEN_BASE: ${{ steps.base.outputs.base }}
HEAD_SHA: ${{ github.sha }}
REF: ${{ github.ref }}
run: |
@@ -163,39 +241,45 @@ jobs:
exit 0
fi
zero=$(printf '%040d' 0)
base="$BEFORE_SHA"
if [ "$EVENT_NAME" = "pull_request" ]; then base="$BASE_SHA"; fi
# #347: непустой before, которого больше не существует, — это
# force-push (ребейз/аменд переписал историю ветки). Диапазон в
# этом случае НЕ угадывается: merge-base после переписанной истории
# уже выдавал «изменились только два doc-файла» при реальном диффе
# в custom_components/**, и тяжёлые job тихо скипались, а общий
# статус рана оставался success — ровно класс тихого пропуска
# #171/#207. Force-push редок, полный прогон дешевле ложного
# «зелёного».
if [ -n "$base" ] && [ "$base" != "$zero" ] \
&& ! git cat-file -e "$base" 2>/dev/null; then
echo "force-push: before=$base переписан — полный прогон без классификации"
# force-push (ребейз/аменд переписал историю ветки). Диапазон в этом
# случае НЕ угадывается: merge-base после переписанной истории уже
# выдавал «изменились только два doc-файла» при реальном диффе в
# custom_components/**, и тяжёлые job тихо скипались, а общий статус
# рана оставался success — ровно класс тихого пропуска #171/#207.
# Force-push редок, полный прогон дешевле ложного «зелёного».
#
# Проверка сохранена как есть и после #387: там база стала доказуемой
# (зелёный предок), но механизм, из-за которого merge-base врал на
# переписанной истории, не разобран до конца. Снимать защиту, не
# объяснив её, — способ получить #347 второй раз.
if [ -n "$BEFORE_SHA" ] && [ "$BEFORE_SHA" != "$zero" ] \
&& ! git cat-file -e "$BEFORE_SHA" 2>/dev/null; then
echo "force-push: before=$BEFORE_SHA переписан — полный прогон без классификации"
{
echo "### Классификация изменённых файлов"
echo "\`before\` (=$base) переписан force-push'ем: диапазон недоказуем,"
echo "\`before\` (=$BEFORE_SHA) переписан force-push'ем: диапазон недоказуем,"
echo "все тяжёлые job запускаются без фильтра путей (#347)."
} >> "$GITHUB_STEP_SUMMARY"
printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT"
exit 0
fi
# Новая ветка: before нулевой, диапазон считается от merge-base с dev,
# иначе классифицировалась бы вся история.
if [ -z "$base" ] || [ "$base" = "$zero" ]; then
git fetch -q origin dev
base=$(git merge-base origin/dev "$HEAD_SHA" || echo "$HEAD_SHA~1")
# Диапазон считается от последнего ДОКАЗАННО зелёного предка (#387),
# а не от головы предыдущего пуша: его прогон штатно отменяется
# следующим пушем. PR — исключение: там база задана событием.
base="$GREEN_BASE"
if [ "$EVENT_NAME" = "pull_request" ]; then base="$BASE_SHA"; fi
if [ -z "$base" ]; then
echo "база не определена — полный прогон без классификации"
printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT"
exit 0
fi
files=$(git diff --name-only "$base" "$HEAD_SHA")
printf '%s\n' "$files" | head -50
has() { printf '%s\n' "$files" | grep -qE "$1" && echo true || echo false; }
{
echo "frontend=$(has '^(src/|demo/|test/|dist/|custom_components/houseplan/frontend/|package(-lock)?\.json$|rollup\.config\.mjs$|tsconfig)')"
echo "backend=$(has '^(custom_components/.*\.py$|tests_backend/|pytest\.ini$)')"
echo "backend=$(has '^(custom_components/.*\.py$|tests_backend/|scripts/support-relay/|pytest\.ini$)')"
echo "integration=$(has '^(custom_components/houseplan/manifest\.json$|hacs\.json$|custom_components/.*\.py$|custom_components/.*/translations/)')"
} >> "$GITHUB_OUTPUT"
@@ -339,17 +423,24 @@ jobs:
EVENT_NAME: ${{ github.event_name }}
BEFORE_SHA: ${{ github.event.before }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
PROVEN_BASE: ${{ needs.changes.outputs.range_base }}
HEAD_SHA: ${{ github.sha }}
REF: ${{ github.ref }}
run: |
set -euo pipefail
git fetch -q origin dev
zero=$(printf '%040d' 0)
base="$BEFORE_SHA"
if [ "$EVENT_NAME" = "pull_request" ]; then base="$BASE_SHA"; fi
# Пуш прямо в dev: диапазон — то, что этот пуш добавил. Новая ветка или
# перезаписанная история: точки отсчёта нет, берём merge-base с dev.
if [ "$REF" != "refs/heads/dev" ] || [ -z "$base" ] || [ "$base" = "$zero" ] \
base=""
# Пуш прямо в dev: диапазон — то, что добавлено с последнего
# ДОКАЗАННО зелёного предка (#388), а не с головы предыдущего пуша:
# его прогон штатно отменяется следующим, и добавленные им строки не
# судил бы никто. Фолбэк — прежний `before`.
if [ "$EVENT_NAME" != "pull_request" ] && [ "$REF" = "refs/heads/dev" ]; then
base="${PROVEN_BASE:-$BEFORE_SHA}"
fi
# Ветка, PR, новая история: точки отсчёта нет, берём merge-base с dev.
# Там диапазон и так шире, поэтому #388 этих случаев не касается.
if [ -z "$base" ] || [ "$base" = "$zero" ] \
|| ! git cat-file -e "$base" 2>/dev/null; then
base=$(git merge-base origin/dev "$HEAD_SHA" || echo "")
fi
@@ -441,6 +532,14 @@ jobs:
path: test-build
- name: Разложить бандл по копиям
run: node scripts/bundle-sync.mjs
# Гард исключений внутри карточки объявлен в demo/serve.mjs с 2026-07-27 и
# до #404 не срабатывал ни разу: счётчик читался раньше, чем Playwright
# доставлял pageerror. Такое ловится только запуском, поэтому пробы живут
# здесь — в единственной job с настоящим браузером. Один шард из трёх:
# проверка не зависит от разбиения, а платить за неё трижды незачем.
- name: "Гард исключений умеет падать (#404)"
if: matrix.shard == 1
run: node demo/guard/verify-guard.mjs
- name: Smoke suite (шард ${{ matrix.shard }} из 3)
env:
SHARD: ${{ matrix.shard }}
@@ -461,6 +560,12 @@ jobs:
echo "ok $name"
else
echo "FAIL $name"
# Диагностические строки смока печатаются раньше вердикта, и
# `tail -20` их срезал: на #411 сам смок сообщал, НАСКОЛЬКО
# разошлись кадры (введено в #302), а в логе прогона осталось
# только «expected true, got false». Числа теперь достаются
# адресно — они и есть разница между «чинить» и «гадать».
grep -aE 'pixel-diffs|^EXC |diagnostic' "/tmp/smoke-logs/$name.log" | tail -5 || true
tail -20 "/tmp/smoke-logs/$name.log"
fail=1
fi
@@ -537,6 +642,14 @@ jobs:
path: dist
- name: Разложить бандл по копиям
run: node scripts/bundle-sync.mjs
# Маркер предыдущего падения с ТЕМ ЖЕ ключом (#386). Восстанавливается до
# прогона, потому что после падения шаги кэша уже не помогут отличить
# виновника от свидетеля.
- name: Прежнее падение на тех же входах
uses: actions/cache/restore@v6
with:
path: .fail-marker
key: fail-golden-${{ needs.reuse.outputs.golden_key }}
- name: Capture or verify golden matrix
id: golden
run: |
@@ -553,6 +666,25 @@ jobs:
with:
name: golden-images
path: artifacts/golden
# Красная golden обязана сказать, ЧЕЙ это долг (#386). Без этого письмо
# «Run failed» называет коммит, который пушнули следующим: 29 августа так
# был назван документ ревью `0f7b6f5`, а сцену без эталона добавил
# `dbbe94ae`. Вердикт job не меняется — меняется только формулировка.
- name: Кто уронил golden
id: fail_note
if: failure()
env:
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: node scripts/gate-reuse.mjs --job=golden --note --marker=.fail-marker
- name: Запомнить первопричину
if: failure() && steps.fail_note.outputs.first == 'true'
# Гонка двух прогонов даёт «Cache already exists»; для маркера падения
# это тем более не отказ гейта — job и так красная по существу.
continue-on-error: true
uses: actions/cache/save@v6
with:
path: .fail-marker
key: fail-golden-${{ needs.reuse.outputs.golden_key }}
# Маркер пишется последним шагом: он существует только если всё выше
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
- name: Записать маркер успеха
@@ -615,9 +747,11 @@ jobs:
- name: Capture the heaviest Glow state
run: |
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --variants=60 --samples=3 --warmups=1 --output=artifacts/performance-smoke/candidate.json
npm run benchmark:glow -- --profile=large-space-card-glow-v1 --variants=60 --samples=3 --warmups=1 --output=artifacts/performance-smoke/space-candidate.json
- name: Enforce absolute smoke ceilings
run: |
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-glow-smoke.json --candidate=artifacts/performance-smoke/candidate.json --output=artifacts/performance-smoke/comparison.json
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-space-glow-smoke.json --candidate=artifacts/performance-smoke/space-candidate.json --output=artifacts/performance-smoke/space-comparison.json
# #330 AC7: перф-контракт ограничений стыков — быстрый (без браузера),
# ловит возврат квадратичных путей в оба зеркала прямо на пуше.
- name: Бюджеты ограничений стыков (#330)
@@ -657,10 +791,57 @@ jobs:
- uses: actions/setup-node@v7
with: { node-version: 22 }
- uses: actions/setup-python@v7
with: { python-version: "3.13" }
- run: pip install pytest voluptuous pytest-homeassistant-custom-component home-assistant-frontend
- name: Backend unit tests (pure + HA harness)
run: python -m pytest tests_backend/ -q
with: { python-version: "3.14" }
- run: |
# Точные версии — в tests_backend/requirements.txt (#392): без них
# резолвер выбирает набор сам, и «зелёный backend» значит разное
# в разные дни. #42 ставит из того же файла ruff для шага линта.
pip install -r tests_backend/requirements.txt
pip list --format=columns | grep -Ei 'homeassistant|voluptuous|^pytest '
- name: Линт бэкенда (ruff, узкий набор)
run: python -m ruff check custom_components/houseplan
# #42: типизация была измеримой только локально — CI её не исполнял, и
# регрессия в любом из allowlist-модулей проходила молча. Список модулей
# берётся ИЗ pyproject.toml, а не дублируется здесь: разошедшийся дубль
# означал бы зелёный шаг, проверяющий не то. Пустой список — отказ.
- name: Типы бэкенда (mypy strict по allowlist)
run: |
modules=$(python -c "
import tomllib
cfg = tomllib.load(open('pyproject.toml', 'rb'))
for o in cfg['tool']['mypy'].get('overrides', []):
if o.get('strict'):
print(' '.join('-p ' + m for m in o['module']))
")
test -n "$modules" || { echo '::error::strict-allowlist в pyproject.toml пуст — проверять нечего'; exit 1; }
echo "mypy strict: $modules"
python -m mypy $modules
# #42: защита от тихого скипа HA-harness — импорт и порог collect
- name: HA-harness присутствует
run: |
python -c "import homeassistant"
collected=$(python -m pytest tests_backend/ -q --collect-only 2>/dev/null | grep -c "test_ha_" || true)
echo "collected HA-harness tests: $collected"
test "$collected" -ge 50
- name: Backend unit tests (pure + HA harness, coverage)
run: >
python -m pytest tests_backend/ -q
--cov=custom_components/houseplan --cov-branch
--cov-report=xml --cov-report=term
- name: Support relay unit tests
run: python -m unittest discover -s scripts/support-relay/tests -q
# #42: гейт «не ниже baseline» — пороги 90/95 поднимаются trivial-правкой числа
- name: Порог покрытия не ниже baseline
run: |
baseline=$(head -1 scripts/backend-coverage-baseline.txt)
actual=$(python -c "import xml.etree.ElementTree as ET; print(round(float(ET.parse('coverage.xml').getroot().get('line-rate'))*100, 1))")
echo "coverage: $actual% (baseline $baseline%)"
python -c "import sys; sys.exit(0 if float('$actual') >= float('$baseline') - 0.1 else 1)"
- name: Артефакт coverage.xml
uses: actions/upload-artifact@v4
with:
name: backend-coverage
path: coverage.xml
# Маркер пишется последним шагом: он существует только если всё выше
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
- name: Записать маркер успеха
+5 -3
View File
@@ -15,10 +15,8 @@ from . import websocket_api as hp_ws
from .const import (
DOMAIN,
FILES_DIR,
FILES_URL,
FRONTEND_URL,
PLANS_DIR,
PLANS_URL,
VERSION,
)
from .geometry_migration import migrate_config, migrate_layout, pending_from_config
@@ -38,8 +36,12 @@ async def async_setup(hass: HomeAssistant, config) -> bool:
"""Register global handlers (survive config-entry reloads): WS commands, HTTP view."""
hass.data.setdefault(DOMAIN, {})
hp_ws.async_register(hass)
from .http_api import HouseplanContentView, HouseplanImportPreviewView, HouseplanUploadView
from .frontend_assets import HouseplanFrontendAssetView
from .http_api import (
HouseplanContentView,
HouseplanImportPreviewView,
HouseplanUploadView,
)
hass.http.register_view(HouseplanUploadView())
hass.http.register_view(HouseplanContentView())
@@ -2,7 +2,6 @@
from __future__ import annotations
import voluptuous as vol
from homeassistant import config_entries
from .const import CONF_ADMIN_ONLY, DOMAIN
+52 -2
View File
@@ -47,7 +47,19 @@ PLAN_ORPHAN_TTL_S = 3600
SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.69.0-beta.3"
VERSION = "1.70.0-beta.2"
# #43: the support transport is deliberately not configurable. A user supplied
# URL would turn the integration into an SSRF proxy and make the privacy notice
# false. The relay is deployed and operated by the project; changing it is a
# reviewed release change, not a Home Assistant option.
SUPPORT_RELAY_URL = "https://support.houseplan.tech/v1/reports"
SUPPORT_PREVIEW_TTL_S = 10 * 60
MAX_SUPPORT_PREVIEWS_PER_USER = 3
MAX_SUPPORT_PREVIEWS_TOTAL = 3
MAX_SUPPORT_ATTACHMENT_BYTES = 8 * 1024 * 1024
MAX_SUPPORT_MESSAGE_CODEPOINTS = 10_000
MAX_SUPPORT_CONTACT_CODEPOINTS = 320
# Portable backup format. This is deliberately independent from the Home
# Assistant Store version above: storage migrations and files exported by a
@@ -63,8 +75,46 @@ MAX_IMPORT_PREVIEWS_PER_USER = 3
# accounts.
MAX_IMPORT_PREVIEWS_TOTAL = 3
DEFAULT_CONFIG: dict = {
DEFAULT_CONFIG: dict[str, object] = {
"spaces": [],
"markers": [],
"settings": {"bg_mode": "daynight"},
}
# #42: THE stable public error-code contract. Every code a user-facing
# failure can carry — send_error literals, exception-class codes and the
# literal MarkerControlError codes — lives here; the scanner in
# tests_backend/test_backend_quality.py fails when a source emits a code
# outside this set (fail-closed), and every fixed code has a localized
# `backup.error.<code>` message on the frontend.
ERROR_CODES: frozenset[str] = frozenset({
"capacity_exceeded", "commit_failed", "conflict",
"content_confirmation_required", "duplicate_marker_control",
"future_model", "in_use", "invalid_config", "invalid_content",
"invalid_data", "invalid_format", "invalid_json", "invalid_layout",
"invalid_light_entity", "invalid_marker_control", "invalid_name",
"invalid_partition_opening_host",
"invalid_partition_opening_jamb_margin", "invalid_passage_fields",
"invalid_space_id", "invalid_toggle_entity", "invalid_value_badge",
"invalid_value_badge_attribute", "invalid_value_badge_position",
"invalid_value_badge_source", "invalid_value_source",
"invalid_value_source_attribute", "io_error", "marker_control_cycle",
"marker_control_missing", "marker_control_not_light",
"marker_control_self", "missing_content", "missing_plan", "no_backup",
"not_ready", "not_toggleable", "nothing_to_repair", "preview_expired",
"preview_owner_mismatch", "space_in_use", "space_not_found",
"support_invalid_message", "support_package_too_large",
"support_preview_expired", "support_rate_limited", "support_rejected",
"support_unavailable",
"too_large", "unauthorized", "unsupported_export_version",
"value_badge_source_required", "wall_model_client_outdated",
"wall_model_migration_blocked",
})
# Template-code families: f-string codes carry one of these prefixes and are
# served by the generic per-code fallback on the frontend.
ERROR_CODE_FAMILIES: tuple[str, ...] = (
"junction_limit_",
"value_badge_",
"value_source_",
)
@@ -10,7 +10,6 @@ import copy
import math
from typing import Any
COORDINATE_DECIMALS = 9
COORDINATE_FACTOR = 10**COORDINATE_DECIMALS
LATTICE_GRID_N = 240
@@ -1,113 +1,125 @@
{
"schema": 1,
"fingerprint": "b76f28b2db2f8889fefaa8ea917e3afbad86383c2ea21d1fec3eba92af67c5b6",
"fingerprint": "6235a41ecc5f366d871f78c16e149901360ea2a58761e839b0f53c6527b5e9ed",
"entry": "houseplan-card.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-DCggDxve.js",
"houseplan-assets/houseplan-card-CZDS_78e.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 273697,
"initialViewGzipBytes": 290378,
"lazyFiles": [
"houseplan-assets/backdrop-pick-qoGkr9B3.js",
"houseplan-assets/de-CDaPitbg.js",
"houseplan-assets/editor-CasKiVVQ.js",
"houseplan-assets/houseplan-editor-runtime-DpcuLOlh.js",
"houseplan-assets/houseplan-onboarding-runtime-g7xdzkz_.js"
"houseplan-assets/backdrop-pick-BOES8LdP.js",
"houseplan-assets/de-CAtsunWz.js",
"houseplan-assets/editor-DCuNBi0k.js",
"houseplan-assets/fr-Bqax3ohv.js",
"houseplan-assets/houseplan-editor-runtime-DvhfS-p7.js",
"houseplan-assets/houseplan-onboarding-runtime-BeUYskCk.js"
],
"lazyGzipBytes": 168977,
"lazyGzipBytes": 205828,
"lazyEditorFiles": [
"houseplan-assets/backdrop-pick-qoGkr9B3.js",
"houseplan-assets/editor-CasKiVVQ.js",
"houseplan-assets/houseplan-editor-runtime-DpcuLOlh.js"
"houseplan-assets/backdrop-pick-BOES8LdP.js",
"houseplan-assets/editor-DCuNBi0k.js",
"houseplan-assets/houseplan-editor-runtime-DvhfS-p7.js"
],
"lazyEditorGzipBytes": 139700,
"lazyEditorGzipBytes": 148719,
"lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-qoGkr9B3.js",
"houseplan-assets/houseplan-onboarding-runtime-g7xdzkz_.js"
"houseplan-assets/backdrop-pick-BOES8LdP.js",
"houseplan-assets/houseplan-onboarding-runtime-BeUYskCk.js"
],
"lazyOnboardingGzipBytes": 9445,
"lazyOnboardingGzipBytes": 14029,
"lazyLocaleFiles": [
"houseplan-assets/de-CDaPitbg.js"
"houseplan-assets/de-CAtsunWz.js",
"houseplan-assets/fr-Bqax3ohv.js"
],
"lazyLocaleGzipBytes": 22783,
"lazyLocaleGzipBytes": 50151,
"files": [
{
"path": "houseplan-assets/backdrop-pick-qoGkr9B3.js",
"sha256": "e3bf2e1ca0f3352a31390d29596c901b60d3451bd65acb11045a4d2b9ab666a6",
"rawBytes": 6967,
"gzipBytes": 2951,
"path": "houseplan-assets/backdrop-pick-BOES8LdP.js",
"sha256": "aea5ea988e5aff3ffa8765511b0ed96acd7b4b3b5f3719242360716f3c6bd502",
"rawBytes": 20636,
"gzipBytes": 7071,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-DCggDxve.js"
"houseplan-assets/houseplan-card-CZDS_78e.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/de-CDaPitbg.js",
"sha256": "968d659500403eee7ab7c8a0d6159621972df977b0b52a20c324921b6e309e09",
"rawBytes": 74836,
"gzipBytes": 22783,
"path": "houseplan-assets/de-CAtsunWz.js",
"sha256": "883dd5ab7905636373de30c2793fc5418d867087644dc198f93576eb4b87cd3a",
"rawBytes": 83981,
"gzipBytes": 25347,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-CasKiVVQ.js",
"sha256": "c0ec3ab48e3d7a5bb767f02e7519ecdff932e4cb70a34f562a67b65f322c2350",
"rawBytes": 3831,
"path": "houseplan-assets/editor-DCuNBi0k.js",
"sha256": "55ddef2226d3bc262b582216e556c667ad3dd8c2e793891afee33d1fb6687bdb",
"rawBytes": 3826,
"gzipBytes": 1581,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-DCggDxve.js"
"houseplan-assets/houseplan-card-CZDS_78e.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-DCggDxve.js",
"sha256": "57bd9d474cdccceed23e0acf6b309ea216a29debf5eed20657ba978307b80133",
"rawBytes": 964180,
"gzipBytes": 272947,
"path": "houseplan-assets/fr-Bqax3ohv.js",
"sha256": "5f75459b58f7bd380c21eb6998b417e743f65270425b2bc1dc46e9785d71ac56",
"rawBytes": 86212,
"gzipBytes": 24804,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-CZDS_78e.js",
"sha256": "7bac8a128b73c811e62484ce99a13a121b7bb25c1dfc8fa48a49715f1c4a3690",
"rawBytes": 1032271,
"gzipBytes": 289581,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-CDaPitbg.js",
"houseplan-assets/editor-CasKiVVQ.js",
"houseplan-assets/houseplan-editor-runtime-DpcuLOlh.js",
"houseplan-assets/houseplan-onboarding-runtime-g7xdzkz_.js"
"houseplan-assets/de-CAtsunWz.js",
"houseplan-assets/editor-DCuNBi0k.js",
"houseplan-assets/fr-Bqax3ohv.js",
"houseplan-assets/houseplan-editor-runtime-DvhfS-p7.js",
"houseplan-assets/houseplan-onboarding-runtime-BeUYskCk.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-DpcuLOlh.js",
"sha256": "4d0827d46f53b2ed22be6bf7a16dd9ab4fa97a04d340f236165eb6cd281891f9",
"rawBytes": 523356,
"gzipBytes": 135168,
"path": "houseplan-assets/houseplan-editor-runtime-DvhfS-p7.js",
"sha256": "0d15c5f868b3684dbf6d81ebfbe925f874f08b3e8a68db12f160f3807d22ee20",
"rawBytes": 543272,
"gzipBytes": 140067,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-qoGkr9B3.js",
"houseplan-assets/houseplan-card-DCggDxve.js"
"houseplan-assets/backdrop-pick-BOES8LdP.js",
"houseplan-assets/houseplan-card-CZDS_78e.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-g7xdzkz_.js",
"sha256": "292254483f1dd92dccbafc1600335127cf50206b5a58e53972ef514c7368c9f6",
"rawBytes": 26195,
"gzipBytes": 6494,
"path": "houseplan-assets/houseplan-onboarding-runtime-BeUYskCk.js",
"sha256": "c119a22e0435de0f3e7ed8c62f5813f7aeee31e1da8698f3506ab19e4a7c87a1",
"rawBytes": 28088,
"gzipBytes": 6958,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-qoGkr9B3.js",
"houseplan-assets/houseplan-card-DCggDxve.js"
"houseplan-assets/backdrop-pick-BOES8LdP.js",
"houseplan-assets/houseplan-card-CZDS_78e.js"
],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "c463ada4a8f02a5decf4a62a425cffb08f7d8ef22ec9c964e05474dce8b8aa16",
"rawBytes": 1089,
"gzipBytes": 750,
"sha256": "d538bbccca6042eee23ee0df4506530c5bf121824167e6a30cac722ce6ba1b1c",
"rawBytes": 1183,
"gzipBytes": 797,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-DCggDxve.js"
"houseplan-assets/houseplan-card-CZDS_78e.js"
],
"dynamicImports": []
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,14 +0,0 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="b76f28b2db2f8889fefaa8ea917e3afbad86383c2ea21d1fec3eba92af67c5b6";import{c as e,l as o,t,cZ as s,A as a,c_ as i,c$ as l,d0 as n,E as r,b as c}from"./houseplan-card-DCggDxve.js";class f extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,f={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},h=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
.computeLabel=${e=>f[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`;return c`
${d(h.slice(0,3))}
${_?c`<div class="default-floor-error" role="alert"
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
${t(s,"editor.default_floor_missing",{id:_})}
</div>`:a}
${d(h.slice(3))}
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}f.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",f);
@@ -0,0 +1,14 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="6235a41ecc5f366d871f78c16e149901360ea2a58761e839b0f53c6527b5e9ed";import{b as e,l as o,t,d8 as s,A as a,d9 as i,da as l,db as n,E as r,c}from"./houseplan-card-CZDS_78e.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
.computeLabel=${e=>h[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`;return c`
${d(f.slice(0,3))}
${_?c`<div class="default-floor-error" role="alert"
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
${t(s,"editor.default_floor_missing",{id:_})}
</div>`:a}
${d(f.slice(3))}
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="b76f28b2db2f8889fefaa8ea917e3afbad86383c2ea21d1fec3eba92af67c5b6";try{await import("./houseplan-assets/houseplan-card-DCggDxve.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="6235a41ecc5f366d871f78c16e149901360ea2a58761e839b0f53c6527b5e9ed";try{await import("./houseplan-assets/houseplan-card-CZDS_78e.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
@@ -5,6 +5,7 @@ from pathlib import Path
from aiohttp import web
from homeassistant.components.http import HomeAssistantView
try:
from homeassistant.components.http import KEY_HASS
except ImportError: # pragma: no cover - old HA compatibility
+10 -7
View File
@@ -12,7 +12,6 @@ from functools import partial
from pathlib import Path
from aiohttp import web
from homeassistant.components.http import HomeAssistantView
try: # KEY_HASS — the modern way to access hass from the aiohttp application
@@ -21,11 +20,15 @@ except ImportError: # older HA versions
KEY_HASS = "hass" # type: ignore[assignment]
from homeassistant.core import HomeAssistant
from .const import (
CONF_ADMIN_ONLY, CONTENT_URL, FILES_DIR, FILES_URL, MAX_FILES_BYTES,
MAX_FILES_COUNT, MAX_EXPORT_BYTES, PLANS_DIR,
)
from .auth import may_write
from .const import (
CONTENT_URL,
FILES_DIR,
MAX_EXPORT_BYTES,
MAX_FILES_BYTES,
MAX_FILES_COUNT,
PLANS_DIR,
)
from .import_export import ImportFailure, create_preview
from .plans import TMP_PREFIX, QuotaError, check_quota, reserve_filename
from .registry_snapshot import import_registry_snapshot
@@ -113,7 +116,7 @@ class HouseplanImportPreviewView(HomeAssistantView):
except ImportFailure as err:
status = 413 if err.code == "too_large" else 400
return web.json_response({"error": err.code, "message": err.message}, status=status)
except Exception: # noqa: BLE001
except Exception: # noqa: BLE001 - the HTTP boundary must answer 400, never leak a traceback
_LOGGER.exception("House Plan import preview failed")
return web.json_response({"error": "invalid_format"}, status=400)
return web.json_response(result)
@@ -264,7 +267,7 @@ class HouseplanUploadView(HomeAssistantView):
break
if pending:
await hass.async_add_executor_job(_flush, tmp, pending)
except Exception as err: # noqa: BLE001
except Exception as err: # noqa: BLE001 - a broken multipart stream must answer 400, not crash the view
_LOGGER.warning("House Plan upload: multipart read error: %s", err)
error = ({"error": "bad_request"}, 400)
+73 -14
View File
@@ -6,7 +6,6 @@ only the exact candidate that produced that preview.
"""
from __future__ import annotations
import copy
import hashlib
import json
import math
@@ -24,8 +23,8 @@ import voluptuous as vol
from .const import (
CONTENT_URL,
EXPORT_VERSION,
FILES_URL,
FILES_DIR,
FILES_URL,
IMPORT_PREVIEW_TTL_S,
MAX_EXPORT_BYTES,
MAX_IMPORT_PREVIEWS_PER_USER,
@@ -36,10 +35,6 @@ from .const import (
VERSION,
)
from .store import HouseplanData
from .wall_segment_model import (
WallSegmentMigrationError,
commit_wall_segment_model,
)
from .validation import (
CONFIG_SCHEMA,
LAYOUT_SCHEMA,
@@ -48,16 +43,21 @@ from .validation import (
MAX_LAYOUT,
MAX_MARKERS,
MAX_SPACES,
MarkerControlError,
OpeningPassageError,
PartitionOpeningHostError,
PartitionOpeningJambMarginError,
sanitize_filename,
sanitize_marker_id,
validate_marker_controls,
validate_marker_light_entities,
validate_marker_value_badges,
validate_opening_passages, validate_partition_opening_hosts,
MarkerControlError,
OpeningPassageError,
PartitionOpeningHostError,
PartitionOpeningJambMarginError,
validate_opening_passages,
validate_partition_opening_hosts,
)
from .wall_segment_model import (
WallSegmentMigrationError,
commit_wall_segment_model,
)
FORMAT = "houseplan-export"
@@ -96,7 +96,7 @@ _DECOR_KIND_FIELDS = {
"rect": ("x", "y", "w", "h", "angle", "fill", "fill_color", "fill_opacity"),
"ellipse": ("x", "y", "w", "h", "angle", "fill", "fill_color", "fill_opacity"),
"text": ("x", "y", "text", "size", "size_cm", "scale", "angle"),
"furniture": ("symbol", "x", "y", "w", "h", "angle"),
"furniture": ("symbol", "x", "y", "w", "h", "angle", "flip_h", "flip_v"),
}
@@ -501,6 +501,14 @@ def create_export(
continue
kept.append(ref)
marker["controls"] = kept or None
# #385(г): the two neutralisation formats below differ on
# purpose and are a PAIR — each disarms an external ref the
# way its own model represents "no source": the badge keeps
# its object with enabled=False/source=None (enabled is part
# of the badge model), while value_source's absence IS auto,
# so the key is dropped. Both count in dropped_marker_links;
# changing either format would break round-trips of existing
# exports.
badge = marker.get("value_badge")
source = badge.get("source") if isinstance(badge, dict) else None
ref = source.get("ref") if isinstance(source, dict) \
@@ -510,6 +518,13 @@ def create_export(
badge["enabled"] = False
badge["source"] = None
dropped_marker_links += 1
value_source = marker.get("value_source")
ref = value_source.get("ref") if isinstance(value_source, dict) \
and value_source.get("kind") == "derived_marker_state" else None
if isinstance(ref, str) and ref.startswith("marker:") \
and ref[len("marker:"):] not in selected_ids:
marker.pop("value_source", None)
dropped_marker_links += 1
config = {
"spaces": [_json_copy(space)],
"markers": _json_copy(selected_markers),
@@ -699,7 +714,7 @@ def _transfer_dropped_marker_links(document: dict[str, Any]) -> int:
if not isinstance(transfer, dict):
raise ImportFailure("invalid_format", "Transfer metadata must be an object")
value = transfer.get("dropped_marker_links", 0)
maximum = MAX_MARKERS * (MAX_CONTROLS + 1)
maximum = MAX_MARKERS * (MAX_CONTROLS + 2)
if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= maximum:
raise ImportFailure("invalid_format", "Invalid dropped marker link count")
return value
@@ -803,6 +818,16 @@ def _drop_invalid_import_marker_links(
badge["enabled"] = False
badge["source"] = None
dropped += 1
source = marker.get("value_source")
if isinstance(source, dict) and source.get("kind") == "derived_marker_state":
ref = source.get("ref")
target_id = ref[len("marker:"):] \
if isinstance(ref, str) and ref.startswith("marker:") else ""
target = by_id.get(target_id)
if not target_id or target is None or target.get("removed") is True \
or target.get("is_light") is not True:
marker.pop("value_source", None)
dropped += 1
return dropped
@@ -1110,6 +1135,20 @@ def _repair_target_space_refs(
preserve_related(
marker_id, "marker.value_badge", old_target, resolve_marker,
)
source = marker.get("value_source")
if isinstance(source, dict) and source.get("kind") == "derived_marker_state":
ref = source.get("ref")
old_target = ref[len("marker:"):] \
if isinstance(ref, str) and ref.startswith("marker:") else None
mapped = replace(
marker_id, "marker.value_source", old_target, resolve_marker_link,
)
if mapped is not None:
source["ref"] = "marker:" + mapped
else:
preserve_related(
marker_id, "marker.value_source", old_target, resolve_marker,
)
# Destination wins collisions. Rekey only proven dead plan-owned keys;
# opaque HA owners remain literal.
@@ -1345,13 +1384,17 @@ def build_space_merge(
source = badge.get("source") if isinstance(badge, dict) else None
if isinstance(source, dict) and source.get("kind") == "derived_marker_state":
dropped_marker_links += 1
value_source = marker.get("value_source")
if isinstance(value_source, dict) \
and value_source.get("kind") == "derived_marker_state":
dropped_marker_links += 1
marker["binding"] = "virtual"
marker["display"] = "static_icon"
for field in (
"area", "controls", "tap_action", "tap_target", "tap_confirm",
"vacuum", "is_light", "use_climate_temp", "glow_color",
"glow_radius_cm", "light_entity", "toggle_entity", "hidden", "removed",
"value_badge",
"value_badge", "value_source",
):
marker.pop(field, None)
output_markers.append(marker)
@@ -1395,6 +1438,21 @@ def build_space_merge(
badge["enabled"] = False
badge["source"] = None
dropped_marker_links += 1
source = marker.get("value_source")
if isinstance(source, dict) and source.get("kind") == "derived_marker_state":
ref = source.get("ref")
old_target = ref[len("marker:"):] \
if isinstance(ref, str) and ref.startswith("marker:") else ""
target = None if old_target in virtualized_targets else marker_map.get(old_target)
if target:
source["ref"] = "marker:" + target
_report_remap(
reference_report, "incoming", "marker.value_source",
str(marker.get("id", "?")), old_target,
)
else:
marker.pop("value_source", None)
dropped_marker_links += 1
output_markers_by_id = {
str(marker.get("id")): marker for marker in output_markers
@@ -1667,6 +1725,7 @@ def _materialize_import_candidate(
settings = config.get("settings") or {}
settings.pop("known_devices", None)
settings.pop("new_device_ids", None)
settings.pop("marker_area_snapshot", None)
details = {
"dropped_marker_links": _transfer_dropped_marker_links(prepared),
}
+26 -24
View File
@@ -19,12 +19,14 @@ ugly, not corrupt — see docs/specs/329-junction-limits.md §5.
from __future__ import annotations
import math
import logging
import math
from typing import Any
from .wall_segment_model import (
WALL_SEGMENT_MODEL_VERSION, WallSegmentMigrationError, commit_wall_segment_model,
WALL_SEGMENT_MODEL_VERSION,
WallSegmentMigrationError,
commit_wall_segment_model,
)
_LOGGER = logging.getLogger(__name__)
@@ -82,11 +84,11 @@ def _quantize_key_coord(value: float) -> float:
return 0.0 if rounded == 0 else rounded
def _key(point) -> str:
def _key(point: list[float]) -> str:
return f"{_quantize_key_coord(point[0])},{_quantize_key_coord(point[1])}"
def _length(a, b) -> float:
def _length(a: list[float], b: list[float]) -> float:
return math.hypot(b[0] - a[0], b[1] - a[1])
@@ -94,9 +96,9 @@ def cm_to_units(cm: float, cell_cm: float, grid_pitch: float = GRID_STEP_N) -> f
return (cm / (cell_cm or 1)) * grid_pitch
def limit_segments(space: dict) -> list[dict]:
def limit_segments(space: dict[str, Any]) -> list[dict[str, Any]]:
"""Every wall the limits judge: contour atoms, partitions, draft segments."""
segments: list[dict] = []
segments: list[dict[str, Any]] = []
for segment in space.get("wall_segments") or []:
if _finite_point(segment.get("a")) and _finite_point(segment.get("b")):
segments.append({
@@ -128,7 +130,7 @@ def limit_segments(space: dict) -> list[dict]:
if _length(segment["a"], segment["b"]) > _EPS]
def check_nodes(segments: list[dict]) -> list[tuple[str, str, float, float]]:
def check_nodes(segments: list[dict[str, Any]]) -> list[tuple[str, str, float, float]]:
"""П1 + П2: valence of a node and the narrowest wedge in it."""
rays: dict[str, list[float]] = {}
for segment in segments:
@@ -163,20 +165,20 @@ def check_nodes(segments: list[dict]) -> list[tuple[str, str, float, float]]:
return violations
def _axis_degrees(segment: dict) -> float:
def _axis_degrees(segment: dict[str, Any]) -> float:
degrees = math.degrees(math.atan2(
segment["b"][1] - segment["a"][1], segment["b"][0] - segment["a"][0]
))
return (degrees % 180 + 180) % 180
def _collinear(left: dict, right: dict, tolerance_deg: float = 1.0) -> bool:
def _collinear(left: dict[str, Any], right: dict[str, Any], tolerance_deg: float = 1.0) -> bool:
delta = abs(_axis_degrees(left) - _axis_degrees(right))
return min(delta, 180 - delta) <= tolerance_deg
def _build_node_index(segments: list[dict]) -> dict[str, list[dict]]:
by_node: dict[str, list[dict]] = {}
def _build_node_index(segments: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
by_node: dict[str, list[dict[str, Any]]] = {}
for item in segments:
for point in (item["a"], item["b"]):
by_node.setdefault(_key(point), []).append(item)
@@ -184,8 +186,8 @@ def _build_node_index(segments: list[dict]) -> dict[str, list[dict]]:
def collinear_run_length_units(
segment: dict, segments: list[dict],
by_node_index: dict[str, list[dict]] | None = None,
segment: dict[str, Any], segments: list[dict[str, Any]],
by_node_index: dict[str, list[dict[str, Any]]] | None = None,
) -> float:
"""Length of the WALL a segment belongs to, not of the atom.
@@ -218,7 +220,7 @@ def collinear_run_length_units(
return total
def check_segment_lengths(
segments: list[dict], cell_cm: float, grid_pitch: float = GRID_STEP_N,
segments: list[dict[str, Any]], cell_cm: float, grid_pitch: float = GRID_STEP_N,
) -> list[tuple[str, str, float, float]]:
"""П3: a wall is at least 20 cm and never shorter than its own thickness."""
violations: list[tuple[str, str, float, float]] = []
@@ -235,7 +237,7 @@ def check_segment_lengths(
return violations
def _distance_to_segment(point, a, b) -> float:
def _distance_to_segment(point: list[float], a: list[float], b: list[float]) -> float:
dx, dy = b[0] - a[0], b[1] - a[1]
length_sq = dx * dx + dy * dy
t = 0.0 if length_sq <= _EPS else max(0.0, min(1.0, (
@@ -245,7 +247,7 @@ def _distance_to_segment(point, a, b) -> float:
def check_node_distances(
segments: list[dict], cell_cm: float, grid_pitch: float = GRID_STEP_N,
segments: list[dict[str, Any]], cell_cm: float, grid_pitch: float = GRID_STEP_N,
) -> list[tuple[str, str, float, float]]:
"""П4: non-incident nodes and node-to-foreign-wall clearance.
@@ -256,19 +258,19 @@ def check_node_distances(
suite. Pair order inside the subject follows the lexicographic key order,
which replaces the i<j of the all-pairs loop.
"""
nodes: dict[str, list] = {}
nodes: dict[str, list[Any]] = {}
for segment in segments:
nodes[_key(segment["a"])] = segment["a"]
nodes[_key(segment["b"])] = segment["b"]
min_units = cm_to_units(MIN_NODE_DISTANCE_CM, cell_cm, grid_pitch)
size = min_units if min_units > _EPS else 1.0
node_grid: dict[tuple[int, int], list] = {}
node_grid: dict[tuple[int, int], list[Any]] = {}
for node_key, point in nodes.items():
node_grid.setdefault(
(int(point[0] // size), int(point[1] // size)), []
).append((node_key, point))
segment_grid: dict[tuple[int, int], list] = {}
segment_grid: dict[tuple[int, int], list[Any]] = {}
for segment in segments:
x0 = min(segment["a"][0], segment["b"][0]) - min_units
x1 = max(segment["a"][0], segment["b"][0]) + min_units
@@ -314,7 +316,7 @@ def check_node_distances(
))
return violations
def space_violations(space: dict) -> list[tuple[str, str, float, float]]:
def space_violations(space: dict[str, Any]) -> list[tuple[str, str, float, float]]:
"""П1–П4 over one space, in the frontend's order."""
segments = limit_segments(space)
cell_cm = float(space.get("cell_cm") or 1)
@@ -325,7 +327,7 @@ def space_violations(space: dict) -> list[tuple[str, str, float, float]]:
]
def _migrated_spaces(config: dict | None, *, side: str = "previous") -> dict[str, dict]:
def _migrated_spaces(config: dict[str, Any] | None, *, side: str = "previous") -> dict[str, dict[str, Any]]:
"""Spaces of one document AFTER the wall-segment migration, by id.
The limits read `wall_segments`, so a document that predates the catalogue
@@ -375,7 +377,7 @@ def _migrated_spaces(config: dict | None, *, side: str = "previous") -> dict[str
}
def space_violation_counts(spaces: dict[str, dict]) -> dict[str, dict[str, int]]:
def space_violation_counts(spaces: dict[str, dict[str, Any]]) -> dict[str, dict[str, int]]:
"""Violation counts per space per rule — the shape the barrier consumes
and the rev cache (#330 §4.2) stores. Documents are NOT retained."""
result: dict[str, dict[str, int]] = {}
@@ -388,7 +390,7 @@ def space_violation_counts(spaces: dict[str, dict]) -> dict[str, dict[str, int]]
def validate_junction_limits(
config: dict, previous: dict | None = None, *,
config: dict[str, Any], previous: dict[str, Any] | None = None, *,
baseline_counts: dict[str, dict[str, int]] | None = None,
) -> dict[str, dict[str, int]]:
"""Refuse a write that ADDS a junction violation; inherit the rest.
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.69.0-beta.3"
"version": "1.70.0-beta.2"
}
-1
View File
@@ -297,7 +297,6 @@ def collect_plans(
a file-system problem must not turn a durable commit into a failed call.
"""
new_refs = plan_refs(new_cfg)
old_refs = plan_refs(old_cfg)
# A commit knows what it superseded. The timer only knows what nothing
# points at *right now*, and for a plan that is a reversible state: the
# editor detaches the image when a space switches to "draw" and says the
+18 -4
View File
@@ -85,7 +85,11 @@ rules:
comment: No authentication against an external service.
test-coverage:
status: todo
comment: Backend covered by pure tests + HA-harness tests in CI; measuring >95% is planned.
comment: >-
#42 stage 1 shipped the machinery: pytest-cov in CI with a committed
baseline gate, coverage.xml artifact and a hard fail on a silently
skipped HA harness. The 90%/95% thresholds are raised by follow-up
baseline bumps; the rule flips to done at >=95%.
# ---- Gold (selected; entity/device rules are exempt — no entities) ----
diagnostics:
status: done
@@ -97,13 +101,23 @@ rules:
status: done
comment: Missing plan files raise repair issues (translation_key broken_plan).
docs-troubleshooting:
status: todo
status: done
comment: >-
USER-GUIDE.md #22 Troubleshooting and USER-GUIDE.ru.md #22 «Диагностика»
cover load failures, missing devices, geometry, vacuum and reporting.
docs-examples:
status: todo
status: done
comment: >-
USER-GUIDE carries executable YAML examples for both cards (view,
space card with fit/light_pools, filters) and the setup walkthrough.
# ---- Platinum ----
strict-typing:
status: todo
comment: Python is annotated; mypy strict pass is planned.
comment: >-
#42 stage 1: mypy strict gates six pure modules (const, projection,
coordinate_canonicalization, frontend_asset_manifest, junction_limits,
plans) via a grow-only allowlist; the HA-boundary modules are the next
stage.
async-dependency:
status: exempt
comment: No dependencies.
+4 -1
View File
@@ -25,7 +25,6 @@ from .coordinate_canonicalization import (
canonicalize_layout_geometry,
)
_LOGGER = logging.getLogger(__name__)
_BG_MODES = frozenset({"static", "daynight"})
@@ -101,6 +100,10 @@ class HouseplanData:
# Parsed import candidates are short-lived, user-bound and memory-only.
# dict keeps insertion order, which lets the preview service evict oldest.
import_previews: dict[str, dict[str, Any]] = field(default_factory=dict)
# #43: already-sanitized support-package bytes only. Raw config/layout is
# never retained here; the write lock below is used to take one coherent
# deep copy and the privacy projection immediately replaces it.
support_previews: dict[str, dict[str, Any]] = field(default_factory=dict)
# #330 §4.2: junction-limit violation counts of the STORED document,
# keyed by its rev — (rev, {space_id: {rule: count}}). One slot,
# memory-only, invalidated by a rev mismatch; the previous document is
@@ -0,0 +1,503 @@
"""Strict, allowlisted House Plan support-package projection (#43).
This module deliberately has no Home Assistant imports. Besides making the
privacy boundary easy to test, that prevents an innocent serializer helper from
gaining access to registries, states, URLs or paths that the package must never
contain. The caller supplies one coherent config/layout copy and bounded,
already-classified runtime facts; this code constructs a new object field by
field. It never serializes the source and then tries to redact it.
"""
from __future__ import annotations
import hashlib
import json
import re
import secrets
from collections import Counter
from dataclasses import dataclass, field
from typing import Any
from .const import (
EXPORT_VERSION,
MAX_SUPPORT_ATTACHMENT_BYTES,
PLAN_MODEL_VERSION,
)
PACKAGE_FORMAT = "houseplan-support-package"
PACKAGE_VERSION = 1
_SAFE_VERSION = re.compile(r"\A[0-9A-Za-z._+-]{1,32}\Z")
_SAFE_ICON = re.compile(r"\Amdi:[a-z0-9-]{1,80}\Z")
_LANGUAGES = frozenset({"en", "ru", "de", "fr"})
_BROWSERS = frozenset({"chromium", "firefox", "webkit", "unknown"})
_REGISTRY_ACCESS = frozenset({"full", "partial", "unavailable"})
_REGISTRY_AGE = frozenset({"fresh", "stale", "unknown"})
class SupportPackageError(ValueError):
"""A stable public failure without source data in its text."""
def __init__(self, code: str) -> None:
super().__init__(code)
self.code = code
def _safe_version(value: object) -> str:
text = str(value or "unknown")
return text if _SAFE_VERSION.fullmatch(text) else "unknown"
def validate_frontend_facts(value: object) -> dict[str, Any]:
"""Accept only the small runtime enum/boolean contract from the browser."""
if not isinstance(value, dict):
raise SupportPackageError("support_rejected")
browser = value.get("browser_family")
language = value.get("language")
registry = value.get("registry_access")
age = value.get("registry_age_bucket")
major = value.get("browser_major")
if browser not in _BROWSERS or language not in _LANGUAGES:
raise SupportPackageError("support_rejected")
if registry not in _REGISTRY_ACCESS or age not in _REGISTRY_AGE:
raise SupportPackageError("support_rejected")
if isinstance(major, bool) or not isinstance(major, int) or not 0 <= major <= 999:
raise SupportPackageError("support_rejected")
for key in ("coarse_pointer", "hover_capable"):
if not isinstance(value.get(key), bool):
raise SupportPackageError("support_rejected")
return {
"browser_family": browser,
"browser_major": major,
"language": language,
"coarse_pointer": value["coarse_pointer"],
"hover_capable": value["hover_capable"],
"registry_access": registry,
"registry_age_bucket": age,
}
@dataclass
class _Pseudonyms:
namespace: str
maps: dict[str, dict[str, str]] = field(default_factory=dict)
def get(self, kind: str, raw: object) -> str | None:
if raw is None:
return None
value = str(raw)
if not value:
return None
items = self.maps.setdefault(kind, {})
if value not in items:
items[value] = f"{kind}-{self.namespace}-{len(items) + 1}"
return items[value]
def _copy_keys(source: object, keys: tuple[str, ...]) -> dict[str, Any]:
if not isinstance(source, dict):
return {}
return {key: source[key] for key in keys if key in source}
def _point(value: object) -> list[float] | None:
if not isinstance(value, (list, tuple)) or len(value) != 2:
return None
if any(isinstance(item, bool) or not isinstance(item, (int, float)) for item in value):
return None
return [value[0], value[1]]
def _points(value: object) -> list[list[float]]:
if not isinstance(value, list):
return []
return [point for item in value if (point := _point(item)) is not None]
def _entity_ref(ids: _Pseudonyms, value: object) -> str | None:
return ids.get("entity", value)
def _binding(ids: _Pseudonyms, value: object) -> tuple[str, str]:
text = str(value or "")
if text == "virtual":
return "virtual", "virtual"
kind, separator, raw = text.partition(":")
if separator and kind in {"device", "entity"} and raw:
# Reuse the same per-kind namespace as controls/value sources so one
# raw entity remains one pseudonym everywhere in this package.
pseudo = ids.get(kind, raw)
return kind, f"{kind}:{pseudo}"
return "unknown", "unknown"
def _custom_fill(value: object) -> dict[str, Any] | None:
if not isinstance(value, dict):
return None
return _copy_keys(value, ("c", "a"))
def _global_settings(value: object) -> dict[str, Any]:
out = _copy_keys(value, ("glow_radius_cm", "bg_color", "north_deg", "bg_mode", "sun_rays"))
if not isinstance(value, dict):
return out
fill_colors = value.get("fill_colors")
if isinstance(fill_colors, dict):
out["fill_colors"] = {
str(key): _copy_keys(item, ("c", "a"))
for key, item in fill_colors.items()
if isinstance(key, str) and isinstance(item, dict)
}
style = value.get("decor_default_style")
if isinstance(style, dict):
out["decor_default_style"] = _copy_keys(
style, ("color", "opacity", "width_cm", "fill", "fill_color", "fill_opacity"),
)
return out
def _space_settings(value: object) -> dict[str, Any]:
out = _copy_keys(value, (
"show_borders", "show_names", "room_color", "bg_color", "room_opacity",
"fill_mode", "glow_enabled", "temp_min", "temp_max", "show_lqi",
"hide_decor", "hide_openings", "label_temp", "label_hum", "label_lqi",
"label_light", "card_font_scale", "north_deg", "bg_mode", "sun_rays",
))
if isinstance(value, dict) and "custom_fill" in value:
out["custom_fill"] = _custom_fill(value.get("custom_fill"))
return out
def _room_settings(ids: _Pseudonyms, value: object) -> dict[str, Any]:
out = _copy_keys(value, ("fill_mode", "glow", "name_scale", "label_scale"))
if not isinstance(value, dict):
return out
if "custom_fill" in value:
out["custom_fill"] = _custom_fill(value.get("custom_fill"))
for key in ("temp_source", "hum_source"):
source = value.get(key)
if source:
kind, pseudo = _binding(ids, source)
out[key] = pseudo
out[f"{key}_kind"] = kind
return out
def _project_room(ids: _Pseudonyms, room: dict[str, Any], index: int) -> dict[str, Any]:
out: dict[str, Any] = {
"id": ids.get("room", room.get("id")),
"name": f"Room {index}",
}
out.update(_copy_keys(room, ("x", "y", "w", "h")))
if "poly" in room:
out["poly"] = _points(room.get("poly"))
if isinstance(room.get("wall_ids"), list):
out["wall_ids"] = [ids.get("wall", item) for item in room["wall_ids"]]
if isinstance(room.get("open_to"), list):
out["open_to"] = [ids.get("room", item) for item in room["open_to"]]
settings = _room_settings(ids, room.get("settings"))
if settings:
out["settings"] = settings
return out
def _project_decor(ids: _Pseudonyms, item: dict[str, Any]) -> dict[str, Any]:
kind = str(item.get("kind") or "unknown")
out: dict[str, Any] = {"id": ids.get("decor", item.get("id")), "kind": kind}
out.update(_copy_keys(item, (
"color", "opacity", "width_cm", "width", "x1", "y1", "x2", "y2",
"line_style", "x", "y", "w", "h", "angle", "fill", "fill_color",
"fill_opacity", "size", "size_cm", "scale", "symbol", "flip_h", "flip_v",
)))
if kind == "text":
out["text"] = "[redacted text]"
if item.get("entity"):
out["entity"] = _entity_ref(ids, item.get("entity"))
return out
def _project_value_source(ids: _Pseudonyms, value: object) -> dict[str, Any] | None:
if not isinstance(value, dict):
return None
kind = value.get("kind")
if kind in {"entity_state", "entity_attribute"}:
out: dict[str, Any] = {"kind": kind, "entity_id": _entity_ref(ids, value.get("entity_id"))}
# Attribute names can be arbitrary user strings and are not needed to
# reproduce geometry or marker placement, so they are deliberately absent.
return out
if kind == "derived_lqi":
return {"kind": kind}
if kind == "derived_marker_state":
raw = str(value.get("ref") or "")
marker = raw[7:] if raw.startswith("marker:") else raw
return {"kind": kind, "ref": f"marker:{ids.get('marker', marker)}"}
return None
def _project_marker(ids: _Pseudonyms, marker: dict[str, Any]) -> dict[str, Any]:
binding_kind, binding = _binding(ids, marker.get("binding"))
out: dict[str, Any] = {
"id": ids.get("marker", marker.get("id")),
"binding": binding,
"binding_kind": binding_kind,
}
out.update(_copy_keys(marker, (
"hidden", "removed", "tap_action", "tap_confirm", "display", "ripple_color",
"ripple_size", "size", "angle", "glow_radius_cm", "glow_color", "is_light",
"use_climate_temp",
)))
icon = marker.get("icon")
if isinstance(icon, str) and _SAFE_ICON.fullmatch(icon):
out["icon"] = icon
if marker.get("space"):
out["space"] = ids.get("space", marker.get("space"))
if marker.get("room_id"):
out["room_id"] = ids.get("room", marker.get("room_id"))
for key in ("light_entity", "toggle_entity", "tap_target"):
if marker.get(key):
out[key] = _entity_ref(ids, marker.get(key))
controls = marker.get("controls")
if isinstance(controls, list):
out["controls"] = [_entity_ref(ids, value) for value in controls]
vacuum = marker.get("vacuum")
if isinstance(vacuum, dict):
out["vacuum"] = _copy_keys(vacuum, ("live", "trail", "trail_mode", "room_highlight"))
for key in ("value_source",):
projected = _project_value_source(ids, marker.get(key))
if projected:
out[key] = projected
badge = marker.get("value_badge")
if isinstance(badge, dict):
safe_badge = _copy_keys(badge, ("enabled", "position"))
source = _project_value_source(ids, badge.get("source"))
if source:
safe_badge["source"] = source
out["value_badge"] = safe_badge
return out
def _project_space(ids: _Pseudonyms, space: dict[str, Any], index: int) -> dict[str, Any]:
out: dict[str, Any] = {
"id": ids.get("space", space.get("id")),
"title": f"Space {index}",
"has_plan": bool(space.get("plan_url")),
}
out.update(_copy_keys(space, (
"cell_cm", "plan_aspect", "plan_x", "plan_y", "plan_scale", "plan_scale_x",
"plan_scale_y", "plan_angle", "view_box", "zero_wall_style",
)))
settings = _space_settings(space.get("settings"))
if settings:
out["settings"] = settings
out["rooms"] = [
_project_room(ids, room, room_index)
for room_index, room in enumerate(space.get("rooms") or [], 1)
if isinstance(room, dict)
]
out["wall_segments"] = [
{
"id": ids.get("wall", item.get("id")),
"a": _point(item.get("a")), "b": _point(item.get("b")), "cm": item.get("cm"),
}
for item in space.get("wall_segments") or [] if isinstance(item, dict)
]
out["walls"] = [
{
"key": ids.get("wall", item.get("key")), "cm": item.get("cm"),
**({"a": _point(item.get("a")), "b": _point(item.get("b"))}
if "a" in item and "b" in item else {}),
}
for item in space.get("walls") or [] if isinstance(item, dict)
]
out["room_drafts"] = []
for draft in space.get("room_drafts") or []:
if not isinstance(draft, dict):
continue
out["room_drafts"].append({
"id": ids.get("draft", draft.get("id")),
"points": _points(draft.get("points")),
"segments": [
{
**({"id": ids.get("wall", segment.get("id"))} if segment.get("id") else {}),
"cm": segment.get("cm"),
}
for segment in draft.get("segments") or [] if isinstance(segment, dict)
],
})
out["partitions"] = [
{"id": ids.get("partition", item.get("id")), "a": _point(item.get("a")),
"b": _point(item.get("b")), "cm": item.get("cm")}
for item in space.get("partitions") or [] if isinstance(item, dict)
]
out["wall_columns"] = [
{"id": ids.get("column", item.get("id")), **_copy_keys(item, ("shape", "center", "cm", "angle"))}
for item in space.get("wall_columns") or [] if isinstance(item, dict)
]
out["openings"] = []
for opening in space.get("openings") or []:
if not isinstance(opening, dict):
continue
projected = {"id": ids.get("opening", opening.get("id"))}
projected.update(_copy_keys(opening, (
"type", "x", "y", "angle", "length", "invert", "flip_h", "flip_v",
)))
for key in ("contact", "lock"):
if opening.get(key):
projected[key] = _entity_ref(ids, opening.get(key))
host = opening.get("host")
if isinstance(host, dict) and host.get("kind") in {"wall", "partition"}:
kind = host["kind"]
projected["host"] = {
"kind": kind,
"id": ids.get(kind, host.get("id")),
"t": host.get("t"),
}
out["openings"].append(projected)
out["decor"] = [
_project_decor(ids, item) for item in space.get("decor") or [] if isinstance(item, dict)
]
out["open_spans"] = [
{"a": _point(item.get("a")), "b": _point(item.get("b"))}
for item in space.get("open_spans") or [] if isinstance(item, dict)
]
return out
def _project_layout(ids: _Pseudonyms, layout: object) -> dict[str, Any]:
if not isinstance(layout, dict):
return {}
marker_ids = ids.maps.get("marker", {})
room_ids = ids.maps.get("room", {})
out: dict[str, Any] = {}
for raw_key, value in layout.items():
if not isinstance(raw_key, str) or not isinstance(value, dict):
continue
if raw_key in marker_ids:
key = marker_ids[raw_key]
elif raw_key.startswith("rl_") and raw_key[3:] in room_ids:
key = "rl_" + room_ids[raw_key[3:]]
else:
# Unknown keys may be stale device ids. Omitting them is the only
# fail-closed choice; copying them would disclose the raw id.
continue
position = _copy_keys(value, ("x", "y", "k"))
if value.get("s"):
position["s"] = ids.get("space", value.get("s"))
out[key] = position
return out
def _summary(config: object, layout: object) -> dict[str, Any]:
spaces = config.get("spaces", []) if isinstance(config, dict) else []
markers = config.get("markers", []) if isinstance(config, dict) else []
kinds: Counter[str] = Counter()
bindings: Counter[str] = Counter()
lifecycles: Counter[str] = Counter()
decor_kinds: Counter[str] = Counter()
for marker in markers:
if not isinstance(marker, dict):
continue
bindings[_binding_kind(marker.get("binding"))] += 1
lifecycles[
"removed" if marker.get("removed") else "hidden" if marker.get("hidden") else "active"
] += 1
for space in spaces:
if not isinstance(space, dict):
continue
for opening in space.get("openings") or []:
if isinstance(opening, dict):
kinds[str(opening.get("type") or "unknown")] += 1
for item in space.get("decor") or []:
if isinstance(item, dict):
decor_kinds[str(item.get("kind") or "unknown")] += 1
return {
"spaces": len(spaces),
"rooms": sum(len(space.get("rooms") or []) for space in spaces if isinstance(space, dict)),
"room_drafts": sum(len(space.get("room_drafts") or []) for space in spaces if isinstance(space, dict)),
"walls": sum(len(space.get("wall_segments") or space.get("walls") or []) for space in spaces if isinstance(space, dict)),
"partitions": sum(len(space.get("partitions") or []) for space in spaces if isinstance(space, dict)),
"columns": sum(len(space.get("wall_columns") or []) for space in spaces if isinstance(space, dict)),
"openings": dict(sorted(kinds.items())),
"decor": dict(sorted(decor_kinds.items())),
"markers": {
"total": len(markers),
"lifecycle": dict(sorted(lifecycles.items())),
"binding": dict(sorted(bindings.items())),
},
"layout_entries": len(layout) if isinstance(layout, dict) else 0,
}
def _binding_kind(value: object) -> str:
text = str(value or "")
if text == "virtual":
return "virtual"
if text.startswith("device:"):
return "device"
if text.startswith("entity:"):
return "entity"
return "unknown"
def build_support_package(
config: dict[str, Any],
layout: dict[str, Any],
*,
config_rev: int,
layout_rev: int,
card_version: str,
integration_version: str,
home_assistant_version: str,
runtime: dict[str, Any],
repairs: list[dict[str, Any]] | None = None,
namespace: str | None = None,
) -> tuple[bytes, dict[str, Any]]:
"""Build canonical bytes and a bounded summary for the preview response."""
facts = validate_frontend_facts(runtime)
ids = _Pseudonyms(namespace or secrets.token_hex(4))
spaces = [item for item in config.get("spaces", []) if isinstance(item, dict)]
markers = [item for item in config.get("markers", []) if isinstance(item, dict)]
projected_spaces = [
_project_space(ids, space, index) for index, space in enumerate(spaces, 1)
]
projected_markers = [_project_marker(ids, marker) for marker in markers]
summary = _summary(config, layout)
package = {
"format": PACKAGE_FORMAT,
"version": PACKAGE_VERSION,
"versions": {
"card": _safe_version(card_version),
"integration": _safe_version(integration_version),
"home_assistant": _safe_version(home_assistant_version),
"model": PLAN_MODEL_VERSION,
"export_schema": EXPORT_VERSION,
},
"runtime": facts,
"revisions": {"config": int(config_rev), "layout": int(layout_rev)},
"summary": summary,
"validation": {
"config": "valid", "layout": "valid", "unknown_fields": "dropped",
},
"repairs": repairs or [],
"plan_backup": {
"config": {
"model_version": int(config.get("model_version", PLAN_MODEL_VERSION)),
"settings": _global_settings(config.get("settings")),
"spaces": projected_spaces,
"markers": projected_markers,
},
"layout": _project_layout(ids, layout),
},
}
raw = (json.dumps(
package, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False,
) + "\n").encode("utf-8")
if len(raw) > MAX_SUPPORT_ATTACHMENT_BYTES:
raise SupportPackageError("support_package_too_large")
return raw, {
"size": len(raw),
"sha256": hashlib.sha256(raw).hexdigest(),
"spaces": summary["spaces"],
"format": PACKAGE_FORMAT,
"version": PACKAGE_VERSION,
"versions": package["versions"],
}
@@ -0,0 +1,101 @@
"""Bounded outbound submit to the compile-time House Plan support relay (#43)."""
from __future__ import annotations
import json
from typing import Any
from urllib.parse import urlsplit
import aiohttp
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import SUPPORT_RELAY_URL
_REPORT_ID_PREFIX = "hpr-"
class SupportTransportError(RuntimeError):
"""Stable frontend-facing error; never contains the remote response."""
def __init__(self, code: str) -> None:
super().__init__(code)
self.code = code
async def async_submit_report(
hass,
*,
message: str,
contact: str,
versions: dict[str, Any],
idempotency_key: str,
attachment: bytes | None,
attachment_sha256: str | None,
filename_token: str,
) -> str:
"""Send once, without redirects, proxies, arbitrary hosts or reflected text."""
target = urlsplit(SUPPORT_RELAY_URL)
if target.scheme != "https" or target.hostname != "support.houseplan.tech":
raise SupportTransportError("support_unavailable")
request: dict[str, Any] = {
"schema_version": 1,
"message": message,
"idempotency_key": idempotency_key,
"versions": {key: str(value) for key, value in versions.items()},
}
if contact:
request["contact"] = contact
if attachment is not None:
request["attachment"] = {
"size": len(attachment),
"sha256": str(attachment_sha256 or ""),
}
form = aiohttp.FormData()
form.add_field(
"request", json.dumps(request, ensure_ascii=False, separators=(",", ":")),
content_type="application/json",
)
if attachment is not None:
form.add_field(
"attachment", attachment,
filename=f"houseplan-support-{filename_token[:32]}.json",
content_type="application/json",
)
session = async_get_clientsession(hass)
timeout = aiohttp.ClientTimeout(total=20, sock_connect=5)
try:
async with session.post(
SUPPORT_RELAY_URL, data=form, timeout=timeout, allow_redirects=False,
) as response:
if response.status == 429:
raise SupportTransportError("support_rate_limited")
if response.status == 413:
raise SupportTransportError("support_package_too_large")
if response.status in {400, 401, 403, 404, 405, 409, 415, 422}:
raise SupportTransportError("support_rejected")
if response.status != 200:
raise SupportTransportError("support_unavailable")
# A compromised/misconfigured relay cannot make HA buffer an
# unbounded response or reflect its details to the browser.
body = await response.content.read(4097)
if len(body) > 4096:
raise SupportTransportError("support_unavailable")
except SupportTransportError:
raise
except (aiohttp.ClientError, TimeoutError, OSError):
raise SupportTransportError("support_unavailable") from None
try:
payload = json.loads(body.decode("utf-8"))
report_id = payload.get("report_id") if isinstance(payload, dict) else None
except (UnicodeDecodeError, json.JSONDecodeError):
report_id = None
if not isinstance(report_id, str) or not report_id.startswith(_REPORT_ID_PREFIX):
raise SupportTransportError("support_unavailable")
if not 8 <= len(report_id) <= 64 or any(
char not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_."
for char in report_id
):
raise SupportTransportError("support_unavailable")
return report_id
+1 -1
View File
@@ -10,6 +10,7 @@ want to see where the cleanup has already been).
from __future__ import annotations
import asyncio
import logging
import math
import time
from typing import Any
@@ -21,7 +22,6 @@ from homeassistant.helpers.storage import Store
from .const import DOMAIN
import logging
_LOGGER = logging.getLogger(__name__)
TRAIL_CAP = 2000 # raw points per run before decimation
@@ -0,0 +1,30 @@
{
"config": {
"step": {
"user": {
"title": "House Plan",
"data": {
"admin_only": "Seuls les administrateurs peuvent modifier le plan"
}
}
},
"abort": {
"single_instance_allowed": "Déjà configuré — une seule entrée est autorisée."
}
},
"options": {
"step": {
"init": {
"data": {
"admin_only": "Seuls les administrateurs peuvent modifier le plan"
}
}
}
},
"issues": {
"broken_plan": {
"title": "L’image du plan est introuvable",
"description": "Le fichier de plan `{file}` pour l’espace `{space}` est introuvable dans `config/houseplan/plans/`. Ouvrez les paramètres de l’espace dans la carte House Plan et téléversez de nouveau le plan."
}
}
}
+94 -44
View File
@@ -4,10 +4,11 @@ Kept separate so it can be covered by unit tests (only voluptuous is needed).
"""
from __future__ import annotations
from collections import Counter
import copy
import json
import math
import re
from collections import Counter
import voluptuous as vol
@@ -49,9 +50,12 @@ class OpeningPassageError(ValueError):
self.space_id = space_id
self.opening_id = opening_id
self.fields = tuple(sorted(fields))
super().__init__(
f"space={space_id}; opening={opening_id}; fields={','.join(self.fields)}"
)
# #42: structured details as JSON — the frontend parses this instead
# of regexing an English sentence; the legacy "space=..;.." format is
# still accepted there for one beta.
super().__init__(json.dumps({
"space": space_id, "opening": opening_id, "fields": list(self.fields),
}, ensure_ascii=False))
class PartitionOpeningHostError(ValueError):
@@ -72,10 +76,11 @@ class PartitionOpeningJambMarginError(ValueError):
self.opening_id = opening_id
self.margin = margin
self.margin_cm = margin_cm
super().__init__(
f"space={space_id}; opening={opening_id}; "
f"margin={margin:.12g}; margin_cm={margin_cm:.12g}"
)
# #42: structured details as JSON (see OpeningPassageError).
super().__init__(json.dumps({
"space": space_id, "opening": opening_id,
"margin": round(margin, 12), "margin_cm": round(margin_cm, 12),
}, ensure_ascii=False))
class WallModelClientOutdatedError(ValueError):
@@ -410,7 +415,7 @@ def _safe_optimize_partition_delta(space: dict, old_partition: dict) -> bool:
points = sorted(set(round(point, 12) for point in breakpoints
if -_OPTIMIZE_REHOST_EPSILON <= point
<= length + _OPTIMIZE_REHOST_EPSILON))
for lo, hi in zip(points, points[1:]):
for lo, hi in zip(points, points[1:], strict=False): # #42 B905: adjacent pairs, unequal length is the point
if hi - lo <= _OPTIMIZE_REHOST_EPSILON:
continue
if any(start <= lo + _OPTIMIZE_REHOST_EPSILON
@@ -805,7 +810,7 @@ def _matching_previous_marker(
def validate_marker_value_badges(
config: dict, previous: dict | None = None, *, validate_all: bool = False
) -> None:
"""Validate only new/changed badge data; dormant future/legacy data round-trips."""
"""Validate changed badge/face sources; dormant future data round-trips."""
markers = config.get("markers") or []
by_id = {str(marker.get("id")): marker for marker in markers}
old_markers = (previous or {}).get("markers") or []
@@ -814,30 +819,14 @@ def validate_marker_value_badges(
consumed_old_ids: set[str] = set()
known_source_fields = {"kind", "entity_id", "attribute", "ref"}
for marker_id, marker in by_id.items():
old_marker = _matching_previous_marker(
marker, marker_id, old_by_id, old_markers, new_ids,
consumed_old_ids, validate_all,
)
badge = marker.get("value_badge")
old_badge = None if validate_all else (old_marker or {}).get("value_badge")
if not validate_all and badge == old_badge:
continue
if badge is None:
continue
if not isinstance(badge, dict):
raise MarkerControlError("invalid_value_badge", "Value badge must be an object")
if not isinstance(badge.get("enabled"), bool):
raise MarkerControlError("invalid_value_badge", "Value badge enabled must be boolean")
if badge.get("position") not in VALUE_BADGE_POSITIONS:
raise MarkerControlError("invalid_value_badge_position", "Invalid value badge position")
source = badge.get("source")
if badge["enabled"] and not isinstance(source, dict):
raise MarkerControlError("value_badge_source_required", "Enabled value badge needs a source")
if source is None:
continue
def validate_source(source: object, field: str) -> None:
prefix = "value_badge" if field == "value_badge" else "value_source"
source_error = "invalid_value_badge_source" if field == "value_badge" \
else "invalid_value_source"
attribute_error = "invalid_value_badge_attribute" if field == "value_badge" \
else "invalid_value_source_attribute"
if not isinstance(source, dict) or source.get("kind") not in VALUE_BADGE_SOURCE_KINDS:
raise MarkerControlError("invalid_value_badge_source", "Invalid value badge source")
raise MarkerControlError(source_error, f"Invalid {field} source")
kind = source["kind"]
allowed_fields = {
"entity_state": {"kind", "entity_id"},
@@ -846,23 +835,51 @@ def validate_marker_value_badges(
"derived_marker_state": {"kind", "ref"},
}[kind]
if (known_source_fields & set(source)) - allowed_fields:
raise MarkerControlError("invalid_value_badge_source", "Inconsistent value badge source")
raise MarkerControlError(source_error, f"Inconsistent {field} source")
if kind in {"entity_state", "entity_attribute"}:
entity_id = source.get("entity_id")
if not isinstance(entity_id, str) or not _CONTROL_ENTITY_ID_RE.fullmatch(entity_id):
raise MarkerControlError("invalid_value_badge_source", "Invalid value badge entity id")
if kind == "entity_attribute":
if source.get("attribute") not in VALUE_BADGE_ATTRIBUTES:
raise MarkerControlError("invalid_value_badge_attribute", "Invalid value badge attribute")
raise MarkerControlError(source_error, f"Invalid {field} entity id")
if kind == "entity_attribute" and source.get("attribute") not in VALUE_BADGE_ATTRIBUTES:
raise MarkerControlError(attribute_error, f"Invalid {field} attribute")
if kind == "derived_marker_state":
ref = source.get("ref")
if not isinstance(ref, str) or not ref.startswith(MARKER_CONTROL_PREFIX) or not ref[len(MARKER_CONTROL_PREFIX):]:
raise MarkerControlError("invalid_value_badge_source", "Invalid marker value badge target")
if not isinstance(ref, str) or not ref.startswith(MARKER_CONTROL_PREFIX) \
or not ref[len(MARKER_CONTROL_PREFIX):]:
raise MarkerControlError(source_error, f"Invalid {field} target")
target = by_id.get(ref[len(MARKER_CONTROL_PREFIX):])
if target is None or target.get("removed") is True:
raise MarkerControlError("value_badge_marker_missing", "Marker value badge target does not exist")
raise MarkerControlError(f"{prefix}_marker_missing", f"{field} target does not exist")
if target.get("is_light") is not True:
raise MarkerControlError("value_badge_marker_not_light", "Marker value badge target is not a forced light")
raise MarkerControlError(f"{prefix}_marker_not_light", f"{field} target is not a forced light")
for marker_id, marker in by_id.items():
old_marker = _matching_previous_marker(
marker, marker_id, old_by_id, old_markers, new_ids,
consumed_old_ids, validate_all,
)
badge = marker.get("value_badge")
old_badge = None if validate_all else (old_marker or {}).get("value_badge")
if validate_all or badge != old_badge:
if badge is not None:
if not isinstance(badge, dict):
raise MarkerControlError("invalid_value_badge", "Value badge must be an object")
if not isinstance(badge.get("enabled"), bool):
raise MarkerControlError("invalid_value_badge", "Value badge enabled must be boolean")
if badge.get("position") not in VALUE_BADGE_POSITIONS:
raise MarkerControlError("invalid_value_badge_position", "Invalid value badge position")
source = badge.get("source")
if badge["enabled"] and not isinstance(source, dict):
raise MarkerControlError(
"value_badge_source_required", "Enabled value badge needs a source"
)
if source is not None:
validate_source(source, "value_badge")
source = marker.get("value_source")
old_source = None if validate_all else (old_marker or {}).get("value_source")
if (validate_all or source != old_source) and source is not None:
validate_source(source, "value_source")
def validate_marker_light_entities(
@@ -1073,6 +1090,7 @@ MAX_OPEN_TO = 50
MAX_CONTROLS = 200
MAX_PDFS = 50
MAX_KNOWN_DEVICES = 20000
MAX_MARKER_AREA_SNAPSHOT = MAX_KNOWN_DEVICES
MAX_TEXT = 500 # names, models, ids
MAX_DESCRIPTION = 4000
MAX_URL = 2000
@@ -1086,6 +1104,7 @@ CELL_CM_MIN = 0.1
CELL_CM_MAX = 1000.0
_TEXT = vol.All(str, vol.Length(max=MAX_TEXT))
_NONEMPTY_TEXT = vol.All(str, vol.Length(min=1, max=MAX_TEXT))
_TEXT_OR_NONE = vol.Any(None, _TEXT)
_URL = vol.All(str, vol.Length(max=MAX_URL))
@@ -1362,6 +1381,7 @@ DECOR_SCHEMA = vol.Any(
vol.Required("symbol"): _FURN_SYMBOL,
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
vol.Required("w"): _FURN_SIZE, vol.Required("h"): _FURN_SIZE,
vol.Optional("flip_h"): bool, vol.Optional("flip_v"): bool,
vol.Optional("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0))},
extra=vol.ALLOW_EXTRA),
)
@@ -1415,7 +1435,7 @@ def _room_draft_segments(value: dict) -> dict:
"""An open draft has exactly one thickness per consecutive edge."""
if len(value.get("segments", [])) != max(0, len(value.get("points", [])) - 1):
raise vol.Invalid("room draft segments must match consecutive point pairs")
if any(a == b for a, b in zip(value.get("points", []), value.get("points", [])[1:])):
if any(a == b for a, b in zip(value.get("points", []), value.get("points", [])[1:], strict=False)): # #42 B905: adjacent pairs
raise vol.Invalid("room draft consecutive points must differ")
return value
@@ -1678,7 +1698,7 @@ MARKER_SCHEMA = vol.Schema(
vol.Optional("model"): _TEXT_OR_NONE,
vol.Optional("link"): vol.Any(None, _URL),
vol.Optional("description"): vol.Any(None, vol.All(str, vol.Length(max=MAX_DESCRIPTION))),
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", "run", "cover", None),
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", "run", "none", "cover", None),
# the 'run' target: only the runnable domains, nothing else is callable
vol.Optional("tap_target"): vol.Any(
None, vol.All(str, vol.Length(max=MAX_TEXT), vol.Match(r"^(automation|script|scene)\.[A-Za-z0-9_]+$"))
@@ -1743,6 +1763,12 @@ MARKER_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
),
),
# Explicit source for display:value. Semantic delta validation shares
# the badge source contract while keeping untouched future literals.
vol.Optional("value_source"): vol.Any(
None,
vol.Schema({}, extra=vol.ALLOW_EXTRA),
),
# climate current_temperature: badge + room-average vote (off unless True)
vol.Optional("use_climate_temp"): vol.Any(bool, None),
vol.Optional("room_id"): vol.Any(str, None),
@@ -1885,6 +1911,18 @@ CONFIG_SCHEMA = vol.All(
vol.Optional("glow_radius_cm"): vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)),
# background around the plan, all spaces (a space may override)
vol.Optional("bg_color"): _COLOR,
# #377: the Background editor's default style for new decor
# objects; absence of the key means the built-in default
vol.Optional("decor_default_style"): vol.Schema(
{
vol.Optional("color"): _COLOR,
vol.Optional("opacity"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
vol.Optional("width_cm"): vol.All(vol.Coerce(float), vol.Range(min=0.1, max=100)),
vol.Optional("fill"): bool,
vol.Optional("fill_color"): _COLOR,
vol.Optional("fill_opacity"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
}
),
# sun on the plan (docs/SUN.md): global defaults
vol.Optional("north_deg"): _north_deg,
vol.Optional("bg_mode"): _BG_MODE,
@@ -1895,6 +1933,18 @@ CONFIG_SCHEMA = vol.All(
vol.Optional("weather_entity"): vol.Any(None, _TEXT),
vol.Optional("known_devices"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
vol.Optional("new_device_ids"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
vol.Optional("marker_area_snapshot"): vol.All(
vol.Schema({
_NONEMPTY_TEXT: vol.Schema({
vol.Required("binding"): vol.All(
_NONEMPTY_TEXT,
vol.Match(r"^(?:device|entity):.+$"),
),
vol.Required("area"): _NONEMPTY_TEXT,
}),
}),
vol.Length(max=MAX_MARKER_AREA_SNAPSHOT),
),
vol.Optional("fill_colors"): vol.Schema(
{
str: vol.Schema(
@@ -15,7 +15,6 @@ from typing import Any
from .coordinate_canonicalization import canonicalize_config_geometry
WALL_SEGMENT_MODEL_VERSION = 9
GRID_STEP_N = 1 / 240
EPS = 1e-9
@@ -192,10 +191,11 @@ def _atomize(
float(span["b"][0]), float(span["b"][1]),
])
if not legacy_segments:
linked = lambda first, second: (
str(second.get("id", "")) in (first.get("open_to") or [])
or str(first.get("id", "")) in (second.get("open_to") or [])
)
def linked(first: dict, second: dict) -> bool: # #42 E731
return (
str(second.get("id", "")) in (first.get("open_to") or [])
or str(first.get("id", "")) in (second.get("open_to") or [])
)
for first_index, first in enumerate(rooms):
for second in rooms[first_index + 1:]:
if not linked(first, second):
@@ -223,7 +223,6 @@ def _atomize(
])
for segment in canonical_zero_segments:
global_breaks.extend((segment[:2], segment[2:]))
zero_segments = canonical_zero_segments + legacy_segments
# #316 §3.1: a legacy open_spans/open_to cut never zeroes the atom that
# carries an existing contour opening; its edges become atom boundaries so
@@ -348,10 +347,15 @@ def _atomize(
parent_index = parents[index]
atom["parent_keys"].add(_wall_key(original[parent_index], original[(parent_index + 1) % len(original)]))
midpoint = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]
covered_by = lambda cuts: any(
_distance_to_segment(midpoint, cut[:2], cut[2:]) <= GRID_STEP_N * 0.04
for cut in cuts
)
# #42 B023/E731: the loop variable is bound as a default — the
# closure is only called inside this iteration, but the binding
# makes that safety structural instead of incidental.
def covered_by(cuts: list[list[float]], *, midpoint: list[float] = midpoint) -> bool:
return any(
_distance_to_segment(midpoint, cut[:2], cut[2:]) <= GRID_STEP_N * 0.04
for cut in cuts
)
# Canonical cm:0 atoms stay zero; a LEGACY cut spares the atom
# that carries an opening (#316 §3.1).
atom["zero_wall"] = atom["zero_wall"] or covered_by(canonical_zero_segments) or (
@@ -547,7 +551,11 @@ def _host_openings(
continue
raise WallSegmentMigrationError("opening-host", str(opening.get("id", ""))) from None
def eligible(segment: dict[str, Any]) -> bool:
# #42 B023: loop variables are bound as defaults (see covered_by).
def eligible(
segment: dict[str, Any], *,
centre: list[float] = centre, angle: float = angle, half: float = half,
) -> bool:
if float(segment.get("cm", 0)) <= 0:
return False
t = _project_t(centre, segment["a"], segment["b"])
@@ -559,7 +567,10 @@ def _host_openings(
and half >= 0 and t * span - half >= -EPS
and t * span + half <= span + EPS)
def materialize(carrier: dict[str, Any]) -> None:
def materialize(
carrier: dict[str, Any], *,
opening: dict[str, Any] = opening, centre: list[float] = centre,
) -> None:
opening["host"] = {
"kind": "wall", "id": carrier["id"],
"t": max(0.0, min(1.0, _project_t(centre, carrier["a"], carrier["b"]))),
@@ -581,15 +592,18 @@ def _host_openings(
if not initial_migration:
raise WallSegmentMigrationError("opening-host", str(opening.get("id", "")))
def pick(pool: list[dict[str, Any]]) -> dict[str, Any] | None:
def pick(
pool: list[dict[str, Any]], *,
current: dict[str, Any] | None = current, centre: list[float] = centre,
) -> dict[str, Any] | None:
if not pool:
return None
if current is not None and any(item is current for item in pool):
return current
return sorted(pool, key=lambda segment: (
_distance_to_segment(centre, segment["a"], segment["b"]),
-float(segment.get("cm", 0)),
str(segment.get("id", "")),
return sorted(pool, key=lambda candidate: (
_distance_to_segment(centre, candidate["a"], candidate["b"]),
-float(candidate.get("cm", 0)),
str(candidate.get("id", "")),
))[0]
# #316 §3.2 tie-break. No distant fallback pool (CODE-REVIEW-316-r1
+328 -28
View File
@@ -1,36 +1,50 @@
"""House Plan WS commands: layout, space configuration, plan uploads."""
from __future__ import annotations
import logging
import base64
import binascii
import copy
import json
import logging
import secrets
import time
from collections import Counter
from datetime import UTC, datetime
from functools import partial
from pathlib import Path
from typing import Any
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.const import __version__ as HA_VERSION
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import issue_registry as ir
from .auth import may_write
from .const import (
CONTENT_URL,
DEFAULT_CONFIG,
DOMAIN,
CONF_ADMIN_ONLY, DEFAULT_CONFIG,
CONTENT_URL, FILES_DIR, MAX_PLANS_BYTES, MAX_PLANS_FILES, MAX_PLANS_LISTED,
EXPORT_VERSION,
FILES_DIR,
MAX_PLANS_BYTES,
MAX_PLANS_FILES,
MAX_PLANS_LISTED,
MAX_SIGN_PATHS,
PLANS_DIR, PLANS_URL,
MAX_SUPPORT_CONTACT_CODEPOINTS,
MAX_SUPPORT_MESSAGE_CODEPOINTS,
MAX_SUPPORT_PREVIEWS_PER_USER,
MAX_SUPPORT_PREVIEWS_TOTAL,
PLAN_MODEL_VERSION,
PLANS_DIR,
PLANS_URL,
SUPPORT_PREVIEW_TTL_S,
VERSION,
)
from .coordinate_canonicalization import (
canonicalize_config_geometry,
canonicalize_layout_geometry,
)
from .auth import may_write
from .import_export import (
ImportFailure,
content_manifest,
@@ -40,43 +54,66 @@ from .import_export import (
prepare_apply,
revalidate_candidate,
)
from .junction_limits import JunctionLimitError, validate_junction_limits
from .plans import (
QuotaError, check_quota, collect_attachments, collect_plans, is_plan_file,
plan_basename, plan_refs, reserve_filename,
QuotaError,
check_quota,
collect_attachments,
collect_plans,
is_plan_file,
plan_basename,
plan_refs,
reserve_filename,
)
from .projection import project_config, project_layout
from .registry_snapshot import import_registry_snapshot
from .store import (
LAYOUT_STORE_CORE_KEYS,
OPTIMIZE_BACKUP as _OPTIMIZE_BACKUP,
OPTIMIZE_PENDING as _OPTIMIZE_PENDING,
HouseplanData,
async_save_config_state,
async_save_layout_state,
get_data,
get_entry,
)
from .store import (
OPTIMIZE_BACKUP as _OPTIMIZE_BACKUP,
)
from .store import (
OPTIMIZE_PENDING as _OPTIMIZE_PENDING,
)
from .support_package import SupportPackageError, build_support_package
from .support_transport import SupportTransportError, async_submit_report
from .validation import (
CONFIG_SCHEMA,
LAYOUT_SCHEMA,
MAX_CONFIG_BYTES,
MAX_PLAN_BYTES,
PLAN_EXTENSIONS,
POS_SCHEMA,
MarkerControlError,
OpeningPassageError,
PartitionOpeningHostError,
PartitionOpeningJambMarginError,
WallModelClientOutdatedError,
sanitize_filename,
valid_space_id,
validate_marker_controls,
validate_marker_light_entities,
validate_marker_value_badges,
validate_opening_passages,
validate_partition_opening_hosts,
validate_wall_model_transition,
)
from .virtual_lights import (
EVENT_VIRTUAL_LIGHT_UPDATED,
async_toggle_virtual_light,
async_virtual_light_snapshot,
)
from .registry_snapshot import import_registry_snapshot
from .projection import project_config, project_layout
from .wall_segment_model import (
WALL_SEGMENT_MODEL_VERSION,
WallSegmentMigrationError,
commit_wall_segment_model,
)
from .junction_limits import JunctionLimitError, validate_junction_limits
from .validation import (
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES,
PLAN_EXTENSIONS, POS_SCHEMA, MarkerControlError, OpeningPassageError,
PartitionOpeningHostError, PartitionOpeningJambMarginError,
WallModelClientOutdatedError, sanitize_filename,
validate_opening_passages, validate_partition_opening_hosts,
validate_marker_controls, validate_marker_light_entities,
validate_marker_value_badges, validate_wall_model_transition, valid_space_id,
)
_LOGGER = logging.getLogger(__name__)
@@ -161,6 +198,9 @@ def async_register(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_export_create)
websocket_api.async_register_command(hass, ws_import_revalidate)
websocket_api.async_register_command(hass, ws_import_apply)
websocket_api.async_register_command(hass, ws_support_preview)
websocket_api.async_register_command(hass, ws_support_preview_discard)
websocket_api.async_register_command(hass, ws_support_submit)
def _runtime(hass: HomeAssistant, connection, msg_id: int) -> HouseplanData | None:
@@ -189,6 +229,30 @@ def _send_import_error(connection, msg_id: int, err: ImportFailure) -> None:
connection.send_error(msg_id, err.code, err.message)
def _send_support_error(connection, msg_id: int, code: str) -> None:
"""Return only a stable code; support data never enters an error string."""
connection.send_error(msg_id, code, code)
def _prune_support_previews(rt: HouseplanData, now: float | None = None) -> None:
current = time.monotonic() if now is None else now
for token, preview in list(rt.support_previews.items()):
if float(preview.get("expires", 0)) <= current:
rt.support_previews.pop(token, None)
def _support_repairs(hass: HomeAssistant) -> list[dict[str, Any]]:
"""Expose stable House Plan repair families, never raw issue ids/placeholders."""
counts: Counter[str] = Counter()
registry = ir.async_get(hass)
for domain, issue_id in list(registry.issues):
if domain != DOMAIN:
continue
if str(issue_id).startswith("broken_plan_"):
counts["broken_plan"] += 1
return [{"code": code, "count": count} for code, count in sorted(counts.items())]
def _layout_metadata(stored: dict[str, Any]) -> dict[str, Any]:
"""Return the exact non-layout portion of a layout-store document."""
return {
@@ -261,7 +325,7 @@ async def _commit_import_pair(
try:
await _persist_pair_intent(rt, rollback)
await _converge_pair(rt, rollback)
except Exception as rollback_error: # noqa: BLE001
except Exception as rollback_error: # noqa: BLE001 - a failed rollback must not mask the original commit error
_LOGGER.exception(
"House Plan import rollback could not finish; rollback intent remains for setup"
)
@@ -312,7 +376,7 @@ async def ws_export_create(hass: HomeAssistant, connection, msg: dict[str, Any])
except ImportFailure as err:
_send_import_error(connection, msg["id"], err)
return
except Exception: # noqa: BLE001
except Exception: # noqa: BLE001 - defensive: a listener must never break the write path
_LOGGER.exception("House Plan export failed")
connection.send_error(msg["id"], "invalid_config", "Could not create export")
return
@@ -486,7 +550,7 @@ async def ws_import_apply(hass: HomeAssistant, connection, msg: dict[str, Any])
except ImportFailure as err:
_send_import_error(connection, msg["id"], err)
return
except Exception: # noqa: BLE001
except Exception: # noqa: BLE001 - defensive: a listener must never break the write path
_LOGGER.exception("House Plan import commit failed")
connection.send_error(msg["id"], "commit_failed", "Import commit failed")
return
@@ -1618,7 +1682,7 @@ async def ws_space_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
except vol.Invalid as err:
connection.send_error(msg["id"], "invalid_config", str(err))
return
except Exception: # noqa: BLE001
except Exception: # noqa: BLE001 - best-effort backup pruning never fails the request
_LOGGER.exception("House Plan space delete failed")
connection.send_error(msg["id"], "commit_failed", "Space delete failed")
return
@@ -2010,3 +2074,239 @@ async def ws_trail_delete(hass: HomeAssistant, connection: websocket_api.ActiveC
rec = hass.data.get(DOMAIN, {}).get("trail_recorder")
removed = await rec.async_delete(msg["marker_id"]) if rec else False
connection.send_result(msg["id"], {"ok": True, "removed": removed})
# ---------------- private support package / feedback (#43) ----------------
def _support_string(value: object) -> str:
if not isinstance(value, str):
raise vol.Invalid("support field must be a string")
return value
def _support_bool(value: object) -> bool:
if not isinstance(value, bool):
raise vol.Invalid("support field must be a boolean")
return value
def _support_browser_major(value: object) -> int:
if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= 999:
raise vol.Invalid("browser_major must be an integer in 0..999")
return value
_SUPPORT_TOKEN = vol.All(
_support_string, vol.Length(min=16, max=128), vol.Match(r"^[0-9a-f]+$")
)
_SUPPORT_ID = vol.All(
_support_string, vol.Length(min=8, max=128), vol.Match(r"^[A-Za-z0-9_.:-]+$")
)
_SUPPORT_VERSION = vol.All(
_support_string, vol.Length(min=1, max=32), vol.Match(r"^[0-9A-Za-z._+-]+$")
)
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/support/preview",
vol.Required("card_version"): _SUPPORT_VERSION,
vol.Required("browser_family"): vol.In(["chromium", "firefox", "webkit", "unknown"]),
vol.Required("browser_major"): _support_browser_major,
vol.Required("language"): vol.In(["en", "ru", "de", "fr"]),
vol.Required("coarse_pointer"): _support_bool,
vol.Required("hover_capable"): _support_bool,
vol.Required("registry_access"): vol.In(["full", "partial", "unavailable"]),
vol.Required("registry_age_bucket"): vol.In(["fresh", "stale", "unknown"]),
vol.Required("draft_id"): _SUPPORT_ID,
}
)
@websocket_api.async_response
async def ws_support_preview(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
"""Build and retain one exact, already-sanitized support-package snapshot."""
if not _check_write(hass, connection):
_send_support_error(connection, msg["id"], "unauthorized")
return
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
# The write lock guarantees that config/layout and both revisions describe
# one accepted pair. Projection happens after the deep copy, so the lock is
# not held while JSON is built and a large plan cannot stall an editor save.
async with rt.write_lock:
config_data = await rt.config_store.async_load() or {}
layout_data = await rt.store.async_load() or {}
config = copy.deepcopy({**DEFAULT_CONFIG, **(config_data.get("config") or {})})
layout = copy.deepcopy(layout_data.get("layout") or {})
config_rev = int(config_data.get("rev", 0))
layout_rev = int(layout_data.get("rev", 0))
# A package must not turn malformed stored data into an apparently valid
# diagnostic artifact. Validation runs on disposable copies because the
# schemas canonicalize coordinates.
repairs = _support_repairs(hass)
def _build_snapshot() -> tuple[bytes, dict[str, Any]]:
CONFIG_SCHEMA(copy.deepcopy(config))
LAYOUT_SCHEMA(copy.deepcopy(layout))
return build_support_package(
config,
layout,
config_rev=config_rev,
layout_rev=layout_rev,
card_version=msg["card_version"],
integration_version=VERSION,
home_assistant_version=HA_VERSION,
runtime={
"browser_family": msg["browser_family"],
"browser_major": msg["browser_major"],
"language": msg["language"],
"coarse_pointer": msg["coarse_pointer"],
"hover_capable": msg["hover_capable"],
"registry_access": msg["registry_access"],
"registry_age_bucket": msg["registry_age_bucket"],
},
repairs=repairs,
)
try:
# The maximum valid package is deliberately large. Keep validation,
# pseudonymisation and canonical JSON away from Home Assistant's event
# loop while retaining the coherent copies captured under write_lock.
raw, preview = await hass.async_add_executor_job(_build_snapshot)
except (vol.Invalid, ValueError, TypeError, OverflowError) as error:
code = error.code if isinstance(error, SupportPackageError) else "support_rejected"
_send_support_error(connection, msg["id"], code)
return
now = time.monotonic()
owner = _connection_user_id(connection)
_prune_support_previews(rt, now)
# A refresh replaces only this card instance's draft. Other cards keep
# their token and exact bytes.
for old_token, record in list(rt.support_previews.items()):
if record.get("owner") == owner and record.get("draft_id") == msg["draft_id"]:
rt.support_previews.pop(old_token, None)
owned = sum(1 for item in rt.support_previews.values() if item.get("owner") == owner)
if owned >= MAX_SUPPORT_PREVIEWS_PER_USER or len(rt.support_previews) >= MAX_SUPPORT_PREVIEWS_TOTAL:
_send_support_error(connection, msg["id"], "support_rate_limited")
return
token = secrets.token_hex(24)
expires = now + SUPPORT_PREVIEW_TTL_S
rt.support_previews[token] = {
"owner": owner,
"draft_id": msg["draft_id"],
"created": now,
"expires": expires,
"bytes": raw,
"sha256": preview["sha256"],
"versions": preview["versions"],
}
connection.send_result(
msg["id"],
{
"token": token,
"expires_in": SUPPORT_PREVIEW_TTL_S,
"size": preview["size"],
"sha256": preview["sha256"],
"spaces": preview["spaces"],
"format": preview["format"],
"version": preview["version"],
"text": raw.decode("utf-8"),
},
)
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/support/preview/discard",
vol.Required("token"): _SUPPORT_TOKEN,
}
)
@websocket_api.async_response
async def ws_support_preview_discard(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
"""Idempotently discard only a preview owned by this HA user."""
if not _check_write(hass, connection):
_send_support_error(connection, msg["id"], "unauthorized")
return
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
_prune_support_previews(rt)
preview = rt.support_previews.get(msg["token"])
if preview is not None and preview.get("owner") != _connection_user_id(connection):
_send_support_error(connection, msg["id"], "support_preview_expired")
return
rt.support_previews.pop(msg["token"], None)
connection.send_result(msg["id"], {"ok": True})
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/support/submit",
vol.Required("message"): _support_string,
vol.Optional("contact", default=""): _support_string,
vol.Optional("preview_token"): _SUPPORT_TOKEN,
vol.Required("idempotency_key"): _SUPPORT_ID,
}
)
@websocket_api.async_response
async def ws_support_submit(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
"""Submit text and, when selected, the exact previewed package bytes."""
if not _check_write(hass, connection):
_send_support_error(connection, msg["id"], "unauthorized")
return
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
message = msg["message"].strip()
contact = msg.get("contact", "").strip()
if not message or len(message) > MAX_SUPPORT_MESSAGE_CODEPOINTS:
_send_support_error(connection, msg["id"], "support_invalid_message")
return
if len(contact) > MAX_SUPPORT_CONTACT_CODEPOINTS:
_send_support_error(connection, msg["id"], "support_rejected")
return
_prune_support_previews(rt)
token = msg.get("preview_token")
preview = rt.support_previews.get(token) if token else None
if token and (preview is None or preview.get("owner") != _connection_user_id(connection)):
_send_support_error(connection, msg["id"], "support_preview_expired")
return
attachment = preview.get("bytes") if preview else None
versions = preview.get("versions") if preview else {
"card": VERSION,
"integration": VERSION,
"home_assistant": HA_VERSION,
"model": PLAN_MODEL_VERSION,
"export_schema": EXPORT_VERSION,
}
try:
report_id = await async_submit_report(
hass,
message=message,
contact=contact,
versions=versions,
idempotency_key=msg["idempotency_key"],
attachment=attachment,
attachment_sha256=preview.get("sha256") if preview else None,
filename_token=token or msg["idempotency_key"].lower().replace("_", "-"),
)
except SupportTransportError as error:
_send_support_error(connection, msg["id"], error.code)
return
if token:
# Retry after a timeout keeps the token; only a confirmed delivery
# consumes it. The relay's idempotency record handles uncertain first
# attempts with the same frontend key.
rt.support_previews.pop(token, None)
connection.send_result(msg["id"], {"report_id": report_id})
+36 -11
View File
@@ -7,12 +7,19 @@ import { launch } from './serve.mjs';
import { assertFreshDemoBundle } from './bundle-freshness.mjs';
import { summarizeLongTasks, summarizeTimings } from './performance/evaluate.mjs';
import { makeLargeHouseFixture } from './fixtures/large-house.mjs';
import { assertCardContract, GLOW_CARD_CONTRACT } from './performance/card-contract.mjs';
import {
assertCardContract, GLOW_CARD_CONTRACT, SPACE_GLOW_CARD_CONTRACT,
} from './performance/card-contract.mjs';
const valueArg = (name) => process.argv.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
const profile = valueArg('profile') || 'large-light-blend-v1';
if (!['large-light-blend-v1', 'large-house-glow-overlay-v1'].includes(profile))
if (![
'large-light-blend-v1', 'large-house-glow-overlay-v1',
'large-space-card-default-v1', 'large-space-card-glow-v1',
].includes(profile))
throw new Error(`unknown Glow profile: ${profile}`);
const staticCardProfile = profile.startsWith('large-space-card-');
const staticGlowProfile = profile === 'large-space-card-glow-v1';
const parsedSamples = Number(valueArg('samples'));
const parsedWarmups = Number(valueArg('warmups'));
const samples = Math.max(1, Math.min(20, Number.isFinite(parsedSamples) && parsedSamples > 0 ? parsedSamples : 7));
@@ -196,22 +203,37 @@ try {
config: { unit_system: { length: 'km' } },
});
const cacheSnapshot = (card) => ({
cleanFloor: card._cleanFloorCache?.size ?? 0,
glowClip: card._glowClipCache?.size ?? 0,
wallUnion: card._wallUnionCache ? 1 : 0,
openingTunnel: card._openingTunnelCache ? 1 : 0,
openingWallIndex: card._openingWallIndexCache ? 1 : 0,
...(card.localName === 'houseplan-space-card' ? {
// #380: the previous stable predates the shared static-card Glow
// runtime. Its default profile still has a valid empty cache, and
// the opt-in Glow profile is explicitly bootstrap-compared below.
glowClip: card._glowRuntimeState?.clipCache?.size ?? 0,
} : {
cleanFloor: card._cleanFloorCache?.size ?? 0,
glowClip: card._glowClipCache?.size ?? 0,
wallUnion: card._wallUnionCache ? 1 : 0,
openingTunnel: card._openingTunnelCache ? 1 : 0,
openingWallIndex: card._openingWallIndexCache ? 1 : 0,
}),
});
window.__card?.remove?.();
localStorage.clear();
const host = document.getElementById('host');
const result = { sample, longTasks: {}, renderCounts: {}, poolCounts: {} };
const card = document.createElement('houseplan-card');
card.setConfig({ type: 'custom:houseplan-card', title: `Glow ${profile}`, icon_size: 2.4 });
const staticCardProfile = profile.startsWith('large-space-card-');
const card = document.createElement(staticCardProfile ? 'houseplan-space-card' : 'houseplan-card');
card.setConfig(staticCardProfile ? {
type: 'custom:houseplan-space-card', space: fixture.config.spaces[0].id,
show_button: false, light_pools: profile === 'large-space-card-glow-v1',
} : {
type: 'custom:houseplan-card', title: `Glow ${profile}`, icon_size: 2.4,
});
host.replaceChildren(card);
card.hass = hassFor(statesFor(1));
await until(() => staticCardProfile
? !!card._snap && card._devices?.length > 0
: card._loadOk && card._devices?.length === fixture.deviceCount);
window.__hpAssertCardContract(card, cardContract);
await until(() => card._loadOk && card._devices?.length === fixture.deviceCount);
if ('_glowScreenBlend' in card) {
const probeDeadline = performance.now() + 2500;
while (!card._glowScreenBlend && performance.now() < probeDeadline)
@@ -259,7 +281,10 @@ try {
result.renderedDevices = card._devices?.length ?? 0;
result.screenBlend = card._glowScreenBlend === true;
return result;
}, { fixture, profile, sample, cardContract: GLOW_CARD_CONTRACT });
}, {
fixture, profile, sample,
cardContract: staticCardProfile ? SPACE_GLOW_CARD_CONTRACT : GLOW_CARD_CONTRACT,
});
const captureStarted = performance.now();
await page.screenshot({ type: 'png' });
row.screenshotCaptureMs = Number((performance.now() - captureStarted).toFixed(2));
+13 -1
View File
@@ -5,6 +5,7 @@ import { dirname, resolve } from 'node:path';
import { launch } from './serve.mjs';
import { LARGE_HOUSE_COUNTS, makeLargeHouseFixture } from './fixtures/large-house.mjs';
import { assertFreshDemoBundle } from './bundle-freshness.mjs';
import { ensureHarnessEditorRuntime } from './editor-runtime-compat.mjs';
import { summarizeLongTasks, summarizeTimings } from './performance/evaluate.mjs';
import { assertCardContract, LARGE_HOUSE_CARD_CONTRACT } from './performance/card-contract.mjs';
@@ -52,6 +53,9 @@ await page.addStyleTag({
await page.addScriptTag({
content: `window.__hpAssertCardContract = ${assertCardContract.toString()};`,
});
await page.addScriptTag({
content: `window.__hpEnsureHarnessEditorRuntime = ${ensureHarnessEditorRuntime.toString()};`,
});
const chromium = await browser.version();
let buildFingerprint;
try {
@@ -187,6 +191,11 @@ try {
card._onLabsSnapshot({ active: Object.freeze(['iso']), space: '' });
}
card.hass = hassFor(fixture.states);
// launch() warms its own bootstrap card, but each measured sample creates
// a fresh card. Current lazy bundles therefore need their own controller
// construction; monolithic stable bundles remain immediate (#380).
if (!await window.__hpEnsureHarnessEditorRuntime(card))
throw new Error('large-house editor runtime did not preload');
window.__hpAssertCardContract(card, cardContract);
if (requiresIsometric && (typeof card._setProjection !== 'function'
|| !(card._isoGeometryCache instanceof Map))) {
@@ -346,7 +355,10 @@ try {
target: null,
};
card._rszEdgeDown(quietEvent, room.id, 1);
const plan = card._resize?.plan;
// #380: the previous stable owns the active session directly; current
// candidates own it through ResizeController. The explicit contract
// above requires one of these shapes before measurements begin.
const plan = card._resize?.plan || card._rszDrag?.plan;
if (!plan) throw new Error('large-house resize plan was not created');
const target = [
plan.a[0] + plan.n[0] * card._gridPitch,
+178 -18
View File
@@ -86,6 +86,15 @@ const applyDocumentationState = (page, scenario) => page.evaluate(async (current
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const card = window.__goldenCard;
if (!card) throw new Error(`documentation card is missing: ${current.id}`);
const settleCamera = async () => {
const started = performance.now();
do { await frame(); }
while (card._cameraTransition?.active && performance.now() - started < 1200);
if (card._cameraTransition?.active)
throw new Error(`documentation camera did not settle: ${current.id}`);
// One quiet pair proves that the settled reactive frame reached layout.
await frame();
};
if (current.title) {
card.setConfig({ ...card._config, title: current.title });
@@ -142,12 +151,102 @@ const applyDocumentationState = (page, scenario) => page.evaluate(async (current
throw new Error('documentation viewport does not show the device presentation preview');
}
await settleCamera();
return { dialog: !!card.renderRoot.querySelector('hp-dialog') };
}, scenario);
mkdirSync(OUTPUT, { recursive: true });
const { page, browser } = await launch();
// Растеризация закрепляется теми же тремя флагами, что у golden
// (`demo/golden/run.mjs`), и по той же причине — только golden их имел с самого
// начала, а съёмка документации запускалась без единого (#410).
//
// Измерено на dev SHA 184e0098: два прогона канонического workflow на одном и
// том же коммите, одном Chromium 151.0.7922.34 и одном oxipng 10.2.0 дали три
// разошедшихся кадра из десяти (06-device-editor, 08-room-card,
// 09-device-info). Дельта — единицы уровней в RGB на сглаженных границах, alpha
// не менялась: подпись субпиксельного сглаживания, а не изменения продукта.
//
// `--disable-lcd-text` убирает субпиксельное сглаживание (оно и плавало),
// `--font-render-hinting=none` снимает зависимость от хинтинга,
// `--force-color-profile=srgb` фиксирует профиль. `reducedMotion: 'reduce'`
// добавлен к `animations: 'disabled'` у самого скриншота: первое гасит анимации
// в CSS, второе — уже начатые переходы на момент съёмки.
const DETERMINISTIC_ARGS = [
'--force-color-profile=srgb', '--font-render-hinting=none', '--disable-lcd-text',
];
/**
* Режим замера стабильности (#410): `node demo/docs/capture.mjs --stability=3`.
*
* Отвечает на вопрос, который иначе решается гаданием: плавает ли кадр ВНУТРИ
* одного состояния страницы или разница копится между подготовками сценария.
* Для каждого сценария делается N снимков подряд без единой правки состояния, и
* они сравниваются попиксельно прямо в странице — тем же приёмом, что у golden
* (`createImageBitmap` + canvas), чтобы не тащить декодер PNG в зависимости.
*
* Ничего не пишет на диск и манифест не трогает: это измерение, а не съёмка.
*/
const unstable = [];
const stabilityArg = process.argv.find((arg) => arg.startsWith('--stability'));
const STABILITY_SHOTS = stabilityArg
? Math.max(2, Number(stabilityArg.split('=')[1] || 3))
: 0;
const comparePairs = (target, shots) => target.evaluate(async (base64Shots) => {
const decode = async (base64) => {
const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
return createImageBitmap(new Blob([bytes], { type: 'image/png' }));
};
const read = (bitmap) => {
const canvas = document.createElement('canvas');
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const context = canvas.getContext('2d', { willReadFrequently: true });
context.drawImage(bitmap, 0, 0);
return context.getImageData(0, 0, bitmap.width, bitmap.height).data;
};
const frames = await Promise.all(base64Shots.map(async (shot) => {
const bitmap = await decode(shot);
return { data: read(bitmap), width: bitmap.width, height: bitmap.height };
}));
const first = frames[0];
return frames.slice(1).map((frame, index) => {
if (frame.width !== first.width || frame.height !== first.height) {
return { pair: `1↔${index + 2}`, sizeMismatch: true };
}
let pixels = 0;
let maxDelta = 0;
let alphaTouched = 0;
const box = { x0: Infinity, y0: Infinity, x1: -1, y1: -1 };
for (let at = 0; at < first.data.length; at += 4) {
const dr = Math.abs(first.data[at] - frame.data[at]);
const dg = Math.abs(first.data[at + 1] - frame.data[at + 1]);
const db = Math.abs(first.data[at + 2] - frame.data[at + 2]);
const da = Math.abs(first.data[at + 3] - frame.data[at + 3]);
if (!dr && !dg && !db && !da) continue;
pixels += 1;
maxDelta = Math.max(maxDelta, dr, dg, db);
if (da) alphaTouched += 1;
const pixel = at / 4;
const x = pixel % first.width;
const y = Math.floor(pixel / first.width);
box.x0 = Math.min(box.x0, x); box.y0 = Math.min(box.y0, y);
box.x1 = Math.max(box.x1, x); box.y1 = Math.max(box.y1, y);
}
return {
pair: `1↔${index + 2}`,
size: [first.width, first.height],
pixels,
maxDelta,
alphaTouched,
box: pixels ? box : null,
};
});
}, shots.map((shot) => shot.toString('base64')));
const { page, browser } = await launch(
undefined, undefined, DETERMINISTIC_ARGS, { reducedMotion: 'reduce' },
);
const browserErrors = [];
page.on('pageerror', (error) => browserErrors.push(error.message));
@@ -164,12 +263,63 @@ try {
const runtime = await applyDocumentationState(page, scenario);
if (scenario.expectDialog && !runtime.dialog)
throw new Error(`documentation scenario did not open its dialog: ${scenario.id}`);
const clip = scenario.capture === 'room-card'
const rawClip = scenario.capture === 'room-card'
? await roomCardClip(page)
: await goldenClip(page, scenario.capture);
const image = await page.screenshot({
// Обрезка выравнивается по целым пикселям (#410).
//
// Прямоугольник считается из живого DOM через getBoundingClientRect, то есть
// приходит дробным. Дробная обрезка заставляет Chromium ресемплить кадр, и
// тогда сдвиг раскладки на десятую пикселя — от метрики шрифта, от
// появившегося скроллбара — переписывает границы всех элементов на единицы
// уровней. Ровно эта подпись и была в #410: 76 пикселей, максимум 2 уровня,
// alpha не тронута, всё на сглаженных границах полей.
//
// Замер показал, где искать: три снимка подряд в одном состоянии страницы
// совпадают побайтово у всех десяти сценариев, а между прогонами плавают
// два. Значит дело не в рендере, а в том, что приходит на вход съёмке.
//
// Внешняя рамка расширяется, а не округляется к ближайшему: обрезка обязана
// содержать цель целиком, потерять полпикселя по краю нельзя.
const clip = rawClip && {
x: Math.floor(rawClip.x),
y: Math.floor(rawClip.y),
width: Math.ceil(rawClip.x + rawClip.width) - Math.floor(rawClip.x),
height: Math.ceil(rawClip.y + rawClip.height) - Math.floor(rawClip.y),
};
if (STABILITY_SHOTS && rawClip) {
console.log(`${scenario.id} обрезка: сырая`
+ ` ${rawClip.x},${rawClip.y} ${rawClip.width}x${rawClip.height}`
+ ` → целая ${clip.x},${clip.y} ${clip.width}x${clip.height}`);
}
// Два кадра ожидания перед съёмкой — как в golden. `animations: 'disabled'`
// гасит анимации, но не гарантирует, что уже запланированный ре-рендер
// успел лечь в композитор до захвата.
await page.evaluate(() => new Promise((done) => {
requestAnimationFrame(() => requestAnimationFrame(done));
}));
const shotOptions = {
...(clip ? { clip } : {}), animations: 'disabled', caret: 'hide', scale: 'css',
});
};
if (STABILITY_SHOTS) {
const shots = [];
for (let attempt = 0; attempt < STABILITY_SHOTS; attempt += 1) {
await page.evaluate(() => new Promise((done) => {
requestAnimationFrame(() => requestAnimationFrame(done));
}));
shots.push(await page.screenshot(shotOptions));
}
for (const result of await comparePairs(page, shots)) {
console.log(`${scenario.id} ${result.pair}: пикселей ${result.pixels}`
+ `, максимум ${result.maxDelta}, alpha ${result.alphaTouched}`
+ (result.box ? `, bbox ${result.box.x0},${result.box.y0}`
+ `..${result.box.x1},${result.box.y1}` : '')
+ `, кадр ${result.size ? result.size.join('x') : '?'}`);
if (result.pixels || result.sizeMismatch) unstable.push(`${scenario.id} ${result.pair}`);
}
continue;
}
const image = await page.screenshot(shotOptions);
const imagePath = resolve(OUTPUT, scenario.file);
writeFileSync(imagePath, image);
// Хеш считается ПОСЛЕ перепаковки: манифест обязан описывать те байты,
@@ -186,20 +336,30 @@ try {
console.log(`captured ${scenario.id} -> docs/images/${scenario.file}`);
}
if (browserErrors.length) throw new Error(`browser errors: ${browserErrors.join(' | ')}`);
const manifest = {
version: DOC_SCREENSHOT_VERSION,
fixture: 'synthetic-only',
// Кто снимал. Смена браузера переписывает все картинки без содержательных
// изменений (#246), поэтому окружение съёмки — часть доказательства.
chromium: browser.version(),
// Чем жали — тоже часть доказательства: без инструмента байты другие.
oxipng: oxipngVersion,
sourceFingerprint: fingerprint,
captureScriptSha256: sha256(readFileSync(SCRIPT)),
command: 'npm run build && node demo/docs/capture.mjs',
scenarios,
};
writeFileSync(resolve(OUTPUT, 'screenshots.json'), `${JSON.stringify(manifest, null, 2)}\n`);
if (STABILITY_SHOTS) {
console.log(`\nзамер стабильности: ${STABILITY_SHOTS} снимка на сценарий, манифест не тронут`);
if (unstable.length) {
// Режим — не только диагностика, но и гейт: если кадр снова начнёт зависеть
// от времени, это должно останавливать съёмку, а не печататься в лог.
throw new Error(`кадр плавает внутри одного состояния страницы: ${unstable.join(', ')}`);
}
console.log('кадр не плавает ни в одном сценарии');
} else {
const manifest = {
version: DOC_SCREENSHOT_VERSION,
fixture: 'synthetic-only',
// Кто снимал. Смена браузера переписывает все картинки без содержательных
// изменений (#246), поэтому окружение съёмки — часть доказательства.
chromium: browser.version(),
// Чем жали — тоже часть доказательства: без инструмента байты другие.
oxipng: oxipngVersion,
sourceFingerprint: fingerprint,
captureScriptSha256: sha256(readFileSync(SCRIPT)),
command: 'npm run build && node demo/docs/capture.mjs',
scenarios,
};
writeFileSync(resolve(OUTPUT, 'screenshots.json'), `${JSON.stringify(manifest, null, 2)}\n`);
}
} finally {
await browser.close();
}
+15
View File
@@ -0,0 +1,15 @@
/**
* Prepare editor internals for demo harnesses across comparison baselines.
*
* Since #337 current builds keep the editor in a lazy runtime and expose an
* explicit preload method. Older stable builds are monolithic: absence of the
* method means there is nothing to preload, not that the baseline is broken.
* A present method remains fail-closed through its result or exception.
*/
export async function ensureHarnessEditorRuntime(
card = globalThis.window?.__card,
) {
const ensure = card?._ensureEditorRuntime;
if (typeof ensure !== 'function') return true;
return Boolean(await ensure.call(card));
}
+3
View File
@@ -63,6 +63,9 @@ const previous = existsSync(manifestPath)
// байт-в-байт, доказывают, что среда съёмки та же, что у принятого эталона.
const witnessCheck = goldenWitnessRefusal({
results: report.results,
// #408: от размера матрицы, а не от числа уцелевших эталонов — иначе порог
// обходится удалением каталога эталонов.
sceneCount: GOLDEN_SCENARIOS.length,
declared,
declaredNew,
previousHashes: previous,
+20 -15
View File
@@ -1,11 +1,11 @@
{
"schema": 1,
"matrixVersion": 50,
"acceptedAt": "2026-08-29T08:39:24.783Z",
"sourceFingerprint": "e45ac741d448e089239176cab2ff317351e9b80db7d2f7434e7b7fd34c0c8e22",
"matrixVersion": 53,
"acceptedAt": "2026-08-30T15:29:10.420Z",
"sourceFingerprint": "099688ecae2a6f015325a6ffa40c7112bd2726309f7760aec7d6b2534ad2f347",
"chromium": "151.0.7922.34",
"witnesses": {
"count": 123,
"count": 96,
"floor": 10
},
"scenarios": {
@@ -65,14 +65,14 @@
"opening-symbol-diagonal-partition-dark": "620102c00e77613cb341c906829bfcc7e9a5430075aa576f563adafa8bdd8f08",
"opening-symbol-flip-pairs-light": "6d780d1d06909e6e5a7ebb35987b9681d6902d78408e974dfe2fe587521990ce",
"isometric-opening-symbol-parity-dark": "1e254ed43da00a16d3048e9a2d29db0a43c7790991b1006ecb7205d002bd1f63",
"geometry-devices-editor-dark": "acc779a2a21d5c2a26f40d1a23495cafaffc460c57c1100660fb3cde554303f1",
"device-inbox-desktop-en-light": "c04254626f613b11e1b9165abf6bfad4b04a2670acab2887e38062d7d16b4533",
"device-inbox-desktop-ru-dark": "2e7e7175ed770fea657828beda68b62d6eb657a4b72222b1977df3d7d692b0d6",
"device-inbox-narrow-ru-dark": "5263f518d4a415caedbb7917c79f9acbb03de234feec11e3128c235d2bd5b39f",
"geometry-devices-editor-dark": "a9a9d22b37e03fde37120889a4d9aa50b9d318a31e416504f74e2905b5d947ff",
"device-inbox-desktop-en-light": "23fef91974a18f12a71858e67ce981e7b0d34892883cb1c2884cd2695a38bcfc",
"device-inbox-desktop-ru-dark": "95a4c0b93a6150b145b967b55344727287843be09bc77b25b23fb0fdaf911142",
"device-inbox-narrow-ru-dark": "061031091fd29a3be0af435e84f1e3fe7a4e2a485e7fefc617aa24b5ec1cdb39",
"geometry-decor-editor-dark": "4a4d40844f4f92b0e1baf169a25f8a15a507682cea677c631dc57f68e31e373f",
"tray-wide-selection-en": "056f3b54d60bba83ef313f9c12fe697ea6a0b3d48db0470735394ee0d3f72c39",
"tray-wide-tool-ru": "f8e050c96cb3e78f1ab8d0b8b276b3d154950e795c5b620148b9e395330cffe9",
"tray-medium-group-en": "4513abc13f1bc24e4d04f14445069c8ce9785dc3fddcdc99bcee41b5eadb8d53",
"tray-medium-group-en": "9b74b675760be423e410176c72b2aa413c19ba7411ddd2d2d0324cf5ba2e1dd6",
"tray-medium-selection-ru": "44de98a53d7de665daf2a3f466c281caa66ef6d6cd2941125e2cbfd975f8c8f4",
"tray-narrow-palette-en": "392eeac327c899add9792e356d75f48ae541c38bd1c2ace6bdde8e9dcdf17fd6",
"furniture-categories-light": "13ab156ce69ff67f82b0aaa1e755d7b2ed63bda2a06f248f56d713c0d88e6fb7",
@@ -80,6 +80,8 @@
"furniture-variants-light": "a2f4742c392ff73b0b68ee91970a81c4550295180c51f4b9b3d3f19fc58cf2a4",
"furniture-placement-preview-light": "282ecb96c5d251323c39ccc2fd27775d8c20a15e3a7d6b86eb2bd93dab46c312",
"furniture-plan-art-dark": "d12db5d0a3d99caed4228ba04983f762794909efd366b999e240eba310724110",
"furniture-transform-light": "1fbcae3d547412ecda2404428c8cc1668c0a0c66c3196dee72aba67268aed611",
"furniture-transform-dark": "9fd5e084bbcf72880cd796e0869cd8e48bef9f5beb099d82a8663a3344a1dfa2",
"tray-narrow-tool-ru": "3d002d2a153fe615e8dd7c9fca466525477318992d4bd4bb099dbfb3d9195c4a",
"geometry-diagonal-45-opening-dark": "ba9462322da34b20fff1ee1f950c516855dd7ac56131e13863f576e2d0ba5177",
"openings-thick-wall-dark": "539cdb9cb1460cd42c2e51901ea50f496e8214d02633e110e2780c3f177154db",
@@ -89,6 +91,7 @@
"openings-hidden-view-dark": "19ab4fd2ffce0fea0fe609b007a3e60f7abd84b40935cbf3c5fa9dc1fda21046",
"lighting-glow-sun-dark": "08e2964df6fd03abad4b9a81f1dbd0fe3812fb6b5f7908aeb027ad2953b2f88c",
"device-value-badge-positions-dark": "75ab0210d697f952b8a8977649e84308de3ce3b59c84ec93071179a1d6cb015d",
"device-value-face-cover-source-dark": "c48fa33e0a85164ee64bfce6dbc4127c96b4a1c065e4604310f1b16be144639d",
"device-icon-state-table-light": "85c785824ccafc2d5f50e834ad2d746319d64ecb292d1b9cf7fab8661d5d410d",
"device-icon-state-table-dark": "03a90523132c3dbe6c78d44d01fe89df93e2d0474f91ef84e7107e6e4bc7d337",
"device-text-shell-long-light": "97a113b5164f373b4af2922c8e71bc1f063d8c2761b72c3182697fcb1aac3cab",
@@ -131,17 +134,19 @@
"large-house-zoom-250-dark": "0a74099e265b38f376bb7bfd86d4a57c5d493ac3870478a7d55c48cf99e8f892",
"large-house-warm-remount-dark": "1c14dba8cdbc4628984091d395c1ac14faead1d43338e44aa3cb57976e519df6",
"device-dialog-desktop-en": "e142521eae1bd40861c1d92eadbcfd6d88aefa689cd5ee9b4c1b6378c8ce712c",
"device-dialog-mobile-ru": "7921b7d5e2c082fc4dd5053607a8228c2e63e0e72a401d86153effd54c2c6ca0",
"device-dialog-desktop-de": "ac3f657e072006f5c1686d89da61df922991a04f69d2d7994a03d1ba93532681",
"device-dialog-mobile-ru": "bde342feb5667a76884c081a4d9dd9bbacbbf7158f48f1c68322408bdde3d053",
"device-dialog-desktop-de": "ef620a0b5d14f24d0d5021bcd720fe1531c84832442cf0f7964397450ed8ebc3",
"german-view-mobile-light": "9496071b11f510f1c851a9c60583a96bfe768c1fd1e12a2b1b9183dee7e453a8",
"toggle-entity-dialog-desktop-en": "f4924466de5e134c2d1f2456ff0c05f8aa8106f6b06398e87a263e3c536f16ea",
"toggle-entity-dialog-mobile-ru": "77e8fc03817a784888993490cdfba675482b8c56483199c1efa512db8531516b",
"toggle-entity-dialog-desktop-en": "e192bab83ae99bfb8d33046921f0a3009ebaa8074bd30971ab41a1eeac69ea0d",
"toggle-entity-dialog-mobile-ru": "5143ad6846bc605dc52a97abe1b6593596fb25bb80a121f3c6279f1b7f421fee",
"device-help-popover-light-ru": "259a50065b7f09d07db656b6cfe79d1de34397665ff8dd8a8e361f6f29a7ea6a",
"decor-color-popover-mobile-ru": "606c41d53f9a996b24990032505180159ce032e2b4f14dfb214c79a8c4b85702",
"decor-color-popover-desktop-en": "f626f246c8e29d1b1e5e067e0923438eb1222606388a33c3071e86307b4ca130",
"general-color-popover-desktop-en": "008865918113e05847a7ad8ae0fdc068b5326acf1c591d855806b333084978a3",
"device-ripple-color-popover-mobile-ru": "4f2365818f46fe1e2b401e5835f5d29ea9c41b288d894025d7512db92f579762",
"space-room-color-popover-desktop-ru": "946c550232ff3819bb44058eff6c5deadfccf85527c23065137ee02fa457aed9",
"settings-help-zoom-200-en-light": "bb1192a52bab5ebc6984a5d799a32d05f9ce374ce6b493f5f618b639eedce8d5",
"settings-help-zoom-200-ru-dark": "13e7fdd19017c38a5659394e622ff54ac8a426f9cb6e85b3248864767aa9f42b",
"device-ripple-color-popover-mobile-ru": "01c538f8b504ab9f5e8d64cb4cbbf3131c5de9b0909c996e2d61f4523801127c",
"space-room-color-popover-desktop-ru": "ff0e633c7fbedfa04883792682d8e407aa0633bbd46ed12e0ab8bc2207be9ccb",
"backup-full-preview-desktop-en": "6b68188269fdbaddaf4f2ce8fc0d10ea41e050f0c08cdbd9507548871a3786ef",
"backup-plan-only-export-desktop-en": "805fb7cd15959a7eee0b17150c4417e335f1b1030a438f7051c2b006a5f7d4f4",
"backup-space-preview-mobile-ru": "82cd93cb495ad77b92d635b4a3715601b1ae0e5e2974edcbbd461fe872c75bad",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 372 KiB

After

Width:  |  Height:  |  Size: 373 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 268 KiB

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 277 KiB

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 331 KiB

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 200 KiB

+118 -4
View File
@@ -32,6 +32,9 @@ const spanOverDoorFixture = JSON.parse(readFileSync(
const wallUnionIsolationFixture = JSON.parse(readFileSync(
new URL('../../test/fixtures/278-wall-union-isolation.json', import.meta.url), 'utf8',
));
const cardVersion = JSON.parse(readFileSync(
new URL('../../package.json', import.meta.url), 'utf8',
)).version;
const fixtureFor = (scenario) => scenario.fixture === 'large'
? makeLargeHouseFixture()
@@ -672,6 +675,12 @@ export function prepareGoldenFixture(scenario) {
...structuredClone(scenario.markerOverrides),
];
}
if (scenario.markerAreaSnapshot) {
fixture.config.settings = {
...(fixture.config.settings || {}),
marker_area_snapshot: structuredClone(scenario.markerAreaSnapshot),
};
}
if (scenario.layoutOverrides) {
const missing = Object.keys(scenario.layoutOverrides).filter((id) => !(id in (fixture.layout || {})));
if (missing.length) throw new Error(`golden layoutOverrides reference missing item(s): ${missing.join(', ')}`);
@@ -727,7 +736,7 @@ export async function prepareGoldenScenario(page, scenario) {
await page.mouse.move(0, 0);
const fixture = prepareGoldenFixture(scenario);
const result = await page.evaluate(async ({ fixture, scenario }) => {
const result = await page.evaluate(async ({ fixture, scenario, cardVersion }) => {
const wait = (ms) => new Promise((done) => setTimeout(done, ms));
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const until = async (predicate, timeout = 10000) => {
@@ -742,6 +751,11 @@ export async function prepareGoldenScenario(page, scenario) {
await card.updateComplete;
await frame();
};
const settleCamera = async (card) => {
await until(() => !card._cameraTransition?.active, 1500);
await card.updateComplete;
await frame();
};
window.__goldenCard?.remove?.();
window.__goldenEditor?.remove?.();
window.__card?.remove?.();
@@ -1203,6 +1217,20 @@ export async function prepareGoldenScenario(page, scenario) {
+ `cursor=${JSON.stringify(card._cursorPt)} nearest=${JSON.stringify(nearest)}`);
}
}
if (scenario.decorSelection) {
card._decorTool = 'select';
card._decorSel = scenario.decorSelection;
card.requestUpdate();
await card.updateComplete;
await frame();
const selected = card._decorList.find((shape) => shape.id === scenario.decorSelection);
const transformFrame = card.renderRoot.querySelector('.dtframe.dtfurnitureframe');
if (selected?.kind !== 'furniture' || !transformFrame
|| transformFrame.querySelectorAll('.dthandle.dtedge').length !== 4
|| transformFrame.querySelectorAll('.dthandle').length !== 9) {
throw new Error(`golden furniture transform frame is incomplete: ${scenario.id}`);
}
}
if (scenario.editorTray) {
let expectedKind = '';
if (scenario.editorTray === 'plan-selection') {
@@ -1342,7 +1370,14 @@ export async function prepareGoldenScenario(page, scenario) {
total: '1', rooms: '0', devices: '1', groups: '0',
}))
|| !body.textContent.includes('unresolved-floor')) {
throw new Error('golden orphan-reference Optimize dialog is incomplete');
throw new Error('golden orphan-reference Optimize dialog is incomplete: '
+ JSON.stringify({
markersDetached: report?.markersDetached,
orphanRoomLabelsRemoved: report?.orphanRoomLabelsRemoved,
orphanDevicePositionsRemoved: report?.orphanDevicePositionsRemoved,
liveMissingPositions: report?.liveMissingPositions,
body: body?.textContent,
}));
}
} else if (scenario.dialog === 'optimize-preflight') {
const names = [
@@ -1542,6 +1577,83 @@ export async function prepareGoldenScenario(page, scenario) {
await frame();
trigger.click();
await picker.updateComplete;
} else if (scenario.dialog === 'general-help') {
card._openSettingsDialog();
await card.updateComplete;
const dialog = card.renderRoot.querySelector('hp-dialog');
const help = dialog?.querySelector(`hp-help[data-help-key="${scenario.openHelp}"]`);
await help?.updateComplete;
const trigger = help?.renderRoot?.querySelector('.trigger');
if (!dialog || !trigger)
throw new Error(`golden general-settings help trigger missing: ${scenario.openHelp}`);
trigger.scrollIntoView({ block: 'center', inline: 'nearest' });
await frame();
trigger.click();
await help.updateComplete;
await frame();
const surface = help.renderRoot?.querySelector('.tooltip:popover-open')
|| dialog.renderRoot?.querySelector('[data-hp-overlay="help"]')
?.shadowRoot?.querySelector('.tooltip');
const viewport = { width: innerWidth, height: innerHeight };
const triggerRect = trigger.getBoundingClientRect();
const surfaceRect = surface?.getBoundingClientRect();
const inside = (rect) => !!rect && rect.left >= -1 && rect.top >= -1
&& rect.right <= viewport.width + 1 && rect.bottom <= viewport.height + 1;
if (trigger.getAttribute('aria-expanded') !== 'true' || !inside(triggerRect)
|| !inside(surfaceRect) || dialog.scrollWidth > dialog.clientWidth + 1)
throw new Error(`golden general-settings help is clipped: ${scenario.openHelp}`);
} else if (scenario.dialog === 'support') {
card._haIntegrationVersion = cardVersion;
card._openSupportDialog();
await card.updateComplete;
const opened = card._supportDialog;
const text = '{"format":"houseplan-support-package","version":1}\n';
const preview = {
token: 'a'.repeat(48),
expiresAt: Date.now() + 300_000,
size: new TextEncoder().encode(text).byteLength,
sha256: 'b'.repeat(64),
spaces: 2,
format: 'houseplan-support-package',
version: 1,
text,
preparedAt: Date.now() - 60_000,
};
const patch = scenario.supportState === 'empty' ? {}
: scenario.supportState === 'preview' ? {
contact: 'user@example.test', message: 'The room light is not rendered.',
attach: true, status: 'ready', preview,
} : scenario.supportState === 'validation' ? {
status: 'error', errorCode: 'validation.message_required',
} : scenario.supportState === 'success' ? {
contact: 'user@example.test', message: 'The room light is not rendered.',
status: 'success', reportId: 'HP-43-GOLDEN',
} : scenario.supportState === 'relay-error' ? {
contact: '@houseplan-user', message: 'The report relay timed out.',
attach: true, status: 'error', preview, errorCode: 'support_rate_limited',
} : null;
if (!opened || !patch) {
throw new Error(`invalid golden support state: ${scenario.id}`);
}
card._supportDialog = { ...opened, ...patch };
card.requestUpdate();
await card.updateComplete;
await frame();
const dialog = card.renderRoot.querySelector('#support-dialog');
const body = dialog?.querySelector('.supportbody');
const expected = scenario.supportState === 'preview' ? '.supportpreview'
: scenario.supportState === 'validation' || scenario.supportState === 'relay-error'
? '#support-error'
: scenario.supportState === 'success' ? '#support-receipt' : '.supportform';
const focus = dialog?.querySelector(expected);
focus?.scrollIntoView({ block: 'center', inline: 'nearest' });
await frame();
if (!dialog || !body || !focus || body.scrollWidth > body.clientWidth + 1
|| dialog.scrollWidth > dialog.clientWidth + 1
|| !dialog.querySelector('.aboutver')
|| dialog.querySelectorAll('a.aboutlink').length < 3) {
throw new Error(`golden support dialog is incomplete or clipped: ${scenario.id}`);
}
} else if (scenario.dialog === 'device-ripple-color') {
card._setMode('devices');
await card.updateComplete;
@@ -1672,7 +1784,9 @@ export async function prepareGoldenScenario(page, scenario) {
}
}
await document.fonts?.ready;
await frame();
// Camera motion is intentionally visible in production, but reviewed
// goldens own the settled UI. Never capture a timing-dependent RAF frame.
await settleCamera(card);
return {
space: card._space,
mode: card._mode,
@@ -1694,7 +1808,7 @@ export async function prepareGoldenScenario(page, scenario) {
cachedRays: card._sunRaysCache?.rays?.length || 0,
} } : {}),
};
}, { fixture, scenario });
}, { fixture, scenario, cardVersion });
if (scenario.tabDrag) {
const drag = await page.evaluate((placement) => {
const card = window.__goldenCard;
+121 -1
View File
@@ -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 = 50;
export const GOLDEN_MATRIX_VERSION = 54;
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
@@ -432,6 +432,37 @@ export const GOLDEN_SCENARIOS = Object.freeze([
w: 0.26, h: 0.17, color: '#141832', opacity: 1, width_cm: 1.2 },
],
language: 'en', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'furniture-transform-light', fixture: 'visual', space: 'golden-geometry', mode: 'decor',
decorOverride: [
...decorLayerFixture.filter((shape) => shape.kind !== 'furniture'),
{ id: 'furn-normal', kind: 'furniture', symbol: 'sofa_corner_right', x: 0.08, y: 0.16,
w: 0.18, h: 0.12, color: '#141832', opacity: 1, width_cm: 1.2 },
{ id: 'furn-flip-h', kind: 'furniture', symbol: 'sofa_corner_right', x: 0.30, y: 0.16,
w: 0.18, h: 0.12, flip_h: true, color: '#141832', opacity: 1, width_cm: 1.2 },
{ id: 'furn-flip-v', kind: 'furniture', symbol: 'sofa_corner_right', x: 0.52, y: 0.16,
w: 0.18, h: 0.12, flip_v: true, color: '#141832', opacity: 1, width_cm: 1.2 },
{ id: 'furn-flip-hv', kind: 'furniture', symbol: 'sofa_corner_right', x: 0.74, y: 0.16,
w: 0.18, h: 0.12, flip_h: true, flip_v: true,
color: '#141832', opacity: 1, width_cm: 1.2 },
],
decorSelection: 'furn-flip-hv', language: 'ru', theme: 'light',
viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'furniture-transform-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
decorOverride: [
...decorLayerFixture.filter((shape) => shape.kind !== 'furniture'),
{ id: 'furn-normal', kind: 'furniture', symbol: 'sofa_corner_right', x: 0.08, y: 0.16,
w: 0.18, h: 0.12, angle: 30, color: '#d8def0', opacity: 1, width_cm: 1.2 },
{ id: 'furn-flip-h', kind: 'furniture', symbol: 'sofa_corner_right', x: 0.30, y: 0.16,
w: 0.18, h: 0.12, angle: 30, flip_h: true,
color: '#d8def0', opacity: 1, width_cm: 1.2 },
{ id: 'furn-flip-v', kind: 'furniture', symbol: 'sofa_corner_right', x: 0.52, y: 0.16,
w: 0.18, h: 0.12, angle: 30, flip_v: true,
color: '#d8def0', opacity: 1, width_cm: 1.2 },
{ id: 'furn-flip-hv', kind: 'furniture', symbol: 'sofa_corner_right', x: 0.74, y: 0.16,
w: 0.18, h: 0.12, angle: 30, flip_h: true, flip_v: true,
color: '#d8def0', opacity: 1, width_cm: 1.2 },
],
language: 'en', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'tray-narrow-tool-ru', fixture: 'visual', space: 'golden-geometry', mode: 'decor',
editorTray: 'decor-tool', language: 'ru', theme: 'dark',
viewport: { width: 390, height: 760 }, ...page },
@@ -493,6 +524,25 @@ export const GOLDEN_SCENARIOS = Object.freeze([
'golden-climate': { s: 'golden-lighting', x: 0.80, y: 0.28 },
},
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'device-value-face-cover-source-dark', fixture: 'visual',
space: 'golden-lighting', mode: 'view', glowEnabled: false, sunRays: false,
showNames: false,
markerOverrides: [{
id: 'golden-left-linkquality', binding: 'entity:cover.golden_door_half',
display: 'value', size: 3,
value_source: {
kind: 'entity_attribute', entity_id: 'cover.golden_door_half',
attribute: 'current_position',
},
}],
stateOverrides: {
'cover.golden_door_half': { attributes: { current_position: 42 } },
},
layoutOverrides: {
'golden-left-linkquality': { s: 'golden-lighting', x: 0.50, y: 0.50 },
},
deviceOnly: 'golden-left-linkquality',
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
...['light', 'dark'].map((theme) => ({
id: `device-icon-state-table-${theme}`, fixture: 'visual', space: 'golden-lighting', mode: 'view',
glowEnabled: false, sunRays: false,
@@ -516,6 +566,30 @@ export const GOLDEN_SCENARIOS = Object.freeze([
position: 'bottom',
} },
],
// This scenario deliberately places two devices outside their HA rooms to
// exercise icon states at fixed coordinates. Seed the Area-following
// provenance so #126 does not reinterpret that test-only layout as stale
// legacy placement and turn an unrelated visual matrix into a relocation
// scenario.
markerAreaSnapshot: {
'golden-light-one': { binding: 'device:golden-light-one', area: 'golden_light_left' },
'golden-light-two': { binding: 'device:golden-light-two', area: 'golden_light_left' },
'golden-light-three': { binding: 'device:golden-light-three', area: 'golden_light_right' },
'golden-presence': { binding: 'device:golden-presence', area: 'golden_light_right' },
'golden-climate': { binding: 'device:golden-climate', area: 'golden_light_right' },
'golden-left-temperature': {
binding: 'device:golden-left-temperature', area: 'golden_light_left',
},
'golden-right-temperature': {
binding: 'device:golden-right-temperature', area: 'golden_light_right',
},
'golden-left-linkquality': {
binding: 'device:golden-left-linkquality', area: 'golden_light_left',
},
'golden-right-linkquality': {
binding: 'device:golden-right-linkquality', area: 'golden_light_right',
},
},
stateOverrides: {
'light.golden_light_one': { attributes: { lqi: 40 } },
'light.golden_light_two': { attributes: { lqi: 41 } },
@@ -739,6 +813,38 @@ export const GOLDEN_SCENARIOS = Object.freeze([
{ id: 'general-color-popover-desktop-en', fixture: 'visual', space: 'golden-geometry',
dialog: 'general-color', language: 'en', theme: 'light',
viewport: { width: 900, height: 900 }, ...page },
// #86 AC7: renderer-level 200% browser zoom is 390 CSS px at DPR 2
// (780 physical px). Keep both themes reviewed because help placement and
// contrast are part of the visual contract, not just a DOM assertion.
{ id: 'settings-help-zoom-200-en-light', fixture: 'visual', space: 'golden-geometry',
dialog: 'general-help', openHelp: 'gs.bg_mode.help', deviceScaleFactor: 2,
helpTextRegion: { key: 'gs.bg_mode.help', minPixels: 30 },
language: 'en', theme: 'light', viewport: { width: 390, height: 900 }, ...page },
{ id: 'settings-help-zoom-200-ru-dark', fixture: 'visual', space: 'golden-geometry',
dialog: 'general-help', openHelp: 'gs.bg_mode.help', deviceScaleFactor: 2,
helpTextRegion: { key: 'gs.bg_mode.help', minPixels: 30 },
language: 'ru', theme: 'dark', viewport: { width: 390, height: 900 }, ...page },
// #43 AC14: the support dialog owns reviewed desktop, phone and tablet
// states. The six scenes deliberately cover both themes while keeping the
// individual states named in the specification independently reviewable.
{ id: 'support-desktop-empty-light-en', fixture: 'visual', space: 'golden-geometry',
dialog: 'support', supportState: 'empty', language: 'en', theme: 'light',
viewport: { width: 1000, height: 980 }, ...page },
{ id: 'support-desktop-preview-dark-en', fixture: 'visual', space: 'golden-geometry',
dialog: 'support', supportState: 'preview', language: 'en', theme: 'dark',
viewport: { width: 1000, height: 980 }, ...page },
{ id: 'support-phone-validation-light-ru', fixture: 'visual', space: 'golden-geometry',
dialog: 'support', supportState: 'validation', language: 'ru', theme: 'light',
viewport: { width: 320, height: 760 }, ...page },
{ id: 'support-phone-success-dark-en', fixture: 'visual', space: 'golden-geometry',
dialog: 'support', supportState: 'success', language: 'en', theme: 'dark',
viewport: { width: 390, height: 820 }, ...page },
{ id: 'support-relay-error-light-en', fixture: 'visual', space: 'golden-geometry',
dialog: 'support', supportState: 'relay-error', language: 'en', theme: 'light',
viewport: { width: 900, height: 900 }, ...page },
{ id: 'support-tablet-preview-dark-ru', fixture: 'visual', space: 'golden-geometry',
dialog: 'support', supportState: 'preview', language: 'ru', theme: 'dark',
viewport: { width: 768, height: 900 }, ...page },
{ id: 'device-ripple-color-popover-mobile-ru', fixture: 'visual', space: 'golden-lighting',
dialog: 'device-ripple-color', deviceId: 'golden-light-two',
language: 'ru', theme: 'dark', viewport: { width: 390, height: 1000 }, ...page },
@@ -771,6 +877,15 @@ export const GOLDEN_SCENARIOS = Object.freeze([
id: 'golden-presence', binding: 'virtual', removed: true,
space: 'removed-floor', name: 'Retired marker',
}],
// The orphan-reference fixture intentionally reuses ids of live registry
// devices. Pin their pre-#126 provenance so the lifecycle synchronizer
// cannot consume the deliberately orphaned positions before Optimize
// inspects them.
markerAreaSnapshot: {
'golden-light-one': { binding: 'device:golden-light-one', area: 'golden_light_left' },
'golden-light-two': { binding: 'device:golden-light-two', area: 'golden_light_left' },
'golden-presence': { binding: 'device:golden-presence', area: 'golden_light_right' },
},
layoutOverrides: {
'golden-light-two': { s: 'removed-floor', x: 0.35, y: 0.72 },
'golden-light-one': { s: 'unresolved-floor', x: 0.20, y: 0.34 },
@@ -788,6 +903,11 @@ export const GOLDEN_SCENARIOS = Object.freeze([
id: 'golden-presence', binding: 'virtual', removed: true,
space: 'removed-floor', name: 'Старый маркер',
}],
markerAreaSnapshot: {
'golden-light-one': { binding: 'device:golden-light-one', area: 'golden_light_left' },
'golden-light-two': { binding: 'device:golden-light-two', area: 'golden_light_left' },
'golden-presence': { binding: 'device:golden-presence', area: 'golden_light_right' },
},
layoutOverrides: {
'golden-light-two': { s: 'removed-floor', x: 0.35, y: 0.72 },
'golden-light-one': { s: 'unresolved-floor', x: 0.20, y: 0.34 },
+30 -7
View File
@@ -605,7 +605,7 @@ if (existsSync(baselineManifestPath)) {
const browserArgs = ['--force-color-profile=srgb', '--font-render-hinting=none', '--disable-lcd-text'];
const browserContext = { locale: 'en-US', timezoneId: 'UTC', colorScheme: 'dark', reducedMotion: 'reduce' };
const { page, browser } = await launch(
const { page: primaryPage, browser } = await launch(
{ width: 1000, height: 900 },
1,
browserArgs,
@@ -613,12 +613,30 @@ const { page, browser } = await launch(
);
const chromium = await browser.version();
const results = [];
const pageErrors = [];
page.on('pageerror', (error) => pageErrors.push(error.message));
const pagesByScale = new Map([[1, primaryPage]]);
const extraBrowsers = [];
for (const scale of new Set(scenarios.map((scenario) => scenario.deviceScaleFactor || 1))) {
if (scale === 1) continue;
const scaled = await launch(
{ width: 1000, height: 900 }, scale, browserArgs, browserContext,
);
pagesByScale.set(scale, scaled.page);
extraBrowsers.push(scaled.browser);
}
const pageErrors = new Map();
for (const page of pagesByScale.values()) {
const errors = [];
pageErrors.set(page, errors);
page.on('pageerror', (error) => errors.push(error.message));
}
let buildFingerprint = null;
try {
buildFingerprint = await assertFreshDemoBundle(page, ROOT);
buildFingerprint = await assertFreshDemoBundle(primaryPage, ROOT);
for (const scenario of scenarios) {
const expectedScale = scenario.deviceScaleFactor || 1;
const page = pagesByScale.get(expectedScale);
if (!page) throw new Error(`golden renderer scale is unavailable: ${expectedScale}`);
const activePageErrors = pageErrors.get(page);
const result = {
id: scenario.id,
threshold: scenario.threshold,
@@ -626,9 +644,14 @@ try {
actualSha256: null,
};
try {
pageErrors.length = 0;
activePageErrors.length = 0;
result.runtime = await prepareGoldenScenario(page, scenario);
if (pageErrors.length) throw new Error(`browser exception: ${pageErrors.join(' | ')}`);
const actualScale = await page.evaluate(() => devicePixelRatio);
result.deviceScaleFactor = actualScale;
if (Math.abs(actualScale - expectedScale) > 0.001)
throw new Error(`golden renderer scale mismatch: ${actualScale} != ${expectedScale}`);
if (activePageErrors.length)
throw new Error(`browser exception: ${activePageErrors.join(' | ')}`);
if (scenario.openingSymbolContract) {
result.openingSymbolContract = await assertOpeningSymbolContract(
page, scenario.openingSymbolContract,
@@ -851,7 +874,7 @@ try {
console.log(`${result.status.padEnd(17)} ${scenario.id}`);
}
} finally {
await browser.close();
await Promise.all([...extraBrowsers.map((item) => item.close()), browser.close()]);
}
const expectedScenarioIds = GOLDEN_SCENARIOS.map((scenario) => scenario.id);
+22
View File
@@ -0,0 +1,22 @@
/**
* Build the exact snapshot consumed by the static-card half of the grid-scale
* smoke. The main and static cards normally load through different stores; a
* comparative fixture must therefore hand the static card one atomic
* config+layout pair instead of mixing the demo backend config with the main
* card's temporary layout.
*/
export function coherentGridScaleStaticPatch({ config, layout, revision }) {
if (!config || typeof config !== 'object' || !Array.isArray(config.spaces)) {
throw new Error('grid-scale static fixture requires a complete config');
}
if (!layout || typeof layout !== 'object' || Array.isArray(layout)) {
throw new Error('grid-scale static fixture requires a complete layout');
}
const token = Number.isFinite(revision) ? revision : 0;
return {
config: structuredClone(config),
configFingerprint: `grid-scale-fixture:${token}:config`,
layout: structuredClone(layout),
layoutFingerprint: `grid-scale-fixture:${token}:layout`,
};
}
+18
View File
@@ -0,0 +1,18 @@
# Пробы гарда исключений (#404)
Здесь лежат фикстуры, которые **должны падать**: каждая проверяет, что гард
«uncaught exception внутри карточки» умеет краснеть, а не только объявлен.
Три причины, по которым это отдельный каталог, а не `demo/smoke_*` и не
`demo/fixtures/`:
1. **Не `smoke_*`** — иначе шарды CI будут гонять заведомо красный файл и
покрасят себя.
2. **Не `demo/fixtures/`** — этот каталог входит в корпус `sourceFingerprint`
(`scripts/source-fingerprint.mjs`), то есть каждый новый `.mjs` там объявляет
устаревшими закоммиченный бандл, скриншот-индекс документации и golden-индекс.
Пробы гарда ни одного пикселя не касаются, платить пересъёмкой за них нечем.
3. **Каталог, а не файл** — проб три, и они читаются как набор.
Запускает их `verify-guard.mjs`; он же вызывается из job «Смоки в браузере»
и служит guard'ом двух мутантов в `scripts/mutation-gate.mjs`.
+10
View File
@@ -0,0 +1,10 @@
// Проба #404 AC8: закрытая страница не ломает вердикт.
//
// Round-trip к закрытой странице бросает, и если бы гард этого не проглатывал,
// починка одного дефекта завела бы другой — падение вердикта вместо вердикта.
// Здесь исключений нет вовсе, поэтому файл обязан завершаться НУЛЁМ.
import { launch, finish } from '../serve.mjs';
const { browser, page } = await launch();
await page.close();
await finish(browser, { probe: 'closed page' });
+14
View File
@@ -0,0 +1,14 @@
// Проба #404 AC1: исключение в самом хвосте, без обращений к странице после.
//
// Это единственная слепая зона, из-за которой заводился #404: смок всё измерил,
// печатает результат, а карточка в это время бросает. Событие `pageerror`
// приходит асинхронно, и до починки счётчик читался раньше доставки.
//
// Файл ОБЯЗАН завершаться ненулевым кодом. Ноль означает, что гард снова слеп.
import { launch, finish } from '../serve.mjs';
const { browser, page } = await launch();
await page.evaluate(() => { setTimeout(() => { throw new Error('guard-tail-exception'); }, 0); });
// Ни одного обращения к странице после этой строки: round-trip обязан сделать
// сам гард, иначе доказательство ничего не доказывает.
await finish(browser, { probe: 'tail exception' });
+8
View File
@@ -0,0 +1,8 @@
// Проба #404 AC3: необработанное отклонение промиса считается так же, как
// исключение. Связь с #405 (оброненный промис в удалении черновика) держится
// именно этим: там дефект приходит в карточку в виде rejection, а не throw.
import { launch, finish } from '../serve.mjs';
const { browser, page } = await launch();
await page.evaluate(() => { setTimeout(() => { Promise.reject(new Error('guard-tail-rejection')); }, 0); });
await finish(browser, { probe: 'tail rejection' });
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env node
/**
* Отрицательный прогон гарда исключений (#404).
*
* Гард объявлен в `demo/serve.mjs` с 2026-07-27 и до этой задачи не срабатывал
* ни разу: счётчик читался раньше, чем Playwright доставлял `pageerror`. Такое
* ловится только запуском — «проверка, которая не умеет падать» выглядит
* идентично работающей во всём, кроме исхода.
*
* Почему не тест в `test/`: пробам нужен настоящий Chromium, а job «Фронтенд»,
* где идёт `npm test`, браузеры не ставит. Тест там молча скипался бы — то есть
* ровно тот тихий успех, против которого всё это и делается. Поэтому проверка
* живёт в job «Смоки в браузере», где браузер есть, и вызывается один раз.
*/
import { spawnSync } from 'node:child_process';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
/** Каждая проба: чего ждём от кода возврата и что обязано быть в выводе. */
const PROBES = [
{
file: 'guard_tail_exception.mjs',
expectExit: 1,
expectOutput: /uncaught exception\(s\) inside the card/,
because: 'исключение в хвосте — та самая слепая зона, из-за которой заводился #404',
},
{
file: 'guard_tail_rejection.mjs',
expectExit: 1,
expectOutput: /uncaught exception\(s\) inside the card/,
because: 'отклонение промиса приходит тем же каналом; на этом держится связь с #405',
},
{
file: 'guard_closed_page.mjs',
expectExit: 0,
expectOutput: /OK/,
because: 'round-trip к закрытой странице не имеет права ронять вердикт',
},
];
let failed = 0;
for (const probe of PROBES) {
const run = spawnSync(process.execPath, [resolve(HERE, probe.file)], {
encoding: 'utf8', cwd: resolve(HERE, '../..'), timeout: 90_000,
});
const output = `${run.stdout || ''}${run.stderr || ''}`;
const exitOk = run.status === probe.expectExit;
const textOk = probe.expectOutput.test(output);
if (exitOk && textOk) {
console.log(`ok ${probe.file} → exit ${run.status}`);
continue;
}
failed += 1;
console.error(`FAIL ${probe.file}: ${probe.because}`);
console.error(` ожидался exit ${probe.expectExit}, получен ${run.status}`);
if (!textOk) console.error(` в выводе нет ${probe.expectOutput}`);
console.error(output.split('\n').slice(-12).map((line) => ` | ${line}`).join('\n'));
}
if (failed) {
console.error(`\nгард исключений не доказан: проб провалено ${failed} из ${PROBES.length}`);
process.exit(1);
}
console.log(`\nгард исключений доказан на ${PROBES.length} пробах`);
+13 -2
View File
@@ -150,8 +150,8 @@ legitimate fixture extension without weakening the separate zero-growth gate.
## Glow profiles
Both Glow profiles run deterministic 1/10/30/60-pool variants at DPR 1 and
Chromium CPU throttling x4, but deliberately exercise different fixtures:
The full-card Glow profiles run deterministic 1/10/30/60-pool variants at DPR
1 and Chromium CPU throttling x4, but deliberately exercise different fixtures:
- `large-light-blend-v1` compares the isolated screen group with the previous
normal-layer implementation on the shared frontend/backend schema fixture
@@ -160,11 +160,22 @@ Chromium CPU throttling x4, but deliberately exercise different fixtures:
independent Glow on the existing 60-room/200-device large-house fixture,
without changing `large-house-v1`.
The same runner also owns the two static-card profiles introduced with #374:
- `large-space-card-default-v1` proves that the default-off card keeps the
historical no-visibility-work path and a zero-entry Glow cache;
- `large-space-card-glow-v1` measures the explicit `light_pools:true` path on
one large static space, including HA ticks, heap/cache growth and capture.
```bash
npm run benchmark:glow -- --profile=large-light-blend-v1 --output=artifacts/performance/glow.json
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --output=artifacts/performance/overlay.json
npm run benchmark:glow -- --profile=large-space-card-default-v1 --output=artifacts/performance/space-default.json
npm run benchmark:glow -- --profile=large-space-card-glow-v1 --output=artifacts/performance/space-glow.json
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --variants=60 --samples=3 --warmups=1 --output=artifacts/performance-smoke/candidate.json
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-glow-smoke.json --candidate=artifacts/performance-smoke/candidate.json --output=artifacts/performance-smoke/comparison.json
npm run benchmark:glow -- --profile=large-space-card-glow-v1 --variants=60 --samples=3 --warmups=1 --output=artifacts/performance-smoke/space-candidate.json
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-space-glow-smoke.json --candidate=artifacts/performance-smoke/space-candidate.json --output=artifacts/performance-smoke/space-comparison.json
```
Reports include per-variant state-update timings, render/pool counts, Long
@@ -0,0 +1,27 @@
{
"schema": 1,
"profile": "large-space-card-default-v1",
"minimumSamples": 7,
"timings": {
"stateUpdate1Ms": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 50, "hardMaxMs": 750 },
"stateUpdate10Ms": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 75, "hardMaxMs": 1100 },
"stateUpdate30Ms": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 100, "hardMaxMs": 1500 },
"stateUpdate60Ms": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 150, "hardMaxMs": 2200 },
"screenshotCaptureMs": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 250, "hardMaxMs": 3200 }
},
"longTasks": {
"maxSingleMs": 2700,
"maxSingleRegressionRatio": 0.5,
"maxSingleNoiseAllowanceMs": 200,
"maxCountP95": 12,
"maxCountRegressionRatio": 0.5,
"countNoiseAllowance": 2,
"maxTotalP95Ms": 5500,
"maxTotalRegressionRatio": 0.5,
"noiseAllowanceMs": 250
},
"heap": { "required": true, "hardMaxGrowthBytes": 67108864, "maxRegressionRatio": 0.75, "noiseAllowanceBytes": 16777216 },
"cacheEntries": { "glowClip": 0 },
"cacheGrowth": { "glowClip": 0 },
"renderedDevices": 200
}
@@ -0,0 +1,27 @@
{
"schema": 1,
"profile": "large-space-card-glow-v1",
"minimumSamples": 7,
"timings": {
"stateUpdate1Ms": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 50, "hardMaxMs": 750 },
"stateUpdate10Ms": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 75, "hardMaxMs": 1100 },
"stateUpdate30Ms": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 100, "hardMaxMs": 1500 },
"stateUpdate60Ms": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 150, "hardMaxMs": 2200 },
"screenshotCaptureMs": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 250, "hardMaxMs": 3200 }
},
"longTasks": {
"maxSingleMs": 2700,
"maxSingleRegressionRatio": 0.5,
"maxSingleNoiseAllowanceMs": 200,
"maxCountP95": 12,
"maxCountRegressionRatio": 0.5,
"countNoiseAllowance": 2,
"maxTotalP95Ms": 5500,
"maxTotalRegressionRatio": 0.5,
"noiseAllowanceMs": 250
},
"heap": { "required": true, "hardMaxGrowthBytes": 67108864, "maxRegressionRatio": 0.75, "noiseAllowanceBytes": 16777216 },
"cacheEntries": { "glowClip": 128 },
"cacheGrowth": { "glowClip": 0 },
"renderedDevices": 200
}
@@ -0,0 +1,18 @@
{
"schema": 1,
"profile": "large-space-card-glow-v1",
"minimumSamples": 3,
"timings": {
"stateUpdate60Ms": { "stat": "median", "hardMaxMs": 2200 },
"screenshotCaptureMs": { "stat": "median", "hardMaxMs": 3200 }
},
"longTasks": {
"maxSingleMs": 2700,
"maxCountP95": 12,
"maxTotalP95Ms": 5500
},
"heap": { "required": true, "hardMaxGrowthBytes": 67108864 },
"cacheEntries": { "glowClip": 128 },
"cacheGrowth": { "glowClip": 0 },
"renderedDevices": 200
}
+30 -3
View File
@@ -34,7 +34,6 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
'_loadOk',
'_model',
'_path',
'_resize',
'_serverCfg',
'_settingsDialog',
'_tool',
@@ -46,6 +45,13 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
'_onLabsSnapshot', '_planSnapGeometryCache', '_roomDialog', '_setProjection',
'_wallFaceBatch', '_wallFaceGraphCache',
]),
// #380: v1.68.1 owns the same resize session directly on the card; newer
// bundles moved it into ResizeController. A comparison target must expose
// one of the two explicit shapes; the current member retains its object
// type check and is also verified against current production source.
fieldAlternatives: Object.freeze([
Object.freeze({ current: '_resize', legacy: '_rszDrag' }),
]),
fieldTypes: Object.freeze({
_booting: 'boolean',
_cleanFloorCache: 'map',
@@ -89,6 +95,19 @@ export const GLOW_CARD_CONTRACT = Object.freeze({
}),
});
export const SPACE_GLOW_CARD_CONTRACT = Object.freeze({
label: 'Static-card Glow performance profiles',
methods: Object.freeze([]),
fields: Object.freeze(['_devices', '_loading', '_snap']),
optionalFields: Object.freeze(['_glowRuntimeState', '_glowScreenBlend']),
fieldTypes: Object.freeze({
_devices: 'array',
_loading: 'boolean',
_glowRuntimeState: 'object',
_glowScreenBlend: 'boolean',
}),
});
/** Single fail-fast implementation injected into both browser runners. Keep
* this function self-contained: runners serialize it with `toString()`. */
export function assertCardContract(card, contract) {
@@ -102,11 +121,19 @@ export function assertCardContract(card, contract) {
.map((name) => `${name}()`);
const missingFields = contract.fields
.filter((name) => !(name in card) || card[name] === undefined);
const invalidFields = [...contract.fields, ...(contract.optionalFields || [])]
const missingAlternatives = (contract.fieldAlternatives || [])
.filter((choice) => !Object.values(choice)
.some((name) => name in card && card[name] !== undefined))
.map((choice) => Object.values(choice).join('|'));
const alternativeFields = (contract.fieldAlternatives || [])
.flatMap((choice) => Object.values(choice));
const invalidFields = [
...contract.fields, ...(contract.optionalFields || []), ...alternativeFields,
]
.filter((name) => name in card && contract.fieldTypes?.[name]
&& !matches(card[name], contract.fieldTypes[name]))
.map((name) => `${name}:${contract.fieldTypes[name]}`);
const missing = [...missingMethods, ...missingFields];
const missing = [...missingMethods, ...missingFields, ...missingAlternatives];
if (missing.length || invalidFields.length) {
const details = [
missing.length ? `missing private API: ${missing.join(', ')}` : '',
+84 -3
View File
@@ -2,6 +2,7 @@
// via request interception (no HTTP server needed). Usage: const {page,browser}=await launch();
import { chromium } from 'playwright';
import { assertFreshDemoBundleUnlessAllowed } from './bundle-freshness.mjs';
import { ensureHarnessEditorRuntime } from './editor-runtime-compat.mjs';
import { readFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
@@ -16,6 +17,51 @@ const CT = { '.html': 'text/html', '.js': 'text/javascript', '.svg': 'image/svg+
// prints them and sets the exit code.
const _failures = [];
let _pageErrors = 0;
/**
* Открытые страницы — для round-trip'а перед чтением счётчика (#404).
*
* Ссылок на страницы у `finish(browser, out)` нет, а менять её сигнатуру
* нельзя: так её зовут 205 смоков. Поэтому страницы регистрируются там, где
* создаются.
*/
const _livePages = new Set();
/**
* События `pageerror` Playwright доставляет асинхронно по CDP, а гард читал
* счётчик синхронно (#404). Если исключение возникло после последнего обращения
* смока к странице, счётчик к моменту проверки ещё нулевой, а `browser.close()`
* уносит недоставленное событие. В логе это видно дословно: `EXC` печатается
* ПОСЛЕ результата и ДО `OK`.
*
* Круговой запрос к странице вытесняет ранее поставленные макрозадачи, поэтому
* всё, что страница успела произвести до этого момента, к нам уже дошло.
*
* Честная граница: исключение, возникшее ПОСЛЕ этого round-trip'а — например, в
* обработчике `beforeunload` при закрытии браузера, — не учитывается. Ловить его
* значит ждать неизвестно чего неизвестно сколько; контракт формулируется как
* «всё, что произошло до вызова вердикта».
*/
async function roundTripLivePages() {
for (const page of _livePages) {
try { await page.evaluate(() => 0); } catch { /* закрыта, упала или в навигации */ }
}
}
/**
* Подписать страницу, созданную ВНЕ `launch()` (#404, Medium-1 ревью ТЗ).
*
* Таких мест два — `smoke_zoom_flash` открывает вторую страницу на своём
* контексте, `smoke_svg_sandbox` создаёт три. Их исключения не считал никто:
* первая печатала своё `EXC2` мимо счётчика, остальные три не имели слушателя
* вовсе. Регистрация в `launchInternal` их не покрывает по построению, поэтому
* гард отдаёт наружу ровно одну функцию — и её вызов виден в диффе смока.
*/
export function watchPage(page) {
page.on('pageerror', (e) => { _pageErrors++; console.log('EXC', e.stack || e.message); });
_livePages.add(page);
page.on('close', () => _livePages.delete(page));
return page;
}
/** Assert one named fact. `expected` defaults to true. */
export function check(name, actual, expected = true) {
@@ -30,9 +76,39 @@ export function checkAll(out, expected = {}) {
return out;
}
/**
* Тот же вердикт по исключениям в карточке, что у `finish()`, для смоков со
* своей логикой выхода (#407).
*
* Счётчик `_pageErrors` живёт здесь, а читал его только `finish()`. Шесть
* смоков `finish()` не вызывали вовсе — у каждого была своя ручная развязка
* (`if (!ok) process.exit(1)`) либо `throw` из try/finally, и ни одна не
* спрашивала про исключения. То есть необработанное исключение внутри карточки
* во время этих шести проходило незамеченным всегда: в лог печаталось `EXC`, а
* прогон оставался зелёным.
*
* Функция ничего не бросает намеренно. Смок с ручной развязкой продолжает
* решать сам, что печатать и когда выходить; здесь только выставляется код
* возврата, который дальше уже не отнять — `process.exitCode` переживёт любой
* последующий `console.log`.
*
* @returns true, если карточка бросала — чтобы вызывающий мог добавить своё
* сообщение, не считая исключения заново.
*/
export async function reportPageErrors() {
await roundTripLivePages();
if (!_pageErrors) return false;
console.error(`FAILED: ${_pageErrors} uncaught exception(s) inside the card`);
process.exitCode = 1;
return true;
}
/** Print the result, report failures, close the browser, set the exit code. */
export async function finish(browser, out) {
if (out !== undefined) console.log(JSON.stringify(out, null, 1));
// Порядок обязателен: сначала дать странице доставить события, потом читать
// счётчик (#404). Обратный порядок и был дефектом.
await roundTripLivePages();
if (_pageErrors) _failures.push(`${_pageErrors} uncaught exception(s) inside the card`);
await browser?.close?.();
if (_failures.length) {
@@ -57,8 +133,10 @@ async function launchInternal(
const page = await (await browser.newContext({
viewport, deviceScaleFactor: scale, ...contextOptions,
})).newPage();
// audit T1: an exception inside the card used to be logged and ignored
page.on('pageerror', (e) => { _pageErrors++; console.log('EXC', e.stack || e.message); });
// audit T1: an exception inside the card used to be logged and ignored.
// Подписка и регистрация — одной функцией: разъехавшись, они дали бы
// страницу, чьи исключения считаются, но доставки которых никто не ждёт (#404).
watchPage(page);
await page.route('**/*', (r) => {
const u = new URL(r.request().url());
let p = decodeURIComponent(u.pathname);
@@ -88,8 +166,11 @@ async function launchInternal(
// Existing product smokes exercise editor internals directly. They preload
// the new #337 runtime without changing mode; the dedicated network smoke
// uses launchColdView() and proves the real cold-View boundary separately.
// Comparative performance runs also launch the previous stable through this
// file. A pre-#337 monolithic card has no preload method and is already
// ready; a current card still has to return truthy (#380).
if (preloadEditorRuntime) {
const ready = await page.evaluate(() => window.__card._ensureEditorRuntime());
const ready = await page.evaluate(ensureHarnessEditorRuntime);
if (!ready) throw new Error('editor runtime did not preload for browser smoke');
}
return { page, browser };
+27 -2
View File
@@ -34,10 +34,35 @@ const res = await page.evaluate(async () => {
const pa = c._pos(a);
// поставим b на тот же Y, начнём drag
c._layout = { ...c._layout, [b.id]: { s: c._space, x: (pa.x + g * 12) / 1000, y: pa.y / 1000 } };
c._drag = { id: b.id, sx: 0, sy: 0, ox: 0, oy: 0, moved: true };
const pb = c._pos(b);
c._deviceDrag = {
id: b.id, spaceId: b.space, displayName: b.name, pointerId: 74,
source: null, sx: 0, sy: 0, ox: pb.x, oy: pb.y, moved: true,
before: { s: b.space, x: pb.x / 1000, y: pb.y / 1000 },
start: c._devicePlacementForCanvas(b, pb.x, pb.y),
};
const keptDrag = c._deviceDrag;
c.requestUpdate(); await c.updateComplete;
out.devGuide = guides() >= 1;
c._drag = null; c.requestUpdate(); await c.updateComplete;
// #400: направляющая обязана идти ОТ ДРУГОГО значка, а не от самого себя.
// Прежняя проверка `guides() >= 1` этого не различала: точка всегда
// совпадает сама с собой в пределах допуска, поэтому исключение
// перетаскиваемого маркера могло не работать вовсе — и не работало,
// потому что читало `_drag`, который в этом режиме всегда null.
out.devGuideComesFromAnotherMarker = (() => {
const key = (list) => list.map((point) => point.join(','));
const withDrag = key(c._editorRuntime._alignCandidates());
c._deviceDrag = null;
const withoutDrag = key(c._editorRuntime._alignCandidates());
c._deviceDrag = keptDrag;
// Исключение снимает РОВНО один кандидат — перетаскиваемый маркер.
// Сравнение по разнице списков, а не по заранее посчитанной точке:
// позиция значка живёт вместе с перетаскиванием и к моменту проверки
// уже другая.
const removed = withoutDrag.filter((point) => !withDrag.includes(point));
return withDrag.length === withoutDrag.length - 1 && removed.length === 1;
})();
c._deviceDrag = null; c.requestUpdate(); await c.updateComplete;
out.devGuideGone = guides() === 0;
// 4) подложка: рисование прямоугольника с углом на одном X с углом другой фигуры
c._setMode('decor'); await c.updateComplete;
+418
View File
@@ -0,0 +1,418 @@
// #126: authoritative HA Area changes supersede stale layout on the full card
// and on the read-only hosted space card before persistence can help it.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1000, height: 850 }, 1);
const res = await page.evaluate(async () => {
const c = window.__card;
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const paint = async (card = c) => {
card.requestUpdate();
await card.updateComplete;
};
const calls = [];
let rejectKettleRelocation = false;
const hass = window.__mkHass();
const callWS = hass.callWS;
hass.callWS = async (message) => {
calls.push(structuredClone(message));
if (rejectKettleRelocation && message.type === 'houseplan/config/set'
&& message.config?.settings?.marker_area_snapshot?.d_kettle?.area === 'living_room') {
rejectKettleRelocation = false;
throw new Error('synthetic config failure');
}
return callWS(message);
};
c.hass = hass;
await paint();
c._serverCfg = {
...c._serverCfg,
settings: {
...c._serverCfg.settings,
marker_area_snapshot: {
...(c._serverCfg.settings.marker_area_snapshot || {}),
d_light1: { binding: 'device:d_light1', area: 'living_room' },
},
new_device_ids: [],
},
};
c._layout = { ...c._layout, d_light1: { s: 'f1', x: 0.22, y: 0.22 } };
c._regSignature = '';
c._maybeRebuildDevices();
await paint();
const oldLeft = Number.parseFloat(
c.renderRoot.querySelector('.dev[data-id="d_light1"]')?.style.left || '0',
);
window.__setRegistryArea('device', 'd_light1', 'kitchen');
await wait(450);
await paint();
const moved = c._devices.find((device) => device.id === 'd_light1');
const movedEl = c.renderRoot.querySelector('.dev[data-id="d_light1"]');
const newLeft = Number.parseFloat(movedEl?.style.left || '0');
const firstDeleteCount = calls.filter(
(message) => message.type === 'houseplan/layout/delete' && message.device_id === 'd_light1',
).length;
const firstAttentionShown = c._serverCfg.settings.new_device_ids?.includes('d_light1')
&& !!movedEl?.querySelector('.newdot');
// An identical authoritative callback must not start another persistence loop.
window.__setRegistryArea('device', 'd_light1', 'kitchen');
await wait(300);
const secondDeleteCount = calls.filter(
(message) => message.type === 'houseplan/layout/delete' && message.device_id === 'd_light1',
).length;
// A dialog opened before another Area transition may show the effective
// room, but saving an unrelated field must not persist that stale display as
// an explicit House Plan override.
c._serverCfg = {
...c._serverCfg,
settings: {
...c._serverCfg.settings,
marker_area_snapshot: {
...c._serverCfg.settings.marker_area_snapshot,
d_lamp: { binding: 'device:d_lamp', area: 'living_room' },
},
},
};
c._layout = { ...c._layout, d_lamp: { s: 'f1', x: 0.42, y: 0.5 } };
c._regSignature = '';
c._maybeRebuildDevices();
c._setMode('devices');
c._openMarkerDialog(c._devices.find((device) => device.id === 'd_lamp'));
await paint();
window.__setRegistryArea('device', 'd_lamp', 'kitchen');
await wait(450);
const roomDraftRefreshed = c._markerDialog?.room === 'f1#kitchen'
&& c._markerDialog?.roomTouched === false;
c._markerDialog = { ...c._markerDialog, name: 'Floor lamp renamed' };
await c._saveMarker();
const savedLamp = c._serverCfg.markers.find((marker) => marker.id === 'd_lamp');
c._setMode('view');
await paint();
// Delete is committed before provenance. A rejected config write restores
// the old local snapshot and the next authoritative pass retries safely.
c._serverCfg = {
...c._serverCfg,
settings: {
...c._serverCfg.settings,
marker_area_snapshot: {
...c._serverCfg.settings.marker_area_snapshot,
d_kettle: { binding: 'device:d_kettle', area: 'kitchen' },
},
},
};
const kettlePoint = { s: 'f1', x: 0.72, y: 0.15 };
c._layout = { ...c._layout, d_kettle: kettlePoint };
c._regSignature = '';
c._maybeRebuildDevices();
rejectKettleRelocation = true;
window.__setRegistryArea('device', 'd_kettle', 'living_room');
await wait(500);
const failedConfigRestoredAndRetryable = c._layout.d_kettle?.s === kettlePoint.s
&& c._layout.d_kettle?.x === kettlePoint.x
&& c._layout.d_kettle?.y === kettlePoint.y
&& c._serverCfg.settings.marker_area_snapshot?.d_kettle?.area === 'kitchen'
&& c._areaRelocationSyncKey === ''
&& calls.some((message) => message.type === 'houseplan/layout/update'
&& message.device_id === 'd_kettle');
window.__setRegistryArea('device', 'd_kettle', 'living_room');
await wait(500);
await paint();
const configRetrySucceeded = c._serverCfg.settings.marker_area_snapshot?.d_kettle?.area
=== 'living_room' && c._serverCfg.settings.new_device_ids?.includes('d_kettle');
// Cross-space registry movement uses the same production coordinator: the
// old Garden point is deleted and the device joins the f1 autogrid.
c._serverCfg = {
...c._serverCfg,
settings: {
...c._serverCfg.settings,
marker_area_snapshot: {
...c._serverCfg.settings.marker_area_snapshot,
d_gate: { binding: 'device:d_gate', area: 'garden' },
},
},
};
c._layout = { ...c._layout, d_gate: { s: 'garden', x: 0.73, y: 0.22 } };
c._regSignature = '';
c._maybeRebuildDevices();
window.__setRegistryArea('device', 'd_gate', 'living_room');
await wait(450);
await paint();
const crossSpaceDevice = c._devices.find((device) => device.id === 'd_gate');
const crossSpaceDeleteCount = calls.filter(
(message) => message.type === 'houseplan/layout/delete' && message.device_id === 'd_gate',
).length;
// An explicitly persisted entity binding follows its own registry Area,
// independently of a device marker.
window.__setRegistryArea('entity', 'sun.sun', 'living_room');
c._serverCfg = {
...c._serverCfg,
markers: [
...c._serverCfg.markers.filter((marker) => marker.id !== 'entity_sun'),
{ id: 'entity_sun', binding: 'entity:sun.sun' },
],
settings: {
...c._serverCfg.settings,
marker_area_snapshot: {
...c._serverCfg.settings.marker_area_snapshot,
entity_sun: { binding: 'entity:sun.sun', area: 'living_room' },
},
},
};
c._layout = { ...c._layout, entity_sun: { s: 'f1', x: 0.20, y: 0.30 } };
c._regSignature = '';
c._maybeRebuildDevices();
window.__setRegistryArea('entity', 'sun.sun', 'kitchen');
await wait(450);
await paint();
const entityDevice = c._devices.find((device) => device.id === 'entity_sun');
// A user-owned room override remains authoritative when HA changes Area.
c._serverCfg = {
...c._serverCfg,
markers: [
...c._serverCfg.markers.filter((marker) => marker.id !== 'd_tv'),
{ id: 'd_tv', binding: 'device:d_tv', space: 'f1', area: 'living_room' },
],
settings: {
...c._serverCfg.settings,
marker_area_snapshot: {
...c._serverCfg.settings.marker_area_snapshot,
d_tv: { binding: 'device:d_tv', area: 'living_room' },
},
},
};
const explicitPoint = { s: 'f1', x: 0.13, y: 0.50 };
c._layout = { ...c._layout, d_tv: explicitPoint };
c._regSignature = '';
c._maybeRebuildDevices();
const explicitDeletesBefore = calls.filter(
(message) => message.type === 'houseplan/layout/delete' && message.device_id === 'd_tv',
).length;
window.__setRegistryArea('device', 'd_tv', 'kitchen');
await wait(350);
await paint();
const explicitDevice = c._devices.find((device) => device.id === 'd_tv');
const explicitDeletesAfter = calls.filter(
(message) => message.type === 'houseplan/layout/delete' && message.device_id === 'd_tv',
).length;
// A markerless light group is a composite, not a direct entity marker. Its
// Area may change, but #126 must not discard its saved point or flag it.
window.__addRegistryEntity('light.area_group', 'living_room', 'on', 'group');
await wait(150);
c._regSignature = '';
c._maybeRebuildDevices();
await paint();
const compositeId = 'lg_light.area_group';
const compositePoint = { s: 'f1', x: 0.30, y: 0.40 };
c._layout = { ...c._layout, [compositeId]: compositePoint };
c._serverCfg = {
...c._serverCfg,
settings: {
...c._serverCfg.settings,
marker_area_snapshot: {
...c._serverCfg.settings.marker_area_snapshot,
[compositeId]: { binding: 'entity:light.area_group', area: 'living_room' },
},
},
};
const compositeDeletesBefore = calls.filter(
(message) => message.type === 'houseplan/layout/delete' && message.device_id === compositeId,
).length;
const compositeAttentionBefore = c._newIds.has(compositeId);
window.__setRegistryArea('entity', 'light.area_group', 'kitchen');
await wait(350);
await paint();
const compositeDevice = c._devices.find((device) => device.id === compositeId);
const compositeDeletesAfter = calls.filter(
(message) => message.type === 'houseplan/layout/delete' && message.device_id === compositeId,
).length;
// Cold-start legacy backfill: the first authoritative build sees a saved
// point strictly inside the old Area room but no provenance. It must run
// through the real card startup and preserve delete-before-config ordering.
const coldConfig = structuredClone(c._serverCfg);
coldConfig.settings.marker_area_snapshot = {
...coldConfig.settings.marker_area_snapshot,
};
delete coldConfig.settings.marker_area_snapshot.d_motion;
coldConfig.settings.new_device_ids = (coldConfig.settings.new_device_ids || [])
.filter((id) => id !== 'd_motion');
const coldLayout = { ...c._layout, d_motion: { s: 'f1', x: 0.40, y: 0.75 } };
window.__setRegistryArea('device', 'd_motion', 'kitchen');
await wait(100);
const coldCalls = [];
let coldRev = 40;
const coldHass = window.__mkHass();
const coldBaseCallWS = coldHass.callWS;
coldHass.callWS = async (message) => {
coldCalls.push(structuredClone(message));
if (message.type === 'houseplan/config/get') {
return { config: coldConfig, rev: coldRev, can_write: true };
}
if (message.type === 'houseplan/layout/get') {
return { layout: coldLayout, rev: coldRev };
}
if (message.type === 'houseplan/layout/delete') {
delete coldLayout[message.device_id];
return { ok: true, rev: ++coldRev };
}
if (message.type === 'houseplan/config/set') {
Object.assign(coldConfig, structuredClone(message.config));
return { ok: true, rev: ++coldRev };
}
return coldBaseCallWS(message);
};
const coldCard = document.createElement('houseplan-card');
coldCard.setConfig({ type: 'custom:houseplan-card', title: 'Cold Area backfill' });
document.body.append(coldCard);
coldCard.hass = coldHass;
for (let attempt = 0; attempt < 20 && !coldCard._loadedOnce; attempt += 1) await wait(100);
await wait(450);
await paint(coldCard);
const coldDeleteIndex = coldCalls.findIndex(
(message) => message.type === 'houseplan/layout/delete' && message.device_id === 'd_motion',
);
const coldConfigIndex = coldCalls.findIndex(
(message) => message.type === 'houseplan/config/set'
&& message.config?.settings?.marker_area_snapshot?.d_motion?.area === 'kitchen',
);
const coldDevice = coldCard._devices.find((device) => device.id === 'd_motion');
const coldStartBackfilled = coldDevice?.area === 'kitchen'
&& !coldCard._layout.d_motion
&& coldCard._serverCfg.settings.marker_area_snapshot?.d_motion?.area === 'kitchen'
&& coldCard._serverCfg.settings.new_device_ids?.includes('d_motion')
&& coldDeleteIndex >= 0 && coldConfigIndex > coldDeleteIndex;
coldCard.remove();
// A separate read-only card receives an old snapshot/layout but the already
// authoritative new registry Area. It must be truthful without any writes.
const staticConfig = structuredClone(c._serverCfg);
staticConfig.settings.marker_area_snapshot.d_leak = {
binding: 'device:d_leak', area: 'living_room',
};
staticConfig.settings.new_device_ids = [];
const staticLayout = { ...c._layout, d_leak: { s: 'f1', x: 0.22, y: 0.22 } };
const staticCalls = [];
const staticHass = window.__mkHass();
const staticCallWS = staticHass.callWS;
staticHass.callWS = async (message) => {
staticCalls.push(structuredClone(message));
if (message.type === 'houseplan/config/get') {
return { config: staticConfig, rev: 20, can_write: false };
}
if (message.type === 'houseplan/layout/get') return { layout: staticLayout, rev: 20 };
return staticCallWS(message);
};
const staticCard = document.createElement('houseplan-space-card');
staticCard.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
staticCard.hass = staticHass;
document.body.append(staticCard);
await wait(500);
await paint(staticCard);
const staticEl = staticCard.renderRoot.querySelector('.dev[data-id="d_leak"]');
const staticLeft = Number.parseFloat(staticEl?.style.left || '0');
// Lifecycle metadata is bounded by the authoritative registry, not by
// explicit editor cleanup alone. A disappeared device with no saved marker
// must lose its snapshot on the next full pass.
const orphanId = 'device-that-disappeared';
c._serverCfg = {
...c._serverCfg,
settings: {
...c._serverCfg.settings,
marker_area_snapshot: {
...c._serverCfg.settings.marker_area_snapshot,
[orphanId]: { binding: `device:${orphanId}`, area: 'living_room' },
},
},
};
c._regSignature = '';
c._maybeRebuildDevices();
await wait(450);
const authoritativeOrphanRemoved = !c._serverCfg.settings.marker_area_snapshot?.[orphanId];
// A separate connection whose full registry calls fail models startup or a
// limited account. The exact same orphan evidence is not authoritative and
// therefore must not be destructive.
const limitedConfig = structuredClone(c._serverCfg);
limitedConfig.settings.marker_area_snapshot[orphanId] = {
binding: `device:${orphanId}`, area: 'living_room',
};
const limitedHass = window.__mkHass();
const limitedCallWS = limitedHass.callWS;
limitedHass.connection = {};
limitedHass.callWS = async (message) => {
if (message.type === 'config/device_registry/list'
|| message.type === 'config/entity_registry/list') throw new Error('synthetic limited registry');
if (message.type === 'houseplan/config/get') {
return { config: limitedConfig, rev: 30, can_write: true };
}
return limitedCallWS(message);
};
const limitedCard = document.createElement('houseplan-card');
limitedCard.setConfig({ type: 'custom:houseplan-card', title: 'Limited registry' });
limitedCard.hass = limitedHass;
document.body.append(limitedCard);
await wait(500);
await paint(limitedCard);
const nonAuthoritativeOrphanPreserved = !!limitedCard._serverCfg?.settings
?.marker_area_snapshot?.[orphanId];
limitedCard.remove();
return {
movedToRegistryArea: moved?.area === 'kitchen' && moved?.space === 'f1',
staleLayoutDeleted: !c._layout.d_light1,
provenanceAdvanced: c._serverCfg.settings.marker_area_snapshot?.d_light1?.area === 'kitchen',
attentionShown: firstAttentionShown,
autoGridReplacedOldPoint: oldLeft < 40 && newLeft > 50,
deleteFirstAndOnce: firstDeleteCount === 1 && secondDeleteCount === 1,
configWritten: calls.some((message) => message.type === 'houseplan/config/set'),
roomDraftRefreshed,
noStaleExplicitOverride: !!savedLamp
&& savedLamp.area === undefined && savedLamp.room_id === undefined,
failedConfigRestoredAndRetryable,
configRetrySucceeded,
crossSpaceRelocated: crossSpaceDevice?.space === 'f1'
&& crossSpaceDevice?.area === 'living_room'
&& !c._layout.d_gate
&& c._serverCfg.settings.marker_area_snapshot?.d_gate?.area === 'living_room'
&& crossSpaceDeleteCount === 1,
standaloneEntityRelocated: entityDevice?.bindingKind === 'entity'
&& entityDevice?.bindingRef === 'sun.sun'
&& entityDevice?.area === 'kitchen'
&& !c._layout.entity_sun
&& c._serverCfg.settings.marker_area_snapshot?.entity_sun?.area === 'kitchen',
explicitOverrideExcluded: explicitDevice?.area === 'living_room'
&& c._layout.d_tv?.s === explicitPoint.s
&& c._layout.d_tv?.x === explicitPoint.x
&& c._layout.d_tv?.y === explicitPoint.y
&& explicitDeletesAfter === explicitDeletesBefore,
compositeGroupExcluded: compositeDevice?.marker == null
&& compositeDevice?.area === 'kitchen'
&& c._layout[compositeId]?.x === compositePoint.x
&& c._layout[compositeId]?.y === compositePoint.y
&& compositeDeletesAfter === compositeDeletesBefore
&& c._newIds.has(compositeId) === compositeAttentionBefore
&& !c._areaRelocationIds.has(compositeId),
coldStartBackfilled,
staticReadOnlyProjection: staticCard._areaRelocationIds.has('d_leak')
&& staticLeft > 50 && !!staticEl?.querySelector('.newdot'),
staticMadeNoWrites: !staticCalls.some((message) =>
message.type === 'houseplan/config/set'
|| ['houseplan/layout/set', 'houseplan/layout/update', 'houseplan/layout/delete']
.includes(message.type)),
authoritativeOrphanRemoved,
nonAuthoritativeOrphanPreserved,
};
});
checkAll(res);
await finish(browser, res);
+239
View File
@@ -0,0 +1,239 @@
// #403: a rejected Area-provenance write restores deleted manual placement,
// while relocation invalidates only history commands owned by that device.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1000, height: 850 }, 1);
const res = await page.evaluate(async () => {
const c = window.__card;
const wait = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));
const paint = async () => {
c.requestUpdate();
await c.updateComplete;
};
const settleRelocation = async () => {
await c._areaRelocationWrite.catch(() => undefined);
await wait(80);
await paint();
};
const samePlacement = (left, right) => !!left && !!right
&& left.s === right.s && left.x === right.x && left.y === right.y;
let serverConfig = structuredClone(c._serverCfg);
let serverLayout = structuredClone(c._layout);
let configRev = Math.max(30, Number(c._cfgRev) || 0);
let layoutRev = Math.max(40, Number(c._layoutRev) || 0);
const calls = [];
let rejectConfig = null;
let rejectRestoreId = null;
let delayConfig = null;
let releaseDelayedConfig = null;
const baseCallWS = c.hass.callWS;
c.hass = { ...c.hass, callWS: async (message) => {
if (message.type === 'houseplan/config/get') {
calls.push({ type: 'config/get' });
return { config: structuredClone(serverConfig), rev: configRev, can_write: true };
}
if (message.type === 'houseplan/layout/get') {
return { layout: structuredClone(serverLayout), rev: layoutRev };
}
if (message.type === 'houseplan/layout/delete') {
calls.push({ type: 'layout/delete', id: message.device_id });
delete serverLayout[message.device_id];
return { ok: true, rev: ++layoutRev };
}
if (message.type === 'houseplan/layout/update') {
const row = { type: 'layout/update', id: message.device_id };
calls.push(row);
if (rejectRestoreId === message.device_id) {
rejectRestoreId = null;
row.result = 'rejected';
throw new Error('synthetic placement restore failure');
}
serverLayout[message.device_id] = structuredClone(message.pos);
return { ok: true, rev: ++layoutRev };
}
if (message.type === 'houseplan/config/set') {
const row = {
type: 'config/set',
snapshot: structuredClone(message.config?.settings?.marker_area_snapshot || {}),
attention: [...(message.config?.settings?.new_device_ids || [])],
};
calls.push(row);
if (rejectConfig
&& row.snapshot[rejectConfig.id]?.area === rejectConfig.area) {
const rejection = rejectConfig;
rejectConfig = null;
row.result = 'rejected';
const error = new Error('synthetic config failure');
if (rejection.code) error.code = rejection.code;
throw error;
}
if (delayConfig && row.snapshot[delayConfig.id]?.area === delayConfig.area) {
const delayed = delayConfig;
delayConfig = null;
row.result = 'delayed';
await new Promise((resolve) => { releaseDelayedConfig = resolve; });
row.result = 'accepted';
if (delayed.id === '') throw new Error('unreachable delayed fixture');
}
serverConfig = structuredClone(message.config);
return { ok: true, rev: ++configRev };
}
return baseCallWS(message);
} };
c._serverStorage = true;
c._serverCanWrite = true;
c._layoutRev = layoutRev;
c._cfgRev = configRev;
const installScenario = async ({ id, oldArea, point }) => {
await settleRelocation();
window.__setRegistryArea('device', id, oldArea);
await wait(120);
c._serverCfg = {
...c._serverCfg,
settings: {
...c._serverCfg.settings,
marker_area_snapshot: {
...(c._serverCfg.settings.marker_area_snapshot || {}),
[id]: { binding: `device:${id}`, area: oldArea },
},
new_device_ids: (c._serverCfg.settings.new_device_ids || [])
.filter((candidate) => candidate !== id),
},
};
c._layout = { ...c._layout, [id]: structuredClone(point) };
serverConfig = structuredClone(c._serverCfg);
serverLayout = structuredClone(c._layout);
c._cfgContentFingerprint = '';
c._layoutContentFingerprint = '';
c._areaRelocationSyncKey = '';
c._regSignature = '';
c._maybeRebuildDevices();
await settleRelocation();
};
const pushMove = (id, name, fromX, toX) => {
const device = c._devices.find((candidate) => candidate.id === id);
if (!device) return false;
c._devicePositionHistory.push({
name,
before: { deviceId: id, spaceId: device.space,
placement: { s: device.space, x: fromX, y: 0.31 } },
after: { deviceId: id, spaceId: device.space,
placement: { s: device.space, x: toX, y: 0.31 } },
});
return true;
};
const stableId = 'd_lamp';
const relocatedId = 'd_light1';
const original = { s: 'f1', x: 0.22, y: 0.22 };
await installScenario({ id: relocatedId, oldArea: 'living_room', point: original });
c._devicePositionHistory.clear();
const historyFixtureReady = pushMove(stableId, 'Move lamp', 0.28, 0.34)
&& pushMove(relocatedId, 'Move relocating light', 0.22, 0.27);
rejectConfig = { id: relocatedId, area: 'kitchen', code: null };
window.__setRegistryArea('device', relocatedId, 'kitchen');
await settleRelocation();
const rejectedConfigIndex = calls.findIndex((row) => row.type === 'config/set'
&& row.result === 'rejected' && row.snapshot[relocatedId]?.area === 'kitchen');
const restoreIndex = calls.findIndex((row, index) => index > rejectedConfigIndex
&& row.type === 'layout/update' && row.id === relocatedId);
const restoredLocally = samePlacement(c._layout[relocatedId], original);
const restoredOnServer = samePlacement(serverLayout[relocatedId], original);
const unrelatedHistorySurvives = historyFixtureReady
&& c._devicePositionHistory.size === 1
&& c._devicePositionHistory.undoName === 'Move lamp';
const relocatedHistoryRemoved = c._devicePositionHistory.undoName !== 'Move relocating light'
&& !c._devicePositionHistory.canRedo;
const beforeUndoRelocated = structuredClone(c._layout[relocatedId]);
await c._runDevicePositionHistory('undo');
const unrelatedUndoStillWorks = c._devicePositionHistory.canRedo
&& samePlacement(c._layout[relocatedId], beforeUndoRelocated);
// Retry with a delayed config response. Between delete and acknowledgement
// the old point must not reappear; after release the ordinary #126 result
// (advanced snapshot + attention) must complete.
delayConfig = { id: relocatedId, area: 'kitchen' };
c._regSignature = '';
c._maybeRebuildDevices();
for (let attempt = 0; attempt < 80 && !releaseDelayedConfig; attempt += 1) await wait(10);
await paint();
const movedDuringDelayedWrite = c._devices.find((device) => device.id === relocatedId);
const stalePointSuppressedWhilePending = !!releaseDelayedConfig
&& !c._layout[relocatedId]
&& movedDuringDelayedWrite?.area === 'kitchen';
releaseDelayedConfig?.();
releaseDelayedConfig = null;
await settleRelocation();
const successfulRetry = !serverLayout[relocatedId]
&& c._serverCfg.settings.marker_area_snapshot?.[relocatedId]?.area === 'kitchen'
&& c._serverCfg.settings.new_device_ids?.includes(relocatedId);
// The conflict branch must restore before it reloads and retries. Call
// ordering is the durable witness even though the retry then deletes the
// restored point again and completes normally.
const conflictId = 'd_kettle';
const conflictPoint = { s: 'f1', x: 0.72, y: 0.15 };
await installScenario({ id: conflictId, oldArea: 'kitchen', point: conflictPoint });
c._devicePositionHistory.clear();
pushMove(stableId, 'Move lamp before conflict', 0.30, 0.36);
const conflictStart = calls.length;
rejectConfig = { id: conflictId, area: 'living_room', code: 'conflict' };
window.__setRegistryArea('device', conflictId, 'living_room');
await settleRelocation();
const conflictRows = calls.slice(conflictStart);
const conflictReject = conflictRows.findIndex((row) => row.type === 'config/set'
&& row.result === 'rejected' && row.snapshot[conflictId]?.area === 'living_room');
const conflictRestore = conflictRows.findIndex((row, index) => index > conflictReject
&& row.type === 'layout/update' && row.id === conflictId);
const conflictReload = conflictRows.findIndex((row, index) => index > conflictRestore
&& row.type === 'config/get');
const conflictRetryDelete = conflictRows.findIndex((row, index) => index > conflictReload
&& row.type === 'layout/delete' && row.id === conflictId);
const conflictRestoresBeforeRetry = conflictReject >= 0
&& conflictRestore > conflictReject
&& conflictReload > conflictRestore
&& conflictRetryDelete > conflictReload;
const conflictEventuallyCompletes = !serverLayout[conflictId]
&& c._serverCfg.settings.marker_area_snapshot?.[conflictId]?.area === 'living_room'
&& c._serverCfg.settings.new_device_ids?.includes(conflictId);
const conflictKeepsUnrelatedHistory = c._devicePositionHistory.undoName
=== 'Move lamp before conflict';
// If even layout/update fails, the existing attention channel is persisted
// instead of losing the manual point silently.
const fallbackId = 'd_motion';
const fallbackPoint = { s: 'f1', x: 0.40, y: 0.75 };
await installScenario({ id: fallbackId, oldArea: 'living_room', point: fallbackPoint });
rejectConfig = { id: fallbackId, area: 'kitchen', code: null };
rejectRestoreId = fallbackId;
window.__setRegistryArea('device', fallbackId, 'kitchen');
await settleRelocation();
const fallbackEl = c.renderRoot.querySelector(`.dev[data-id="${fallbackId}"]`);
const failedRestoreLeavesAttention = !serverLayout[fallbackId]
&& c._serverCfg.settings.new_device_ids?.includes(fallbackId)
&& serverConfig.settings.new_device_ids?.includes(fallbackId)
&& c._newIds.has(fallbackId)
&& !!fallbackEl?.querySelector('.newdot');
return {
historyFixtureReady,
rejectedWriteRestoresAfterDelete: rejectedConfigIndex >= 0
&& restoreIndex > rejectedConfigIndex && restoredLocally && restoredOnServer,
unrelatedHistorySurvives,
relocatedHistoryRemoved,
unrelatedUndoStillWorks,
stalePointSuppressedWhilePending,
successfulRetry,
conflictRestoresBeforeRetry,
conflictEventuallyCompletes,
conflictKeepsUnrelatedHistory,
failedRestoreLeavesAttention,
};
});
checkAll(res);
await finish(browser, res);
+3 -3
View File
@@ -12,7 +12,7 @@ const out = {};
Object.assign(out, await page.evaluate(async () => {
const o = {};
const c = window.__card;
const nativeConfirm = window.confirm;
const confirmDanger = c._confirmDanger;
// Пикер читает `showEntities` и `bindingFilter` из открытого диалога, поэтому
// спрашивать его при закрытом бессмысленно: отдельные сущности устройств не
@@ -30,10 +30,10 @@ Object.assign(out, await page.evaluate(async () => {
const remove = async (device) => {
c._openMarkerDialog(device);
await c.updateComplete;
window.confirm = () => true;
c._confirmDanger = async () => true;
await c._deleteMarker();
await c.updateComplete;
window.confirm = nativeConfirm;
c._confirmDanger = confirmDanger;
};
const markers = () => c._serverCfg.markers || [];
const tombstones = () => markers().filter((m) => m.removed === true);
+6
View File
@@ -44,6 +44,12 @@ const load = (cfg, layout, space) => page.evaluate(async ([c2, l2, sp]) => {
await c.updateComplete;
c._resetZoom();
await c.updateComplete;
const started = performance.now();
while (c._cameraTransition?.active) {
if (performance.now() - started > 1000) throw new Error('camera transition did not settle');
await new Promise((r) => requestAnimationFrame(r));
}
await c.updateComplete;
}, [cfg, layout, space]);
// ---------------------------------------------------------------- 01: hidden
+260
View File
@@ -0,0 +1,260 @@
// #402: подтверждение опасного действия не принадлежит ни одной ветке
// render(). Раньше `hp-confirm` рендерился только в финальной ветке цепочки
// ранних return, и в онбординге («ещё нет ни одного пространства») кнопка
// удаления плана была мертва: диалог не появлялся, промис не разрешался
// никогда. Отдельный файл, а не дополнение smoke_danger_confirmation:
// там оснастка проверяет матрицу мутаций и держит открытыми неполные
// фикстуры диалогов, а здесь нужен чистый рендер.
import { launch, checkAll, finish } from './serve.mjs';
// Touch-эмуляция намеренно: `docs/TOUCH-SUPPORT.md` § Safety floor запрещает
// «best effort» в обходе подтверждения разрушающего действия, а сломанная
// ветка пробивала этот пол одинаково на мыши и на пальце. Проверяем там, где
// он обязан держаться.
const { page, browser } = await launch(
{ width: 390, height: 760 }, 1, [], { hasTouch: true, isMobile: true },
);
const out = await page.evaluate(async () => {
const result = {};
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const settle = async () => {
await card.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
};
const settleElement = async (element) => {
await element.updateComplete;
const shell = element.querySelector('hp-dialog');
if (shell) await shell.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
};
const deepActiveElement = () => {
let active = document.activeElement;
while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
return active;
};
const directConfirm = async (kind, action, key) => {
const element = document.createElement('hp-confirm');
element.token = Math.floor(Math.random() * 1_000_000) + 1;
element.request = {
key,
kind,
title: kind === 'destructive' ? 'Delete?' : 'Unlock?',
message: kind === 'destructive'
? 'The plan and all its rooms will be deleted.'
: 'House Plan will send an unlock command.',
confirmLabel: kind === 'destructive' ? 'Delete' : 'Unlock',
cancelLabel: 'Cancel',
};
const decision = new Promise((resolve) => element.addEventListener(
'hp-confirm-decision', (event) => resolve(event.detail.accepted), { once: true },
));
document.body.append(element);
await settleElement(element);
const shell = element.querySelector('hp-dialog');
const native = shell?.shadowRoot?.querySelector('dialog');
const body = element.querySelector('.danger-confirm-body');
const cancel = element.querySelector('button[autofocus]');
const semantics = native?.getAttribute('role') === 'alertdialog'
&& native.getAttribute('aria-describedby') === body?.id
&& !!body?.textContent?.trim();
const focused = deepActiveElement() === cancel;
const stayedNative = !!native && !shell?.shadowRoot?.querySelector('ha-dialog');
if (action === 'escape') {
cancel?.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Escape', bubbles: true, composed: true, cancelable: true,
}));
} else {
cancel?.click();
}
const accepted = await decision;
element.remove();
await new Promise((resolve) => requestAnimationFrame(resolve));
return { semantics, focused, stayedNative, cancelled: accepted === false };
};
const dialogs = () => root().querySelectorAll('hp-confirm').length;
const request = (key) => ({
key,
kind: 'destructive',
title: card._t('confirm.delete_plan_title'),
message: card._t('confirm.delete_plan_body'),
confirmLabel: card._t('btn.delete'),
cancelLabel: card._t('btn.cancel'),
});
const decide = async (accepted) => {
const element = root().querySelector('hp-confirm');
element?.dispatchEvent(new CustomEvent('hp-confirm-decision', {
detail: { token: card._dangerConfirm.token, accepted },
bubbles: true,
composed: true,
}));
await settle();
};
await settle();
result.standaloneStartsWithoutHaDialog = !customElements.get('ha-dialog');
const noHaDestructive = await directConfirm('destructive', 'click', 'no-ha-delete');
result.noHaDestructiveIsDescribedAlert = noHaDestructive.semantics;
result.noHaDestructiveFocusesCancel = noHaDestructive.focused;
result.noHaDestructiveCancelResolvesFalse = noHaDestructive.cancelled;
const noHaWarning = await directConfirm('warning', 'escape', 'no-ha-unlock');
result.noHaWarningIsDescribedAlert = noHaWarning.semantics;
result.noHaWarningFocusesCancel = noHaWarning.focused;
result.noHaWarningEscapeResolvesFalse = noHaWarning.cancelled;
// Основная ветка: ровно один диалог, а не два (риск двойного рендера при
// выносе — если бы блок остался и в ветке, и в обёртке).
const single = card._confirmDanger(request('single'));
await settle();
result.mainBranchRendersExactlyOneConfirm = dialogs() === 1;
card._cancelDangerConfirm();
result.mainBranchCancelResolvesFalse = (await single) === false;
const savedCfg = card._serverCfg;
const savedModel = card._model;
const enterBranch = async (spaces) => {
card._serverCfg = { ...savedCfg, spaces };
card._model = spaces.length ? savedModel : [];
card._cfgEpoch += 1;
card.requestUpdate();
await settle();
};
const leaveBranch = async () => {
card._serverCfg = savedCfg;
card._model = savedModel;
card._cfgEpoch += 1;
card.requestUpdate();
await settle();
};
// Ветка онбординга — буквальный сценарий issue: здесь живёт корзина
// сохранённого плана.
await enterBranch([]);
result.onboardingBranchIsActuallyEntered = !root().querySelector('.stage');
const onboarding = card._confirmDanger(request('onboarding'));
await settle();
result.onboardingBranchShowsConfirm = dialogs() === 1;
await decide(false);
result.onboardingBranchResolves = (await onboarding) === false;
await leaveBranch();
// Открытое подтверждение переживает смену ветки: раньше оно исчезало вместе
// с веткой, оставляя вызывающего с неразрешённым промисом.
const survivor = card._confirmDanger(request('survivor'));
await settle();
const openedInMainBranch = dialogs() === 1;
await enterBranch([]);
const stillOpenInOnboarding = dialogs() === 1;
await decide(true);
result.openConfirmSurvivesBranchChange = openedInMainBranch && stillOpenInOnboarding
&& (await survivor) === true;
await leaveBranch();
// Тап по «Отмена» — тот же путь, что и клик: на touch подтверждение обязано
// спрашиваться, а не деградировать (TOUCH-SUPPORT § Safety floor).
await enterBranch([]);
const byTap = card._confirmDanger(request('touch'));
await settle();
const tapDialog = root().querySelector('hp-confirm');
// Кнопки живут в hp-dialog внутри hp-confirm; «Отмена» — первая в футере
// (она же с autofocus, см. #32 §6.1).
const footer = tapDialog?.renderRoot?.querySelector('.danger-confirm-footer')
|| tapDialog?.shadowRoot?.querySelector('.danger-confirm-footer');
const cancelButton = footer?.querySelector('button');
const tapPoint = cancelButton?.getBoundingClientRect();
if (cancelButton && tapPoint) {
for (const type of ['pointerdown', 'pointerup', 'click']) {
cancelButton.dispatchEvent(new PointerEvent(type, {
pointerId: 1, pointerType: 'touch', isPrimary: true, button: 0,
clientX: tapPoint.left + tapPoint.width / 2,
clientY: tapPoint.top + tapPoint.height / 2,
bubbles: true, composed: true, cancelable: true,
}));
}
}
await settle();
result.touchTapOnCancelResolvesFalse = !!cancelButton && (await byTap) === false
&& dialogs() === 0;
await leaveBranch();
// Соседние подтверждения остались в основной ветке и не поехали за компанию.
const source = card.constructor.toString?.() || '';
result.neighbourConfirmsUntouched = card._tapConfirm === null
&& card._vacCalConfirm === null && typeof source === 'string';
// Неготовая карточка отказывает сразу: подвесить запрос — это и есть дефект.
const savedHass = card.hass;
card.hass = undefined;
const refused = card._confirmDanger(request('not-ready'));
card.hass = savedHass;
card.requestUpdate();
await settle();
result.notReadyCardRefusesInsteadOfHanging = (await refused) === false;
// Match the public surface of HA's pinned ha-dialog closely enough to prove
// branch selection and ARIA forwarding without depending on private shadow
// DOM. Alert confirmations must still avoid it: the pinned component does
// not forward its reflected `type` to the actual dialog role.
class HaDialogStub extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' }).innerHTML = '<slot name="headerTitle"></slot>'
+ '<slot></slot><slot name="footer"></slot>';
}
connectedCallback() {
queueMicrotask(() => this.dispatchEvent(new Event('opened')));
}
}
customElements.define('ha-dialog', HaDialogStub);
const ordinary = document.createElement('hp-dialog');
ordinary.title = 'Ordinary device editor';
ordinary.describedBy = 'ordinary-description';
ordinary.innerHTML = '<p id="ordinary-description">Ordinary dialog description</p>';
document.body.append(ordinary);
await ordinary.updateComplete;
await new Promise((resolve) => requestAnimationFrame(resolve));
const haShell = ordinary.shadowRoot.querySelector('ha-dialog');
result.ordinaryDialogUsesHaBranch = !!haShell
&& !ordinary.shadowRoot.querySelector('dialog');
result.ordinaryDialogForwardsHaAria = haShell?.ariaLabelledBy?.startsWith('hp-dialog-title-')
&& haShell?.ariaDescribedBy === 'ordinary-description';
ordinary.remove();
const haDestructive = await directConfirm('destructive', 'click', 'ha-delete');
result.haDestructiveStaysNativeAlert = haDestructive.semantics
&& haDestructive.stayedNative;
result.haDestructiveFocusesCancel = haDestructive.focused;
result.haDestructiveCancelResolvesFalse = haDestructive.cancelled;
const haWarning = await directConfirm('warning', 'escape', 'ha-unlock');
result.haWarningStaysNativeAlert = haWarning.semantics && haWarning.stayedNative;
result.haWarningFocusesCancel = haWarning.focused;
result.haWarningEscapeResolvesFalse = haWarning.cancelled;
return result;
});
await page.evaluate(async () => {
const element = document.createElement('hp-confirm');
element.token = 4_060_000;
element.request = {
key: 'accessibility-probe', kind: 'destructive', title: 'Accessibility probe',
message: 'All rooms in this plan will be permanently deleted.',
confirmLabel: 'Delete', cancelLabel: 'Cancel',
};
document.body.append(element);
await element.updateComplete;
await element.querySelector('hp-dialog')?.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
});
const accessibilitySnapshot = await page.getByRole('alertdialog', {
name: 'Accessibility probe',
}).ariaSnapshot();
out.realAccessibilityTreeIncludesConsequence = accessibilitySnapshot
.includes('All rooms in this plan will be permanently deleted.');
await page.evaluate(() => document.querySelector('hp-confirm')?.remove());
checkAll(out);
await finish(browser, out);
+283
View File
@@ -0,0 +1,283 @@
// #32: one House Plan confirmation surface owns destructive actions in View,
// onboarding and every editor. This smoke covers the real dialog contract,
// then exercises every migrated mutation class through both lazy runtimes.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 320, height: 760 });
const out = await page.evaluate(async () => {
const result = {};
const card = window.__card;
await card._ensureOnboardingRuntime();
const editor = card._editorRuntime;
const onboarding = card._onboardingRuntime;
const root = () => card.shadowRoot || card.renderRoot;
const settle = async () => {
await card.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
};
const deepActive = () => {
let active = document.activeElement;
while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
return active;
};
const request = (key = 'delete-plan') => ({
key,
kind: 'destructive',
title: card._t('confirm.delete_plan_title'),
message: card._t('confirm.delete_plan_body'),
objectName: 'A deliberately very long plan filename — Grundriss für das Dachgeschoss.png',
confirmLabel: card._t('btn.delete'),
cancelLabel: card._t('btn.cancel'),
});
const visible = () => {
const component = root().querySelector('hp-confirm');
const dialog = component?.querySelector('hp-dialog');
return {
component,
dialog,
native: dialog?.shadowRoot?.querySelector('dialog'),
surface: dialog?.shadowRoot?.querySelector('.surface'),
footer: dialog?.querySelector('.danger-confirm-footer'),
buttons: [...(dialog?.querySelectorAll('.danger-confirm-footer button') || [])],
};
};
const opener = document.createElement('button');
opener.textContent = 'danger action';
root().append(opener);
opener.focus();
const cancelled = card._confirmDanger(request());
await settle();
let ui = visible();
result.sharedDialogVisible = !!ui.component && !!ui.native?.open
&& ui.dialog?.title === card._t('confirm.delete_plan_title');
result.cancelHasInitialFocus = deepActive() === ui.buttons[0]
&& ui.buttons[0]?.hasAttribute('autofocus');
result.narrowContentFits = !!ui.surface && !!ui.footer
&& ui.surface.getBoundingClientRect().left >= -1
&& ui.surface.getBoundingClientRect().right <= innerWidth + 1
&& ui.surface.scrollWidth <= ui.surface.clientWidth + 1
&& ui.footer.scrollWidth <= ui.footer.clientWidth + 1
&& ui.buttons.every((button) => button.getBoundingClientRect().right <= innerWidth + 1);
ui.buttons[0].click();
result.cancelButtonIsSafe = await cancelled === false;
await settle();
result.cancelRestoresFocus = deepActive() === opener;
const accepted = card._confirmDanger(request());
await settle();
ui = visible();
ui.buttons[1].click();
result.acceptButtonResolvesOnce = await accepted === true;
const escaped = card._confirmDanger(request('escape'));
await settle();
deepActive()?.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Escape', bubbles: true, composed: true, cancelable: true,
}));
result.escapeIsSafe = await escaped === false;
const closed = card._confirmDanger(request('close'));
await settle();
visible().dialog.shadowRoot.querySelector('.close').click();
result.closeIsSafe = await closed === false;
const scrimmed = card._confirmDanger(request('scrim'));
await settle();
const native = visible().native;
native.dispatchEvent(new MouseEvent('click', { bubbles: true }));
result.scrimIsSafe = await scrimmed === false;
const first = card._confirmDanger(request('first'));
const second = card._confirmDanger(request('second'));
await settle();
visible().buttons[1].click();
result.replacementCancelsOldRequest = await first === false && await second === true;
const localeFits = async (language, expectedCancel) => {
card._config = { ...(card._config || {}), language };
card.requestUpdate();
for (let attempt = 0; attempt < 50 && card._t('btn.cancel') !== expectedCancel; attempt++) {
await new Promise((resolve) => setTimeout(resolve, 10));
await settle();
}
const pending = card._confirmDanger(request(`locale-${language}`));
await settle();
const current = visible();
const fits = current.buttons[0]?.textContent.trim() === expectedCancel
&& current.surface.scrollWidth <= current.surface.clientWidth + 1
&& current.footer.scrollWidth <= current.footer.clientWidth + 1;
current.buttons[0].click();
await pending;
return fits;
};
result.germanNarrowFits = await localeFits('de', 'Abbrechen');
result.frenchNarrowFits = await localeFits('fr', 'Annuler');
opener.remove();
// From here the UI promise is replaced with deterministic decisions so the
// action matrix can prove mutation ownership independently of layout.
const originalConfirm = card._confirmDanger;
let decision = false;
card._confirmDanger = async () => decision;
let geometryCommits = 0;
editor._geometrySnapshot = () => ({ spaceId: 'danger-space' });
editor._commitPhysicalGeometry = () => { geometryCommits++; return true; };
const baseSpace = () => ({
id: 'danger-space', title: 'Danger space', cell_cm: 5,
view_box: [0, 0, 1, 1], rooms: [], settings: {},
});
const setDraft = (draft) => {
card._space = 'danger-space';
card._serverCfg = { spaces: [{ ...baseSpace(), room_drafts: [draft] }], markers: [], settings: {} };
};
setDraft({ id: 'draft-whole', points: [[0, 0], [1, 0]], segments: [{ cm: 10 }] });
card._physicalDialog = { kind: 'draft', id: 'draft-whole', segment: 0, cm: '10', length: '1 m' };
await editor._deleteDraftWhole();
const wholeCancel = card._serverCfg.spaces[0].room_drafts.length === 1 && geometryCommits === 0;
decision = true;
await editor._deleteDraftWhole();
result.draftWholeCancelAccept = wholeCancel
&& !card._serverCfg.spaces[0].room_drafts && geometryCommits === 1;
decision = false;
setDraft({
id: 'draft-segment', points: [[0, 0], [0.5, 0], [1, 0]],
segments: [{ cm: 10 }, { cm: 10 }],
});
card._physicalDialog = { kind: 'draft', id: 'draft-segment', segment: 0, cm: '10', length: '1 m' };
await editor._deleteDraftSegment();
const segmentCancel = card._serverCfg.spaces[0].room_drafts[0].segments.length === 2
&& geometryCommits === 1;
decision = true;
await editor._deleteDraftSegment();
result.draftSegmentCancelAccept = segmentCancel
&& card._serverCfg.spaces[0].room_drafts[0].segments.length === 1
&& geometryCommits === 2;
let configSaves = 0;
editor._saveConfigNow = async () => { configSaves++; };
card._serverCfg = {
spaces: [baseSpace()], settings: {},
markers: [{ id: 'danger-marker', binding: 'entity:switch.danger', space: 'danger-space' }],
};
card._devices = [];
// #404 AC5: диалог собирает сам продукт, а не фикстура.
//
// Ручная фикстура врала: у объявленного типа больше сорока обязательных
// полей, и рендер падал на первом же недостающем — сначала
// `_bindingHasHaPage(undefined).split(':')`, а после добавления binding и
// bindingMode на следующем. Дописывать поля по одному значит гоняться за
// типом; штатный путь «новый маркер» отдаёт полный объект с дефолтами
// продукта, и переопределить остаётся ровно то, что нужно этой проверке.
//
// Дефекта поведения здесь нет и не было: все 15 мест в src/, создающих
// диалог, идут этим же путём. Врал только смок — и гард этого не видел.
editor._openMarkerDialog();
card._markerDialog = {
...card._markerDialog,
devId: 'danger-marker', name: 'Danger marker',
binding: 'entity:switch.danger', bindingMode: 'ha', busy: false,
};
decision = false;
await editor._deleteMarker();
const markerCancel = card._serverCfg.markers.length === 1 && configSaves === 0;
decision = true;
await editor._deleteMarker();
result.markerCancelAccept = markerCancel && card._serverCfg.markers.length === 1
&& card._serverCfg.markers[0].id === 'danger-marker'
&& card._serverCfg.markers[0].removed === true
&& card._serverCfg.markers[0].hidden === true && configSaves === 1;
const wsCalls = [];
card.hass = {
...card.hass,
callWS: async (message) => {
wsCalls.push(message);
if (message.type === 'houseplan/config/get') return { config: card._serverCfg, rev: 1 };
if (message.type === 'houseplan/layout/get') return { layout: card._layout, rev: 1 };
return {};
},
};
const setPlanDialog = () => {
card._spaceDialog = {
mode: 'edit', spaceId: 'danger-space', title: 'Danger space', planUrl: null,
planFile: null, source: 'draw', showBorders: true, showNames: true,
zeroWallStyle: 'dashed', displayTouched: true, hideDecor: false,
hideOpenings: false, roomColor: '#888888', roomOpacity: 1, bgColor: null,
bgMode: null, northDeg: null, sunRays: null, fillMode: 'custom', customFill: null,
glowEnabled: true, tempMin: 15, tempMax: 30, showLqi: true, cardFontScale: 1,
labelTemp: true, labelHum: true, labelLqi: true, labelLight: true, cellCm: 5,
busy: false, saved: [{ name: 'old.png', url: '/old.png', size: 1, modified: 1, used_by: [] }],
};
};
for (const [name, runtime] of [['editor', editor], ['onboarding', onboarding]]) {
setPlanDialog();
decision = false;
const before = wsCalls.length;
await runtime._deleteServerPlan('old.png');
const cancelSafe = wsCalls.length === before;
decision = true;
await runtime._deleteServerPlan('old.png');
result[`${name}PlanCancelAccept`] = cancelSafe
&& wsCalls.filter((call) => call.type === 'houseplan/plans/delete').length
=== (name === 'editor' ? 1 : 2);
}
card._saveConfigDebounced.cancel();
card._persistLayout.cancel();
card._writeChain = Promise.resolve();
card._adoptStructuralResponses = () => ({ configChanged: false, layoutChanged: false });
for (const [name, runtime] of [['editor', editor], ['onboarding', onboarding]]) {
card._space = 'another-space';
card._serverCfg = { spaces: [baseSpace()], markers: [], settings: {} };
setPlanDialog();
decision = false;
const before = wsCalls.filter((call) => call.type === 'houseplan/space/delete').length;
await runtime._deleteSpace();
const cancelSafe = wsCalls.filter((call) => call.type === 'houseplan/space/delete').length === before;
decision = true;
await runtime._deleteSpace();
result[`${name}SpaceCancelAccept`] = cancelSafe
&& wsCalls.filter((call) => call.type === 'houseplan/space/delete').length === before + 1;
}
const services = [];
card._openingEntityAvailable = () => true;
card._openingInfo = {
id: 'door', type: 'door', rx: 0.5, ry: 0.5, len_cm: 90, lock: 'lock.danger',
};
card.hass = {
...card.hass,
states: {
...card.hass.states,
'lock.danger': { entity_id: 'lock.danger', state: 'locked', attributes: { friendly_name: 'Door' } },
},
callService: async (...args) => { services.push(args); },
};
decision = false;
await card._lockAction('lock.danger', 'unlock');
const unlockCancel = services.length === 0;
decision = true;
await card._lockAction('lock.danger', 'unlock');
result.unlockCancelAccept = unlockCancel && services.length === 1
&& services[0][0] === 'lock' && services[0][1] === 'unlock';
let resolveRace;
card._confirmDanger = () => new Promise((resolve) => { resolveRace = resolve; });
setDraft({ id: 'race-draft', points: [[0, 0], [1, 0]], segments: [{ cm: 10 }] });
card._physicalDialog = { kind: 'draft', id: 'race-draft', segment: 0, cm: '10', length: '1 m' };
const raced = editor._deleteDraftWhole();
card._space = 'changed-space';
resolveRace(true);
await raced;
result.staleContextCannotMutate = card._serverCfg.spaces[0].room_drafts.length === 1
&& geometryCommits === 2;
card._confirmDanger = originalConfirm;
return result;
});
checkAll(out);
await finish(browser, out);
+91
View File
@@ -0,0 +1,91 @@
// #377: the Background editor's default style persists into
// settings.decor_default_style — one debounced write per palette drag (AC4),
// and a card born with the key draws new objects with the saved style (AC5).
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const settleMode = async () => {
const started = performance.now();
do { await new Promise((resolve) => requestAnimationFrame(resolve)); }
while (c._modeTransitionBusy && performance.now() - started < 1500);
await c.updateComplete;
};
const base = c.hass.callWS;
const writes = [];
let rev = 40;
c.hass = { ...c.hass, callWS: async (m) => {
if (m.type === 'houseplan/config/set') {
writes.push(JSON.parse(JSON.stringify(m.config.settings?.decor_default_style ?? null)));
rev += 1;
return { ok: true, rev };
}
return base(m);
} };
c._cfgRev = rev;
// войти в Background-редактор — там живёт рантайм с _updateDecorStyle
[...sr().querySelectorAll('.modetab')][2].click(); await settleMode();
out.decorMode = c._mode === 'decor';
const runtime = c._editorRuntime;
out.hasRuntime = !!runtime;
// AC4: серия быстрых изменений (драг по палитре) = одна запись после дебаунса
runtime._updateDecorStyle({ ...c._decorStyle, color: '#101010' });
runtime._updateDecorStyle({ ...c._decorStyle, color: '#404040' });
runtime._updateDecorStyle({ ...c._decorStyle, color: '#8b0000' });
out.instantStyle = c._decorStyle.color === '#8b0000';
await new Promise((r) => setTimeout(r, 400));
out.noEagerWrite = writes.length === 0; // дебаунс ещё держит
// 750 мс > внутренний дебаунс _saveConfig (500 мс): если бы персист ушёл
// сразу (без своего дебаунса в 1 с), запись уже долетела бы до callWS
await new Promise((r) => setTimeout(r, 350));
out.stillNoWriteBeforeDebounce = writes.length === 0;
await new Promise((r) => setTimeout(r, 550));
c._saveConfigDebounced?.flush?.();
await new Promise((r) => setTimeout(r, 200));
out.oneWrite = writes.length === 1;
out.writeCarriesKey = !!writes[0] && writes[0].color === '#8b0000'
&& writes[0].width_cm === 3.6 && writes[0].fill === false;
// возврат в дефолт = запись БЕЗ ключа
runtime._updateDecorStyle({
color: '#607d8b', opacity: 1, widthCm: 3.6,
fill: false, fillColor: '#607d8b', fillOpacity: 0.25,
});
await new Promise((r) => setTimeout(r, 1100));
c._saveConfigDebounced?.flush?.();
await new Promise((r) => setTimeout(r, 200));
out.defaultRemovesKey = writes.length === 2 && writes[1] === null;
// AC5: карта, рождённая с ключом, сеет _decorStyle из него.
// Чистый reload: кэш мгновенного рендера убираем, чтобы посев шёл из
// серверного конфига (после настоящей записи кэш нёс бы тот же ключ).
localStorage.removeItem('houseplan_card_cfg_v1');
const fresh = document.createElement('houseplan-card');
fresh.setConfig({ type: 'custom:houseplan-card' });
fresh.hass = { ...c.hass, callWS: async (m) => {
if (m.type === 'houseplan/config/get') {
const r = await base(m);
const config = JSON.parse(JSON.stringify(r.config));
config.settings = { ...(config.settings || {}),
decor_default_style: { color: '#8b0000', width_cm: 5 } };
return { ...r, config, rev };
}
return base(m);
} };
document.body.appendChild(fresh);
const t0 = Date.now();
while (!fresh._serverCfg && Date.now() - t0 < 6000) await new Promise((r) => setTimeout(r, 60));
out.seeded = fresh._decorStyle.color === '#8b0000'
&& fresh._decorStyle.widthCm === 5
&& fresh._decorStyle.fillColor === '#607d8b'; // частичный ключ наследует дефолт
fresh.remove();
return out;
});
checkAll(res, ['decorMode', 'hasRuntime', 'instantStyle', 'noEagerWrite',
'stillNoWriteBeforeDebounce', 'oneWrite',
'writeCarriesKey', 'defaultRemovesKey', 'seeded']);
await finish(browser);
+4 -1
View File
@@ -1,5 +1,5 @@
// Smoke: the full houseplan-card honours the #space=<id> deep-link (hashchange).
import { launch } from './serve.mjs';
import { launch, reportPageErrors } from './serve.mjs';
const { page, browser } = await launch({ width: 820, height: 760 }, 1);
const res = await page.evaluate(async () => {
const card = window.__card;
@@ -22,5 +22,8 @@ const res = await page.evaluate(async () => {
await browser.close();
const ok = res.after === res.target && res.afterBad === res.target;
console.log(JSON.stringify(res));
// #407: своя развязка про исключения в карточке не спрашивает. Вердикт обязан
// именно остановить: иначе строка успеха печатается после «FAILED».
if (await reportPageErrors()) process.exit(1);
if (!ok) { console.error('FAIL deeplink smoke'); process.exit(1); }
console.log('OK deep-link: full card switches to #space=<id>, ignores invalid ids');
+5 -3
View File
@@ -1,4 +1,5 @@
// #29: one lifecycle catalog replaces the separate Add / hidden-device paths.
// #29: one lifecycle catalog replaces the separate hidden-device paths.
// #363 restores a direct Add shortcut without creating a second catalog.
// This smoke exercises the real Lit dialog and verifies that browsing it is
// read-only while nested marker flows return to the same catalog state.
import { launch, checkAll, finish } from './serve.mjs';
@@ -21,8 +22,9 @@ const out = await page.evaluate(async () => {
const toolbar = [...root().querySelectorAll('.editbar .btn')];
const labels = toolbar.map((node) => node.textContent.trim());
const result = {
oneCatalogEntryPoint: labels.some((label) => /Devices/i.test(label))
&& !labels.some((label) => /Hidden and disabled|^Add$/i.test(label)),
catalogAndDirectAddEntryPoints: labels.some((label) => /Devices/i.test(label))
&& labels.some((label) => /^Add$/i.test(label))
&& !labels.some((label) => /Hidden and disabled/i.test(label)),
};
const before = JSON.stringify({
+397
View File
@@ -0,0 +1,397 @@
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const wait = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));
const settle = async () => {
const started = performance.now();
do { await new Promise((resolve) => requestAnimationFrame(resolve)); }
while (c._modeTransitionBusy && performance.now() - started < 1500);
await c.updateComplete;
};
const idle = async () => {
const started = performance.now();
while (c._devicePositionBusy && performance.now() - started < 1500) await wait(10);
await c.updateComplete;
};
[...sr().querySelectorAll('.modetab')][1].click();
await settle();
const deviceNode = () => sr().querySelector('.dev:not(.ha-disabled)');
let node = deviceNode();
const deviceId = node?.getAttribute('data-id');
const device = c._devices.find((candidate) => candidate.id === deviceId);
if (!node || !device) return { fixtureReady: false };
out.fixtureReady = true;
const originalCallWs = c.hass.callWS;
let revision = 20;
let serverLayout = structuredClone(c._layout);
const writes = [];
let failNextUpdate = false;
let failNextDelete = false;
c.hass = { ...c.hass, callWS: async (message) => {
if (message.type === 'houseplan/config/get') {
// #397 AC3: the reconnect path reads BOTH answers, and a config that
// differs clears the history for its own reason (`configChanged` in
// `_adoptStructuralResponses`). To isolate the layout question the
// fake server echoes the config the card already holds.
return { config: structuredClone(c._serverCfg), rev: c._cfgRev };
}
if (message.type === 'houseplan/layout/get') {
return { layout: structuredClone(serverLayout), rev: revision };
}
if (message.type === 'houseplan/layout/update') {
if (failNextUpdate) { failNextUpdate = false; throw new Error('update refused'); }
writes.push({ type: 'update', id: message.device_id, pos: structuredClone(message.pos) });
serverLayout = { ...serverLayout, [message.device_id]: structuredClone(message.pos) };
return { rev: ++revision };
}
if (message.type === 'houseplan/layout/delete') {
if (failNextDelete) { failNextDelete = false; throw new Error('delete refused'); }
writes.push({ type: 'delete', id: message.device_id });
delete serverLayout[message.device_id];
return { rev: ++revision };
}
return originalCallWs(message);
} };
c._serverStorage = true;
c._layoutRev = revision;
const fire = (target, type, x, y, pointerId = 74) => target.dispatchEvent(new PointerEvent(type, {
pointerId, pointerType: 'mouse', button: 0, buttons: type === 'pointerup' ? 0 : 1,
clientX: x, clientY: y, bubbles: true, composed: true, cancelable: true,
}));
const drag = async ({ dx = 80, cancel = false, cancelType = 'pointercancel', moves = 10, pointerId = 74 } = {}) => {
node = deviceNode();
const rect = node.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
fire(node, 'pointerdown', x, y, pointerId);
for (let index = 1; index <= moves; index++) {
fire(node, 'pointermove', x + dx * index / moves, y, pointerId);
}
const writesBeforeEnd = writes.length;
fire(node, cancel ? cancelType : 'pointerup', x + dx, y, pointerId);
await idle();
return writesBeforeEnd;
};
const beginPreview = (dx, pointerId) => {
node = deviceNode();
const rect = node.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
fire(node, 'pointerdown', x, y, pointerId);
fire(node, 'pointermove', x + dx, y, pointerId);
return { node, x, y };
};
const explicit = c._devicePlacementForCanvas(device, c._pos(device).x, c._pos(device).y);
c._layout = { ...c._layout, [deviceId]: { ...explicit, k: 0, future: 'kept' } };
serverLayout = structuredClone(c._layout);
c._layoutContentFingerprint = '';
c._devicePositionHistory.clear();
c.requestUpdate();
await c.updateComplete;
const writesBeforeRelease = await drag();
out.previewDoesNotPersist = writesBeforeRelease === 0;
out.oneDragOneWrite = writes.length === 1 && writes[0].type === 'update';
out.oneDragOneCommand = c._devicePositionHistory.size === 1;
out.futureFieldsSurviveDrag = c._layout[deviceId].k === 0
&& c._layout[deviceId].future === 'kept';
out.undoEnabled = !sr().querySelector('[data-device-position-history="undo"]').disabled;
// #397 AC1: after a write the local copy IS what went over the wire.
out.localCopyEqualsTheWire = writes.length > 0
&& JSON.stringify(c._layout[deviceId]) === JSON.stringify({
...c._layout[deviceId], ...writes[writes.length - 1].pos,
})
&& JSON.stringify(serverLayout[deviceId]) === JSON.stringify(writes[writes.length - 1].pos);
const afterDrag = structuredClone(c._layout[deviceId]);
sr().querySelector('[data-device-position-history="undo"]').click();
await idle();
// #397: the card now keeps what it sent — the canonical position — so a
// restored placement may differ from the raw `explicit` by the lattice snap
// (< 1e-9 of the plan, invisible). The equality is therefore stated to that
// precision, and the snap itself is pinned separately below: a real logical
// drift would exceed it by orders of magnitude.
const near = (a, b) => Math.abs(a - b) < 1e-9;
out.undoRestoresExactStart = near(c._layout[deviceId].x, explicit.x)
&& near(c._layout[deviceId].y, explicit.y)
&& c._layout[deviceId].k === 0
&& c._devicePositionHistory.canRedo;
sr().querySelector('[data-device-position-history="redo"]').click();
await idle();
out.redoRestoresExactEnd = JSON.stringify(c._layout[deviceId]) === JSON.stringify(afterDrag);
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'z', code: 'KeyZ', ctrlKey: true, bubbles: true, composed: true, cancelable: true,
}));
await idle();
out.keyboardUndoWorks = c._devicePositionHistory.canRedo
&& near(c._layout[deviceId].x, explicit.x) && near(c._layout[deviceId].y, explicit.y);
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'z', code: 'KeyZ', ctrlKey: true, shiftKey: true,
bubbles: true, composed: true, cancelable: true,
}));
await idle();
out.keyboardRedoWorks = JSON.stringify(c._layout[deviceId]) === JSON.stringify(afterDrag);
// Escape and an Undo request during preview abort only the uncommitted drag.
const beforeEscape = structuredClone(c._layout[deviceId]);
const writesBeforeEscape = writes.length;
beginPreview(95, 741);
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Escape', code: 'Escape', bubbles: true, composed: true, cancelable: true,
}));
await c.updateComplete;
out.escapeAbortsWithoutWrite = JSON.stringify(c._layout[deviceId]) === JSON.stringify(beforeEscape)
&& writes.length === writesBeforeEscape && c._deviceDrag === null;
const historySizeBeforePreviewUndo = c._devicePositionHistory.size;
beginPreview(105, 742);
sr().querySelector('[data-device-position-history="undo"]').click();
await c.updateComplete;
out.undoDuringDragOnlyAborts = JSON.stringify(c._layout[deviceId]) === JSON.stringify(beforeEscape)
&& writes.length === writesBeforeEscape
&& c._devicePositionHistory.size === historySizeBeforePreviewUndo
&& c._deviceDrag === null;
// Leaving the editor is another abort boundary; re-enter for the remaining cases.
beginPreview(115, 743);
[...sr().querySelectorAll('.modetab')][0].click();
await settle();
out.modeSwitchAbortsWithoutWrite = JSON.stringify(c._layout[deviceId]) === JSON.stringify(beforeEscape)
&& writes.length === writesBeforeEscape && c._deviceDrag === null;
[...sr().querySelectorAll('.modetab')][1].click();
await settle();
// A no-op after Undo must not destroy the redo branch.
sr().querySelector('[data-device-position-history="undo"]').click();
await idle();
const redoName = c._devicePositionHistory.redoName;
const writesBeforeNoop = writes.length;
await drag({ dx: 0, moves: 0, pointerId: 75 });
out.noopKeepsRedo = c._devicePositionHistory.redoName === redoName
&& writes.length === writesBeforeNoop;
sr().querySelector('[data-device-position-history="redo"]').click();
await idle();
// Cancellation restores the raw before-state and performs no final write.
const beforeCancel = structuredClone(c._layout[deviceId]);
const writesBeforeCancel = writes.length;
await drag({ dx: 120, cancel: true, pointerId: 76 });
out.cancelRestoresWithoutWrite = JSON.stringify(c._layout[deviceId]) === JSON.stringify(beforeCancel)
&& writes.length === writesBeforeCancel;
await drag({ dx: 120, cancel: true, cancelType: 'lostpointercapture', pointerId: 761 });
out.lostCaptureRestoresWithoutWrite = JSON.stringify(c._layout[deviceId]) === JSON.stringify(beforeCancel)
&& writes.length === writesBeforeCancel;
// A second pointer invalidates the first single-pointer transaction.
node = deviceNode();
let rect = node.getBoundingClientRect();
let x = rect.left + rect.width / 2;
let y = rect.top + rect.height / 2;
fire(node, 'pointerdown', x, y, 762);
fire(node, 'pointermove', x + 100, y, 762);
fire(node, 'pointerdown', x, y, 763);
await c.updateComplete;
out.secondPointerAbortsWithoutWrite = JSON.stringify(c._layout[deviceId]) === JSON.stringify(beforeCancel)
&& writes.length === writesBeforeCancel && c._deviceDrag === null;
// An auto-positioned marker round-trips through delete/update.
c._devicePositionHistory.clear();
delete c._layout[deviceId];
delete serverLayout[deviceId];
c.requestUpdate();
await c.updateComplete;
await drag({ dx: 90, pointerId: 77 });
const autoAfter = structuredClone(c._layout[deviceId]);
const beforeAutoUndo = writes.length;
sr().querySelector('[data-device-position-history="undo"]').click();
await idle();
out.autoUndoDeletesExplicitPlacement = !(deviceId in c._layout)
&& writes[beforeAutoUndo]?.type === 'delete';
sr().querySelector('[data-device-position-history="redo"]').click();
await idle();
out.autoRedoRestoresPlacement = JSON.stringify(c._layout[deviceId]) === JSON.stringify(autoAfter)
&& writes.at(-1)?.type === 'update';
// Failed final update never enters history; failed Undo restores stack direction.
c._devicePositionHistory.clear();
const beforeFailedDrag = structuredClone(c._layout[deviceId]);
failNextUpdate = true;
await drag({ dx: 110, pointerId: 78 });
out.failedDragRollsBack = JSON.stringify(c._layout[deviceId]) === JSON.stringify(beforeFailedDrag)
&& !c._devicePositionHistory.canUndo;
delete c._layout[deviceId];
delete serverLayout[deviceId];
c.requestUpdate();
await c.updateComplete;
await drag({ dx: 100, pointerId: 79 });
const beforeFailedDelete = structuredClone(c._layout[deviceId]);
failNextDelete = true;
sr().querySelector('[data-device-position-history="undo"]').click();
await idle();
out.failedUndoRestoresStackDirection = JSON.stringify(c._layout[deviceId])
=== JSON.stringify(beforeFailedDelete)
&& c._devicePositionHistory.canUndo && !c._devicePositionHistory.canRedo;
// Native text history stays native even with an available command.
const input = document.createElement('input');
sr().append(input);
const sizeBeforeInput = c._devicePositionHistory.size;
input.dispatchEvent(new KeyboardEvent('keydown', {
key: 'z', code: 'KeyZ', ctrlKey: true, bubbles: true, composed: true, cancelable: true,
}));
await wait();
out.nativeInputHistoryNotIntercepted = c._devicePositionHistory.size === sizeBeforeInput;
input.remove();
// Same-content reload is an own/reconnect echo; different content is remote
// authority. #397: the server snapshot is NOT copied from the card here — it
// already holds what actually went over the wire (the fake WS above stores
// `message.pos`). The former `serverLayout = structuredClone(c._layout)`
// erased by hand the very divergence this check exists to catch: the wire
// carries the canonical position while `_layout` kept the raw one, so the
// card mistook its own echo for a remote edit and cleared the stack. With
// the assignment gone the check reddens on the unfixed code.
const ownEchoServerPos = structuredClone(serverLayout[deviceId]);
const ownEchoLocalPos = structuredClone(c._layout[deviceId]);
await c._reloadLayoutOnly();
out.sameContentReloadKeepsHistory = c._devicePositionHistory.canUndo;
// The point of the check is only meaningful if the two sides were equal to
// begin with: pin that explicitly instead of assuming it.
out.ownEchoMatchesWhatWentOverTheWire =
JSON.stringify(ownEchoServerPos) === JSON.stringify(ownEchoLocalPos);
serverLayout = {
...serverLayout,
[deviceId]: { ...serverLayout[deviceId], x: serverLayout[deviceId].x + 0.01 },
};
await c._reloadLayoutOnly();
out.remoteContentClearsHistory = !c._devicePositionHistory.canUndo
&& !c._devicePositionHistory.canRedo;
// #397 AC5b: the echo of a DELETE must not clear the stack either — the
// delete branch removes a key instead of replacing a value, so proving the
// update branch says nothing about it.
const echoProbe = c._devices.find((candidate) => candidate.id !== deviceId
&& candidate.bindingStatus?.kind !== 'ha_disabled');
if (echoProbe) {
// The probe position is deliberately NON-canonical (0.024999999999999942
// snaps to 0.025): a round number would travel unchanged and the checks
// below would pass for the wrong reason — there would be no divergence to
// detect in the first place.
const rawProbe = { s: echoProbe.space, x: 0.024999999999999942, y: 0.7 / 7 };
c._layout = { ...c._layout, [echoProbe.id]: structuredClone(rawProbe) };
await c._persistDevicePlacement(echoProbe.id, structuredClone(rawProbe));
c._devicePositionHistory.push({
name: 'echo probe move',
before: { deviceId: echoProbe.id, spaceId: echoProbe.space, placement: null },
after: { deviceId: echoProbe.id, spaceId: echoProbe.space,
placement: structuredClone(rawProbe) },
});
// #397 AC3: the reconnect path (`_loadFromServer` →
// `_adoptStructuralResponses`) is the OTHER reader of the same value: it
// compares the stored `_layoutContentFingerprint` with the server answer
// instead of recomputing it. It must be exercised right after a write,
// before any reload has had a chance to align the two sides — otherwise
// the check passes for the wrong reason.
const reconnectBefore = c._devicePositionHistory.canUndo;
await c._loadFromServer();
out.reconnectKeepsHistory = reconnectBefore && c._devicePositionHistory.canUndo;
await c._persistDevicePlacement(echoProbe.id, null);
await c._reloadLayoutOnly();
out.deleteEchoKeepsHistory = c._devicePositionHistory.canUndo
&& c._layout[echoProbe.id] === undefined;
// #397 AC7: while a write is in flight the card is the authority — a
// server answer holding the OLD position must not win the merge.
const inFlightPos = { s: echoProbe.space, x: 0.63, y: 0.21 };
serverLayout = { ...serverLayout, [echoProbe.id]: { s: echoProbe.space, x: 0.1, y: 0.1 } };
c._sentPos.set(echoProbe.id, structuredClone(inFlightPos));
c._layout = { ...c._layout, [echoProbe.id]: structuredClone(inFlightPos) };
await c._reloadLayoutOnly();
out.inFlightPositionWinsTheMerge =
JSON.stringify(c._layout[echoProbe.id]) === JSON.stringify(inFlightPos);
c._sentPos.delete(echoProbe.id);
} else {
out.deleteEchoKeepsHistory = null;
out.reconnectKeepsHistory = null;
out.inFlightPositionWinsTheMerge = null;
}
// A valid command owns its original space and makes the result visible there.
const otherDevice = c._devices.find((candidate) => candidate.space !== device.space
&& candidate.bindingStatus?.kind !== 'ha_disabled');
if (otherDevice) {
const otherBefore = { s: otherDevice.space, x: 0.55, y: 0.55 };
const otherAfter = { s: otherDevice.space, x: 0.6, y: 0.55 };
c._layout = { ...c._layout, [otherDevice.id]: structuredClone(otherAfter) };
serverLayout = structuredClone(c._layout);
c._layoutContentFingerprint = '';
c._devicePositionHistory.push({
name: 'other-space move',
before: { deviceId: otherDevice.id, spaceId: otherDevice.space, placement: otherBefore },
after: { deviceId: otherDevice.id, spaceId: otherDevice.space, placement: otherAfter },
});
c.requestUpdate();
await c.updateComplete;
sr().querySelector('[data-device-position-history="undo"]').click();
await idle();
await settle();
const observedOther = c._layout[otherDevice.id];
out.otherSpaceUndoIsVisible = c._space === otherDevice.space
&& observedOther?.s === otherBefore.s
&& observedOther?.x === otherBefore.x
&& observedOther?.y === otherBefore.y;
c._commitSpace(device.space);
await settle();
} else {
out.otherSpaceUndoIsVisible = false;
}
// Stale commands fail closed instead of recreating a deleted/rebound/disabled marker.
const staleCommand = (id, spaceId) => ({
name: 'stale move',
before: { deviceId: id, spaceId, placement: { s: spaceId, x: 0.4, y: 0.4 } },
after: { deviceId: id, spaceId, placement: { s: spaceId, x: 0.45, y: 0.4 } },
});
const writesBeforeStale = writes.length;
c._devicePositionHistory.clear();
c._devicePositionHistory.push(staleCommand('removed-device', device.space));
c.requestUpdate();
await c.updateComplete;
sr().querySelector('[data-device-position-history="undo"]').click();
await c.updateComplete;
out.deletedDeviceCommandFailsClosed = !c._devicePositionHistory.canUndo
&& !c._devicePositionHistory.canRedo && writes.length === writesBeforeStale;
c._devicePositionHistory.push(staleCommand(device.id, 'garden'));
c.requestUpdate();
await c.updateComplete;
sr().querySelector('[data-device-position-history="undo"]').click();
await c.updateComplete;
out.reboundDeviceCommandFailsClosed = !c._devicePositionHistory.canUndo
&& !c._devicePositionHistory.canRedo && writes.length === writesBeforeStale;
const devicesBeforeDisable = c._devices;
c._devices = devicesBeforeDisable.map((candidate) => candidate.id === device.id
? { ...candidate, bindingStatus: { kind: 'ha_disabled' } } : candidate);
c._devicePositionHistory.push(staleCommand(device.id, device.space));
c.requestUpdate();
await c.updateComplete;
sr().querySelector('[data-device-position-history="undo"]').click();
await c.updateComplete;
out.disabledDeviceCommandFailsClosed = !c._devicePositionHistory.canUndo
&& !c._devicePositionHistory.canRedo && writes.length === writesBeforeStale;
c._devices = devicesBeforeDisable;
return out;
});
checkAll(res, {});
await finish(browser, res);
+135
View File
@@ -0,0 +1,135 @@
// #44: the Discovery-filters section makes group_lights and
// exclude_integrations visible, previewable and saved in one write.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 980, height: 820 });
const out = await page.evaluate(async () => {
const c = window.__card;
const root = () => c.renderRoot || c.shadowRoot;
// #126 seeds registry-Area provenance on the first authoritative build.
// Start this write-count contract only after that unrelated bootstrap write.
await c._areaRelocationWrite;
const writes = [];
const base = c.hass.callWS;
let rev = 70;
c.hass = { ...c.hass, callWS: async (m) => {
if (m.type === 'houseplan/config/set') {
writes.push(JSON.parse(JSON.stringify({
group_lights: m.config.settings?.group_lights ?? null,
exclude_integrations: m.config.settings?.exclude_integrations ?? null,
})));
rev += 1;
return { ok: true, rev };
}
return base(m);
} };
c._cfgRev = rev;
await new Promise((resolve) => setTimeout(resolve, 650));
c._saveConfigDebounced?.flush?.();
await c._writeChain;
await c._areaRelocationWrite;
writes.length = 0;
const result = {};
c._setMode('devices');
await c.updateComplete;
c._openDeviceInbox();
await c.updateComplete;
const tabs = [...root().querySelectorAll('.device-inbox-tabs [role="tab"]')];
tabs[1]?.click(); // «Доступны»
await c.updateComplete;
const section = root().querySelector('.device-inbox-discovery');
result.sectionOnAvailable = !!section;
section?.querySelector('summary')?.click();
await c.updateComplete;
// AC1: тумблер группировки
const runtime = c._editorRuntime;
const dialog = () => c._deviceInbox;
const toggle = root().querySelector('.device-inbox-discovery .srcrow input');
result.toggleDefaultOn = !!toggle?.checked;
toggle.checked = false;
toggle.dispatchEvent(new Event('change', { bubbles: true }));
await c.updateComplete;
// AC2: добавить исключение — превью-счётчик «скроется» ненулевой
// демо: все устройства платформы 'demo' — исключение честно всё материализует
const anyIntegration = 'demo';
// AC3: явный маркер той же интеграции обязан пережить запись фильтров
c._serverCfg = { ...c._serverCfg, markers: [...(c._serverCfg.markers || []), {
id: 'explicit-44', binding: 'device:d_lamp', space: c._serverCfg.spaces[0].id,
}] };
c._regSignature = '';
c._maybeRebuildDevices();
await c._areaRelocationWrite;
await new Promise((resolve) => setTimeout(resolve, 650));
c._saveConfigDebounced?.flush?.();
await c._writeChain;
writes.length = 0;
const beforeExcluded = runtime._discoveryFilterState(dialog()).excluded.length;
c._deviceInbox = { ...dialog(), draftExcluded: [
...runtime._discoveryFilterState(dialog()).excluded, anyIntegration,
] };
await c.updateComplete;
const preview = runtime._discoveryFilterPreview(dialog());
result.previewHides = preview.hide > 0;
result.previewLightsCounted = preview.lights >= 0;
// Save: одна запись, ключи в конфиге
await runtime._saveDiscoveryFilters(dialog());
await new Promise((r) => setTimeout(r, 100));
c._saveConfigDebounced?.flush?.();
await new Promise((r) => setTimeout(r, 200));
result.oneWrite = writes.length === 1;
result.writeCarriesBoth = writes[0]
&& writes[0].group_lights === false
&& Array.isArray(writes[0].exclude_integrations)
&& writes[0].exclude_integrations.includes(anyIntegration)
&& writes[0].exclude_integrations.length === beforeExcluded + 1;
// AC3: явный маркер пережил запись фильтров (в т.ч. в конфиге)
result.markersUntouched = (c._serverCfg.markers || [])
.some((marker) => marker.id === 'explicit-44');
// AC1-обратно: вернуть группировку и рекомендуемые → ключи удаляются
const d2 = c._deviceInbox;
c._deviceInbox = { ...d2, draftGroupLights: true, draftExcluded: null };
await c.updateComplete;
await runtime._saveDiscoveryFilters(c._deviceInbox);
await new Promise((r) => setTimeout(r, 100));
c._saveConfigDebounced?.flush?.();
await new Promise((r) => setTimeout(r, 200));
result.secondWrite = writes.length === 2;
result.defaultsRemoveKeys = writes[1]
&& writes[1].group_lights === null && writes[1].exclude_integrations === null;
// AC4: причина с именем интеграции — строим строку и проверяем текст
const excludedNow = new Set([anyIntegration]);
c._serverCfg = { ...c._serverCfg, settings: {
...c._serverCfg.settings, exclude_integrations: [...excludedNow],
} };
c._deviceInboxMemo = null; c._regSignature = '';
c._maybeRebuildDevices?.(); c.requestUpdate();
await c.updateComplete;
await new Promise((r) => setTimeout(r, 120));
await c.updateComplete;
// причина с именем интеграции живёт на строках reason=excluded_integration
// (категория строк — какой была до задачи; вкладку не навязываем)
const tabsNow = [...root().querySelectorAll('.device-inbox-tabs [role="tab"]')];
let reasonNames = false;
for (const tab of tabsNow) {
tab.click();
await c.updateComplete;
const reasons = [...root().querySelectorAll('.device-inbox-reason')]
.map((node) => node.textContent || '');
if (reasons.some((text) => text.includes(anyIntegration))) { reasonNames = true; break; }
}
result.reasonNamesIntegration = reasonNames;
c._closeDeviceInbox?.();
return result;
});
checkAll(out, ['sectionOnAvailable', 'toggleDefaultOn', 'previewHides', 'previewLightsCounted',
'oneWrite', 'writeCarriesBoth', 'markersUntouched', 'secondWrite', 'defaultsRemoveKeys',
'reasonNamesIntegration']);
await finish(browser);
+27 -12
View File
@@ -35,21 +35,35 @@ const out = await page.evaluate(async () => {
bubbles: true, ...extra,
});
};
const beginDeviceDrag = (device, position, pointerId) => {
const saved = c._layout[device.id];
c._deviceDrag = {
id: device.id, spaceId: device.space, displayName: device.name,
pointerId, source: null, sx: 400, sy: 400,
ox: position.x, oy: position.y, moved: false,
before: saved ? { x: saved.x, y: saved.y, ...(saved.s ? { s: saved.s } : {}) } : null,
start: c._devicePlacementForCanvas(device, position.x, position.y),
};
};
const awaitDeviceWrite = async () => {
while (c._devicePositionBusy) await new Promise((resolve) => setTimeout(resolve, 5));
await c.updateComplete;
};
// ---- (a) a DEVICE MARKER goes to 2.5 / 2.2 ---------------------------
c._setMode('devices'); await c.updateComplete;
const dev = c._devices.find((d) => !d.virtual);
const p0 = c._pos(dev);
c._drag = { id: dev.id, sx: 400, sy: 400, ox: p0.x, oy: p0.y, moved: false };
c._pointerMove(mk(2.5 * NORM_W - p0.x, 2.2 * NORM_W - p0.y), dev);
c._pointerUp(new PointerEvent('pointerup'), dev);
await c.updateComplete;
beginDeviceDrag(dev, p0, 11);
c._pointerMove(mk(2.5 * NORM_W - p0.x, 2.2 * NORM_W - p0.y, 400, 400,
{ pointerId: 11 }), dev);
c._pointerUp(new PointerEvent('pointerup', { pointerId: 11 }), dev);
await awaitDeviceWrite();
const saved = c._layout[dev.id];
o.markerLeftTheOldCanvas = near(saved.x, 2.5) && near(saved.y, 2.2);
o.markerIsOnTheGrid = onGrid(saved.x) && onGrid(saved.y);
// ---- (b) it SURVIVES a rebuild ---------------------------------------
c._drag = null;
c._modelCache = null; c._frame = null; c._defPos = c._defaultPositions();
c.requestUpdate(); await c.updateComplete;
const back = c._pos(c._devices.find((d) => d.id === dev.id));
@@ -104,20 +118,21 @@ const out = await page.evaluate(async () => {
c._setMode('devices'); await c.updateComplete;
const dev2 = c._devices.find((d) => d.id !== dev.id && !d.virtual);
const q0 = c._pos(dev2);
c._drag = { id: dev2.id, sx: 400, sy: 400, ox: q0.x, oy: q0.y, moved: false };
c._pointerMove(mk(9e9, 9e9), dev2);
c._pointerUp(new PointerEvent('pointerup'), dev2);
c._drag = null;
beginDeviceDrag(dev2, q0, 12);
c._pointerMove(mk(9e9, 9e9, 400, 400, { pointerId: 12 }), dev2);
c._pointerUp(new PointerEvent('pointerup', { pointerId: 12 }), dev2);
await awaitDeviceWrite();
o.garbageStillClamped = c._layout[dev2.id].x === 5000 && c._layout[dev2.id].y === 5000;
// ---- (f) Shift cannot land between the nodes ---------------------------
const dev3 = c._devices.find((d) => d.id !== dev.id && d.id !== dev2.id && !d.virtual);
const r0 = c._pos(dev3);
const halfStep = NORM_W / 240 / 2;
c._drag = { id: dev3.id, sx: 400, sy: 400, ox: r0.x, oy: r0.y, moved: false };
c._pointerMove(mk(2.5 * NORM_W + halfStep - r0.x, 2.5 * NORM_W - r0.y, 400, 400, { shiftKey: true }), dev3);
c._drag = null;
beginDeviceDrag(dev3, r0, 13);
c._pointerMove(mk(2.5 * NORM_W + halfStep - r0.x, 2.5 * NORM_W - r0.y,
400, 400, { shiftKey: true, pointerId: 13 }), dev3);
o.shiftStillSnapsToTheNodes = onGrid(c._layout[dev3.id].x);
c._cancelDeviceDrag();
c._setMode('view');
return o;
+19 -1
View File
@@ -156,7 +156,19 @@ const res = await page.evaluate(async () => {
tabs()[1].click(); await c.updateComplete;
out.directSwitch = c._mode === 'devices';
out.devBar = !!sr().querySelector('.editbar.devbar');
out.devBarBtns = sr().querySelectorAll('.editbar.devbar .btn:not(.barclose)').length === 2; // devices catalog + icon rules (#29)
const deviceToolbarButtons = [...sr().querySelectorAll('.editbar.devbar .btn:not(.barclose)')];
const addDeviceButton = deviceToolbarButtons[0];
out.devBarBtns = deviceToolbarButtons.length === 5;
out.deviceHistoryControlsArePersistent = !!sr().querySelector('[data-device-position-history="undo"]')
&& !!sr().querySelector('[data-device-position-history="redo"]')
&& sr().querySelector('[data-device-position-history="undo"]').disabled
&& sr().querySelector('[data-device-position-history="redo"]').disabled;
out.addDeviceShortcutUsesNativeKeyboardSemantics = addDeviceButton?.tagName === 'BUTTON'
&& addDeviceButton.disabled === false;
out.addDeviceShortcut = addDeviceButton?.textContent.trim() === c._t('devbar.add')
&& addDeviceButton.title === c._t('title.add_device')
&& addDeviceButton.querySelector('ha-icon')?.getAttribute('icon') === 'mdi:plus-box-outline'
&& deviceToolbarButtons[1]?.textContent.trim() === c._t('device_inbox.button');
const swapChrome = sr().querySelector('.editorchrome');
const swapInner = swapChrome.querySelector('.editorchrome-inner');
out.editorSwapAnimatesHeight = c._modeTransitionBusy
@@ -164,6 +176,12 @@ const res = await page.evaluate(async () => {
out.editorSwapAnimatesContent = c._modeTransitionBusy && !!swapInner
&& (c._modeTransitionVisual?.toolbarContentOpacity ?? 1) < 1;
await settleMode();
addDeviceButton.click(); await c.updateComplete;
out.addDeviceShortcutOpensNewDialog = !!c._markerDialog
&& c._markerDialog.name === ''
&& c._markerDialog.bindingMode === 'virtual'
&& !Object.prototype.hasOwnProperty.call(c._markerDialog, 'devId');
c._markerDialog = null; await c.updateComplete;
// 5) инструменты устройств из шапки исчезли (в .bar их больше нет)
out.headerCleanInDev = !sr().querySelector('.bar > .btn[title*="' + (c._t('title.add_device')) + '"]');
// 6) крестик на панели → Просмотр
+11 -4
View File
@@ -5,7 +5,7 @@
// and `await import(entry)` + `createElement(...).setConfig(...)` stays valid.
import { readFileSync } from 'node:fs';
import { chromium } from 'playwright';
import { check, checkAll } from './serve.mjs';
import { check, checkAll, finish } from './serve.mjs';
const entry = readFileSync('dist/houseplan-card.js');
const page404 = `<!doctype html><meta charset="utf-8"><body><script type="module">
@@ -41,13 +41,20 @@ const run = async (locale, expected) => {
const en = await run('en-US');
const ru = await run('ru-RU');
const fr = await run('fr-FR');
const out = {
entrySurvivesMissingChunk: en.pageErrors === 0 && ru.pageErrors === 0,
entrySurvivesMissingChunk: en.pageErrors === 0 && ru.pageErrors === 0 && fr.pageErrors === 0,
englishMessageVisible: en.text.includes('House Plan was updated')
&& en.text.includes('reload the page'),
russianMessageVisible: ru.text.includes('House Plan обновился')
&& ru.text.includes('перезагрузите страницу'),
frenchMessageVisible: fr.text.includes('House Plan a été mis à jour')
&& fr.text.includes('recharger la page'),
};
checkAll(out);
check('smoke_entry_stale', Object.values(out).every(Boolean));
if (Object.values(out).every(Boolean)) console.log('OK');
// #407: до этого смок не мог провалиться в принципе. `check`/`checkAll`
// складывали неудачи в `_failures`, но их никто не печатал и код возврата не
// выставлял — ровно тот паттерн «печатали булевы значения и всегда выходили
// нулём», который описан в шапке serve.mjs как исправленный в 2026-07-27.
// Браузеры закрыты внутри run(), поэтому finish() получает undefined.
await finish(undefined, out);
+50 -4
View File
@@ -10,7 +10,13 @@ const out = await page.evaluate(async () => {
const o = {};
const c = window.__card;
const PITCH = 1000 / 240;
window.confirm = () => true;
// The product owns dangerous confirmations through hp-confirm now. This
// smoke covers geometry transactions, while smoke_danger_confirmation
// exercises the real dialog; keep the decision deterministic here.
let confirmDecision = true;
c._confirmDanger = () => new Promise((resolve) => {
setTimeout(() => resolve(confirmDecision), 0);
});
// Exercise hp-dialog's HA branch without depending on the Home Assistant
// frontend in the standalone demo. The fixed one-line fallback below
// reproduces ha-dialog-header's public custom-property contract: Houseplan
@@ -187,15 +193,55 @@ const out = await page.evaluate(async () => {
c._physicalDialog = null;
await c.updateComplete;
c._physicalSel = { kind: 'partition', id: sp.partitions[0].id };
c._deletePhysicalSelection();
const partitionId = sp.partitions[0].id;
sp.openings = [{
id: 'hosted-opening', type: 'door', width_cm: 90,
host: { kind: 'partition', id: partitionId, t: 0.5 },
}];
c._physicalSel = { kind: 'partition', id: partitionId };
const hostedDelete = c._deletePhysicalSelection();
o.hostedPartitionDeleteReturnsPromise = typeof hostedDelete?.then === 'function';
await hostedDelete;
o.hostedPartitionWaitsForDecision = !!c._partitionDeleteDialog
&& c._serverCfg.spaces[0].partitions?.some((partition) => partition.id === partitionId);
c._partitionDeleteDialog = null;
delete sp.openings;
c._physicalSel = { kind: 'partition', id: partitionId };
const partitionDelete = c._deletePhysicalSelection();
o.partitionDeleteReturnsPromise = typeof partitionDelete?.then === 'function';
await partitionDelete;
o.deleteRemovesPartition = !c._serverCfg.spaces[0].partitions;
const draftId = c._serverCfg.spaces[0].room_drafts[0].id;
const historyBeforeCancel = c._geometryHistory.size;
confirmDecision = false;
c._physicalSel = { kind: 'draft', id: draftId, segment: 0 };
c._deletePhysicalSelection();
const cancelledDelete = c._deletePhysicalSelection();
o.cancelledDraftDeleteReturnsPromise = typeof cancelledDelete?.then === 'function';
await cancelledDelete;
o.cancelledDraftDeleteKeepsOutlineAndHistory = c._serverCfg.spaces[0].room_drafts?.[0]?.id === draftId
&& c._geometryHistory.size === historyBeforeCancel;
confirmDecision = true;
c._physicalSel = { kind: 'draft', id: draftId, segment: 0 };
const draftDelete = c._deletePhysicalSelection();
o.draftDeleteReturnsPromise = typeof draftDelete?.then === 'function';
await draftDelete;
o.deleteOnDraftRemovesWholeOutline = !c._serverCfg.spaces[0].room_drafts;
const columnId = c._serverCfg.spaces[0].wall_columns[0].id;
c._physicalSel = { kind: 'column', id: columnId };
const columnDelete = c._deletePhysicalSelection();
o.columnDeleteReturnsPromise = typeof columnDelete?.then === 'function';
await columnDelete;
o.deleteRemovesColumn = !c._serverCfg.spaces[0].wall_columns;
c._physicalSel = null;
const emptyDelete = c._deletePhysicalSelection();
o.emptyDeleteReturnsPromise = typeof emptyDelete?.then === 'function';
await emptyDelete;
drawAndFinish([400, 100], [500, 100], 120);
o.invalidThicknessCreatesNothing = !c._serverCfg.spaces[0].partitions;
+39
View File
@@ -0,0 +1,39 @@
// #371: French is the second lazy locale — community-contributed dictionary
// (@OUARZA). This smoke pins the fr-specific wiring only: automatic selection
// from an fr-CA profile, one lazy chunk per page, and the initial graph free
// of French. The generic failure machinery (retry, fallback, render gate,
// failure toast) is locale-agnostic and stays proven by smoke_german_locale.
import { readFileSync } from 'node:fs';
import { launchColdView, checkAll, finish } from './serve.mjs';
const manifest = JSON.parse(readFileSync('dist/houseplan-assets.json', 'utf8'));
const frPath = manifest.files.map((f) => f.path).find((p) => /(?:^|\/)fr-[^/]+\.js$/.test(p));
if (!frPath) throw new Error('French locale chunk is absent from the bundle manifest');
const frName = frPath.split('/').at(-1);
const { page, browser } = await launchColdView();
const requested = [];
page.on('request', (r) => requested.push(new URL(r.url()).pathname));
const out = await page.evaluate(async (frChunk) => {
const card = window.__card;
const out = {};
const waitFor = async (predicate, timeout = 8000) => {
const started = Date.now();
while (!predicate() && Date.now() - started < timeout) {
await new Promise((resolve) => setTimeout(resolve, 40));
}
return !!predicate();
};
// профиль HA fr-CA → французский, без явного выбора
card.hass = { ...card.hass, locale: { ...card.hass.locale, language: 'fr-CA' } };
card.requestUpdate();
out.frenchCommits = await waitFor(() => card._t('btn.save') === 'Enregistrer');
out.frenchTitleGlossary = card._t('space.header') === 'Espace';
out.langAttrIsFr = await waitFor(() => card.getAttribute('lang') === 'fr');
return out;
}, frName);
out.frChunkRequestedOnce = requested.filter((p) => p.endsWith(`/${frName}`)).length === 1;
out.initialGraphHasNoFrench = !manifest.initialViewFiles.some((p) => /(?:^|\/)fr-[^/]+\.js$/.test(p))
&& manifest.lazyLocaleFiles.some((p) => /(?:^|\/)fr-[^/]+\.js$/.test(p));
checkAll(out);
await finish(browser, out);
+119 -22
View File
@@ -8,8 +8,9 @@
// конфиг; touch/pen не рисуют hover и сохраняют только чистый tap;
// 5) магнит к стене: предмет прижимается к ближайшей стене и доворачивается
// по ней; Shift временно обходит магнит, сохраняя grid snap;
// 6) у выделенного предмета та же рамка, что у текстового блока, — угловые
// ручки (НЕзависимые ширина и глубина) и ручка поворота с шагом 5°;
// 6) у выделенного предмета расширенная рамка: угловые и четыре одноосевые
// ручки; угол сохраняет пропорции без Shift, с Shift оси независимы;
// поворот свободный, а Shift привязывает его к 45°;
// 7) во время растягивания угла показываются живые плашки размеров;
// 8) фигура пишется в конфиг и переживает пересборку, хуки card-mod на месте.
// ПАДАЕТ на сборке до этой задачи: инструмента «Мебель» нет, палитры нет,
@@ -314,14 +315,28 @@ const res = await page.evaluate(async () => {
await c.updateComplete;
// ================= 7. рамка, углы, плашки, поворот =======================
// Previous movement deliberately exercised the wall magnet and may have
// rotated the piece. Isolate the transform fixture at 0° and without flips.
c._curSpaceCfg.decor = c._decorList.map((shape) => {
if (shape.id !== sofaId) return shape;
const clean = { ...shape };
delete clean.angle; delete clean.flip_h; delete clean.flip_v;
return clean;
});
c._cfgEpoch++;
c._decorSel = sofaId; c.requestUpdate();
await c.updateComplete; await sleep(60); await c.updateComplete;
const frame = () => sr().querySelector('.dtframe');
out.frameOnSelection = !!frame();
out.sameFrameAsTheTextBlock = !!frame()
&& frame().querySelectorAll('.dthandle').length === 5
&& frame().querySelectorAll('.dtknob').length === 5
out.furnitureFrameHasFourSideHandles = !!frame()
&& frame().classList.contains('dtfurnitureframe')
&& frame().querySelectorAll('.dthandle').length === 9
&& frame().querySelectorAll('.dtknob').length === 9
&& frame().querySelectorAll('.dthandle.dtedge').length === 4
&& !!frame().querySelector('.dtrot');
out.sideHandlesExposeAxisCursors = [...frame().querySelectorAll('.dthandle.dtedge')]
.every((handle) => ['ew-resize', 'ns-resize'].includes(getComputedStyle(handle).cursor));
out.rotationHandleUsesCircularCursor = getComputedStyle(frame().querySelector('.dtrot')).cursor.includes('url(');
const rOf = (sel) => { const e = frame()?.querySelector(sel); return e ? +e.getAttribute('r') : NaN; };
out.handleSizeIsTheTaskOneSize = Math.abs(rOf('.dtknob') * 4 - rOf('.dthandle.dtrot'))
/ Math.max(rOf('.dthandle.dtrot'), 1e-9) < 0.05;
@@ -329,11 +344,26 @@ const res = await page.evaluate(async () => {
&& near(c._dtBox.w, sofaNow().w * 1000, 1e-6)
&& near(c._dtBox.h, sofaNow().h * 1000, 1e-6);
// угол: НЕзависимые ширина и глубина + живые плашки
const visiblePath = el(sofaId);
const hitPath = sr().querySelector(`.dfurniturehit[data-id="${sofaId}"]`);
out.selectionHaloUsesTheRealArtworkPath = !!visiblePath && !!hitPath
&& hitPath.getAttribute('d') === visiblePath.getAttribute('d')
&& hitPath.getAttribute('transform') === visiblePath.getAttribute('transform')
&& Number(hitPath.getAttribute('stroke-width')) > Number(visiblePath.getAttribute('stroke-width'))
&& getComputedStyle(hitPath).pointerEvents === 'stroke'
&& /transparent|rgba\(0, 0, 0, 0\)/.test(getComputedStyle(hitPath).stroke);
// Shift: independent, continuous width/depth + live plates.
const before = { w: sofaNow().w, h: sofaNow().h };
const b = c._dtBox || { x: 0, y: 0, w: 0, h: 0 };
const handles = [...(frame()?.querySelectorAll('.dthandle') || [])];
const se = handles[3] || stageEl(); // SE — четвёртый угол
// #400: угловая ручка ищется по РОЛИ, а не по индексу в DOM. Индекс держался
// на порядке отрисовки, а порядок — это решение о приоритете хита (углы
// рисуются последними, чтобы выигрывать нажатие у осевых на мелкой мебели),
// и оно имеет право меняться. Юго-восточный угол — второй `nwse`.
const cornerHandles = handles.filter((el) => !el.classList.contains('dtrot')
&& !el.classList.contains('dtedge'));
const se = cornerHandles[2] || stageEl(); // SE — [1, 1, 'nwse']
ev('pointerdown', se, b.x + b.w, b.y + b.h);
out.cornerDragStarted = c._dtDrag?.kind === 'scale' && !!c._dtDrag?.orig;
ev('pointermove', stageEl(), b.x + b.w + 100, b.y + b.h + 20, { shiftKey: true });
@@ -341,29 +371,60 @@ const res = await page.evaluate(async () => {
const grown = sofaNow();
out.cornerGrowsTheWidth = grown.w > before.w && near(grown.w * 1000, b.w + 100, 1e-6);
out.cornerGrowsTheDepthINDEPENDENTLY = grown.h > before.h
&& near(((grown.h * 1000) / PITCH) % 1, 0, 1e-6);
&& near(grown.h * 1000, b.h + 20, 1e-6);
out.shiftResizeIsSubGrid = !near((grown.h * 1000) / PITCH,
Math.round((grown.h * 1000) / PITCH), 1e-4);
out.oppositeCornerStaysPut = near(grown.x * 1000, b.x, 1e-6) && near(grown.y * 1000, b.y, 1e-6);
const plates = [...sr().querySelectorAll('.measurelabel.furnmeasure')];
out.twoLivePlates = plates.length === 2;
out.platesShowRealLengths = plates.length === 2
&& plates.every((p) => /\d/.test(p.textContent))
&& plates.some((p) => /m|м|′/.test(p.textContent));
// …и без Shift размер прилипает к сетке
// Without Shift the original ratio is preserved, still without grid snap.
ev('pointermove', stageEl(), b.x + b.w + 101.7, b.y + b.h + 21.3);
await c.updateComplete;
const snapped = sofaNow();
const wCells = (snapped.w * 1000) / PITCH;
const hCells = (snapped.h * 1000) / PITCH;
// Normalised config is rounded to six decimals, so a mathematical cell 27
// may read back as 26.9999999. Compare with the nearest integer, not `% 1`.
out.sizeSnapsToTheGrid = near(wCells, Math.round(wCells), 1e-4)
&& near(hCells, Math.round(hCells), 1e-4);
const proportional = sofaNow();
const wCells = (proportional.w * 1000) / PITCH;
out.defaultResizePreservesRatio = near(proportional.w / proportional.h, before.w / before.h, 1e-5);
out.defaultResizeIsSubGrid = !near(wCells, Math.round(wCells), 1e-4);
ev('pointerup', stageEl(), b.x + b.w + 101.7, b.y + b.h + 21.3);
await c.updateComplete;
out.cornerDragEnded = !c._dtDrag;
out.noPlatesAfterTheDrag = !sr().querySelector('.measurelabel.furnmeasure');
// поворот: шаг 5°, Shift мимо шага — та же механика, что у текста
// A middle handle changes only one axis and keeps the opposite edge fixed.
const edgeBefore = { ...sofaNow() };
const eb = c._dtBox;
const rightEdge = [...frame().querySelectorAll('.dthandle.dtedge')].find((handle) =>
near(+handle.getAttribute('cx'), eb.x + eb.w, 1e-6));
ev('pointerdown', rightEdge, eb.x + eb.w, eb.y + eb.h / 2);
ev('pointermove', stageEl(), eb.x + eb.w + 13.7, eb.y + eb.h / 2);
await c.updateComplete;
const edgeAfter = sofaNow();
out.edgeHandleChangesOneAxisContinuously = near(edgeAfter.h, edgeBefore.h, 1e-9)
&& near(edgeAfter.w * 1000, edgeBefore.w * 1000 + 13.7, 1e-6)
&& near(edgeAfter.x, edgeBefore.x, 1e-9) && near(edgeAfter.y, edgeBefore.y, 1e-9);
ev('pointerup', stageEl(), eb.x + eb.w + 13.7, eb.y + eb.h / 2);
await c.updateComplete;
// Crossing the fixed corner toggles only the crossed axis. pointercancel
// must restore the exact pre-gesture object and create no persisted flip.
const crossBefore = JSON.parse(JSON.stringify(sofaNow()));
const cb = c._dtBox;
const crossHandles = [...frame().querySelectorAll('.dthandle')]
.filter((el) => !el.classList.contains('dtrot') && !el.classList.contains('dtedge'));
const crossSe = crossHandles[2]; // #400: по роли, не по позиции в DOM
ev('pointerdown', crossSe, cb.x + cb.w, cb.y + cb.h);
ev('pointermove', stageEl(), cb.x - 25, cb.y + cb.h + 10, { shiftKey: true });
await c.updateComplete;
const crossed = sofaNow();
out.crossingTogglesOnlyHorizontalFlip = crossed.flip_h === true && !crossed.flip_v
&& crossed.w > 0 && crossed.h > 0 && near(crossed.x * 1000 + crossed.w * 1000, cb.x, 1e-6);
ev('pointercancel', stageEl(), cb.x - 25, cb.y + cb.h + 10, { shiftKey: true });
await c.updateComplete;
out.crossingCancelRestoresTheObject = JSON.stringify(sofaNow()) === JSON.stringify(crossBefore);
// Furniture rotation is free; Shift snaps to the nearest 45°.
await sleep(40); await c.updateComplete;
const cx = (sofaNow().x + sofaNow().w / 2) * 1000;
const cy = (sofaNow().y + sofaNow().h / 2) * 1000;
@@ -376,12 +437,14 @@ const res = await page.evaluate(async () => {
out.rotates45 = near(sofaNow().angle || 0, 45, 1e-6);
ev('pointermove', stageEl(), cx + 100, cy + 2); // ~1.1° → липнет к 0
await c.updateComplete;
out.snapsTo5 = (sofaNow().angle || 0) === 0;
const fine = sofaNow().angle;
out.rotationIsFreeWithoutShift = fine > 0.5 && fine < 5;
ev('pointermove', stageEl(), cx + 100, cy + 2, { shiftKey: true });
await c.updateComplete;
const fine = sofaNow().angle;
out.shiftGoesPastTheStep = fine > 0.5 && fine < 5;
ev('pointerup', stageEl(), cx + 100, cy + 2, { shiftKey: true });
out.shiftSnapsRotationTo45 = (sofaNow().angle || 0) === 0;
ev('pointermove', stageEl(), cx + 100, cy + 100);
await c.updateComplete;
ev('pointerup', stageEl(), cx + 100, cy + 100);
await c.updateComplete;
out.rotationIsRendered = /rotate\(/.test(attr(sofaId, 'transform') || '');
@@ -403,7 +466,41 @@ const res = await page.evaluate(async () => {
out.objectDialogSelectsStoredSymbol = sr().querySelector(
'.body select.namein option:checked',
)?.value === stored.symbol;
c._decorShapeDialog = null;
const sizeInputs = [...sr().querySelectorAll('hp-dialog input[type="number"][step="any"]')];
const flipInputs = [...sr().querySelectorAll('hp-dialog .dfill input[type="checkbox"]')];
out.furniturePropertiesExposeSignedSizesAndTwoFlips = sizeInputs.length === 2 && flipInputs.length === 2;
if (sizeInputs[0]) {
sizeInputs[0].value = String(-Math.abs(Number(sizeInputs[0].value)));
sizeInputs[0].dispatchEvent(new Event('input', { bubbles: true, composed: true }));
await c.updateComplete;
}
out.negativeWidthSynchronisesHorizontalCheckbox = !!c._decorShapeDialog?.flipH
&& !!sr().querySelectorAll('hp-dialog .dfill input[type="checkbox"]')[0]?.checked;
const currentFlipInputs = [...sr().querySelectorAll('hp-dialog .dfill input[type="checkbox"]')];
if (currentFlipInputs[1]) {
currentFlipInputs[1].checked = true;
currentFlipInputs[1].dispatchEvent(new Event('change', { bubbles: true, composed: true }));
await c.updateComplete;
}
const currentSizes = [...sr().querySelectorAll('hp-dialog input[type="number"][step="any"]')];
out.verticalCheckboxSynchronisesNegativeDepth = !!c._decorShapeDialog?.flipV
&& Number(currentSizes[1]?.value) < 0;
if (currentSizes[0]) {
currentSizes[0].value = '0';
currentSizes[0].dispatchEvent(new Event('input', { bubbles: true, composed: true }));
await c.updateComplete;
}
out.zeroSizeBlocksSave = sr().querySelector('hp-dialog button.btn.primary')?.disabled === true
&& sr().querySelector('hp-dialog input[type="number"][step="any"]')?.getAttribute('aria-invalid') === 'true';
c._decorShapeDialog = {
...c._decorShapeDialog,
sizeWField: String(-Math.abs(Number(stored.w) * 1000 * CELL / PITCH / 100)),
};
c._decorSaveShape(); await c.updateComplete;
const propertySaved = sofaNow();
out.propertySavePersistsPositiveExtentsAndFlags = propertySaved.w > 0 && propertySaved.h > 0
&& propertySaved.flip_h === true && propertySaved.flip_v === true
&& c._decorShapeDialog === null;
// ================= 9. инертность и удаление ==============================
c._decorTool = 'furniture'; c._furnPalette = null; await c.updateComplete;
+64 -1
View File
@@ -7,6 +7,7 @@ const out = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const frame = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const stageEl = () => sr().querySelector('.stage');
const toScreen = (x, y) => {
@@ -107,6 +108,67 @@ const out = await page.evaluate(async () => {
out.escapeClosesPalette = !c._furnPalette && c._decorTool === 'select';
out.escapeDetachesListeners = shiftRemoves === addsBeforeEscape;
// #400 AC1/AC2: на мелкой мебели угловая и осевая ручки перекрываются —
// радиус хита один и тот же (1.8 % вида), и на объекте уже, чем 4·hr, круги
// пересекаются. Побеждает нарисованный последним. Угол обязан быть сверху:
// боком масштабируется одна ось, углом — обе, и мелкая мебель — как раз тот
// случай, где пропорциональное изменение нужнее всего.
//
// Проверяется настоящим pointerdown в геометрический угол рамки:
// elementFromPoint через shadow root тут бесполезен (возвращает саму
// карточку), а какой обработчик сработал — видно только по вызову.
const handleProbe = async (size) => {
c._setMode('decor'); await c.updateComplete;
c._decorTool = 'select';
c._curSpaceCfg.decor = [{ id: 'probe', kind: 'furniture', symbol: 'fridge',
x: 0.3, y: 0.3, w: size, h: size, angle: 0, color: '#ff00ff', opacity: 1, width_cm: 8 }];
c._cfgEpoch++; c._decorSel = 'probe';
c._editorRuntime?._syncDecorFrame?.();
c.requestUpdate(); await c.updateComplete; await frame();
const box = sr().querySelector('.dtframe');
if (!box) return { frame: false };
const handles = [...box.querySelectorAll('.dthandle')];
const corner = handles.find((el) => !el.classList.contains('dtrot')
&& !el.classList.contains('dtedge'));
if (!corner) return { frame: true, corner: false };
const r = corner.getBoundingClientRect();
const cx = r.left + r.width / 2;
const cy = r.top + r.height / 2;
// Кто получит событие в этой точке: считаем нажатия на каждой ручке.
let hitEdge = 0; let hitCorner = 0;
const mark = (el) => {
const isEdge = el.classList.contains('dtedge');
const listener = () => { if (isEdge) hitEdge++; else hitCorner++; };
el.addEventListener('pointerdown', listener, true);
return () => el.removeEventListener('pointerdown', listener, true);
};
const off = handles.filter((el) => !el.classList.contains('dtrot')).map(mark);
const top = sr().elementsFromPoint
? sr().elementsFromPoint(cx, cy)
: document.elementsFromPoint(cx, cy);
const target = top.find((el) => el.classList && el.classList.contains('dthandle'));
if (target) {
target.dispatchEvent(new PointerEvent('pointerdown', {
clientX: cx, clientY: cy, pointerId: 91, isPrimary: true, button: 0,
bubbles: true, composed: true, cancelable: true,
}));
}
off.forEach((fn) => fn());
c._dtDrag = null;
return { frame: true, corner: true, hitCorner, hitEdge,
targetIsCorner: !!target && !target.classList.contains('dtedge') };
};
const small = await handleProbe(0.04); // 40 см на 10-метровом плане
const big = await handleProbe(0.16); // 160 см
out.smallFurnitureCornerWinsTheHit = small.targetIsCorner === true && small.hitCorner === 1
&& small.hitEdge === 0;
out.largeFurnitureKeepsBothHandles = big.targetIsCorner === true && big.hitCorner === 1
&& big.hitEdge === 0;
out.handleProbeDiagnostics = { small, big };
c._curSpaceCfg.decor = [];
c._decorSel = null;
c._setMode('background'); await c.updateComplete;
window.addEventListener = addReal;
window.removeEventListener = removeReal;
// прибрать поставленный диван
@@ -116,5 +178,6 @@ const out = await page.evaluate(async () => {
c.requestUpdate(); await c.updateComplete;
return out;
});
checkAll(out);
const { handleProbeDiagnostics, ...checks } = out;
checkAll(checks);
await finish(browser, out);
+7 -26
View File
@@ -1,5 +1,4 @@
import { launch, check, checkAll, finish } from './serve.mjs';
import { readFileSync } from 'node:fs';
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
@@ -22,10 +21,10 @@ const res = await page.evaluate(async () => {
.find((row) => row.textContent.trim() === c._t('gs.wall_group'));
out.glowRadiusInsideGlowGroup = !!glowRadius && !!wallGroup
&& !!(glowRadius.compareDocumentPosition(wallGroup) & Node.DOCUMENT_POSITION_FOLLOWING);
// 1b) блок About: версия + две внешние ссылки (target=_blank rel=noopener)
out.aboutVersion = sr().querySelector('hp-dialog .aboutver')?.textContent.trim() ?? null;
out.aboutLinks = [...sr().querySelectorAll('hp-dialog a.aboutlink')].map((a) => ({
href: a.getAttribute('href'), target: a.getAttribute('target'), rel: a.getAttribute('rel') }));
// #43: About moved into the dedicated Help & Feedback dialog and must not
// survive as a duplicate at the bottom of General Settings.
out.aboutMovedOut = !sr().querySelector('hp-dialog .aboutver')
&& sr().querySelectorAll('hp-dialog a.aboutlink').length === 0;
// 2) сменить цвет light_on и сохранить
c._setFillColor('light_on', { c: '#ff00ff', a: 0.5 });
await c._saveSettingsDialog(); await c.updateComplete;
@@ -54,30 +53,12 @@ const res = await page.evaluate(async () => {
out.spaceLqiOverridesCardDefault = sr().querySelectorAll('.dev .lqi').length > 0;
return out;
});
// CARD_VERSION из собранного бандла (тот же текст уходит в console-баннер).
// terser либо инлайнит строку (v1.56.0), либо оставляет переменную (v${xx}) —
// во втором случае доразрешаем её по присваиванию xx="1.56.0".
// Версия — SemVer, у пре-релиза есть суффикс (1.58.0-beta.1), он тоже часть строки.
const assetsRoot = new URL('./srv/assets/', import.meta.url);
const assetManifest = JSON.parse(readFileSync(new URL('houseplan-assets.json', assetsRoot), 'utf8'));
const bundle = assetManifest.files
.filter((file) => file.path.endsWith('.js'))
.map((file) => readFileSync(new URL(file.path, assetsRoot), 'utf8'))
.join('\n');
const SEMVER = '\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?';
const m = bundle.match(new RegExp(`HOUSEPLAN-CARD %c v(?:(${SEMVER})|\\$\\{(\\w+)\\})`));
const BUNDLE_VERSION = m?.[1] ?? (m?.[2] && bundle.match(new RegExp(`[^\\w$]${m[2]}="(${SEMVER})"`))?.[1]);
check('bundleVersionFound', typeof BUNDLE_VERSION === 'string' && BUNDLE_VERSION.length > 0);
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
checkAll(res, {
"rows": 15, // 11 цветов (включая wall_fill) + радиус свечения + фон
// + «Оптимизировать планы» (docs/CANVAS.md §9)
"groups": ["Fill: lights", "Fill: temperature", "Fill: zigbee signal", "Light-source glow", "Walls", "Stage background", "Sun", "Backup and transfer", "Plan maintenance", "About"],
"aboutVersion": `Houseplan Card v${BUNDLE_VERSION}`, // та же константа, что в баннере
"aboutLinks": [
{ "href": "https://github.com/Matysh/houseplan-card", "target": "_blank", "rel": "noopener" },
{ "href": "https://t.me/ha_houseplan", "target": "_blank", "rel": "noopener" },
],
"groups": ["Fill: lights", "Fill: temperature", "Fill: zigbee signal", "Light-source glow", "Walls", "Stage background", "Sun", "Backup and transfer", "Plan maintenance"],
"aboutMovedOut": true,
"saved": {"c": "#ff00ff", "a": 0.5},
"newSpaceUsesDaynight": true,
"floorImportUsesDaynight": true,
+57 -11
View File
@@ -1,6 +1,6 @@
/** Browser pixel smoke for the isolated additive group (#19). */
import { readFileSync } from 'node:fs';
import { launch } from './serve.mjs';
import { launch, reportPageErrors } from './serve.mjs';
const fixture = JSON.parse(readFileSync(
new URL('../test/fixtures/glow/additive-pools.json', import.meta.url), 'utf8',
@@ -87,20 +87,53 @@ try {
.filter((pool) => pool.hasAttribute('clip-path')).length;
await customElements.whenDefined('houseplan-space-card');
const staticOffCard = document.createElement('houseplan-space-card');
staticOffCard.setConfig({
type: 'custom:houseplan-space-card', space: fixture.config.spaces[0].id,
show_button: false,
});
staticOffCard.hass = hass;
document.body.appendChild(staticOffCard);
await until(() => staticOffCard.renderRoot?.querySelector('.hp-static-stage'));
await staticOffCard.updateComplete;
const staticBase = staticOffCard.renderRoot.querySelector('.glow-base-layer .glow-base');
const staticOffPools = staticOffCard.renderRoot.querySelectorAll('.glow-pool, .glowlayer').length;
const baseParity = !!fullBase && !!staticBase
&& fullBase.getAttribute('fill') === staticBase.getAttribute('fill')
&& fullBase.getAttribute('fill-opacity') === staticBase.getAttribute('fill-opacity');
const staticCard = document.createElement('houseplan-space-card');
staticCard.setConfig({
type: 'custom:houseplan-space-card', space: fixture.config.spaces[0].id,
show_button: false,
show_button: false, light_pools: true,
});
staticCard.hass = hass;
document.body.appendChild(staticCard);
await until(() => staticCard.renderRoot?.querySelector('.hp-static-stage'));
await until(() => staticCard._glowScreenBlend === true, 3000, 'static screen probe');
await until(() => staticCard.renderRoot.querySelectorAll('.glow-pool').length === 60,
10000, 'static Glow pools');
await staticCard.updateComplete;
const staticBase = staticCard.renderRoot.querySelector('.glow-base-layer .glow-base');
const staticPools = staticCard.renderRoot.querySelectorAll('.glow-pool, .glowlayer').length;
const baseParity = !!fullBase && !!staticBase
&& fullBase.getAttribute('fill') === staticBase.getAttribute('fill')
&& fullBase.getAttribute('fill-opacity') === staticBase.getAttribute('fill-opacity');
await frame();
const staticPoolGroup = staticCard.renderRoot.querySelector('.glow-pools');
const staticPoolNodes = [...staticCard.renderRoot.querySelectorAll('.glow-pool')];
const staticClippedPools = staticPoolNodes.filter((pool) => pool.hasAttribute('clip-path')).length;
const poolSnapshot = (root, pool) => {
const clipId = pool?.getAttribute('clip-path')?.match(/^url\(#(.+)\)$/)?.[1] || '';
const clip = [...root.querySelectorAll('clipPath')].find((node) => node.id === clipId);
return {
source: pool?.parentElement?.getAttribute('data-glow-source') || '',
cx: pool?.getAttribute('cx') || '', cy: pool?.getAttribute('cy') || '',
r: pool?.getAttribute('r') || '', parts: pool?.getAttribute('data-lit-parts') || '',
lit: [...(clip?.querySelectorAll('.glow-lit') || [])].map((path) => path.getAttribute('d') || ''),
};
};
const fullFirst = poolSnapshot(card.renderRoot, card.renderRoot.querySelector('.glow-pool'));
const staticFirst = poolSnapshot(staticCard.renderRoot, staticPoolNodes[0]);
const staticPointerEvents = getComputedStyle(
staticCard.renderRoot.querySelector('.hp-static-stage'),
).pointerEvents;
staticOffCard.remove();
staticCard.remove();
// The fixture deliberately has Temperature mode without temperature
@@ -129,7 +162,10 @@ try {
baseLayerOrder: follows(dataTunnel, fullBase)
&& follows(fullBase, baseTunnel) && follows(baseTunnel, poolFrame),
fullBase: !!fullBase, baseTunnel: !!baseTunnel,
staticBase: !!staticBase, staticPools, baseParity,
staticBase: !!staticBase, staticOffPools, baseParity,
staticPools: staticPoolNodes.length, staticClippedPools,
staticBlend: staticPoolGroup?.getAttribute('data-blend'),
staticPointerEvents, fullFirst, staticFirst,
customFill: customRoom?.style.getPropertyValue('--room-fill'),
customOpacity: customRoom?.style.getPropertyValue('--room-fill-op'),
customBase: !!customBase, customBaseTunnel: !!customBaseTunnel,
@@ -170,13 +206,23 @@ try {
if (!close(pixel(out.forward, 3), [106, 123, 140, 255])) throw new Error('non-pool sector/background changed');
if (out.clippedPools !== out.pools) throw new Error(`lost per-source clips: ${out.clippedPools}/${out.pools}`);
if (!out.baseLayerOrder) throw new Error('data/base/tunnel/pool layer order changed');
if (!out.fullBase || !out.baseTunnel || !out.staticBase || out.staticPools !== 0 || !out.baseParity)
throw new Error(`missing-data Glow fallback/parity failed: full=${out.fullBase}, tunnel=${out.baseTunnel}, static=${out.staticBase}, parity=${out.baseParity}`);
if (!out.fullBase || !out.baseTunnel || !out.staticBase || out.staticOffPools !== 0 || !out.baseParity)
throw new Error(`missing-data Glow fallback/parity failed: full=${out.fullBase}, tunnel=${out.baseTunnel}, static=${out.staticBase}, offPools=${out.staticOffPools}, parity=${out.baseParity}`);
if (out.staticPools !== out.pools || out.staticClippedPools !== out.staticPools)
throw new Error(`static pools/clips differ: pools=${out.staticPools}/${out.pools}, clips=${out.staticClippedPools}`);
if (out.staticBlend !== 'screen' || out.staticPointerEvents !== 'none')
throw new Error(`static Glow composition changed: blend=${out.staticBlend}, pointer=${out.staticPointerEvents}`);
if (JSON.stringify(out.staticFirst) !== JSON.stringify(out.fullFirst))
throw new Error(`full/static source geometry differs: ${JSON.stringify(out.fullFirst)} vs ${JSON.stringify(out.staticFirst)}`);
if (out.customFill !== '#486a8f' || Number(out.customOpacity) !== 0.42)
throw new Error(`custom data fill changed: ${out.customFill}/${out.customOpacity}`);
if (out.customBase || out.customBaseTunnel || !out.customLayerOrder)
throw new Error(`data fill was tinted/reordered by Glow base: full=${out.customBase}, tunnel=${out.customBaseTunnel}, order=${out.customLayerOrder}`);
console.log(JSON.stringify({ ok: true, blend: out.blend, pools: out.pools, staticParity: true }));
// #407: проверки выше бросают на своей регрессии, но про исключения внутри
// карточки не спрашивает ни одна. Бросаем и здесь — тогда `finally` закроет
// браузер, а строка успеха не напечатается после «FAILED».
if (await reportPageErrors()) throw new Error('uncaught exception inside the card — see EXC above');
console.log(JSON.stringify({ ok: true, blend: out.blend, pools: out.pools, staticParity: true, staticPools: out.staticPools }));
} finally {
await browser.close();
}
+32 -2
View File
@@ -1,5 +1,6 @@
// Issue #239: a finer coordinate grid changes precision, never the visible plan.
import { launch, checkAll, finish } from './serve.mjs';
import { coherentGridScaleStaticPatch } from './grid-scale-static-fixture.mjs';
const { page, browser } = await launch({ width: 1000, height: 860 }, 1, [], {
reducedMotion: 'reduce',
@@ -168,8 +169,16 @@ await page.evaluate(async () => {
while (!compact.renderRoot?.querySelector('.hp-static-stage') && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 30));
}
while ((compact._loading || !compact._snap) && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 30));
}
await compact.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
return {
config: card._serverCfg,
layout: sharedLayout,
revision: card._cfgEpoch,
};
};
});
@@ -240,7 +249,21 @@ const capture = async (cellCm, mode, {
const pixels = await stableScreenshot(stage);
let staticPixels = null;
if (staticCard) {
await page.evaluate(() => window.__makeStaticGridCard());
const source = await page.evaluate(() => window.__makeStaticGridCard());
const patch = coherentGridScaleStaticPatch(source);
await page.evaluate(async (nextPatch) => {
const compact = document.querySelector('#grid-static-host houseplan-space-card');
if (!compact) throw new Error('grid-scale static fixture card disappeared');
if (!compact._snap) throw new Error('grid-scale static fixture did not load its base snapshot');
// Preserve runtime-only Set/Map values from the in-page snapshot. Only
// config+layout cross the Playwright boundary; both come from one scale.
compact._snap = { ...compact._snap, ...nextPatch };
compact._loadedOnce = true;
compact._refreshDevices();
compact.requestUpdate();
await compact.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
}, patch);
staticPixels = await stableScreenshot(staticStage());
}
return { metrics, pixels, staticPixels };
@@ -262,7 +285,14 @@ const pixelDiff = async (left, right) => page.evaluate(async ([a, b]) => {
const decode = async (base64) => createImageBitmap(await (await fetch(`data:image/png;base64,${base64}`)).blob());
const [first, second] = await Promise.all([decode(a), decode(b)]);
if (first.width !== second.width || first.height !== second.height) {
return { sameSize: false, changed: Infinity, maxDelta: Infinity, meanDelta: Infinity };
return {
sameSize: false,
firstSize: [first.width, first.height],
secondSize: [second.width, second.height],
changed: Infinity,
maxDelta: Infinity,
meanDelta: Infinity,
};
}
const canvas = document.createElement('canvas');
canvas.width = first.width; canvas.height = first.height;
+16 -3
View File
@@ -71,9 +71,22 @@ const out = await page.evaluate(async () => {
bubbles: true, ...extra,
});
};
c._drag = { id: dev.id, sx: 400, sy: 400, ox: p0.x, oy: p0.y, moved: false };
c._pointerMove(mkDelta(137.7 - p0.x + OFF, 251.3 - p0.y + OFF), dev);
c._drag = null;
const devNode = sr().querySelector(`.dev[data-id="${dev.id}"]`);
const devRect = devNode.getBoundingClientRect();
const stageRect = sr().querySelector('.stage').getBoundingClientRect();
const deviceView = c._viewOr(c._baseVb());
const startX = devRect.left + devRect.width / 2;
const startY = devRect.top + devRect.height / 2;
const dxPx = ((137.7 - p0.x + OFF) / deviceView.w) * stageRect.width;
const dyPx = ((251.3 - p0.y + OFF) / deviceView.h) * stageRect.height;
const devicePointer = (type, x, y, buttons) => devNode.dispatchEvent(new PointerEvent(type, {
pointerId: 174, pointerType: 'mouse', button: 0, buttons,
clientX: x, clientY: y, bubbles: true, composed: true, cancelable: true,
}));
devicePointer('pointerdown', startX, startY, 1);
devicePointer('pointermove', startX + dxPx, startY + dyPx, 1);
devicePointer('pointerup', startX + dxPx, startY + dyPx, 0);
while (c._devicePositionBusy) await new Promise((resolve) => setTimeout(resolve, 5));
o.deviceLandsOnANode = onGridN(c._layout[dev.id].x) && onGridN(c._layout[dev.id].y);
// ---- 1c) a ROOM LABEL too ---------------------------------------------
+192 -1
View File
@@ -1,6 +1,6 @@
// Issue #68: contextual help trigger parity, dialog overlay ownership and the
// real non-Popover fallback. Run by the prerelease smoke suite, not on edits.
import { launch, checkAll, finish } from './serve.mjs';
import { launch, launchColdView, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 390, height: 780 }, 1);
await page.keyboard.press('Tab');
@@ -252,9 +252,200 @@ const res = await page.evaluate(async () => {
await card.updateComplete;
out.secondEscapeClosesDialog = !card._markerDialog;
// #86 Party 1: all general-settings placements are real help controls and
// opening one of them cannot mutate its associated setting.
card._openSettingsDialog();
await card.updateComplete;
const generalKeys = ['gs.glow_radius.help', 'gs.bg_mode.help', 'gs.north.help'];
out.party1GeneralInventory = generalKeys.every((key) => !!helpButton(key));
const generalBefore = JSON.stringify({
glowRadius: card._settingsDialog?.glowRadius,
bgMode: card._settingsDialog?.bgMode,
northDeg: card._settingsDialog?.northDeg,
});
helpButton('gs.bg_mode.help')?.click();
await frame();
out.party1GeneralHelpIsReadOnly = generalBefore === JSON.stringify({
glowRadius: card._settingsDialog?.glowRadius,
bgMode: card._settingsDialog?.bgMode,
northDeg: card._settingsDialog?.northDeg,
});
card._settingsDialog = null;
card.requestUpdate();
await card.updateComplete;
// The regular editor and cold-install onboarding render separate space
// dialog templates; this smoke covers the editor copy while the source/i18n
// contract covers both implementations.
card._openSpaceDialog('edit', card._space);
await card.updateComplete;
const spaceKeys = [
'space.cell_cm.help', 'space.zero_wall_style.help', 'space.bg_mode.help',
'space.north.help', 'space.fill_mode.help',
];
out.party1SpaceInventory = spaceKeys.every((key) => !!helpButton(key));
const spaceBefore = JSON.stringify({
cellCm: card._spaceDialog?.cellCm,
zeroWallStyle: card._spaceDialog?.zeroWallStyle,
bgMode: card._spaceDialog?.bgMode,
northDeg: card._spaceDialog?.northDeg,
fillMode: card._spaceDialog?.fillMode,
});
helpButton('space.zero_wall_style.help')?.click();
await frame();
out.party1SpaceHelpIsReadOnly = spaceBefore === JSON.stringify({
cellCm: card._spaceDialog?.cellCm,
zeroWallStyle: card._spaceDialog?.zeroWallStyle,
bgMode: card._spaceDialog?.bgMode,
northDeg: card._spaceDialog?.northDeg,
fillMode: card._spaceDialog?.fillMode,
});
card._spaceDialog = null;
card.requestUpdate();
await card.updateComplete;
card._openDeviceInbox();
await card.updateComplete;
const hiddenBefore = card._showHidden;
const hiddenHelpButton = helpButton('device_inbox.show_hidden.help');
hiddenHelpButton?.click();
await frame();
const hiddenSurface = helpSurface('device_inbox.show_hidden.help');
const hiddenBox = hiddenSurface?.getBoundingClientRect();
out.party1ShowHiddenHelpIsReadOnly = !!hiddenHelpButton
&& card._showHidden === hiddenBefore;
out.party1ShowHiddenHelpInsideNarrowViewport = !!hiddenBox
&& hiddenBox.left >= (visualViewport?.offsetLeft || 0)
&& hiddenBox.top >= (visualViewport?.offsetTop || 0)
&& hiddenBox.right <= (visualViewport?.offsetLeft || 0) + (visualViewport?.width || innerWidth)
&& hiddenBox.bottom <= (visualViewport?.offsetTop || 0) + (visualViewport?.height || innerHeight);
return out;
});
// Empty-install onboarding owns a separate lazy runtime and a second copy of
// the space dialog. Exercise that production chunk, not merely the regular
// editor template or its source inventory (#86 review r1 M1).
const onboarding = await launchColdView({ width: 390, height: 780 });
await onboarding.page.evaluate(async () => {
const card = window.__card;
card._onboardingShown = false;
card._serverCfg = { ...card._serverCfg, spaces: [] };
card._model = [];
card.hass = { ...card.hass, floors: {}, areas: {} };
card.requestUpdate();
await card.updateComplete;
});
await onboarding.page.waitForFunction(() => {
const card = window.__card;
return card._onboardingRuntime && !card._editorRuntime
&& card.renderRoot.querySelector('hp-dialog');
});
const onboardingHelp = await onboarding.page.evaluate(async () => {
const card = window.__card;
const root = card.renderRoot;
const keys = [
'space.cell_cm.help', 'space.zero_wall_style.help', 'space.bg_mode.help',
'space.north.help', 'space.fill_mode.help',
];
const help = (key) => root.querySelector(`hp-help[data-help-key="${key}"]`);
const before = JSON.stringify({
cellCm: card._spaceDialog?.cellCm,
zeroWallStyle: card._spaceDialog?.zeroWallStyle,
bgMode: card._spaceDialog?.bgMode,
northDeg: card._spaceDialog?.northDeg,
fillMode: card._spaceDialog?.fillMode,
});
const zeroWallHelp = help('space.zero_wall_style.help');
zeroWallHelp?.shadowRoot?.querySelector('.trigger')?.click();
await zeroWallHelp?.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const after = JSON.stringify({
cellCm: card._spaceDialog?.cellCm,
zeroWallStyle: card._spaceDialog?.zeroWallStyle,
bgMode: card._spaceDialog?.bgMode,
northDeg: card._spaceDialog?.northDeg,
fillMode: card._spaceDialog?.fillMode,
});
return {
inventory: keys.every((key) => !!help(key)?.shadowRoot?.querySelector('.trigger')),
selectedHelpOpened: zeroWallHelp?.shadowRoot
?.querySelector('.trigger')?.getAttribute('aria-expanded') === 'true',
readOnly: before === after,
editorStillLazy: !!card._onboardingRuntime && !card._editorRuntime,
};
});
res.party1ColdOnboardingInventory = onboardingHelp.inventory;
res.party1ColdOnboardingHelpOpens = onboardingHelp.selectedHelpOpened;
res.party1ColdOnboardingHelpIsReadOnly = onboardingHelp.readOnly;
res.party1ColdOnboardingKeepsEditorLazy = onboardingHelp.editorStillLazy;
await onboarding.browser.close();
// Browser zoom at 200% on a 780 px-wide display exposes a 390 CSS px viewport
// at DPR 2. Playwright cannot change Chromium's toolbar preference, so create
// that exact renderer contract directly; unlike CSS `zoom`, this preserves the
// coordinate space used by fixed/popover surfaces and catches real clipping.
const zoomed = await launch({ width: 390, height: 900 }, 2);
const zoomedLayout = await zoomed.page.evaluate(async () => {
const card = window.__card;
const root = card.renderRoot;
const frame = () => new Promise((resolve) => requestAnimationFrame(resolve));
card._openSettingsDialog();
await card.updateComplete;
await frame();
const help = root.querySelector('hp-help[data-help-key="gs.bg_mode.help"]');
const trigger = help?.shadowRoot?.querySelector('.trigger');
const stage = root.querySelector('.stage');
const dialogBody = root.querySelector('hp-dialog .body');
const stageRect = () => {
const box = stage?.getBoundingClientRect();
return box ? [box.left, box.top, box.width, box.height].map((value) => Math.round(value)) : null;
};
const settleStage = async () => {
let previous = null;
for (let attempt = 0; attempt < 8; attempt++) {
await frame();
const current = stageRect();
if (JSON.stringify(current) === JSON.stringify(previous)) return current;
previous = current;
}
return previous;
};
trigger?.scrollIntoView({ block: 'center', inline: 'nearest' });
await frame();
const before = await settleStage();
trigger?.click();
await help?.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const triggerBox = trigger?.getBoundingClientRect();
const surface = help?.shadowRoot?.querySelector('.tooltip')
|| root.querySelector('hp-dialog')?.shadowRoot
?.querySelector('[data-hp-overlay="help"]')?.shadowRoot?.querySelector('.tooltip');
const surfaceBox = surface?.getBoundingClientRect();
const viewport = window.visualViewport;
const left = viewport?.offsetLeft || 0;
const top = viewport?.offsetTop || 0;
const right = left + (viewport?.width || innerWidth);
const bottom = top + (viewport?.height || innerHeight);
const inside = (box) => !!box && box.width > 0 && box.height > 0
&& box.left >= left && box.top >= top && box.right <= right && box.bottom <= bottom;
return {
zoomApplied: devicePixelRatio === 2 && innerWidth === 390,
triggerInside: inside(triggerBox),
tooltipInside: inside(surfaceBox),
noHorizontalDialogOverflow: !!dialogBody
&& dialogBody.scrollWidth <= dialogBody.clientWidth + 1,
stageStable: JSON.stringify(await settleStage()) === JSON.stringify(before),
};
});
res.party1BrowserZoom200Applied = zoomedLayout.zoomApplied;
res.party1BrowserZoom200TriggerInside = zoomedLayout.triggerInside;
res.party1BrowserZoom200TooltipInside = zoomedLayout.tooltipInside;
res.party1BrowserZoom200NoHorizontalOverflow = zoomedLayout.noHorizontalDialogOverflow;
res.party1BrowserZoom200StageStable = zoomedLayout.stageStable;
await zoomed.browser.close();
res.keyboardFocus = keyboardFocus;
checkAll(res);
+5 -5
View File
@@ -79,10 +79,10 @@ Object.assign(out, await page.evaluate(async () => {
const virtualB = c._devices.find((x) => x.virtual && x.name === 'delete-one-b');
c._openMarkerDialog(virtualA); await c.updateComplete;
o.deleteForVirtual = !!sr().querySelector('hp-dialog .btn.danger');
const savedConfirm = window.confirm;
window.confirm = () => true;
const savedConfirmDanger = c._confirmDanger;
c._confirmDanger = async () => true;
await c._deleteMarker(); await c.updateComplete;
window.confirm = savedConfirm;
c._confirmDanger = savedConfirmDanger;
o.deleteOnlySelectedVirtual = !!virtualA && !!virtualB
&& !(c._serverCfg.markers || []).some((m) => m.id === virtualA.id)
&& (c._serverCfg.markers || []).some((m) => m.id === virtualB.id);
@@ -93,9 +93,9 @@ Object.assign(out, await page.evaluate(async () => {
const oldPos = { s: readd.space, x: 0.913, y: 0.917 };
c._layout = { ...c._layout, [readd.id]: oldPos };
c._openMarkerDialog(readd); await c.updateComplete;
window.confirm = () => true;
c._confirmDanger = async () => true;
await c._deleteMarker(); await c.updateComplete;
window.confirm = savedConfirm;
c._confirmDanger = savedConfirmDanger;
c._openMarkerDialog(); await c.updateComplete;
c._markerDialog = {
...c._markerDialog,
+4 -1
View File
@@ -1,5 +1,5 @@
// Asserts device icon badges sit exactly on their anchor point (no content-box/border drift).
import { launch } from './serve.mjs';
import { launch, reportPageErrors } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 1000 }, 2);
const bad = await page.evaluate(() => {
const c = window.__card;
@@ -24,5 +24,8 @@ const bad = await page.evaluate(() => {
return bad;
});
await browser.close();
// #407: своя развязка про исключения в карточке не спрашивает. Вердикт обязан
// именно остановить: иначе строка успеха печатается после «FAILED».
if (await reportPageErrors()) process.exit(1);
if (bad.length) { console.error('FAIL icon-center: badges off anchor', JSON.stringify(bad.slice(0, 5))); process.exit(1); }
console.log('OK icon-center: all device badges centred on their anchor');

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