Compare commits

...
Author SHA1 Message Date
Sergey Matyunin 5e9ececa58 build: prepare v1.71.0-beta.2 candidate
Issue: #426
Issue: #427
Issue: #428
Issue: #431
Issue: #432
Issue: #434
User-Visible: no
2026-09-03 14:46:39 +03:00
claude[bot] ef0efd5855 docs: review document for #434
Issue: #434
User-Visible: no
2026-09-03 11:30:47 +00:00
Sergey Matyunin c706f8a744 fix: remove stale decor catalog import
Issue: #434
User-Visible: no
2026-09-03 14:25:33 +03:00
claude[bot] bdba9f48ff docs: review document for #434
Issue: #434
User-Visible: no
2026-09-03 11:21:22 +00:00
Sergey Matyuninandclaude[bot] dbda64e89d test: catch stale danger locale gate
Issue: #434
User-Visible: no
2026-09-03 10:55:16 +00:00
Sergey Matyuninandclaude[bot] cba13621c8 fix: harden v1.71 audit boundaries
Issue: #434
User-Visible: yes
2026-09-03 10:55:16 +00:00
claude[bot] 4190db54ab docs: review document for #434
Issue: #434
User-Visible: no
2026-09-03 10:55:16 +00:00
Sergey Matyuninandclaude[bot] a2cdd360bc docs: bound smoke job in audit polish spec
Issue: #434
User-Visible: no
2026-09-03 10:55:16 +00:00
claude[bot] f383d8fd87 docs: review document for #434
Issue: #434
User-Visible: no
2026-09-03 10:55:15 +00:00
Sergey Matyuninandclaude[bot] 8a75c53eca docs: specify v1.71 audit polish
Issue: #434
User-Visible: no
2026-09-03 10:55:15 +00:00
Claude d1091b6d47 test(decor): путь загрузки картинки под автотестом
`src/backdrop-pick.ts` и `src/decor-image-editor.ts` не входили в
tsconfig.test.json — юнитов у них не было вовсе. Смок при этом подменял
`hass.callWS` целиком и до `uploadFromInput`, `upload`, `delete` не
доходил; в шапке файла это было честно написано. AC1 #51 обещал
доказательство «component unit + smoke» — не было ни того, ни другого.

Цена известна: #427 — файл тяжелее 2 МиБ нельзя было добавить ничем,
гасли обе кнопки диалога, включая «уменьшить копию», — прожил четыре
круга ревью именно потому, что этот путь не проверяло ничто.

Оба файла теперь в tsconfig.test.json (компиляция +6 с, без правок
продуктового кода) и покрыты 13 юнитами:

- классификация: проходной файл, тяжелее лимита источника (guard с
  probe.kind='safe' — предупреждение о размере, не об отказе), warn,
  hard, unknown, неподдерживаемый формат;
- состав кнопок диалога на трёх случаях. Декор свыше лимита: «Отмена» и
  «уменьшить копию», без «оставить оригинал» — это и есть #427. Подложка
  на том же файле сохраняет обе кнопки, hard остаётся с одной «Отмена»,
  unknown предупреждает, а не проходит тихо;
- uploadFromInput: сброс input.value, молчание на занятом, отказ формата,
  исключение классификации, передача флага замены в оба слота;
- upload: multipart-поле и URL, порядок каталога, палитра против замены
  ссылки в диалоге, восемь ответов бэкенда с разными текстами, отпускание
  busy на отказе, запрет второго запроса поверх незакончившегося;
- delete: ссылки на ассет, отказ в подтверждении, чистка каталога, карты
  и палитры, различение in_use, сохранность каталога на отказе сервера;
- loadCatalog: отбор валидных строк и тост на отказе.

Смок `smoke_decor_images.mjs` получил последний блок, который проходит
настоящие uploadFromInput → upload → delete в собранном бандле, подменяя
только транспорт: fetchWithAuth и одну ветку callWS.

Свидетели (§2.7, #435). Девять мутаций прогнаны на собранном дереве,
каждая краснит свой тест: условие #427 → тест состава кнобок; снятие
лимита источника; input.value; флаг замены; too_large; busy в upload;
подтверждение удаления; used_by; проглоченный отказ каталога. Мутация
busy сначала вешала прогон и уносила пять тестов cancelledByParent —
тест переписан так, чтобы падать на сравнении, а не по таймауту.

В реестр добавлены три мутанта на продуктовый код: условие #427, потеря
флага замены, схлопывание кодов ошибок.

Гейты: npm test 1806 tests, 1805 pass, 0 fail (было 1791);
mutation-gate --check зелёный. Смок локально не прогнать — Chromium в
песочнице нет, проверит CI.

Issue: #433
User-Visible: no
2026-09-03 13:21:08 +03:00
claude[bot] a6a9757b6d docs: review document for #432
Issue: #432
User-Visible: no
2026-09-03 10:09:05 +00:00
Sergey Matyunin d8e67f530c test(assets): isolate HA asset fixtures
Issue: #432
User-Visible: no
2026-09-03 12:51:20 +03:00
Sergey Matyunin f3c32fb203 fix(assets): bound resolve integrity work
Issue: #432
User-Visible: yes
2026-09-03 12:48:08 +03:00
claude[bot] 58df908db2 docs: review document for #432
Issue: #432
User-Visible: no
2026-09-03 09:38:37 +00:00
Sergey Matyunin 17a1c10bef docs(spec): define bounded asset resolution
Issue: #432
User-Visible: no
2026-09-03 12:32:33 +03:00
claude[bot] 431b462a16 docs: review document for #431
Issue: #431
User-Visible: no
2026-09-03 08:46:43 +00:00
Sergey Matyunin 6559679b63 build: synchronize issue 431 artifacts
Issue: #431
User-Visible: no
2026-09-03 11:33:40 +03:00
Sergey Matyunin 58efb6a1b2 fix(geometry): canonicalize decor image coordinates
Issue: #431
User-Visible: yes
2026-09-03 11:26:31 +03:00
claude[bot] d97bf63feb docs: review document for #431
Issue: #431
User-Visible: no
2026-09-03 08:21:10 +00:00
Sergey Matyunin 260af7bfd2 docs(spec): define image coordinate canonicalization
Issue: #431
User-Visible: no
2026-09-03 11:16:08 +03:00
claude[bot] 34853ba32a docs: review document for #428
Issue: #428
User-Visible: no
2026-09-03 08:09:36 +00:00
Sergey Matyuninandclaude[bot] 972b0e0c0a test(import): account for space id remapping
Issue: #428
User-Visible: no
2026-09-03 08:09:36 +00:00
Sergey Matyuninandclaude[bot] 2c4dd3f638 fix(import): preserve missing decor asset round-trips
Issue: #428
User-Visible: yes
2026-09-03 08:09:36 +00:00
claude[bot] 7cf0105d62 docs: review document for #428
Issue: #428
User-Visible: no
2026-09-03 08:09:36 +00:00
Sergey Matyuninandclaude[bot] 6f8ce48e5b docs(spec): define missing decor asset round-trip
Issue: #428
User-Visible: no
2026-09-03 08:09:36 +00:00
Claude 8b9548775f test(backend): HA-зависимый тест объявляет это сам, а не именем файла
`python3 -m pytest tests_backend/` без Home Assistant обрывался НА СБОРКЕ:
`test_coordinate_canonicalization.py` тянет HA через `store`, а
`collect_ignore_glob` в conftest отсекает только `test_ha_*.py`. Ни один
из трёх сотен чистых тестов при этом не выполнялся, хотя CLAUDE.md и
PROCESS.md §8 обещают ровно обратное. В CI дефект невидим: там HA есть и
список игнора пуст.

Признак «нужен ли файлу HA» был подменён признаком «как файл назван» —
та же конструкция, которая в #389 уронила 85 тестов с голым assert False.

Вариант владельца — третий: `pytest.importorskip("homeassistant")` в
самом файле, до импортов, которые тянут HA. Теперь это честный скип
(«1 skipped» вместо «Interrupted»), остальные файлы прогоняются, а в CI
не скипается ничего.

Обещанная проверка остальных файлов сделана пофайловым collect: из
двадцати HA требует ровно один непоименованный — этот. Чтобы второй не
появился молча, добавлен статический гейт: он читает импорты (не
исполняет), строит множество модулей интеграции, тянущих HA, — с
замыканием по относительным импортам, потому что `import_export`
зависит от HA только через `store`, — и требует у такого теста либо имя
`test_ha_*`, либо importorskip.

Свидетели, каждый проверен отрицательным прогоном:
- снять importorskip → красный «файлам нужен HA, но они этого не
  объявляют»;
- новый чистый файл с импортом store → тот же красный;
- убрать замыкание → красный синтетический тест сканера;
- перестать исключать TYPE_CHECKING → красный он же;
- считать импорты внутри функций → красный он же.
Плюс два свидетеля самого сканера в теле гейта: `store` обязан быть
найден, `coordinate_canonicalization` обязан остаться чистым — иначе
«ничего не нашёл» выглядело бы как «всё в порядке».

Мутант `pure-backend-test-pulls-home-assistant` в реестре: добавляет
импорт store в чистый test_projection.py, guard — этот гейт.

Гейты: npm test 1791 tests, 1790 pass, 0 fail; pytest без HA
312 passed, 3 skipped (было: Interrupted, 0 выполнено);
mutation-gate --check зелёный.

Issue: #436
User-Visible: no
2026-09-03 11:06:25 +03:00
Claude bdf53dc482 docs: §2.7 требует таблицу «чем краснеет» у защитных AC
§2.7 уже требовал нужного: ревьюер обязан убедиться, что тест умеет
падать. Требование было, артефакта у него не было — в документе ревью
ничто не заставляло назвать, чем именно проверка краснеет, и «убедился»
писалось по впечатлению от чтения теста.

Цена известна по факту. Аудит v1.71.0-beta.1 нашёл пять защитных
контрактов #51 и #423, где снятие защиты не покраснило ни один тест
(#430). Все пять прошли код-ревью как доказанные; два теста попали в
закрытие coverage-ratchet под именами, обещавшими то, чего они не
проверяли.

Теперь у защитного AC три столбца: чем доказан, и отдельно — чем
краснеет, с результатом прогона. Пустой третий столбец — Medium.

Считаются защитные AC без свидетеля, а не мутанты на подсистему: у #421
мутанты были, и дыра всё равно проехала. Число мутантов остаётся
признаком — у #423 их ноль, и именно там нашёлся тест, спрашивавший
регулярку, находит ли она подстроку, которую сам же и вырезал.

Границы записаны явно, чтобы правило не стало ритуалом: AC без заявки на
защиту его не касаются, а «проверено чтением» остаётся законным — только
это видно во втором столбце.

Гейты: npm test 1791 tests, 1790 pass, 0 fail.

Issue: #435
User-Visible: no
2026-09-03 10:58:12 +03:00
Claude 1a8a51f2d9 fix(gates): ключ смоков покрывает оснастку и пробы гарда
Прогон #2371 (ee678352) добавил в demo/guard/verify-guard.mjs пробу
гарда benchmark — и job «Смоки в браузере» была ПРОПУЩЕНА как
переиспользованная. Причина в HARNESS.smoke: ключ держал ровно
demo/smoke_*.mjs, а `demo/serve.mjs`, `demo/guard/**` и benchmark, который
проба запускает, в него не входили. Файл исполняется только в этой job,
его правка её ключ не меняла — проба уехала в dev, ни разу не
запустившись.

Пропущенная проверка выглядит точно как пройденная: это тот же дефект,
против которого заведён #430, только этажом выше — не тест не умеет
падать, а job не запускается. Заодно это значит, что правки #404 в
serve.mjs и любой из четырёх проб гарда всё это время могли быть
реюзнуты.

Критерий ключа теперь один и читаемый: всё, что job исполняет.
Benchmark'и берутся все, а не по имени, — любой может быть подключён к
пробам позже, и лишний прогон дешевле молчания. demo/fixtures/** не
добавлен: он в корпусе sourceFingerprint, который уже подмешан в ключ.

Тест проверяет обе половины: файлы попадают в оснастку И правка каждого
меняет ключ. На прежнем предикате он краснеет (проверено).

Гейты: npm test 1791 tests, 1790 pass, 0 fail. Этот коммит меняет ключ
смоков, поэтому job со смоками на нём прогонится по-настоящему — вместе
с той пробой, ради которой всё и затевалось.

Issue: #430
User-Visible: no
2026-09-03 10:38:47 +03:00
Claude ee678352c7 test(gates): свидетели пяти защитных контрактов #51 и #423
Аудит v1.71.0-beta.1 (§3.2 M1/M2/M3/M6/M9) прогнал по мутанту на каждый
контракт: пять снятий защиты не покраснили ни один тест. Восьмой подряд
случай проверок, не умеющих падать, и первый — в бете, закрывавшей #421,
задачу ровно об этом.

Каждый свидетель ниже проверен отрицательным прогоном: мутант краснит
ровно свой тест и не задевает остальные.

1. Полное декодирование растра. `test_supported_raster_headers_and_full_decode`
   спрашивал w/h/mime — их даёт header-парсер; обрезка `PNG_1X1[:33]`
   теряет IEND и отбивается там же. No-op блока Pillow оставлял 35 pass.
   Новый свидетель: PNG с верными сигнатурой, IHDR, IEND, длинами и CRC,
   но с текстом вместо zlib-потока в IDAT. Оба прежних теста
   переименованы — их имена обещали то, чего они не проверяли.
   Пропуск без Pillow остался (validate_asset глотает ImportError
   осознанно), но в окружении с Home Assistant отсутствие Pillow теперь
   красное само по себе — иначе свидетель молча скипался бы в каноне.

2. Канонизация SVG. `ValidatedAsset(canonical,…)` → `ValidatedAsset(data,…)`
   не краснило ничего: все тесты смотрели w/h/mime, ни один — байты.
   Свидетель сверяет байты целиком: пролог и комментарий не переживают
   канонизацию, пустой элемент сжимается.
   Вторая половина — `_check_size(canonical)`. Экранирование `>` в тексте
   раздувает документ вчетверо: загрузка 1.84 МиБ канонизуется в 7.35 МиБ
   и до сих пор проходила входной контроль. Свидетель — ровно такая.

3. Гард внешних URL. Все три «внешних» кейса корпуса ловились другими
   правилами (тег не из словаря, атрибут не из словаря, ветвь href), и
   `if False:` не краснило ничего. Свидетель: разрешённый тег, разрешённый
   атрибут, пять токенов — сработать может только сам гард, сообщение это
   подтверждает.

4. Проекция декора. Единственный кейс задавал flip_h и opacity: 2 → 1;
   ожидание неотличимо от «opacity игнорируется», а flip_v не проверялся
   вовсе. Свидетели: четыре комбинации отражений, opacity 0.4 и 0 против
   заглушки, форма asset_id с согласованным url (прежняя строка ловилась
   сравнением url, поэтому регулярку id можно было удалить незаметно).

5. Гард benchmark из #423. Доказательство было циклическим: тест вырезал
   из текста подстроку и спрашивал регулярку, находит ли она её, — а
   регулярка искала именно её. Динамический режим `--guard-probe`
   существовал с #423 и не вызывался ни одним прогоном. Теперь он в
   `demo/guard/verify-guard.mjs` (умеет аргументы и файл выше каталога),
   а тест из обнаружения требует у нового page-benchmark режим пробы и
   запись в верификаторе.

Мутантов в реестре стало восемь новых: четыре бэкендных, три юнитных,
один на пробах гарда. У #423 их было ноль — единственная задача с
циклическим тестом и она же единственная без мутантов.

Гейты: typecheck зелёный; npm test 1790 tests, 1789 pass, 0 fail;
pytest без HA 310 passed, 2 skipped; npm run build зелёный, dist не
изменился (продуктовый код не тронут); mutation-gate --check применяет
все восемь якорей. Полный прогон новых мутантов — следующим шагом.

Issue: #430
User-Visible: no
2026-09-03 10:28:00 +03:00
claude[bot] c3eb225c8f docs: review document for #427
Issue: #427
User-Visible: no
2026-09-03 07:20:27 +00:00
Sergey Matyunin f71de89975 docs: refresh screenshot source fingerprint
Issue: #427
User-Visible: no
2026-09-03 10:07:25 +03:00
Sergey Matyunin b87e99f9a1 fix: keep decor downscale action available
Issue: #427
User-Visible: yes
2026-09-03 10:04:45 +03:00
Claude 4cabcbe828 chore(gates): убрать числовой храповик бюджета из #423
Проверка `assertSupportBundleOwnership` бросала при
`initialViewGzipBytes >= 291046` — «граф не стал больше, чем на момент
закрытия #423». Запас до порога: пятнадцать байт на бете 1.71.0, сто
четыре сейчас. Пятнадцать байт gzip меньше одной строки локали, поэтому
первый же посторонний коммит получил бы красный CI с сообщением про
копирайт формы поддержки — к которому не имеет отношения.

«Граф не вырос» — критерий приёмки на момент задачи, а не свойство
продукта. Свойство продукта охраняют `INITIAL_VIEW_GZIP_BUDGET` (300000)
и предупреждение о низком запасе: они судят размер целиком и не
привязаны к чужому номеру issue.

Долговечная половина того же контракта — владение графом (маркеры формы
отсутствуют в initial View, присутствуют в ленивом редакторе) — осталась
и по-прежнему падает в обе стороны. Новый тест закрепляет, что размер в
эту функцию вернуться не может: 0, 291046, 10 МБ и `undefined` проходят
одинаково, пока владение соблюдено.

Гейты: typecheck зелёный, npm test 1787/1786 pass 0 fail, npm run build
зелёный (dist побайтово не изменился — продуктовый код не тронут),
node scripts/bundle-budget.mjs зелёный: initial View 290942 Б, бюджет
300000 Б, запас 9058 Б плюс честное предупреждение о запасе.

Issue: #429
User-Visible: no
2026-09-03 09:56:54 +03:00
claude[bot] cc09e8f827 docs: review document for #426
Проверка (CI) / Классификация изменённых файлов (push) Successful in 24s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Successful in 50s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 45s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 17s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 24s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 13m24s
Проверка (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 6m56s
Issue: #426
User-Visible: no
2026-09-03 06:38:29 +00:00
Sergey Matyunin a6eb3d6cfa test: accept room tooltip settings golden
Issue: #426
User-Visible: no
Release: v1.71.0-beta.2
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/33722471494
2026-09-03 09:25:14 +03:00
Sergey Matyunin 223951c042 chore: refresh config schema manifest
Issue: #426
User-Visible: no
2026-09-03 09:16:22 +03:00
Sergey Matyunin 9d1e5fdbba docs: refresh screenshot source fingerprint
Issue: #426
User-Visible: no
2026-09-03 09:12:50 +03:00
Sergey Matyunin 8013f7d052 feat: add room hover tooltip setting
Issue: #426
User-Visible: yes
2026-09-03 09:06:57 +03:00
claude[bot] ac99a2ff08 docs: review document for #426
Issue: #426
User-Visible: no
2026-09-03 05:52:52 +00:00
Sergey Matyunin 587c20699c docs: specify room hover tooltip toggle
Issue: #426
User-Visible: no
2026-09-03 08:47:07 +03:00
127 changed files with 19357 additions and 11856 deletions
+6 -1
View File
@@ -489,6 +489,7 @@ jobs:
needs: [frontend, reuse]
if: needs.reuse.outputs.smoke != 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
# Смоки шардируются: последовательный прогон занимал ~7.5 минут и был
# критическим путём всего Validate. Три шарда режут его примерно вдвое;
# цена — трижды `npm ci` и сборка, около двух оплаченных минут раннера.
@@ -556,10 +557,14 @@ jobs:
if [ $(( (index - 1) % SHARDS + 1 )) -ne "$SHARD" ]; then continue; fi
ran=$((ran + 1))
name=$(basename "$f" .mjs)
if node "$f" > "/tmp/smoke-logs/$name.log" 2>&1; then
if timeout --kill-after=10s 180s node "$f" > "/tmp/smoke-logs/$name.log" 2>&1; then
echo "ok $name"
else
status=$?
echo "FAIL $name"
if [ "$status" -eq 124 ]; then
echo "diagnostic smoke-timeout: $name exceeded 180s"
fi
# Диагностические строки смока печатаются раньше вердикта, и
# `tail -20` их срезал: на #411 сам смок сообщал, НАСКОЛЬКО
# разошлись кадры (введено в #302), а в логе прогона осталось
+29
View File
@@ -193,6 +193,35 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
кода отвечает на вопрос «оно вообще работает»: каждый AC либо доказан
автотестом — и ревьюер убедился, что **тест умеет падать**, — либо разобран по
коду с явной записью «проверено чтением, не исполнением».
- **Защитный AC доказывается таблицей «чем краснеет» (#435).** Для каждого AC,
заявляющего защиту — валидация, гард, лимит, отказ, инвариант, — в документе
ревью обязательна строка из трёх столбцов: **AC · чем доказан** (точная
команда или имя теста) **· чем краснеет** — мутация, снятая защита или
отрицательная проба, с результатом прогона. Пустой третий столбец — находка
Medium, а не примечание.
«Тест умеет падать» без названной мутации и её вывода доказательством не
является. Аудит v1.71.0-beta.1 нашёл пять контрактов #51 и #423, где тест
оставался зелёным на снятой защите; все пять прошли код-ревью как доказанные,
а два теста были записаны в закрытие coverage-ratchet под именами, обещавшими
то, чего они не проверяли (#430).
Мутант в `scripts/mutation-gate.mjs` обязателен, когда защита живёт в
продуктовом коде и проверяется дорогим гейтом (смок, бэкенд, golden): там
ревьюер не воспроизведёт отрицательный прогон второй раз. Для чистых юнитов
достаточно прогона со снятой защитой, приведённого в документе.
Считаются **защитные AC без названного свидетеля**, а не мутанты на
подсистему: у #421 мутанты были, и дыра всё равно проехала. «Сколько мутантов
принесла задача» остаётся признаком — у #423 их ноль, и именно у #423 нашёлся
тест, спрашивавший регулярку, находит ли она подстроку, которую сам же и
вырезал.
Правило не распространяется на AC, не заявляющие защиту (расположение, текст,
формат вывода): там свидетель — обычное сравнение ожидаемого с фактическим, и
третий столбец превратился бы в ритуал. И не отменяет «проверено чтением»:
тогда во втором столбце стоит «чтением», а не имя теста, и читатель ревью
видит разницу.
- **High блокируют.** Medium **в скоупе задачи** чинится в текущем issue:
без High это жёлтый вердикт и возврат автору, фикс проходит повторный цикл.
Medium **вне скоупа** — отдельный issue (#202).
@@ -0,0 +1,139 @@
"""Bounded, shared integrity verification for content-addressed decor assets."""
from __future__ import annotations
import hashlib
import logging
import threading
from collections import OrderedDict
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
ASSET_INTEGRITY_CACHE_ENTRIES = 256
ASSET_HASH_CHUNK_BYTES = 64 * 1024
_HASS_DATA_KEY = "asset_integrity_verifier"
@dataclass(frozen=True)
class FileSignature:
"""File version facts available without reading its content."""
size: int
mtime_ns: int
ctime_ns: int
@dataclass(frozen=True)
class _CacheEntry:
signature: FileSignature
digest: str
@dataclass
class _Flight:
event: threading.Event
digest: str | None = None
def _signature(path: Path) -> FileSignature:
stat = path.stat()
return FileSignature(
size=stat.st_size,
mtime_ns=stat.st_mtime_ns,
ctime_ns=stat.st_ctime_ns,
)
def _stream_sha256(path: Path) -> str:
"""Hash a blob without retaining its bytes in memory."""
digest = hashlib.sha256()
with path.open("rb") as stream:
while chunk := stream.read(ASSET_HASH_CHUNK_BYTES):
digest.update(chunk)
return digest.hexdigest()
class AssetIntegrityVerifier:
"""Thread-safe LRU digest cache with per-file-version single-flight."""
def __init__(
self,
max_entries: int = ASSET_INTEGRITY_CACHE_ENTRIES,
*,
hasher: Callable[[Path], str] | None = None,
event_factory: Callable[[], threading.Event] | None = None,
) -> None:
if max_entries < 1:
raise ValueError("max_entries must be positive")
self._max_entries = max_entries
self._hasher = hasher or _stream_sha256
self._event_factory = event_factory or threading.Event
self._lock = threading.Lock()
self._cache: OrderedDict[str, _CacheEntry] = OrderedDict()
self._inflight: dict[tuple[str, FileSignature], _Flight] = {}
def verify(self, path: Path, expected_digest: str) -> bool:
"""Return whether one stable file version has the expected digest."""
try:
canonical = str(path.resolve())
except (OSError, RuntimeError):
return False
try:
before = _signature(path)
except OSError:
with self._lock:
self._cache.pop(canonical, None)
return False
key = (canonical, before)
with self._lock:
cached = self._cache.get(canonical)
if cached is not None and cached.signature == before:
self._cache.move_to_end(canonical)
return cached.digest == expected_digest
if cached is not None:
self._cache.pop(canonical, None)
flight = self._inflight.get(key)
owner = flight is None
if owner:
flight = _Flight(self._event_factory())
self._inflight[key] = flight
assert flight is not None
if not owner:
flight.event.wait()
return flight.digest == expected_digest
digest: str | None = None
stable = False
try:
digest = self._hasher(path)
# Never publish a digest for bytes that changed while they were read.
stable = _signature(path) == before
except Exception as err: # noqa: BLE001 - filesystem/hash seam fails dark
_LOGGER.debug("House Plan asset integrity check failed: %s", err)
finally:
with self._lock:
if stable and digest is not None:
self._cache[canonical] = _CacheEntry(before, digest)
self._cache.move_to_end(canonical)
while len(self._cache) > self._max_entries:
self._cache.popitem(last=False)
flight.digest = digest
self._inflight.pop(key, None)
flight.event.set()
return stable and digest == expected_digest
def get_asset_integrity_verifier(hass: Any) -> AssetIntegrityVerifier:
"""Return the single verifier shared by HTTP and WS on this HA instance."""
domain_data = hass.data.setdefault(DOMAIN, {})
verifier = domain_data.get(_HASS_DATA_KEY)
if not isinstance(verifier, AssetIntegrityVerifier):
verifier = AssetIntegrityVerifier()
domain_data[_HASS_DATA_KEY] = verifier
return verifier
+1 -1
View File
@@ -51,7 +51,7 @@ SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
ASSETS_DIR = "houseplan/assets"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.71.0-beta.1"
VERSION = "1.71.0-beta.2"
DECOR_ASSETS_API_VERSION = 1
# #43: the support transport is deliberately not configurable. A user supplied
@@ -14,6 +14,7 @@ COORDINATE_DECIMALS = 9
COORDINATE_FACTOR = 10**COORDINATE_DECIMALS
LATTICE_GRID_N = 240
LATTICE_NOISE_STEPS = 1e-4
DECOR_BOX_KINDS = ("rect", "ellipse", "furniture", "image")
def canonicalize_number(value: Any) -> Any:
@@ -150,7 +151,7 @@ def canonicalize_config_geometry(config: Any) -> Any:
kind = decor.get("kind")
if kind == "line":
_lattice_fields(decor, ("x1", "y1", "x2", "y2"))
elif kind in ("rect", "ellipse", "furniture"):
elif kind in DECOR_BOX_KINDS:
_lattice_fields(decor, ("x", "y", "w", "h"))
_scalar_fields(decor, ("angle",))
elif kind == "text":
+53 -9
View File
@@ -10,6 +10,7 @@ import hashlib
import json
import math
import re
import stat
import struct
import xml.etree.ElementTree as ET
from dataclasses import dataclass
@@ -372,20 +373,63 @@ def asset_meta_path(root: Path, asset_id: str) -> Path:
return root / f"{asset_id}.json"
def physical_asset_blobs(root: Path) -> list[Path]:
"""Return exact promoted blob files, independently from their sidecars."""
if not root.is_dir():
return []
blobs: list[Path] = []
for path in root.iterdir():
if (
path.suffix in ASSET_EXTENSIONS
and ASSET_ID_RE.fullmatch(path.stem)
and stat.S_ISREG(path.stat(follow_symlinks=False).st_mode)
):
blobs.append(path)
return sorted(blobs, key=lambda path: path.name)
def physical_asset_usage(root: Path) -> tuple[int, int]:
"""Return promoted blob count and actual bytes used for quota checks."""
blobs = physical_asset_blobs(root)
return len(blobs), sum(path.stat().st_size for path in blobs)
def _read_catalog_row(root: Path, path: Path) -> dict[str, Any] | None:
"""Read one sidecar through the validation shared by list and resolve."""
try:
row = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(row, dict):
return None
aid = str(row.get("asset_id") or "")
ext = row.get("ext")
blob = root / f"{aid}{ext}"
if (
path.stem != aid
or not ASSET_ID_RE.fullmatch(aid)
or ext not in ASSET_EXTENSIONS
or not blob.is_file()
):
return None
return row
except (OSError, ValueError, TypeError):
return None
def read_asset(root: Path, asset_id: str) -> dict[str, Any] | None:
"""Read one exact catalog row without scanning unrelated sidecars."""
if not ASSET_ID_RE.fullmatch(asset_id):
return None
return _read_catalog_row(root, asset_meta_path(root, asset_id))
def read_catalog(root: Path) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
if not root.is_dir():
return rows
for path in root.glob("*.json"):
try:
row = json.loads(path.read_text(encoding="utf-8"))
aid = str(row.get("asset_id") or "")
ext = row.get("ext")
blob = root / f"{aid}{ext}"
if ASSET_ID_RE.fullmatch(aid) and ext in ASSET_EXTENSIONS and blob.is_file():
rows.append(row)
except (OSError, ValueError, TypeError):
continue
row = _read_catalog_row(root, path)
if row is not None:
rows.append(row)
return sorted(
rows,
key=lambda row: (str(row.get("created_at", "")), str(row["asset_id"])),
@@ -1,125 +1,125 @@
{
"schema": 1,
"fingerprint": "816ff9c893e23b7441e73210f85024741ff6c5bf8dabd2fb0df9e8b8055bc693",
"fingerprint": "e5edb95a1f5817c9035c6418ea0f9cb86cde238466f1f5c3174fb5246662c8ec",
"entry": "houseplan-card.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-Dwkibvgo.js",
"houseplan-assets/houseplan-card-o7i25aVl.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 291031,
"initialViewGzipBytes": 291069,
"lazyFiles": [
"houseplan-assets/backdrop-pick-DWD0ZQTc.js",
"houseplan-assets/de-DJX-vLW1.js",
"houseplan-assets/editor-Dvui5H3b.js",
"houseplan-assets/fr-DVmfM6Kw.js",
"houseplan-assets/houseplan-editor-runtime-9AEP51ly.js",
"houseplan-assets/houseplan-onboarding-runtime-CnVK5oOC.js"
"houseplan-assets/backdrop-pick-Dg2Uytel.js",
"houseplan-assets/de-D_5HnJeM.js",
"houseplan-assets/editor-CqQRQq4C.js",
"houseplan-assets/fr-DpKqMyVB.js",
"houseplan-assets/houseplan-editor-runtime-D0JYjEs4.js",
"houseplan-assets/houseplan-onboarding-runtime-C-SJxeJ_.js"
],
"lazyGzipBytes": 214972,
"lazyGzipBytes": 215495,
"lazyEditorFiles": [
"houseplan-assets/backdrop-pick-DWD0ZQTc.js",
"houseplan-assets/editor-Dvui5H3b.js",
"houseplan-assets/houseplan-editor-runtime-9AEP51ly.js"
"houseplan-assets/backdrop-pick-Dg2Uytel.js",
"houseplan-assets/editor-CqQRQq4C.js",
"houseplan-assets/houseplan-editor-runtime-D0JYjEs4.js"
],
"lazyEditorGzipBytes": 159157,
"lazyEditorGzipBytes": 159809,
"lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-DWD0ZQTc.js",
"houseplan-assets/houseplan-onboarding-runtime-CnVK5oOC.js"
"houseplan-assets/backdrop-pick-Dg2Uytel.js",
"houseplan-assets/houseplan-onboarding-runtime-C-SJxeJ_.js"
],
"lazyOnboardingGzipBytes": 14132,
"lazyOnboardingGzipBytes": 14126,
"lazyLocaleFiles": [
"houseplan-assets/de-DJX-vLW1.js",
"houseplan-assets/fr-DVmfM6Kw.js"
"houseplan-assets/de-D_5HnJeM.js",
"houseplan-assets/fr-DpKqMyVB.js"
],
"lazyLocaleGzipBytes": 48859,
"lazyLocaleGzipBytes": 48734,
"files": [
{
"path": "houseplan-assets/backdrop-pick-DWD0ZQTc.js",
"sha256": "455450cbc7b74f052fb112680af9ba4be145af8c3a1061907ec541b63957f22f",
"rawBytes": 20974,
"gzipBytes": 7176,
"path": "houseplan-assets/backdrop-pick-Dg2Uytel.js",
"sha256": "b67eb5e04c63007ff8ca7ff5623a7dcae44ff3c6f3ca5528ac7f1d389f75c4ec",
"rawBytes": 20996,
"gzipBytes": 7174,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-Dwkibvgo.js"
"houseplan-assets/houseplan-card-o7i25aVl.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/de-DJX-vLW1.js",
"sha256": "73d198680c17602addf41855748533ed47233e81e9bdc02a786034aeac0e4e63",
"rawBytes": 81798,
"gzipBytes": 24701,
"path": "houseplan-assets/de-D_5HnJeM.js",
"sha256": "ad2dde1407ae850f963cf81a8c0218c0e6646a86cf3bceb61a305cd54f60dad9",
"rawBytes": 81632,
"gzipBytes": 24639,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-Dvui5H3b.js",
"sha256": "11578ded60410e4e879c9342ee74c8a0968477ea2bae98eb18480a0ad02d303c",
"path": "houseplan-assets/editor-CqQRQq4C.js",
"sha256": "5007144deff010072eb0a6a6d3e5565064a60575baa173a0250cec89b1478823",
"rawBytes": 3826,
"gzipBytes": 1580,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-Dwkibvgo.js"
"houseplan-assets/houseplan-card-o7i25aVl.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/fr-DVmfM6Kw.js",
"sha256": "167bed2822ce7e2b38d47e8dc1d73648d10c04d469979bf05e414000b53487b4",
"rawBytes": 83973,
"gzipBytes": 24158,
"path": "houseplan-assets/fr-DpKqMyVB.js",
"sha256": "3c03580d357a96678d80b012923e7e28e989fe6a612fe0342d5d041e2d37d4f1",
"rawBytes": 83754,
"gzipBytes": 24095,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-Dwkibvgo.js",
"sha256": "d16deb0fc6f591040a2f97d51102e3e355da35cb3d1b69aa79472406c28d3ed2",
"rawBytes": 1033612,
"gzipBytes": 290234,
"path": "houseplan-assets/houseplan-card-o7i25aVl.js",
"sha256": "9b3942532fe83c5d49791859c419fb6f35a8db86c01a09ddccb1a2ec186bfbd3",
"rawBytes": 1033268,
"gzipBytes": 290274,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-DJX-vLW1.js",
"houseplan-assets/editor-Dvui5H3b.js",
"houseplan-assets/fr-DVmfM6Kw.js",
"houseplan-assets/houseplan-editor-runtime-9AEP51ly.js",
"houseplan-assets/houseplan-onboarding-runtime-CnVK5oOC.js"
"houseplan-assets/de-D_5HnJeM.js",
"houseplan-assets/editor-CqQRQq4C.js",
"houseplan-assets/fr-DpKqMyVB.js",
"houseplan-assets/houseplan-editor-runtime-D0JYjEs4.js",
"houseplan-assets/houseplan-onboarding-runtime-C-SJxeJ_.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-9AEP51ly.js",
"sha256": "c1b8f90458afedddbbdde98570c517d6e66dfcee08ad4764b1ac2473bdf89fb4",
"rawBytes": 574225,
"gzipBytes": 150401,
"path": "houseplan-assets/houseplan-editor-runtime-D0JYjEs4.js",
"sha256": "b2a69132a223d6c5b06b8d4a11b19416d33582bae2d39b2448ecc41f44ba98b2",
"rawBytes": 576032,
"gzipBytes": 151055,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-DWD0ZQTc.js",
"houseplan-assets/houseplan-card-Dwkibvgo.js"
"houseplan-assets/backdrop-pick-Dg2Uytel.js",
"houseplan-assets/houseplan-card-o7i25aVl.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-CnVK5oOC.js",
"sha256": "bd169896116229e76b37243106bde64755589017639890d36dd19282c872db7a",
"path": "houseplan-assets/houseplan-onboarding-runtime-C-SJxeJ_.js",
"sha256": "1324b8003d727c99c6ce6d9e48598032b29782f8c8c7b783c1b1073e9b79d9f2",
"rawBytes": 28088,
"gzipBytes": 6956,
"gzipBytes": 6952,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-DWD0ZQTc.js",
"houseplan-assets/houseplan-card-Dwkibvgo.js"
"houseplan-assets/backdrop-pick-Dg2Uytel.js",
"houseplan-assets/houseplan-card-o7i25aVl.js"
],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "730163666440793e44be0ee5482f7080931370f94555a1d52072d3a54698447e",
"sha256": "d6c8be124ce907703e76f24a5e9fedb3abd945213d7befb1f97d1635d82f4830",
"rawBytes": 1183,
"gzipBytes": 797,
"gzipBytes": 795,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-Dwkibvgo.js"
"houseplan-assets/houseplan-card-o7i25aVl.js"
],
"dynamicImports": []
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,14 +1,14 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="816ff9c893e23b7441e73210f85024741ff6c5bf8dabd2fb0df9e8b8055bc693";import{b as e,l as o,t,df as s,A as a,dg as i,dh as l,di as n,E as r,c}from"./houseplan-card-Dwkibvgo.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
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="e5edb95a1f5817c9035c6418ea0f9cb86cde238466f1f5c3174fb5246662c8ec";import{b as e,l as o,t,dg as s,A as a,dh as i,di as l,dj as n,E as r,c}from"./houseplan-card-o7i25aVl.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=>f[e.name]||e.name}
.computeLabel=${e=>h[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`;return c`
${d(h.slice(0,3))}
${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(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);
${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
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="816ff9c893e23b7441e73210f85024741ff6c5bf8dabd2fb0df9e8b8055bc693";try{await import("./houseplan-assets/houseplan-card-Dwkibvgo.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)}
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="e5edb95a1f5817c9035c6418ea0f9cb86cde238466f1f5c3174fb5246662c8ec";try{await import("./houseplan-assets/houseplan-card-o7i25aVl.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)}
+35 -31
View File
@@ -24,6 +24,7 @@ try: # KEY_HASS — the modern way to access hass from the aiohttp application
except ImportError: # older HA versions
KEY_HASS = "hass" # type: ignore[assignment]
from .asset_integrity import get_asset_integrity_verifier
from .auth import may_write
from .const import (
ASSETS_DIR,
@@ -43,8 +44,9 @@ from .decor_assets import (
ASSET_ID_RE,
DecorAssetError,
asset_meta_path,
physical_asset_usage,
public_asset,
read_catalog,
read_asset,
validate_asset,
)
from .import_export import ImportFailure, create_preview
@@ -176,18 +178,13 @@ class HouseplanContentView(HomeAssistantView):
if not str(path).startswith(str(base)):
return web.Response(status=404)
if not await hass.async_add_executor_job(path.is_file):
return web.Response(status=404)
suffix = path.suffix.lower()
if kind == "assets":
try:
digest = await hass.async_add_executor_job(
lambda: hashlib.sha256(path.read_bytes()).hexdigest(),
)
except OSError:
return web.Response(status=404)
if digest != path.stem:
verifier = get_asset_integrity_verifier(hass)
if not await hass.async_add_executor_job(verifier.verify, path, path.stem):
return web.Response(status=404)
elif not await hass.async_add_executor_job(path.is_file):
return web.Response(status=404)
headers = {
"Cache-Control": "private, max-age=31536000, immutable"
if kind == "assets" else "private, max-age=3600",
@@ -269,24 +266,12 @@ class HouseplanDecorAssetUploadView(HomeAssistantView):
def _store() -> tuple[dict, bool]:
root.mkdir(parents=True, exist_ok=True)
existing = next(
(row for row in read_catalog(root) if row["asset_id"] == validated.asset_id),
None,
)
existing = read_asset(root, validated.asset_id)
if existing is not None:
existing_blob = root / f"{validated.asset_id}{existing['ext']}"
if hashlib.sha256(existing_blob.read_bytes()).hexdigest() != validated.asset_id:
raise DecorAssetError("invalid_image", "The stored image failed its integrity check")
return existing, True
rows = read_catalog(root)
used = sum(int(row.get("bytes") or 0) for row in rows)
if len(rows) >= MAX_DECOR_ASSETS_COUNT or used + len(validated.data) > MAX_DECOR_ASSETS_BYTES:
raise DecorAssetError("capacity_exceeded", "The decor image store is full")
try:
if shutil.disk_usage(root).free - len(validated.data) < MIN_FREE_BYTES:
raise DecorAssetError("capacity_exceeded", "Not enough free disk space")
except OSError:
pass
aid = validated.asset_id
blob = root / f"{aid}{validated.ext}"
meta = asset_meta_path(root, aid)
@@ -296,6 +281,33 @@ class HouseplanDecorAssetUploadView(HomeAssistantView):
"ext": validated.ext, "width": validated.width, "height": validated.height,
"bytes": len(validated.data), "created_at": datetime.now(timezone.utc).isoformat(),
}
# A hard stop may have promoted the content-addressed blob before
# its sidecar. Its exact path and digest prove that no new physical
# bytes are needed, so repair remains possible even at full quota.
if blob.exists():
if not blob.is_file() or hashlib.sha256(blob.read_bytes()).hexdigest() != aid:
raise DecorAssetError("invalid_image", "The stored image failed its integrity check")
fd, meta_temp_name = tempfile.mkstemp(prefix=".asset-meta-", dir=str(root))
os.close(fd)
meta_temp = Path(meta_temp_name)
try:
with meta_temp.open("w", encoding="utf-8") as stream:
stream.write(json.dumps(row, ensure_ascii=False, separators=(",", ":")))
stream.flush()
os.fsync(stream.fileno())
os.replace(meta_temp, meta)
return row, False
finally:
meta_temp.unlink(missing_ok=True)
count, used = physical_asset_usage(root)
if count >= MAX_DECOR_ASSETS_COUNT or used + len(validated.data) > MAX_DECOR_ASSETS_BYTES:
raise DecorAssetError("capacity_exceeded", "The decor image store is full")
try:
if shutil.disk_usage(root).free - len(validated.data) < MIN_FREE_BYTES:
raise DecorAssetError("capacity_exceeded", "Not enough free disk space")
except OSError:
pass
fd, temp_name = tempfile.mkstemp(prefix=".asset-", dir=str(root))
os.close(fd)
temp = Path(temp_name)
@@ -310,14 +322,6 @@ class HouseplanDecorAssetUploadView(HomeAssistantView):
stream.write(json.dumps(row, ensure_ascii=False, separators=(",", ":")))
stream.flush()
os.fsync(stream.fileno())
if blob.exists():
# A previous hard stop may have promoted the blob but not
# its sidecar. The content hash proves those bytes; finish
# the catalog transaction rather than leaving a ghost.
if hashlib.sha256(blob.read_bytes()).hexdigest() != aid:
raise DecorAssetError("invalid_image", "The stored image failed its integrity check")
os.replace(meta_temp, meta)
return row, True
os.replace(temp, blob)
promoted_blob = True
os.replace(meta_temp, meta)
+30 -9
View File
@@ -74,6 +74,13 @@ _IMPORT_ID_NAMESPACES = {
}
_MAX_IMPORT_LINEAGE_DEPTH = 16
_REPORT_EXAMPLE_LIMIT = 24
_DECOR_ASSET_MIME_BY_SUFFIX = {
".png": "image/png",
".jpg": "image/jpeg",
".webp": "image/webp",
".svg": "image/svg+xml",
}
_DECOR_ASSET_MIME_TYPES = frozenset(_DECOR_ASSET_MIME_BY_SUFFIX.values())
_SPACE_PLAN_FIELDS = (
"id", "title", "cell_cm", "plan_url", "plan_aspect", "plan_x", "plan_y",
@@ -98,6 +105,7 @@ _DECOR_KIND_FIELDS = {
"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", "flip_h", "flip_v"),
"image": ("asset_id", "x", "y", "w", "h", "angle", "flip_h", "flip_v"),
}
@@ -470,10 +478,9 @@ def content_manifest(config: dict[str, Any], config_root: Path) -> list[dict[str
# Identity is extension-neutral: a missing target must compute
# exactly the same expected manifest as the source.
"url": aid, "asset_id": aid, "storage": "internal",
"mime": metadata.get("mime") or {
".png": "image/png", ".jpg": "image/jpeg", ".webp": "image/webp",
".svg": "image/svg+xml",
}.get(blob.suffix if blob else ""),
"mime": metadata.get("mime") or _DECOR_ASSET_MIME_BY_SUFFIX.get(
blob.suffix if blob else ""
),
"hash": aid, "exists_at_export": exists,
})
return out
@@ -802,7 +809,11 @@ def _validate_plan_only_document(
raise ImportFailure("invalid_format", "Plan-only placement manifest is not canonical")
content = document.get("content_manifest")
if not isinstance(content, list) or any(
not isinstance(item, dict) or item.get("owner") != "space"
not isinstance(item, dict)
or not (
item.get("owner") == "space"
or item.get("owner") == "decor" and item.get("kind") == "decor_asset"
)
for item in content
):
raise ImportFailure("invalid_format", "Plan-only export contains private content")
@@ -1639,14 +1650,24 @@ def _content_state(document: dict[str, Any], same_source: bool, config_root: Pat
for item in expected:
row = dict(item)
declared = supplied_by_id[identity(item)]
row["exists_at_export"] = declared.get("exists_at_export")
declared_exists = declared.get("exists_at_export")
row["exists_at_export"] = declared_exists
if item.get("kind") == "decor_asset":
aid = str(item.get("asset_id") or item.get("url") or "")
declared_mime = declared.get("mime")
valid_mime = (
isinstance(declared_mime, str)
and declared_mime in _DECOR_ASSET_MIME_TYPES
)
missing_mime = declared_exists is False and declared_mime is None
if (declared.get("asset_id") != aid or declared.get("hash") != aid
or declared.get("mime") not in {
"image/png", "image/jpeg", "image/webp", "image/svg+xml",
}):
or type(declared_exists) is not bool
or not (valid_mime or missing_mime)):
raise ImportFailure("invalid_content", "Invalid decor asset manifest row")
# Preserve the source description for preview/digest purposes. It
# never selects or serves target bytes; the exact local hash below
# remains authoritative for availability.
row["mime"] = declared_mime
candidates = sorted((config_root / ASSETS_DIR).glob(f"{aid}.*"))
blob = next((path for path in candidates if path.suffix in {".png", ".jpg", ".webp", ".svg"}), None)
exists = False
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.71.0-beta.1"
"version": "1.71.0-beta.2"
}
@@ -140,6 +140,9 @@ 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
show_room_tooltip = value.get("show_room_tooltip")
if isinstance(show_room_tooltip, bool):
out["show_room_tooltip"] = show_room_tooltip
fill_colors = value.get("fill_colors")
if isinstance(fill_colors, dict):
out["fill_colors"] = {
@@ -1935,6 +1935,7 @@ CONFIG_SCHEMA = vol.All(
vol.Optional("north_deg"): _north_deg,
vol.Optional("bg_mode"): _BG_MODE,
vol.Optional("sun_rays"): bool,
vol.Optional("show_room_tooltip"): bool,
# Removed from the UI/runtime in 2026-08-08. Keep accepting the
# legacy field so an existing stored config can still load; the
# frontend ignores it and removes it on the next settings save.
+31 -13
View File
@@ -21,6 +21,7 @@ from homeassistant.const import __version__ as HA_VERSION
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import issue_registry as ir
from .asset_integrity import get_asset_integrity_verifier
from .auth import may_write
from .const import (
ASSETS_DIR,
@@ -50,10 +51,12 @@ from .coordinate_canonicalization import (
canonicalize_layout_geometry,
)
from .decor_assets import (
ASSET_EXTENSIONS,
ASSET_ID_RE,
asset_meta_path,
asset_refs,
public_asset,
read_asset,
read_catalog,
)
from .import_export import (
@@ -1135,22 +1138,29 @@ async def ws_assets_list(hass: HomeAssistant, connection, msg: dict[str, Any]) -
@websocket_api.async_response
async def ws_assets_resolve(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Resolve each unique id once; absent/corrupt content is reported missing."""
root = Path(hass.config.path(ASSETS_DIR))
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
requested = set(msg["asset_ids"])
allowed = requested
if not _check_write(hass, connection):
async with rt.write_lock:
stored = await rt.config_store.async_load() or {}
referenced = set(asset_refs(stored.get("config") or {}))
allowed = requested & referenced
root = Path(hass.config.path(ASSETS_DIR))
verifier = get_asset_integrity_verifier(hass)
def _resolve() -> tuple[list[dict], list[str]]:
rows: list[dict] = []
found: set[str] = set()
for row in read_catalog(root):
aid = row["asset_id"]
if aid not in requested:
for aid in sorted(allowed):
row = read_asset(root, aid)
if row is None:
continue
path = root / f"{aid}{row['ext']}"
try:
import hashlib
if hashlib.sha256(path.read_bytes()).hexdigest() != aid:
continue
except OSError:
if not verifier.verify(path, aid):
continue
rows.append(public_asset(row))
found.add(aid)
@@ -1188,15 +1198,23 @@ async def ws_assets_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
return
def _delete() -> bool:
row = next((item for item in read_catalog(root) if item["asset_id"] == aid), None)
removed = False
if row:
for extension in ASSET_EXTENSIONS:
path = root / f"{aid}{extension}"
if not path.is_file():
continue
try:
(root / f"{aid}{row['ext']}").unlink()
path.unlink()
removed = True
except FileNotFoundError:
pass
meta = asset_meta_path(root, aid)
if meta.is_file():
try:
meta.unlink()
removed = True
except FileNotFoundError:
pass
asset_meta_path(root, aid).unlink(missing_ok=True)
return removed
removed = await hass.async_add_executor_job(_delete)
+4 -4
View File
@@ -1,11 +1,11 @@
{
"schema": 1,
"matrixVersion": 54,
"acceptedAt": "2026-09-02T05:20:33.824Z",
"sourceFingerprint": "baf59953cfd6eb3543f7da5af21787b20dc76f136b74782ae917f811d2fc1ab5",
"acceptedAt": "2026-09-03T06:25:02.855Z",
"sourceFingerprint": "b69bdfe3df225cf1b96fea0159a03a620e38cb94ec6106f9ee3b0e57cdefd183",
"chromium": "151.0.7922.34",
"witnesses": {
"count": 90,
"count": 132,
"floor": 10
},
"scenarios": {
@@ -142,7 +142,7 @@
"device-help-popover-light-ru": "c2146f13d03841c395b0772f1a21de358c62a8703d3d02e7d026fac8ae29955e",
"decor-color-popover-mobile-ru": "c65318327c6e4631c769419e791a749d667b4dfc36f2e3fc46d6fac53180cf1b",
"decor-color-popover-desktop-en": "60de64b1bc072327cb88d2e876732d50d5cb7441c850f52b7af6f4ed5c6de22b",
"general-color-popover-desktop-en": "762c20c3d0499c1ea444e5d980eec4e78c7112455589b5202010f53a8b51099c",
"general-color-popover-desktop-en": "5b47ab921f889c9ad41cab195e6efeed283f014d37f5377714b54ef30282ebf6",
"settings-help-zoom-200-en-light": "bb1192a52bab5ebc6984a5d799a32d05f9ce374ce6b493f5f618b639eedce8d5",
"settings-help-zoom-200-ru-dark": "13e7fdd19017c38a5659394e622ff54ac8a426f9cb6e85b3248864767aa9f42b",
"support-desktop-empty-light-en": "fecabb63521f258acc66ecdf7a438db10c73d0eac39618c55fb51287080f01f5",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 116 KiB

+13 -2
View File
@@ -12,7 +12,18 @@
(`scripts/source-fingerprint.mjs`), то есть каждый новый `.mjs` там объявляет
устаревшими закоммиченный бандл, скриншот-индекс документации и golden-индекс.
Пробы гарда ни одного пикселя не касаются, платить пересъёмкой за них нечем.
3. **Каталог, а не файл** — проб три, и они читаются как набор.
3. **Каталог, а не файл** — проб несколько, и они читаются как набор.
Запускает их `verify-guard.mjs`; он же вызывается из job «Смоки в браузере»
и служит guard'ом двух мутантов в `scripts/mutation-gate.mjs`.
и служит guard'ом мутантов в `scripts/mutation-gate.mjs`.
Одна проба живёт не здесь: `--guard-probe` у `demo/benchmark_backdrop_decode.mjs`
(#430). Benchmark нельзя переселить в этот каталог — его запускают руками при
рекалибровке порогов, — поэтому `verify-guard.mjs` умеет запускать файл выше
каталога и с аргументами. Режим `--guard-probe` пропускает все замеры, бросает
одно исключение внутри страницы и обязан выйти с кодом 1.
Новый benchmark, открывающий страницу Playwright, обязан получить такой же
режим и запись здесь: этого требует `test/smoke-harness-contract.test.mjs`, и
требует статически — потому что динамическое доказательство возможно только
там, где есть браузер.
+22 -3
View File
@@ -18,7 +18,13 @@ import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
/** Каждая проба: чего ждём от кода возврата и что обязано быть в выводе. */
/**
* Каждая проба: чего ждём от кода возврата и что обязано быть в выводе.
*
* `args` — необязательные аргументы запуска; `file` может указывать и выше
* этого каталога (`../benchmark_*.mjs`), потому что benchmark в `demo/guard/`
* не переселить: его гоняют руками при рекалибровке порогов (#430).
*/
const PROBES = [
{
file: 'guard_tail_exception.mjs',
@@ -38,6 +44,19 @@ const PROBES = [
expectOutput: /OK/,
because: 'round-trip к закрытой странице не имеет права ронять вердикт',
},
{
// #430: до этой задачи гард benchmark доказывался тестом, который искал
// в тексте те самые подстроки, которые сам же и вырезал у мутанта, —
// доказано было, что регулярка не пуста. Режим `--guard-probe` в
// benchmark существовал с #423 и не вызывался ни одним прогоном; теперь
// вызывается здесь, в единственной job с настоящим браузером.
file: '../benchmark_backdrop_decode.mjs',
args: ['--guard-probe'],
expectExit: 1,
expectOutput: /uncaught exception\(s\) inside the card/,
because: 'benchmark открывает страницу Playwright и обязан выносить тот же вердикт,'
+ ' что и смоки: исключение внутри карточки во время замера иначе не увидит никто',
},
{
file: 'guard_report_page_errors.mjs',
expectExit: 1,
@@ -48,14 +67,14 @@ const PROBES = [
let failed = 0;
for (const probe of PROBES) {
const run = spawnSync(process.execPath, [resolve(HERE, probe.file)], {
const run = spawnSync(process.execPath, [resolve(HERE, probe.file), ...probe.args || []], {
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}`);
console.log(`ok ${[probe.file, ...probe.args || []].join(' ')} → exit ${run.status}`);
continue;
}
failed += 1;
+45
View File
@@ -110,6 +110,51 @@ const out = await page.evaluate(async () => {
out.reducedTo4096 = reducedDims[0] === 4096 && reducedDims[1] === 4096;
card._spaceDialog = { ...card._spaceDialog, planFile: null };
// ── #427: decor source >2 MiB can still choose a reduced copy ───────────
// The canonical asset limit forbids only the original. Trailing bytes make
// this valid JPEG exceed the source limit without another huge allocation;
// Chromium deliberately ignores data after JPEG EOI while decoding.
const decorBigFile = new File([
bigBlob, new Uint8Array(2 * 1024 * 1024 + 1),
], 'decor-large.jpg', { type: 'image/jpeg' });
const realUploadDecorImage = card._editorRuntime._uploadDecorImage;
let decorUpload = null;
card._editorRuntime._uploadDecorImage = async (blob, name, replace) => {
decorUpload = { blob, name, replace };
};
await card._editorRuntime._decorImageUpload({
target: { files: [decorBigFile], value: 'x' },
});
await waitFor(() => !!card._backdropGuard); await card.updateComplete;
const decorButtons = guardButtons();
out.decorOversizeOffersReducedWithoutOriginal = decorButtons.length === 2
&& decorButtons.some((button) => button.textContent.includes(card._t('btn.cancel')))
&& decorButtons.some((button) => button.textContent.includes(card._t('backdrop.use_downscaled')))
&& !decorButtons.some((button) => button.textContent.includes(card._t('backdrop.keep_original')));
decorButtons.find((button) => button.textContent.includes(
card._t('backdrop.use_downscaled'),
))?.click();
out.decorOversizeUploadsReducedCopy = await waitFor(() => !!decorUpload
&& !card._backdropGuard, 15000)
&& decorUpload.blob !== decorBigFile
&& decorUpload.blob.size < decorBigFile.size
&& /-reduced\.jpg$/.test(decorUpload.name)
&& decorUpload.replace === false;
card._editorRuntime._uploadDecorImage = realUploadDecorImage;
await card._editorRuntime._decorImageUpload({
target: {
files: [fileOf(pngHeader(20000, 20000), 'decor-hard.png', 'image/png')],
value: 'x',
},
});
await waitFor(() => !!card._backdropGuard); await card.updateComplete;
const decorHardButtons = guardButtons();
out.decorHardStillHasOnlyCancel = decorHardButtons.length === 1
&& decorHardButtons[0].textContent.includes(card._t('btn.cancel'));
decorHardButtons[0]?.click();
await waitFor(() => !card._backdropGuard);
// ── alpha-ветка: PNG с alpha остаётся PNG ────────────────────────────────
const alphaSrc = new OffscreenCanvas(6200, 6200);
const alphaCtx = alphaSrc.getContext('2d');
+39 -5
View File
@@ -41,6 +41,17 @@ const warmGate = await page.evaluate(async () => {
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
};
const originalLanguage = card._config?.language;
const committedBody = root().querySelector('ha-card')?.outerHTML;
const openDecision = card._confirmDanger({
key: 'ready-before-warm',
kind: 'destructive',
title: 'Delete?',
message: 'The plan and all its rooms will be deleted.',
confirmLabel: 'Delete',
cancelLabel: 'Cancel',
});
await settle();
const openedWhileReady = !!root().querySelector('hp-confirm');
card._config = { ...card._config, language: 'de' };
card.requestUpdate();
for (let attempt = 0; attempt < 50 && card._dangerConfirmLocaleGate !== 'warm'; attempt++) {
@@ -49,7 +60,11 @@ const warmGate = await page.evaluate(async () => {
}
const entered = card._dangerConfirmLocaleGate === 'warm' && card.inert;
const before = root().innerHTML;
const cancelledOnTransition = await Promise.race([
openDecision,
new Promise((resolve) => setTimeout(() => resolve('timeout'), 100)),
]);
await settle();
const decision = await Promise.race([
card._confirmDanger({
key: 'warm-language-gate',
@@ -63,15 +78,31 @@ const warmGate = await page.evaluate(async () => {
]);
const result = {
warmLanguageGateActuallyEntered: entered,
readyConfirmationOpenedBeforeWarm: openedWhileReady,
readyToWarmCancelsOpenConfirmation: cancelledOnTransition === false,
readyToWarmKeepsCommittedBody: root().querySelector('ha-card')?.outerHTML === committedBody,
warmLanguageGateRefusesImmediately: decision === false,
warmLanguageGateKeepsControllerEmpty: card._dangerConfirm === null
&& card._dangerConfirmController.state === null,
warmLanguageGateKeepsNoChangeDom: root().innerHTML === before
&& !root().querySelector('hp-confirm'),
warmLanguageGateRemovesDecisionSurface: !root().querySelector('hp-confirm'),
};
// Change the requested language back without waiting for render. A decision
// made in this warm -> ready window must use current config/runtime state,
// not the warm branch painted by the previous update.
card._config = { ...card._config, language: originalLanguage };
card.requestUpdate();
const readyAgain = card._confirmDanger({
key: 'ready-before-render',
kind: 'warning',
title: 'Continue?',
message: 'The language is ready again.',
confirmLabel: 'Continue',
cancelLabel: 'Cancel',
});
result.warmToReadyAllowsBeforeRender = card._dangerConfirmController.state?.request.key
=== 'ready-before-render' && !card.inert;
card._cancelDangerConfirm();
result.warmToReadyDecisionSettles = await readyAgain === false;
await settle();
return result;
});
@@ -81,7 +112,10 @@ await Promise.race([
new Promise((_, reject) => setTimeout(() => reject(new Error('German locale request not seen')), 1000)),
]);
releaseGerman();
await germanCompleted;
await Promise.race([
germanCompleted,
new Promise((_, reject) => setTimeout(() => reject(new Error('German locale request did not complete')), 1000)),
]);
await page.unroute(germanAsset, holdGerman);
const out = await page.evaluate(async () => {
+83
View File
@@ -3,6 +3,13 @@
* Backend upload/parser/storage security is covered by tests_backend; this
* smoke uses deterministic in-memory raster data so it never depends on a
* writable demo server or leaves user files behind.
*
* С #433 сюда входит и путь загрузки: последний блок проходит настоящие
* `uploadFromInput` → `upload` → `delete`, подменяя только транспорт
* (`fetchWithAuth` и одну ветку `callWS`). До этого смок подменял `callWS`
* целиком и до этих методов не доходил — именно поэтому #427 (гасли обе
* кнопки диалога для файла тяжелее 2 МиБ) прожил четыре круга ревью.
* Детерминированная часть того же контракта — `test/decor-image-upload.test.mjs`.
*/
import { launch, checkAll, finish } from './serve.mjs';
@@ -199,6 +206,82 @@ const result = await page.evaluate(async () => {
out.hideDecorHidesImages = !root().querySelector('.decorlayer image[data-kind="image"]');
sp.settings = { ...(sp.settings || {}), hide_decor: false };
await settle();
// ── #433: настоящий uploadFromInput, а не подмена callWS ──────────────────
// Шапка этого файла честно признавалась, что путь загрузки не проверяется
// ничем: смок подменял `callWS` и до `uploadFromInput`/`upload`/`delete` не
// доходил. Цена — #427, четыре круга ревью. Здесь подменяется только
// транспорт (`fetchWithAuth`), а весь путь от change-события до каталога
// проходит продуктовый код собранного бандла.
const id3 = '7'.repeat(64);
const rawUrl3 = `/api/houseplan/content/assets/_/${id3}.png`;
const asset3 = {
asset_id: id3, name: 'small.png', mime: 'image/png',
width: 100, height: 100, bytes: 33, url: rawUrl3, used_by: [],
};
// PNG до конца IHDR: на проходном пути ничего не декодирует ни карточка, ни
// браузер — заголовок читает probeBackdrop, байты уходят как есть.
const pngHead = new Uint8Array(33);
pngHead.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 0);
pngHead.set([0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52], 8);
new DataView(pngHead.buffer).setUint32(16, 100);
new DataView(pngHead.buffer).setUint32(20, 100);
pngHead[24] = 8; pngHead[25] = 2;
const uploads = [];
const toasts = [];
const realToast = c._showToast.bind(c);
const realConfirm = c._confirmDanger.bind(c);
c._showToast = (text) => { toasts.push(text); };
c.hass = { ...c.hass, fetchWithAuth: async (url, init) => {
uploads.push({ url, method: init?.method, name: init?.body?.get('file')?.name });
return { ok: true, json: async () => ({ asset: asset3 }) };
} };
c._decorAssetCatalog = [asset];
c._decorImagePalette = null;
await c._editorRuntime._decorImageUpload({
target: { files: [new File([pngHead], 'small.png', { type: 'image/png' })], value: 'x' },
});
await settle();
out.uploadPassesThroughRealInputHandler = uploads.length === 1
&& uploads[0].url === '/api/houseplan/assets/upload'
&& uploads[0].method === 'POST'
&& uploads[0].name === 'small.png'
&& !c._backdropGuard;
out.uploadedAssetArmsPaletteAndCatalog = c._decorAssetCatalog[0]?.asset_id === id3
&& c._decorAssets.get(id3)?.url === rawUrl3
&& c._decorImagePalette?.asset_id === id3
&& c._decorAssetBusy === false;
// Неподдерживаемый формат: тост и ни одного запроса.
toasts.length = 0;
await c._editorRuntime._decorImageUpload({
target: { files: [new File([pngHead], 'plan.gif', { type: 'image/gif' })], value: 'x' },
});
out.rejectedFormatNeverReachesTransport = uploads.length === 1
&& toasts.length === 1 && toasts[0] === c._t('toast.plan_formats');
// Удаление: подтверждение спрашивается, WS уходит, каталог редеет.
const deletes = [];
const beforeDelete = c.hass.callWS;
c.hass = { ...c.hass, callWS: async (message) => {
if (message.type === 'houseplan/assets/delete') { deletes.push(message.asset_id); return {}; }
return beforeDelete(message);
} };
let confirmAsked = 0;
c._confirmDanger = async () => { confirmAsked++; return true; };
await c._editorRuntime._decorImageDelete(asset3);
await settle();
out.deleteAsksConfirmationAndPrunesCatalog = confirmAsked === 1
&& deletes.length === 1 && deletes[0] === id3
&& !c._decorAssetCatalog.some((row) => row.asset_id === id3)
&& !c._decorAssets.has(id3)
&& c._decorImagePalette === null;
c._showToast = realToast;
c._confirmDanger = realConfirm;
c._decorAssets = new Map([[id, asset], [id2, asset2]]);
c._decorAssetCatalog = [asset, asset2];
await settle();
await customElements.whenDefined('houseplan-space-card');
const config = JSON.parse(JSON.stringify(c._serverCfg));
let staticResolveCalls = 0;
+84
View File
@@ -0,0 +1,84 @@
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const result = await page.evaluate(async () => {
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const room = () => root().querySelector('.room');
const mouse = (type, x, y) => new PointerEvent(type, {
pointerType: 'mouse', bubbles: true, composed: true, clientX: x, clientY: y,
});
const out = {};
card._openSettingsDialog();
await card.updateComplete;
const settingRow = [...root().querySelectorAll('hp-dialog label.srcrow')]
.find((label) => label.textContent.trim() === 'Show the room information window on hover');
out.localizedControl = !!settingRow?.querySelector('ha-switch,input[type="checkbox"]');
out.defaultOn = card._settingsDialog?.showRoomTooltip === true;
card._settingsDialog = { ...card._settingsDialog, showRoomTooltip: false };
card._settingsDialog = null;
out.cancelKeepsAbsentDefault = !Object.hasOwn(card._serverCfg.settings, 'show_room_tooltip');
room().dispatchEvent(mouse('pointerenter', 180, 180));
room().dispatchEvent(mouse('pointermove', 180, 180));
await card.updateComplete;
out.defaultRoomTip = card._tip?.room === true && !!root().querySelector('.tip');
card._openSettingsDialog();
card._settingsDialog = { ...card._settingsDialog, showRoomTooltip: false };
await card._saveSettingsDialog();
await card.updateComplete;
out.falsePersists = card._serverCfg.settings.show_room_tooltip === false;
out.visibleRoomTipCleared = card._tip === null && !root().querySelector('.tip');
let roomAreaCalls = 0;
const originalRoomArea = card._roomArea.bind(card);
card._roomArea = (...args) => {
roomAreaCalls += 1;
return originalRoomArea(...args);
};
room().dispatchEvent(mouse('pointerenter', 190, 190));
room().dispatchEvent(mouse('pointermove', 190, 190));
await card.updateComplete;
out.disabledRoomTip = card._tip === null && !root().querySelector('.tip');
out.disabledSkipsArea = roomAreaCalls === 0;
out.roomHighlightSurvives = card._hoverRoom !== null
&& !!root().querySelector('.room-hover-fill-layer,.room-hover-outline-layer');
root().querySelector('.dev').dispatchEvent(mouse('pointermove', 200, 200));
await card.updateComplete;
out.deviceTipSurvives = card._tip?.room === false && !!root().querySelector('.tip');
card._openSettingsDialog();
out.reopenShowsOff = card._settingsDialog?.showRoomTooltip === false;
card._settingsDialog = { ...card._settingsDialog, showRoomTooltip: true };
await card._saveSettingsDialog();
await card.updateComplete;
out.trueStoredAsAbsent = !Object.hasOwn(card._serverCfg.settings, 'show_room_tooltip');
out.enableDoesNotRestoreStaleTip = card._tip?.room !== true;
room().dispatchEvent(mouse('pointermove', 210, 210));
await card.updateComplete;
out.roomTipRestoredOnMove = card._tip?.room === true && !!root().querySelector('.tip');
return out;
});
checkAll(result, {
localizedControl: true,
defaultOn: true,
cancelKeepsAbsentDefault: true,
defaultRoomTip: true,
falsePersists: true,
visibleRoomTipCleared: true,
disabledRoomTip: true,
disabledSkipsArea: true,
roomHighlightSurvives: true,
deviceTipSurvives: true,
reopenShowsOff: true,
trueStoredAsAbsent: true,
enableDoesNotRestoreStaleTip: true,
roomTipRestoredOnMove: true,
});
await finish(browser, result);
@@ -0,0 +1,76 @@
// #434: the embedded card treats decor_assets_api as fresh runtime authority.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 800, height: 700 });
const out = await page.evaluate(async () => {
await customElements.whenDefined('houseplan-space-card');
const source = window.__card;
const config = structuredClone(source._serverCfg);
const space = config.spaces[0];
const assetId = 'a'.repeat(64);
space.decor = [...(space.decor || []), {
id: 'capability-image', kind: 'image', asset_id: assetId,
x: 0.1, y: 0.1, w: 0.2, h: 0.2,
}];
let capability;
let resolveCalls = 0;
const hass = {
...source.hass,
connection: {},
callWS: async (message) => {
if (message.type === 'houseplan/config/get') return {
config,
rev: 434,
...(capability === undefined ? {} : { decor_assets_api: capability }),
};
if (message.type === 'houseplan/layout/get') return { layout: {}, rev: 1 };
if (message.type === 'houseplan/assets/resolve') {
resolveCalls++;
return { assets: [{
asset_id: assetId, name: 'proof.png', mime: 'image/png',
width: 1, height: 1, bytes: 1,
url: `/api/houseplan/content/assets/_/${assetId}.png`,
}], missing: [] };
}
return source.hass.callWS(message);
},
};
const card = document.createElement('houseplan-space-card');
card.setConfig({ type: 'custom:houseplan-space-card', space: space.id });
card.hass = hass;
document.body.append(card);
const settleLoad = async () => {
for (let attempt = 0; attempt < 100 && card._loading; attempt++) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
await card.updateComplete;
};
await card._load(true);
await settleLoad();
const oldBackendSkippedResolve = resolveCalls === 0 && card._decorAssets.size === 0;
capability = 1;
await card._load(true);
await settleLoad();
const exactCapabilityResolves = resolveCalls === 1 && card._decorAssets.has(assetId);
await card._load(true);
await settleLoad();
const sameEpochUsesCache = resolveCalls === 1;
capability = undefined;
await card._load(true);
await settleLoad();
const downgradeRevokesWithoutResolve = resolveCalls === 1
&& card._snap.decorAssetsApi === null && card._decorAssets.size === 0;
card.remove();
return {
oldBackendSkippedResolve,
exactCapabilityResolves,
sameEpochUsesCache,
downgradeRevokesWithoutResolve,
};
});
checkAll(out);
await finish(browser, out);
+40
View File
@@ -321,6 +321,46 @@ const result = await page.evaluate(async ({ version, previewText, previewSha })
&& card._supportDialog?.errorCode === ''
&& card._supportDialog?.preview === null
&& !root().querySelector('.supportpreview');
const invalidBuild = card._editorRuntime._setSupportAttachment(true);
await until(() => pendingPreviews.length === 1, 'invalid preview queued');
pendingPreviews.shift().resolve({ ...previewPayload('0'), sha256: 'invalid' });
await invalidBuild;
await update();
const validInvalidDiscarded = calls.filter(
(call) => call.type === 'houseplan/support/preview/discard'
&& call.token === '0'.repeat(48),
).length === 1;
const malformedBuild = card._editorRuntime._refreshSupportPreview();
await until(() => pendingPreviews.length === 1, 'malformed-token preview queued');
pendingPreviews.shift().resolve({ ...previewPayload('h'), token: 'not-a-token', sha256: 'invalid' });
await malformedBuild;
await update();
const malformedNotDiscarded = calls.every(
(call) => call.type !== 'houseplan/support/preview/discard' || call.token !== 'not-a-token',
);
const runtime = card._editorRuntime;
const realSupportPatch = runtime._supportPatch.bind(runtime);
runtime._supportPatch = (candidateDraftId, patch) => patch?.status === 'ready'
? false : realSupportPatch(candidateDraftId, patch);
const unadoptedBuild = runtime._refreshSupportPreview();
await until(() => pendingPreviews.length === 1, 'unadoptable preview queued');
pendingPreviews.shift().resolve(previewPayload('1'));
await unadoptedBuild;
runtime._supportPatch = realSupportPatch;
realSupportPatch(card._supportDialog.draftId, {
status: 'error', errorCode: 'support_rejected',
});
await update();
const unadoptedDiscarded = calls.filter(
(call) => call.type === 'houseplan/support/preview/discard'
&& call.token === '1'.repeat(48),
).length === 1;
out.invalidPreviewTokenIsDiscardedExactlyOnce = validInvalidDiscarded;
out.malformedPreviewTokenIsNotEchoed = malformedNotDiscarded;
out.locallyUnadoptedPreviewTokenIsDiscardedExactlyOnce = unadoptedDiscarded;
deferPreviews = false;
await close();
+59 -59
View File
@@ -1,125 +1,125 @@
{
"schema": 1,
"fingerprint": "816ff9c893e23b7441e73210f85024741ff6c5bf8dabd2fb0df9e8b8055bc693",
"fingerprint": "e5edb95a1f5817c9035c6418ea0f9cb86cde238466f1f5c3174fb5246662c8ec",
"entry": "houseplan-card.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-Dwkibvgo.js",
"houseplan-assets/houseplan-card-o7i25aVl.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 291031,
"initialViewGzipBytes": 291069,
"lazyFiles": [
"houseplan-assets/backdrop-pick-DWD0ZQTc.js",
"houseplan-assets/de-DJX-vLW1.js",
"houseplan-assets/editor-Dvui5H3b.js",
"houseplan-assets/fr-DVmfM6Kw.js",
"houseplan-assets/houseplan-editor-runtime-9AEP51ly.js",
"houseplan-assets/houseplan-onboarding-runtime-CnVK5oOC.js"
"houseplan-assets/backdrop-pick-Dg2Uytel.js",
"houseplan-assets/de-D_5HnJeM.js",
"houseplan-assets/editor-CqQRQq4C.js",
"houseplan-assets/fr-DpKqMyVB.js",
"houseplan-assets/houseplan-editor-runtime-D0JYjEs4.js",
"houseplan-assets/houseplan-onboarding-runtime-C-SJxeJ_.js"
],
"lazyGzipBytes": 214972,
"lazyGzipBytes": 215495,
"lazyEditorFiles": [
"houseplan-assets/backdrop-pick-DWD0ZQTc.js",
"houseplan-assets/editor-Dvui5H3b.js",
"houseplan-assets/houseplan-editor-runtime-9AEP51ly.js"
"houseplan-assets/backdrop-pick-Dg2Uytel.js",
"houseplan-assets/editor-CqQRQq4C.js",
"houseplan-assets/houseplan-editor-runtime-D0JYjEs4.js"
],
"lazyEditorGzipBytes": 159157,
"lazyEditorGzipBytes": 159809,
"lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-DWD0ZQTc.js",
"houseplan-assets/houseplan-onboarding-runtime-CnVK5oOC.js"
"houseplan-assets/backdrop-pick-Dg2Uytel.js",
"houseplan-assets/houseplan-onboarding-runtime-C-SJxeJ_.js"
],
"lazyOnboardingGzipBytes": 14132,
"lazyOnboardingGzipBytes": 14126,
"lazyLocaleFiles": [
"houseplan-assets/de-DJX-vLW1.js",
"houseplan-assets/fr-DVmfM6Kw.js"
"houseplan-assets/de-D_5HnJeM.js",
"houseplan-assets/fr-DpKqMyVB.js"
],
"lazyLocaleGzipBytes": 48859,
"lazyLocaleGzipBytes": 48734,
"files": [
{
"path": "houseplan-assets/backdrop-pick-DWD0ZQTc.js",
"sha256": "455450cbc7b74f052fb112680af9ba4be145af8c3a1061907ec541b63957f22f",
"rawBytes": 20974,
"gzipBytes": 7176,
"path": "houseplan-assets/backdrop-pick-Dg2Uytel.js",
"sha256": "b67eb5e04c63007ff8ca7ff5623a7dcae44ff3c6f3ca5528ac7f1d389f75c4ec",
"rawBytes": 20996,
"gzipBytes": 7174,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-Dwkibvgo.js"
"houseplan-assets/houseplan-card-o7i25aVl.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/de-DJX-vLW1.js",
"sha256": "73d198680c17602addf41855748533ed47233e81e9bdc02a786034aeac0e4e63",
"rawBytes": 81798,
"gzipBytes": 24701,
"path": "houseplan-assets/de-D_5HnJeM.js",
"sha256": "ad2dde1407ae850f963cf81a8c0218c0e6646a86cf3bceb61a305cd54f60dad9",
"rawBytes": 81632,
"gzipBytes": 24639,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-Dvui5H3b.js",
"sha256": "11578ded60410e4e879c9342ee74c8a0968477ea2bae98eb18480a0ad02d303c",
"path": "houseplan-assets/editor-CqQRQq4C.js",
"sha256": "5007144deff010072eb0a6a6d3e5565064a60575baa173a0250cec89b1478823",
"rawBytes": 3826,
"gzipBytes": 1580,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-Dwkibvgo.js"
"houseplan-assets/houseplan-card-o7i25aVl.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/fr-DVmfM6Kw.js",
"sha256": "167bed2822ce7e2b38d47e8dc1d73648d10c04d469979bf05e414000b53487b4",
"rawBytes": 83973,
"gzipBytes": 24158,
"path": "houseplan-assets/fr-DpKqMyVB.js",
"sha256": "3c03580d357a96678d80b012923e7e28e989fe6a612fe0342d5d041e2d37d4f1",
"rawBytes": 83754,
"gzipBytes": 24095,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-Dwkibvgo.js",
"sha256": "d16deb0fc6f591040a2f97d51102e3e355da35cb3d1b69aa79472406c28d3ed2",
"rawBytes": 1033612,
"gzipBytes": 290234,
"path": "houseplan-assets/houseplan-card-o7i25aVl.js",
"sha256": "9b3942532fe83c5d49791859c419fb6f35a8db86c01a09ddccb1a2ec186bfbd3",
"rawBytes": 1033268,
"gzipBytes": 290274,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-DJX-vLW1.js",
"houseplan-assets/editor-Dvui5H3b.js",
"houseplan-assets/fr-DVmfM6Kw.js",
"houseplan-assets/houseplan-editor-runtime-9AEP51ly.js",
"houseplan-assets/houseplan-onboarding-runtime-CnVK5oOC.js"
"houseplan-assets/de-D_5HnJeM.js",
"houseplan-assets/editor-CqQRQq4C.js",
"houseplan-assets/fr-DpKqMyVB.js",
"houseplan-assets/houseplan-editor-runtime-D0JYjEs4.js",
"houseplan-assets/houseplan-onboarding-runtime-C-SJxeJ_.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-9AEP51ly.js",
"sha256": "c1b8f90458afedddbbdde98570c517d6e66dfcee08ad4764b1ac2473bdf89fb4",
"rawBytes": 574225,
"gzipBytes": 150401,
"path": "houseplan-assets/houseplan-editor-runtime-D0JYjEs4.js",
"sha256": "b2a69132a223d6c5b06b8d4a11b19416d33582bae2d39b2448ecc41f44ba98b2",
"rawBytes": 576032,
"gzipBytes": 151055,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-DWD0ZQTc.js",
"houseplan-assets/houseplan-card-Dwkibvgo.js"
"houseplan-assets/backdrop-pick-Dg2Uytel.js",
"houseplan-assets/houseplan-card-o7i25aVl.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-CnVK5oOC.js",
"sha256": "bd169896116229e76b37243106bde64755589017639890d36dd19282c872db7a",
"path": "houseplan-assets/houseplan-onboarding-runtime-C-SJxeJ_.js",
"sha256": "1324b8003d727c99c6ce6d9e48598032b29782f8c8c7b783c1b1073e9b79d9f2",
"rawBytes": 28088,
"gzipBytes": 6956,
"gzipBytes": 6952,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-DWD0ZQTc.js",
"houseplan-assets/houseplan-card-Dwkibvgo.js"
"houseplan-assets/backdrop-pick-Dg2Uytel.js",
"houseplan-assets/houseplan-card-o7i25aVl.js"
],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "730163666440793e44be0ee5482f7080931370f94555a1d52072d3a54698447e",
"sha256": "d6c8be124ce907703e76f24a5e9fedb3abd945213d7befb1f97d1635d82f4830",
"rawBytes": 1183,
"gzipBytes": 797,
"gzipBytes": 795,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-Dwkibvgo.js"
"houseplan-assets/houseplan-card-o7i25aVl.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
@@ -1,14 +1,14 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="816ff9c893e23b7441e73210f85024741ff6c5bf8dabd2fb0df9e8b8055bc693";import{b as e,l as o,t,df as s,A as a,dg as i,dh as l,di as n,E as r,c}from"./houseplan-card-Dwkibvgo.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
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="e5edb95a1f5817c9035c6418ea0f9cb86cde238466f1f5c3174fb5246662c8ec";import{b as e,l as o,t,dg as s,A as a,dh as i,di as l,dj as n,E as r,c}from"./houseplan-card-o7i25aVl.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=>f[e.name]||e.name}
.computeLabel=${e=>h[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`;return c`
${d(h.slice(0,3))}
${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(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);
${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
View File
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="816ff9c893e23b7441e73210f85024741ff6c5bf8dabd2fb0df9e8b8055bc693";try{await import("./houseplan-assets/houseplan-card-Dwkibvgo.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)}
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="e5edb95a1f5817c9035c6418ea0f9cb86cde238466f1f5c3174fb5246662c8ec";try{await import("./houseplan-assets/houseplan-card-o7i25aVl.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)}
+36 -7
View File
@@ -380,10 +380,39 @@ Custom Background images use a separate content-addressed store at
`<config>/houseplan/assets/`. Raster input is fully decoded and SVG is parsed
through a strict allowlist before promotion; the SHA-256 of canonical bytes is
the persisted `asset_id`. Config never carries file bytes or a signed URL.
`houseplan/assets/resolve` maps unique ids to authenticated content paths,
while the shared `ContentSigner` batches signatures for `<image>` elements.
Catalog deletion rechecks references across every space under the config write
lock. Missing or corrupt assets are never painted in View.
`houseplan/assets/resolve` maps unique ids to authenticated content paths.
Writers may resolve any catalog id; a read-only household member may resolve
only ids referenced by the current saved config, with forbidden ids reported as
ordinary `missing` entries. The reference snapshot is taken under the config
write lock, but file I/O happens after releasing it. The resolve path reads only
the requested sidecars rather than scanning the catalog. The HTTP content view
keeps its authenticated/signed exact-URL contract.
Resolve and HTTP GET share one HA-instance memory-only integrity verifier. It
streams SHA-256 in bounded chunks and caches at most 256 actual digests by
canonical path plus size/mtime/ctime signature. Per-file-version single-flight
deduplicates concurrent reads without serialising different files; a second
`stat` prevents a digest for bytes changed mid-read from entering the cache.
Missing, changed and corrupt files fail dark. The shared `ContentSigner` batches
signatures for `<image>` elements. Catalog deletion rechecks references across
every space under the config write lock. Missing or corrupt assets are never
painted in View.
Physical inventory is separate from the strict catalog projection. Quota
counts every regular `<sha256><allowed-extension>` blob by actual file size,
including blobs with absent or malformed sidecars; a sidecar without a blob
does not count. Re-uploading exact bytes repairs a digest-proven orphan before
new-file quota checks and reports `reused:false`, while an already valid row
reports `reused:true`. Explicit delete removes only the exact hash sidecar and
exact allow-listed blob names under the reference/upload locks—never a prefix,
temporary file, directory or unknown extension. There is no automatic orphan
collector.
Both cards treat `decor_assets_api` as fresh runtime authority: localStorage
cannot grant it, and each successful `config/get` can revoke it. Static cards
do not call resolve without exact v1 and clear their projection on downgrade.
Resolve caching is scoped by connection, config revision and sorted unique id
set (including missing results); failed transport calls are not cached.
`removed:true` is a binding tombstone, not a renderable marker. It claims an
HA binding against automatic discovery while intentionally exposing that same
@@ -903,7 +932,7 @@ transmit light is the separate `zero_wall_style` policy.
| `houseplan/layout/get` | — | `{layout: {device_id: {x,y}}, rev}` |
| `houseplan/layout/set` | `layout`, `expected_rev?` (omission only at `rev=0` bootstrap) | `{ok, rev}` / err `conflict`; event `houseplan_layout_updated` |
| `houseplan/layout/update` | `device_id`, `pos` | `{ok, rev}`; event `houseplan_layout_updated` |
| `houseplan/config/get` | — | `{config, rev, virtual_lights:{rev,config_rev,off[]}}` (`virtual_lights` optional for rolling compatibility) |
| `houseplan/config/get` | — | `{config, rev, virtual_lights:{rev,config_rev,off[]}, decor_assets_api?}` (runtime capabilities are optional for rolling compatibility) |
| `houseplan/virtual_light/toggle` | `marker_id` | `{marker_id,on,rev}` / err `not_toggleable`; event `houseplan_virtual_light_updated` |
| `houseplan/trail/get` | — | `{trails: {marker: {current, previous}}}` — vacuum runs, raw robot coords |
| `houseplan/trail/delete` | `marker_id` | `{ok, removed}` — erase current/previous runs after marker deletion |
@@ -918,8 +947,8 @@ transmit light is the separate `zero_wall_style` policy.
| `houseplan/files/migrate` | `from_id`, `to_id` | `{mapping}` — COPY, never move |
| `houseplan/files/cleanup` | `marker_id`, `keep?` | replacement-only collection |
| `houseplan/assets/list` | — | reusable image metadata plus authoritative `used_by` references |
| `houseplan/assets/resolve` | `asset_ids[]` (max 200) | verified metadata/content paths plus missing ids |
| `houseplan/assets/delete` | `asset_id` | explicit deletion only when no decor record refers to it |
| `houseplan/assets/resolve` | `asset_ids[]` (max 200) | verified metadata/content paths plus missing ids; writer: catalog, read-only: saved references only |
| `houseplan/assets/delete` | `asset_id` | explicit exact-id deletion of sidecar and allowed-extension blobs, only when no decor record refers to it |
| `houseplan/content/sign` | `paths[]` | `{urls}` — authSig for `<image>`/`<a>` fetches |
| `houseplan/export/create` | `kind`, `space_id?`, `plan_only?`, `card_version` | consistent versioned JSON document + safe filename; plan-only is valid only for one space |
| `houseplan/import/revalidate` | preview `token`, `duplicate_policy?` | refreshed bounded preview and current expected revisions |
+26
View File
@@ -2,6 +2,32 @@
## Unreleased
## v1.71.0-beta.2 — 2026-09-03
- Custom background images now recover cleanly after interrupted uploads,
consume quota consistently, retry after configuration changes and remain
compatible with older integrations; language switching and failed support
previews also settle safely instead of leaving stuck actions or occupied
preview slots ([#434](https://github.com/Matysh/houseplan-card/issues/434)).
- Saved custom images remain visible to read-only household members while
arbitrary asset lookup is blocked, and repeated card/HTTP loads now reuse one
bounded streaming integrity check instead of re-reading every image
([#432](https://github.com/Matysh/houseplan-card/issues/432)).
- Custom decor images now pass through the same stable coordinate-write barrier
as furniture and shapes, so repeated saves and **Optimize Plans** no longer
retain image-only floating-point noise
([#431](https://github.com/Matysh/houseplan-card/issues/431)).
- Exports made while a reusable decor-image file is missing can now be imported
with the existing confirmation instead of rejecting the whole plan; full,
single-space and plan-only exports preserve the repairable image object
([#428](https://github.com/Matysh/houseplan-card/issues/428)).
- Oversized decor-image sources can now be added through the safe reduced-copy
action; House Plan hides only the forbidden original instead of hiding both
choices ([#427](https://github.com/Matysh/houseplan-card/issues/427)).
- General settings can now hide the room information window on mouse hover
without disabling the room highlight or device tooltips; existing plans keep
the window enabled by default ([#426](https://github.com/Matysh/houseplan-card/issues/426)).
## v1.71.0-beta.1 — 2026-09-03
- The Background editor can now upload and reuse private PNG, JPEG, WebP and
+31
View File
@@ -8,6 +8,37 @@
## Не выпущено
## v1.71.0-beta.2 — 2026-09-03
- Пользовательские картинки подложки теперь корректно восстанавливаются после
прерванной загрузки, всегда учитываются в лимите, повторно проверяются после
изменения конфигурации и совместимы со старыми версиями интеграции; смена
языка и неудачное превью обращения также завершаются безопасно, не оставляя
зависших действий и занятых слотов
([#434](https://github.com/Matysh/houseplan-card/issues/434)).
- Сохранённые пользовательские картинки по-прежнему видны домочадцам без права
редактирования, но произвольный поиск файлов теперь закрыт; повторные загрузки
карточки и HTTP используют одну ограниченную потоковую проверку вместо нового
чтения каждой картинки
([#432](https://github.com/Matysh/houseplan-card/issues/432)).
- Пользовательские изображения декора теперь проходят тот же стабильный барьер
записи координат, что мебель и фигуры, поэтому повторные сохранения и
«Оптимизировать планы» больше не сохраняют float-шум только у изображений
([#431](https://github.com/Matysh/houseplan-card/issues/431)).
- Экспорт, созданный при отсутствующем файле пользовательской картинки декора,
теперь можно импортировать с обычным подтверждением вместо ошибки всего
плана; полный экспорт, одно пространство и режим «Только планировка»
сохраняют восстанавливаемый объект
([#428](https://github.com/Matysh/houseplan-card/issues/428)).
- Слишком большой исходник изображения декора теперь можно добавить через
безопасную уменьшенную копию: House Plan скрывает только запрещённый
оригинал, а не оба действия
([#427](https://github.com/Matysh/houseplan-card/issues/427)).
- В общих настройках теперь можно отключить информационное окно комнаты при
наведении мышью, не отключая подсветку комнаты и подсказки устройств;
существующие планы сохраняют включённое поведение по умолчанию
([#426](https://github.com/Matysh/houseplan-card/issues/426)).
## v1.71.0-beta.1 — 2026-09-03
- В редакторе подложки теперь можно загружать и повторно использовать закрытые
+49
View File
@@ -63,6 +63,20 @@ writer produce the same request; accepting either would reopen last-writer-wins
data loss. This changes only the WebSocket write contract. Stored config,
model/store versions, exports and read compatibility are unchanged.
## Room hover information preference (#426)
`settings.show_room_tooltip` is an optional global boolean. Absence and any
invalid legacy/future value read as the historical enabled default; only exact
`false` hides the floating room information window. Saving the enabled value
removes the key. The field does not change room highlighting or device
tooltips, and does not require a model/store version migration.
An older frontend ignores the field and temporarily shows the room window. An
older backend preserves it through the existing unknown-settings policy, so a
new frontend restores the disabled behavior after upgrade. Full backup/import
preserves the setting and the privacy-safe support projection includes only a
validated boolean.
## Stable wall identity — model v8 (#282)
Model v8 adds `space.wall_segments[]`, ordered `rooms[].wall_ids[]`, IDs on
@@ -135,6 +149,12 @@ while the frontend adopts the exact candidate it sends. This removes ULP noise
without changing the schema, JSON number type, model/store version or visible
placement.
Decor uses an explicit box-geometry catalog shared by the frontend contract and
mirrored by the integration: `rect`, `ellipse`, `furniture` and `image`.
Their `x/y/w/h` fields follow the lattice rule above and `angle` follows the
scalar rule; image asset, opacity, mirror flags and unknown fields are not
geometry and remain unchanged.
The operation is lossless at the product scale and intentionally narrow.
`view_box`, `cell_cm`, `plan_aspect`, physical centimetre values,
presentation settings, colours, opacity/brightness/temperature, vacuum
@@ -235,9 +255,38 @@ It records content hash and source availability but never embeds file bytes or
signed URLs. The importer continues to accept v1. A matching verified local
hash is reused; otherwise import requires confirmation and preserves the image
record as an editor repair placeholder instead of removing its geometry.
When the source blob and metadata are already absent, the canonical row has
`exists_at_export:false` and may have `mime:null`; that exact missing state is
importable in full, single-space and plan-only documents. Missing MIME is not a
general validation bypass: the availability flag must be a literal boolean,
identity/hash remain exact, and every supplied non-null MIME must be supported.
Before a permanent downgrade, remove all image objects with a current card and
then explicitly delete their now-unused files from the palette.
The #432 backend hardening does not change that schema, URL shape, export format
or `decor_assets_api:1` capability. A read-only user still resolves images used
by the saved config; only arbitrary unreferenced ids are now returned as
`missing`. Writers keep the full catalog contract. Authenticated and signed
exact content URLs remain valid, while integrity results are shared in a bounded
memory-only cache. Old and new cards therefore remain rolling-compatible with
the hardened integration; the cache is discarded on restart and needs no data
migration or downgrade step.
#434 keeps the same capability version and wire formats while tightening the
rolling boundary. Embedded `houseplan-space-card` instances call
`houseplan/assets/resolve` only after a fresh `config/get` returns exact
`decor_assets_api:1`; cached config from localStorage starts unverified, and a
later missing or malformed capability revokes a previously learned value even
when config content is identical. Positive and missing resolve results are
cached only for the same connection, config revision and id set. No persisted
field, schema migration, export-version change or downgrade action is added.
Decor quota now follows physical allow-listed hash blobs rather than trusted
catalog metadata. An exact re-upload may restore a missing or broken sidecar at
full quota because it adds no blob; the response uses `reused:false` to state
that the catalog entry was created by this request. Older cards can continue to
list and resolve valid rows and ignore this response distinction.
## Independent-wall opening host (#132)
`space.openings[].host` is an optional discriminated object
+9 -9
View File
@@ -1,18 +1,18 @@
<!-- release: v1.71.0-beta.1 -->
<!-- release: v1.71.0-beta.2 -->
## Основное
- В редакторе подложки теперь можно загружать и повторно использовать закрытые PNG, JPEG, WebP и безопасные SVG, а затем размещать и редактировать их как мебель ([#51](https://github.com/Matysh/houseplan-card/issues/51)).
- «Помощь и обратная связь» и диалоги опасных действий стали надёжнее при смене языка, пространства или версии, повторной отправке и некорректном конфиге ([#417](https://github.com/Matysh/houseplan-card/issues/417), [#418](https://github.com/Matysh/houseplan-card/issues/418), [#420](https://github.com/Matysh/houseplan-card/issues/420), [#423](https://github.com/Matysh/houseplan-card/issues/423)).
- Автоматическая привязка к зонам Home Assistant больше не забывает устройства из-за временно пустого или неполного реестра ([#419](https://github.com/Matysh/houseplan-card/issues/419)).
- Пользовательские картинки подложки теперь безопасно уменьшаются, восстанавливаются после прерванной загрузки и отсутствующего файла, а их координаты остаются стабильными после сохранения и оптимизации плана ([#427](https://github.com/Matysh/houseplan-card/issues/427), [#428](https://github.com/Matysh/houseplan-card/issues/428), [#431](https://github.com/Matysh/houseplan-card/issues/431), [#434](https://github.com/Matysh/houseplan-card/issues/434)).
- Доступ к сохранённым картинкам подложки стал безопаснее и быстрее: произвольный поиск файлов закрыт, а повторные загрузки используют ограниченную кэшированную проверку целостности ([#432](https://github.com/Matysh/houseplan-card/issues/432), [#434](https://github.com/Matysh/houseplan-card/issues/434)).
- В общих настройках теперь можно отключить информационное окно комнаты при наведении, сохранив подсветку комнаты и подсказки устройств ([#426](https://github.com/Matysh/houseplan-card/issues/426)).
- Мелкие исправления и улучшения.
## Highlights
- The Background editor can now upload and reuse private PNG, JPEG, WebP and safe SVG images, then place and edit them like furniture ([#51](https://github.com/Matysh/houseplan-card/issues/51)).
- Help & feedback and dangerous-action dialogs are now more reliable across language, space and version changes, edited retries, and malformed configuration ([#417](https://github.com/Matysh/houseplan-card/issues/417), [#418](https://github.com/Matysh/houseplan-card/issues/418), [#420](https://github.com/Matysh/houseplan-card/issues/420), [#423](https://github.com/Matysh/houseplan-card/issues/423)).
- Home Assistant Area relocation tracking no longer forgets devices because the registry is temporarily empty or incomplete ([#419](https://github.com/Matysh/houseplan-card/issues/419)).
- Custom background images can now be safely reduced, recovered after interrupted uploads or missing files, and keep stable coordinates across saves and plan optimization ([#427](https://github.com/Matysh/houseplan-card/issues/427), [#428](https://github.com/Matysh/houseplan-card/issues/428), [#431](https://github.com/Matysh/houseplan-card/issues/431), [#434](https://github.com/Matysh/houseplan-card/issues/434)).
- Access to saved background images is safer and faster: arbitrary file lookup is blocked, while repeated loads reuse a bounded cached integrity check ([#432](https://github.com/Matysh/houseplan-card/issues/432), [#434](https://github.com/Matysh/houseplan-card/issues/434)).
- General settings can now hide the room information window on hover while keeping room highlighting and device tooltips ([#426](https://github.com/Matysh/houseplan-card/issues/426)).
- Small fixes and improvements.
[Полный список изменений на русском](https://github.com/Matysh/houseplan-card/blob/v1.71.0-beta.1/docs/CHANGELOG.ru.md)
· [Full changelog in English](https://github.com/Matysh/houseplan-card/blob/v1.71.0-beta.1/docs/CHANGELOG.md)
[Полный список изменений на русском](https://github.com/Matysh/houseplan-card/blob/v1.71.0-beta.2/docs/CHANGELOG.ru.md)
· [Full changelog in English](https://github.com/Matysh/houseplan-card/blob/v1.71.0-beta.2/docs/CHANGELOG.md)
+3 -3
View File
@@ -17,12 +17,12 @@ change must pass through a published beta/RC before stable. Stable release
commits are promotion-only (versions, generated bundles and release/changelog
metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
## Snapshot (2026-09-02)
## Snapshot (2026-09-03)
| Item | State |
|---|---|
| Version | **v1.71.0-beta.1** everywhere (manifest, const.py, package.json, CARD_VERSION) |
| Current local cycle | v1.71.0-beta.1 is the first prerelease candidate after stable v1.70.0. It adds reusable private raster/SVG images to the Background editor (#51), hardens confirmation and Help & feedback races/privacy/compatibility (#417, #418, #420, #423), and protects HA Area relocation provenance from incomplete registries (#419). Test and capture gates #421, #422, #424 and #425 ship in the same candidate. Publish only after exact-SHA Validate is green. |
| Version | **v1.71.0-beta.2** everywhere (manifest, const.py, package.json, CARD_VERSION) |
| Current local cycle | v1.71.0-beta.2 hardens custom Background images across oversized sources, interrupted uploads, missing-file export/import, coordinate canonicalization, bounded integrity checks and old-integration compatibility (#427, #428, #431, #432, #434). It also adds the option to hide the room information window without disabling room highlighting or device tooltips (#426). Publish only after exact-SHA Validate is green. |
| Hidden Labs Stage | #89 Stage 1 ships in v1.63.0-beta.1. #122 Stage 2 ships in v1.64.0 and evolves the same hidden, expiring `iso` experiment with matte walls, a low exterior floor edge, restrained shared shadows and live vertical door/window/gate panels. Flat remains default; editors and `houseplan-space-card` remain flat; live floor effects and HA actions remain unchanged. Public activation remains a separate task. |
| Workflow | Superseded 2026-08-12: the pre-1.62 rule of "local edits without tests or commits" is **dead** — since release 1.62 every product change follows `PROCESS.md` (issue in `S5-ready`+, branch `issue/<NN>-slug`, trailers on every commit, review pipeline; `AGENTS.md` is the summary). Release mechanics below remain current. A requested pre-release gets a production build plus the smallest targeted unit/smoke set covering the changed surfaces, one tested `dev` commit/tag and a GitHub Release with `prerelease=true`; `main` stays untouched. The complete local frontend/backend/smoke gate runs only before a stable release, after which `main` is fast-forwarded to the exact tested `dev` SHA and the GitHub Release uses `prerelease=false`. Release bodies are short and bilingual (Russian first); every bullet links its GitHub issue (#NN) so the #328 rules stay machine-checkable. A STABLE body aggregates the changelog since the PREVIOUS STABLE release (never since the last beta): features/fixes described across the line's beta changelogs must appear, while bugs that were introduced and fixed strictly inside the beta line (never shipped in any stable) are excluded — draft with `npm run release:notes -- <tag>`, curate by hand, then `npm run release:notes -- <tag> --verify` must pass. `Мелкие исправления и улучшения` / `Small fixes and improvements` is allowed only when the range really contains user-visible work not itemised in the body; a single-issue hotfix ships without it (the verifier enforces this). Every body ends with separate links to the Russian and English changelogs. Open or partially delivered issues are never presented as shipped. Telegram announcements are sent only for stable releases; beta and RC publication is silent. `docs/RELEASE-NOTES.md` is the current canonical body instance; `npm run release:prerelease -- <tag> --issues=… --yes` is the primary local publication path and the manual `Publish prerelease` workflow is its GitHub-only equivalent once present on `main`. Nothing is copied to the home instance by hand |
| GitHub | https://github.com/Matysh/houseplan-card — [Issues](https://github.com/Matysh/houseplan-card/issues) are the canonical task records; their labels carry priority and workflow status (`PROCESS.md` §9). GitHub Projects is no longer used. `main` carries stable releases; pre-release tags may point directly at `dev`. Work lands on `dev` and is merged into `main` for a stable release, so `dev` is normally equal to or ahead of `main`, never behind. Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
+7
View File
@@ -34,6 +34,13 @@ the attachment or a successful submit removes the token; process exit also
removes it. Download uses the same preview text. The backend never rebuilds an
attachment during submit.
A valid 48-character token in a response that the card cannot adopt is also
discarded best-effort exactly once—even if the rest of the response is invalid,
the dialog changed meanwhile, or the local state update was refused. Cleanup
does not depend on whether that request is still current and never replaces the
original UI error. Malformed token strings are not sent back; backend TTL
remains the final guard if a discard transport itself fails.
## Transport and retention
The integration can contact only `https://support.houseplan.tech/v1/reports`.
+41
View File
@@ -1024,6 +1024,10 @@ separately promised workflows:
- [ ] Room hover adds a subtle accent wash and double contour without changing
the underlying room fill or Glow brightness
- [ ] Room tooltip shows average room temperature and humidity after the area line and before LQI; missing values are omitted [auto: smoke_ux_fixes]
- [ ] General settings can hide only the room tooltip: default/Cancel/save/reopen
semantics, skipped area work, persistent room highlight, unaffected
device tooltip and restoration on the next mouse move
[auto: smoke_room_tooltip_toggle]
- [ ] Average room temperature counts ONLY thermometer/air-monitor devices — fridges, TRV heads,
smart-plug chip temperatures (`*_device_temperature`) and diagnostic-category temps are excluded [manual]
- [ ] Space dialog is 500 px wide; the comfort-bounds inputs are compact (56 px)
@@ -3581,6 +3585,43 @@ require hands on real hardware — they remain for the human pass.
mismatched backend leaves About/Guide usable but exposes no fake submit
[pre-beta: success/429/timeout/unknown-command smokes in light/dark].
## v1.71 audit polish (#434)
- [ ] Physical decor inventory counts exact lower-hex allow-listed blob files
and their real sizes independently of sidecars; a valid-shaped sidecar
without its blob remains absent from catalog/list/resolve
[backend: `test_decor_assets.py`; mutations:
`decor-physical-inventory-follows-sidecars`,
`decor-catalog-accepts-sidecar-without-blob`].
- [ ] Exact orphan re-upload repairs metadata at a full physical quota with
`reused:false`; valid catalog reuse stays `true`, and digest mismatch
changes no file. Explicit delete removes all and only exact allow-listed
blob names plus the sidecar [HA: `test_ha_websocket.py`; mutations:
`decor-orphan-repair-runs-after-quota`,
`decor-orphan-repair-claims-reuse`, `decor-delete-skips-orphan-blobs`].
- [ ] Static cards learn exact `decor_assets_api:1` only from fresh config/get,
revoke it on downgrade and never resolve without it. Positive and missing
resolve caches share only connection + config revision + sorted id set;
failed calls retry [unit: `config-store`, `space-card-audit-lows`,
`decor-assets`; smoke: `smoke_space_card_decor_capability`].
- [ ] Ready→warm cancels an existing dangerous-action promise and removes its
dialog while preserving the committed body; a request in warm refuses,
and warm→ready allows immediately before another render
[smoke: `smoke_danger_confirm_branches`; mutations:
`danger-confirm-warm-language-guard-removed`,
`danger-confirm-warm-transition-cancel-removed`].
- [ ] Area absence evidence cannot outlive its current snapshot binding
[unit: `device-area-relocation`; mutation:
`area-cleanup-keeps-candidate-outside-current-snapshot`].
- [ ] The smoke job has its own 20-minute bound and each file is wrapped by GNU
`timeout --kill-after=10s 180s`; both German route waits have one-second
diagnostics [unit: `smoke-exception-guard`; CI: `validate.yml`].
- [ ] Every well-formed token from an invalid, stale or locally unadoptable
support preview response is discarded exactly once; malformed tokens are
not echoed and cleanup never hides the original failure
[smoke: `smoke_support_feedback`; mutation:
`support-invalid-response-leaks-issued-token`].
## Съёмка документации запускается с флагами детерминизма (#424)
- [ ] `demo/docs/browser-args.mjs` содержит `--disable-partial-raster` и
+5
View File
@@ -51,6 +51,11 @@ desktop hover without a reload. Space/mode changes, page hiding and remounts
also discard transient hover. Keyboard focus and explicit click/tap surfaces
remain independent of this visual hover gate.
The default-on **Show the room information window on hover** preference applies
only after this pointer-modality gate has enabled real mouse hover. Turning it
off leaves the room highlight and device tooltips unchanged; it does not add a
touch or pen replacement for the room window.
## What “best-effort editors” means
On a coarse-pointer or no-hover device, an editor operation may:
+18 -8
View File
@@ -55,7 +55,7 @@ override them; a room may override its space; a marker may override its room.
| Level | Meaning | Stored data |
|---|---|---|
| Card | One dashboard instance | Initial space, language, icon size, value/LQI display, live state, kiosk and cycle |
| Global settings | Defaults for all spaces | Fill palette, background, Glow radius, north, sun, weather and icon rules |
| Global settings | Defaults for all spaces | Fill palette, background, Glow radius, north, sun, room-hover information and icon rules |
| Space | Floor, yard, garage or building | Plan image, scale, rooms, walls, openings, decor and display settings |
| Room | A closed outline | Name, optional HA area, temperature/humidity source and local fill |
| Wall | A room-contour or independent segment | Stable identity and thickness from 0 to 100 cm; zero-thickness appearance is selected per space |
@@ -196,6 +196,11 @@ View is the state with no editor open. Close the active editor to return to it.
| Kiosk | Actionable as in View | Read-only | Read-only; no editors |
| Static card | Not live or interactive | Render only | Render only |
The room highlight remains available in View and kiosk. To keep that highlight
but hide the floating room summary, turn off **General settings → Show the room
information window on hover**. The option is on by default and does not affect
device tooltips.
Each editor has a stable primary toolbar. Tool parameters and selected-object
actions appear in a context tray over the top of the canvas. On a narrow screen
the tray scrolls horizontally instead of shrinking the plan.
@@ -763,18 +768,23 @@ available plan variants. **All categories** returns to the first level and
disarms the current symbol. Existing placed furniture keeps its saved size and
position when the built-in artwork is updated.
The Image palette stores reusable files privately in House Plan. A file is at
most 2 MiB; PNG, JPEG, WebP and safe SVG are supported. Picking a file arms one
placement: the pointer preview shows the result, one click adds it at 100 cm
wide (aspect-preserving, height capped at 200 cm), and the tool returns to
Select. Images use the same smooth handles, mirroring and `Shift`-45° rotation
as furniture, but never snap to a wall. Their complete rectangle is selectable,
including transparent pixels.
The Image palette stores reusable files privately in House Plan. Each saved
canonical file is at most 2 MiB; PNG, JPEG, WebP and safe SVG are supported.
When a raster source exceeds that limit, the warning dialog offers to upload a
reduced copy while keeping the oversized original unavailable. Picking a file
arms one placement: the pointer preview shows the result, one click adds it at
100 cm wide (aspect-preserving, height capped at 200 cm), and the tool returns
to Select. Images use the same smooth handles, mirroring and `Shift`-45°
rotation as furniture, but never snap to a wall. Their complete rectangle is
selectable, including transparent pixels.
Deleting or replacing a placed image leaves the reusable file in the palette.
The palette deletes a file only after all placed copies in all spaces are gone.
If a file is missing or fails its integrity check, View hides it; Background
shows a crossed placeholder that can be selected and repaired with Replace.
Exports still keep that image object without embedding the absent file. A later
import shows the existing missing-content confirmation and, once confirmed,
keeps the same repairable placeholder instead of rejecting the whole plan.
![Selected line in the Background editor](images/07-background-editor.png)
+16 -4
View File
@@ -55,7 +55,7 @@ House Plan — локальная интеграция и две Lovelace-кар
| Уровень | Что это | Что хранит |
|---|---|---|
| Карточка | Один экземпляр `houseplan-card` на дашборде | Стартовое пространство, язык, размер иконок, показ значений/LQI, живые состояния, киоск и автолистание |
| Общие настройки | Настройки всего House Plan | Палитры заливок, фон, радиус света, север, солнце, погода, правила иконок |
| Общие настройки | Настройки всего House Plan | Палитры заливок, фон, радиус света, север, солнце, окно комнаты при наведении, правила иконок |
| Пространство | Этаж, двор, гараж, отдельное строение | Подложку, масштаб сетки, комнаты, стены, проёмы, декор и настройки отображения |
| Комната | Только замкнутый контур | Название, необязательную HA-зону, источники температуры/влажности и локальную заливку |
| Стена | Сегмент контура комнаты или независимая стена | Стабильный ID и толщину 0–100 см; вид стен нулевой толщины задаётся для пространства |
@@ -207,6 +207,11 @@ title: План дома
| Киоск | Настенный экран | Интерактивны как в просмотре | Только отображение | Только отображение; редакторы отсутствуют |
| Статическая карточка | Компактная схема пространства | Только рисунок | Только рисунок | Без состояний и интерактивности |
Подсветка комнаты сохраняется в Просмотре и киоске. Чтобы оставить подсветку,
но убрать всплывающее описание комнаты, выключите **Общие настройки →
Показывать окно с информацией при наведении на комнату**. По умолчанию опция
включена и не влияет на подсказки устройств.
В редакторах сетка продолжается по всему рабочему холсту. В режиме просмотра сетка не показывается.
Основная панель каждого редактора содержит только постоянные инструменты, а
@@ -1391,9 +1396,12 @@ Power=`off`/`unavailable` подавляет даже устаревший ак
Палитра «Изображение» хранит файлы в закрытом хранилище House Plan и позволяет
повторно использовать их в разных пространствах. Поддерживаются PNG, JPEG,
WebP и безопасный SVG размером до 2 МиБ. Выбор файла вооружает одно размещение:
предпросмотр следует за указателем, один клик добавляет объект шириной 100 см с
сохранением пропорций (высота не больше 200 см), затем включается «Выбрать».
WebP и безопасный SVG; лимит 2 МиБ относится к сохранённому canonical-файлу.
Если исходный растр больше, диалог предлагает загрузить уменьшенную копию, но
не позволяет оставить слишком большой оригинал. Выбор файла вооружает одно
размещение: предпросмотр следует за указателем, один клик добавляет объект
шириной 100 см с сохранением пропорций (высота не больше 200 см), затем
включается «Выбрать».
Изображение получает те же плавные ручки размера, отражение и привязку поворота
к 45° с `Shift`, что мебель, но никогда не магнитится к стене. Выбирается весь
@@ -1404,6 +1412,10 @@ WebP и безопасный SVG размером до 2 МиБ. Выбор фа
Если файл исчез или не проходит проверку целостности, в просмотре он скрыт, а
в редакторе подложки остаётся перечёркнутая рамка. Её можно выбрать и указать
другой файл без потери позиции, размера, угла, отражения и порядка слоя.
Экспорт всё равно сохраняет такой объект, не встраивая отсутствующий файл.
При последующем импорте House Plan показывает обычное подтверждение
недостающего содержимого и после согласия оставляет ту же восстанавливаемую
рамку, а не отклоняет весь план.
## 15. Солнце: фон и оконные лучи
+4
View File
@@ -79,6 +79,10 @@ device state do not depend on that gate. A device action that actually dispatche
briefly scales its painted shell to 95% and back over 200 ms; informational,
editor and no-op paths do not imitate a successful action.
The global `settings.show_room_tooltip` preference controls only the floating
room information window. Missing or invalid values mean enabled; exact `false`
hides the window while room highlight and every device tooltip remain active.
Removed from this mode (they move, not die):
- icon dragging ("drag anywhere", v1.9 — consciously reversed),
- room-label dragging,
+11 -11
View File
@@ -3,7 +3,7 @@
"fixture": "synthetic-only",
"chromium": "151.0.7922.34",
"oxipng": "oxipng 10.2.0",
"sourceFingerprint": "cb230a51d6b12aed13e4a2db8e08701fcaefed3b54a6e61c4e0cbb1a3483be7d",
"sourceFingerprint": "1169cf86d01e9ff27d4fd4e55f114d299eb094aa84799c395b45ad914cffc24b",
"captureScriptSha256": "cadb8e1bcab9f1dcdd7d75b3b90ddcbaaeb2b8c2a098f575a21f39ff70f5c59c",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -15,7 +15,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "cb230a51d6b12aed13e4a2db8e08701fcaefed3b54a6e61c4e0cbb1a3483be7d",
"sourceSha256": "1169cf86d01e9ff27d4fd4e55f114d299eb094aa84799c395b45ad914cffc24b",
"imageSha256": "80a70361dc18dd0461568df332062e6482c633af5d280954f8b675701418a76d"
},
"view-touch": {
@@ -26,7 +26,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "cb230a51d6b12aed13e4a2db8e08701fcaefed3b54a6e61c4e0cbb1a3483be7d",
"sourceSha256": "1169cf86d01e9ff27d4fd4e55f114d299eb094aa84799c395b45ad914cffc24b",
"imageSha256": "4106cc28847047505f46921ff95765d8abdf5b382d9d17d4c5e4ad129dd8f6be"
},
"space-create": {
@@ -37,7 +37,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "cb230a51d6b12aed13e4a2db8e08701fcaefed3b54a6e61c4e0cbb1a3483be7d",
"sourceSha256": "1169cf86d01e9ff27d4fd4e55f114d299eb094aa84799c395b45ad914cffc24b",
"imageSha256": "617b51b3648498787b5039980c9f3eceb75ba56ed63a1a20e616bc05bc304362"
},
"room-contour-close": {
@@ -48,7 +48,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "cb230a51d6b12aed13e4a2db8e08701fcaefed3b54a6e61c4e0cbb1a3483be7d",
"sourceSha256": "1169cf86d01e9ff27d4fd4e55f114d299eb094aa84799c395b45ad914cffc24b",
"imageSha256": "1dab6cc3b9d1bf7d8c40f0e5137f8c688683c9b7eabc5167da99d41ecfdd5b79"
},
"plan-context-tray": {
@@ -59,7 +59,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "cb230a51d6b12aed13e4a2db8e08701fcaefed3b54a6e61c4e0cbb1a3483be7d",
"sourceSha256": "1169cf86d01e9ff27d4fd4e55f114d299eb094aa84799c395b45ad914cffc24b",
"imageSha256": "94ef50762753c0ac6ddc84d2521c4232a3f9ecd89843810d2df61c517592bed7"
},
"device-editor": {
@@ -70,7 +70,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "cb230a51d6b12aed13e4a2db8e08701fcaefed3b54a6e61c4e0cbb1a3483be7d",
"sourceSha256": "1169cf86d01e9ff27d4fd4e55f114d299eb094aa84799c395b45ad914cffc24b",
"imageSha256": "7a601769de38aa19c2e280f6ff4cf3695b854b1d1c6d1f8de4e6b5e8f7eb1559"
},
"device-display-preview": {
@@ -81,7 +81,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "cb230a51d6b12aed13e4a2db8e08701fcaefed3b54a6e61c4e0cbb1a3483be7d",
"sourceSha256": "1169cf86d01e9ff27d4fd4e55f114d299eb094aa84799c395b45ad914cffc24b",
"imageSha256": "0939875f8631694f4de0ef4fd01032010ede8c7d49fb7c0a857612d4b3afff93"
},
"background-editor": {
@@ -92,7 +92,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "cb230a51d6b12aed13e4a2db8e08701fcaefed3b54a6e61c4e0cbb1a3483be7d",
"sourceSha256": "1169cf86d01e9ff27d4fd4e55f114d299eb094aa84799c395b45ad914cffc24b",
"imageSha256": "054170fd9ef45762b602b4d5c9c3b9ea9724858be61af137970c246f485c13bb"
},
"room-card": {
@@ -103,7 +103,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "cb230a51d6b12aed13e4a2db8e08701fcaefed3b54a6e61c4e0cbb1a3483be7d",
"sourceSha256": "1169cf86d01e9ff27d4fd4e55f114d299eb094aa84799c395b45ad914cffc24b",
"imageSha256": "2ae4a58853d98e10d12456b2078ec2f6a0b597722c8310d7b016abd2bc42561e"
},
"device-info": {
@@ -114,7 +114,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "cb230a51d6b12aed13e4a2db8e08701fcaefed3b54a6e61c4e0cbb1a3483be7d",
"sourceSha256": "1169cf86d01e9ff27d4fd4e55f114d299eb094aa84799c395b45ad914cffc24b",
"imageSha256": "cb37f7eefd936f98ef44969b21dfe8e40ee27d1f558abe8d53c0223f6488b8a6"
}
},
+290
View File
@@ -0,0 +1,290 @@
# CODE-REVIEW-426-r1
- **Issue:** https://github.com/Matysh/houseplan-card/issues/426
- **Этап:** code (код-ревью, PROCESS.md §2.7)
- **Заход:** r1 · блокирующих циклов израсходовано 0 из 4
- **Материал:** `git log --oneline origin/dev..HEAD` / `git diff origin/dev...HEAD`
на ветке `issue/426-room-hover-tooltip-toggle`, HEAD `a6eb3d6cfa492ed1e742231800f31efab7c46e4b`.
- **ТЗ:** `docs/specs/426-room-hover-tooltip-toggle.md` — ревью ТЗ уже
зелёное (`docs/reviews/SPEC-REVIEW-426-r1.md`, SHA `587c2069`), сам текст ТЗ
не менялся между тем ревью и этим (issue содержит ревизию 2, но это
уточнение места editor-only строк без изменения поведения — см. историю
issue).
Это первый заход код-ревью для #426: раздел «дельта/унаследовано из r0» из
§2.9 не применяется (применяется только начиная с r2).
## Скоуп
Шесть коммитов на ветке:
1. `587c2069` docs: specify room hover tooltip toggle (ТЗ)
2. `ac99a2ff` docs: review document for #426 (SPEC-REVIEW артефакт)
3. `8013f7d0` feat: add room hover tooltip setting — **весь продуктовый код**:
`src/types.ts`, `src/logic.ts`, `src/houseplan-card.ts`,
`src/houseplan-editor-runtime.ts`, i18n (4 lazy editor-словаря + перенос
`gs.hint` из initial View в lazy editor-бандл), backend
(`validation.py`, `support_package.py`), тесты (`logic.test.mjs`,
`i18n.test.mjs`, `test_validation.py`, `test_support_package.py`), новый
`demo/smoke_room_tooltip_toggle.mjs`, `scripts/smoke-links.mjs`, docs
(CHANGELOG×2, USER-GUIDE×2, UX-MODES, TOUCH-SUPPORT, CONFIG-COMPATIBILITY,
TESTING), собранные `dist/**`/`custom_components/.../frontend/**`.
4. `9d1e5fdb` docs: refresh screenshot source fingerprint (гейт `check-docs.mjs`)
5. `223951c0` chore: refresh config schema manifest (`scripts/config-schema.json`)
6. `a6eb3d6c` test: accept room tooltip settings golden — приняла новый
`general-color-popover-desktop-en` эталон (единственный изменившийся
golden-кадр).
Продуктовый скоуп совпадает с ТЗ: один глобальный boolean
`settings.show_room_tooltip`, resolver `showRoomTooltipOf`, guard в локальном
`tip`-замыкании комнаты, независимость hover-подсветки/device tooltip,
backend/schema/privacy-projection, i18n, документация, release-артефакты.
## Как проверялось
Ручного тестирования в цикле нет — весь разбор построен на чтении
`git diff origin/dev...HEAD` и на выводе уже прогнанных CI-джобов на этом
дереве.
1. **Продуктовая рамка.** `docs/SCOPE.md` J1 («live spatial overview») —
опция не убирает состояние, только один информационный элемент
вида/интерфейса; никакая строка Core user jobs не запрещает такую точную
настройку показа. View остаётся продуктом для двух персон из трёх —
изменение не трогает hover-подсветку, устройства, действия.
2. **Frontend-контракт** (`src/logic.ts`, `src/types.ts`,
`src/houseplan-card.ts`, `src/houseplan-editor-runtime.ts`) — построчно,
с трассировкой каждого AC1–AC7 к конкретной строке диффа (см. раздел
«Находки» и «Что проверено»).
3. **Backend** (`custom_components/houseplan/validation.py`,
`support_package.py`) и тесты (`tests_backend/test_validation.py`,
`test_support_package.py`) — построчно + логическая проверка, что новые
тесты умеют падать (мутация: убрать `isinstance(..., bool)` → тест
`test_room_tooltip_global_setting_is_strict_boolean_and_round_trips`
перестаёт отклонять `0`/`1`/`"false"`; убрать `isinstance(show_room_tooltip, bool)`
в `support_package.py` → тест на строку `"false"` в
`test_projection_helpers_fail_closed_on_malformed_shapes` перестаёт быть
пустым словарём).
4. **i18n** — сверка всех 4 lazy editor-словарей, `test/i18n.test.mjs`
(жёсткое число `44`, не диапазон — ловит и пропущенный, и лишний ключ).
5. **Docs** — `CONFIG-COMPATIBILITY.md`/`UX-MODES.md`/`TOUCH-SUPPORT.md`/
`USER-GUIDE.{md,ru.md}`/`CHANGELOG.{md,ru.md}` — сверены построчно с
контрактом ТЗ (терминология "Show the room information window on hover" /
"Показывать окно с информацией при наведении на комнату" совпадает с
зафиксированной владельцем строкой).
6. **CI-évidence вместо повторного прогона** (см. «Какие гейты» ниже) —
прочитаны логи трёх Actions-прогонов на этой ветке через `gh run view`.
7. **Golden-эталон** — скачаны обе версии
`general-color-popover-desktop-en.png` (`origin/dev` и `a6eb3d6c`) и
визуально сверены (см. «Находки»/«Что проверено»).
8. `node scripts/smoke-select.mjs --base origin/dev --head HEAD` — получен
список смоков по символам диффа, дальше сверен с логами уже прошедшего
прогона (см. ниже).
## Находки
**High: 0. Medium: 0.** Изменение соответствует ТЗ, все девять AC закрыты
кодом и/или тестом, гейты (см. ниже) зелёные на этом дереве.
Отмечаю две вещи, которые проверил специально, потому что выглядели как
потенциальный риск, но не подтвердились как дефект (Low, не требует правки):
- **`_notePointer` не вызывается на каждый `pointermove`, когда опция
выключена** (`src/houseplan-card.ts:11580-11585`) — до правки `_showTip()`
вызывал `this._notePointer(ev)` на каждый pointermove комнаты; теперь при
`showRoomTooltipOf() === false` цикл выходит раньше и `_notePointer` не
вызывается для pointermove (только для pointerenter через `enterRoom`).
Прочитал `PointerModalityController.note()`
(`src/pointer-modality.ts:78-82`) и `nextPointerModality`
(`src/pointer-modality.ts:20-28`): модальность — защёлка, меняется только
при событии другого `pointerType`, а не тухнет со временем. Поскольку
`pointerenter` уже фиксирует модальность на входе в комнату, повторные
вызовы на move были бы no-op'ами. Регрессии нет — **проверено чтением, не
исполнением**.
- **Optimistic write в `_saveSettingsDialog` не откатывается при ошибке
сохранения** (`src/houseplan-editor-runtime.ts:10243-10256`) —
`this.host._serverCfg` мутируется новым `settings` (включая
`show_room_tooltip`) **до** `await this._saveConfigNow()`; `catch`-ветка
снимает только `busy`, не восстанавливает `_serverCfg`. Формально это
означает, что при сетевой ошибке эффективное значение опции в памяти уже
«новое», хотя запись не подтверждена сервером — то есть именно то, что
AC1.5 запрещает («не выдаёт несохранённый draft за применённое значение»).
Но это **не новое поведение**: тот же паттерн уже действует для `sunRays`,
`bgColor`, `northDeg`, `glow_radius_cm`, `bgMode` в той же функции, и ТЗ
сознательно пишет «по существующему контракту общих настроек» — то есть
автор идёт по прецеденту, а не изобретает новый разрыв. Чинить
существующий контракт всего диалога — вне скоупа #426 (это была бы правка
соседнего поведения, не связанного с room-tooltip). Не завожу отдельный
issue: находка Low на уровне существующего кода, не Medium/High и не
попутный дефект, привнесённый этим диффом.
## Что проверено и корректно
- **AC1 (UI/default).** Один переключатель сразу после `gs.hint`
(`houseplan-editor-runtime.ts:10538-10547`), `_boolInput`, доступное имя из
видимой строки — как и `sunRays`. `_openSettingsDialog` инициализирует
draft через единственный resolver `showRoomTooltipOf(this.host._settings)`
(`:9034-9039`). Юнит-тест на resolver
(`test/logic.test.mjs`) покрывает absent/`undefined`/`null`/`true`/`0`/`1`/
`'false'`/`[]`/`{}` → `true`, только точный `false` → `false` — то есть
ровно табличный план AC1 из ТЗ, тест умеет падать (замена на
`Boolean(value)` даёт `false` для нескольких из этих проб).
- **AC2 (persistence).** Save: `if (d.showRoomTooltip) delete settings.show_room_tooltip; else settings.show_room_tooltip = false;`
(`:10237-10238`) — та же двусторонняя схема, что у `sunRays`. Cancel/Escape
не проходят через `_saveSettingsDialog` (не тронуто диффом — общий
контракт диалога). Backend: `vol.Optional("show_room_tooltip"): bool`
(`validation.py:1938`) отклоняет `None`/`0`/`1`/`"false"`/`[]`/`{}`,
принимает оба boolean с round-trip — тест
`test_room_tooltip_global_setting_is_strict_boolean_and_round_trips`
доказывает и допуск, и отказ.
- **AC3 (tooltip off).** Guard **до** `_roomArea(r)`
(`houseplan-card.ts:11582-11586`): при `false` — `return` раньше вычисления
площади и раньше `_showTip()`; попутно чистит уже показанный
`.tip`, если он был room-tooltip (`if (this._tip?.room) this._tip = null;`).
Save с выключенным значением дополнительно чистит tip сразу после
`_saveConfigNow()` (`:10245`). Смок
`demo/smoke_room_tooltip_toggle.mjs` проверяет оба пути:
`disabledRoomTip`/`disabledSkipsArea` (счётчик вызовов `_roomArea`) и
`visibleRoomTipCleared` после Save — прогнан в CI (см. ниже), `ok`.
- **AC4 (default parity).** Ветка `showRoomTooltipOf() === true` не меняет
путь: тот же вызов `_showTip(e, title, areaText ?? '', showLqi ? ... : null, this._roomTemp(r), this._roomHum(r), true)`
— единственное отличие от старого кода это добавленный `room: boolean`
флаг в конце сигнатуры (используется только для идентификации источника
tip, не влияет на title/area/temp/hum/lqi/positioning). Смок
`defaultRoomTip`/`roomTipRestoredOnMove` подтверждает содержимое и
восстановление после reload/повторного включения.
- **AC5 (hover/device independence).** `enterRoom` (вынесенный из пяти
дублирующихся инлайн-обработчиков `pointerenter`) не содержит нового
guard'а — `_hoverRoom` продолжает устанавливаться независимо от
`show_room_tooltip`. `_showTip()` не тронут: device tooltip
(`houseplan-card.ts:12463+`) вызывает `_showTip(...)` без нового
параметра → `room` по умолчанию `false`, не подавляется новым guard'ом (он
находится только внутри room-специфичного замыкания `tip`, не в общем
`_showTip`). Смок: `roomHighlightSurvives`, `deviceTipSurvives` (`_tip?.room === false`).
- **AC6 (pointer/mode parity).** Новый guard стоит **внутри** уже
существующего `if (this._mode !== 'view') return;`, а модальность
(`_pointerModality.hoverEnabled`) по-прежнему проверяется в `_showTip()`
на true-ветке — touch/pen ничего не меняют. Разобрано и решение выше про
`_notePointer` — проверено чтением, регрессии нет.
- **AC7 (compatibility/privacy).** `CONFIG_SCHEMA` — explicit `bool`,
`ALLOW_EXTRA` сохранён. `support_package._global_settings` копирует ключ
только если `isinstance(show_room_tooltip, bool)`
(`support_package.py:142-144`) — невалидная форма (`"false"`) не
копируется, тест `test_projection_helpers_fail_closed_on_malformed_shapes`
это подтверждает. Отсутствие ключа не материализуется нигде (frontend
save удаляет ключ на `true`, backend не создаёт его при чтении). Таблица
mixed-version в ТЗ соответствует `ALLOW_EXTRA` (старый backend) и
strict-schema (новый backend) поведению, прочитанному в коде.
- **AC8 (i18n/docs/release).** Все 4 словаря обновлены, `gs.hint` перенесён
из initial-View словарей (`src/i18n/{en,de,fr,ru}.json`, удалён) в lazy
editor-словари (`src/i18n/support/{en,de,fr,ru}.json`, добавлен вместе с
`gs.show_room_tooltip`) — снижает initial View graph, как и требовало ТЗ.
`test/i18n.test.mjs` жёстко проверяет `44` ключа (было 42) — тест падает и
при пропуске, и при лишнем ключе. Оба CHANGELOG в одном коммите
(`8013f7d0`) с `User-Visible: yes`. USER-GUIDE EN/RU, UX-MODES,
TOUCH-SUPPORT, CONFIG-COMPATIBILITY обновлены и согласованы терминологией
("Show the room information window on hover" / доменная строка RU).
- **AC9 (гейты и бюджет).** См. раздел «Какие гейты прогнаны» — все
перечисленные в AC9 гейты зелёные на этом SHA (через прямой прогон или
легитимный byte-identical reuse, см. ниже), default golden кадры не
изменились (единственный изменившийся кадр — `general-color-popover-desktop-en`,
который явно является ожидаемым следствием новой строки в диалоге, принят
по правилам §11).
## Golden-эталон: проверка правомерности принятия
Коммит `a6eb3d6c` меняет ровно один кадр, `general-color-popover-desktop-en`,
и несёт `Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/33722471494`
— это соответствует требованию §11 «эталоны golden принимаются только через
`npm run golden:accept -- --reviewed` на полном артефакте Linux CI».
Проверил сам прогон (`gh run view 33722471494`): джоб «Golden-кадры против
принятых эталонов» упал (ожидаемо — новый кадр ещё не принят), но перед этим
отработали и позеленели «Фронтенд: типы/юниты/мутанты/бандл»,
«Бэкенд: pytest», «Смоки: все шарды зелёные» — то есть кандидат собран из
полного, валидного дерева, не подогнан вручную.
Скачал обе версии PNG (`origin/dev` и `a6eb3d6c`) и сравнил визуально: разница
— это ровно новая строка-переключатель «Show the room information window on
hover» между `gs.hint` и `Fill: lights`, из-за которой весь низ диалога (в том
числе открытый color-popover) сдвинулся вниз на высоту одной строки. Никакого
постороннего визуального изменения нет. Решение автора не создавать отдельный
новый golden-сценарий (ТЗ, «Release-артефакты») было верным для *нового*
сценария; для *существующего* `general-color-popover-desktop-en`, который
непреднамеренно задело смещение вёрстки, потребовалось (и было сделано)
штатное принятие через reviewed CI-артефакт — никакого расхождения с
процессом.
## Какие гейты прогнаны и почему
Полные наборы (§8, предрелизный гейт) прогонять было не нужно — прогонялись/
подтверждались только гейты, которых касается этот diff, через чтение логов
уже отработавших Actions-прогонов этой ветки:
| Гейт | Статус | Источник |
|---|---|---|
| `npx tsc --noEmit`, `npm test`, `npm run build` + сверка 3 копий бандла | ✅ зелёный, не перегонял | Validate на `a6eb3d6c`, https://github.com/Matysh/houseplan-card/actions/runs/33723133249, джоб «Фронтенд: типы, юниты, мутанты, синхрон бандла» — как указано в постановке задачи, этот прогон уже подтверждён и повторного прогона не требует |
| `node scripts/check-docs.mjs` (source fingerprint) | ✅ | Коммит `9d1e5fdb` обновил `docs/images/screenshots.json`; предполётный docs-джоб зелёный в обоих прогонах |
| Config-schema manifest | ✅ | Коммит `223951c0` обновил `scripts/config-schema.json`, добавив `config.settings.show_room_tooltip` |
| `python -m pytest tests_backend -q` | ✅, не перегонял — byte-identical reuse | Реально выполнен и позеленел в run `33722471494` (джоб «Бэкенд: pytest», 2m9s, включает новые `test_validation.py`/`test_support_package.py`); в финальном run `33723133249` переиспользован («входы побайтово те же», #208) — backend-код между этими прогонами не менялся |
| Golden (`npm run golden:verify`) | ✅ | Run `33723133249`, джоб «Golden-кадры против принятых эталонов» зелёный **после** принятия эталона в `a6eb3d6c`; сам процесс принятия проверен отдельно выше |
| Browser smokes, выбранные по диффу | ✅, не перегонял — byte-identical reuse | `node scripts/smoke-select.mjs --base origin/dev --head HEAD` дал 20 «прямых совпадений» (список ниже) и 18 «слабых связей» (только `_config`); вместо выборочного прогона проверил логи — **весь набор из 215 смоков** реально выполнен в run `33722471494` (3 шарда, все ✓, финальный агрегат «Смоки: все шарды зелёные» ✓), включая явно `demo/smoke_room_tooltip_toggle` (`ok smoke_room_tooltip_toggle` в логе шарда 1); в финальном run `33723133249` переиспользован тем же byte-identical правилом. Это сильнее любой выборки по символам — весь набор, а не только совпавшие по символам файлы |
| `npm run invariants -- --config ...` | Не прогонял — не требуется | Diff не трогает геометрию: нет изменений в rooms/walls/layout/marker.space/open_spans, только новый independent boolean и его resolver |
| Performance-профили | Не прогонял — не требуется | Не названы в AC9 как отдельный профиль; ТЗ («Производительность и безопасность») обоснованно исключает их, guard стоит до `_roomArea()`; perf-smoke job переиспользован (#208) на обоих прогонах как побайтово идентичный |
| `test/single-source-numbers.test.mjs` | Не запускал целево | Diff не добавляет и не дублирует ни одного видимого числового значения — переключатель boolean, площадь/температура/влажность/LQI не тронуты; тест из общего юнит-набора всё равно прошёл в рамках зелёного `npm test` |
### Прямые совпадения по `smoke-select.mjs` — решение по каждой строке
Все нижеперечисленные фактически прогнаны и зелены в CI (см. таблицу выше:
весь набор из 215 смоков выполнялся, а не только эти 20), решение
«прогнать» принято до выборки — фиксирую построчно для аудита:
- `smoke_feedback_v2`, `smoke_touch_tips` (`_hoverRoom`,`_notePointer`,`_tip`) — прямое совпадение, прогнан
- `smoke_room_tooltip_toggle` — новый целевой смок задачи, прогнан
- `smoke_help_affordance`, `smoke_bg_color`, `smoke_color_picker_consumers`, `smoke_dialog_zombie`, `smoke_esc_dialogs`, `smoke_gs_always`, `smoke_ha_controls`, `smoke_sun` (`_settingsDialog`/`_config`) — общие настройки задеты новой строкой в диалоге, прогнан
- `smoke_junction_patch_resilience`, `smoke_multiwall_junction`, `smoke_wall_key_roundtrip`, `smoke_isometric_live_touch`, `smoke_sun_live_bg` (`_hoverRoom`) — не про room-tooltip, но используют тот же геттер; прогнан вместе со всеми
- `smoke_decor`, `smoke_room_climate_placement`, `smoke_room_settings`, `smoke_ux_fixes` (`_tip`) — device/room tooltip соседние сценарии, прогнан
Слабые связи (18, все через один `_config`) не разбирались построчно — общий
прогон всего набора делает это избыточным; ни один из них не относится к
room-tooltip по существу.
## Чего не проверял
- Полный HA-харнесс/performance-профили за пределами того, что уже
переиспользовано CI как byte-identical (#208) — не требуется по AC9 и не
затронуто диффом.
- Ручное открытие приложения в браузере — цикл ревью не предусматривает
ручного тестирования; вместо этого разобран код и прочитаны логи реально
выполненного (не смоделированного) браузерного смок-прогона.
- Мутационная проверка вручную (я не патчил код и не перезапускал тесты
локально) — оценена **логическая** способность тестов упасть по чтению
кода теста и производственного кода одновременно; там, где это
нетривиально (backend bool schema, i18n key count), это явно
зафиксировано выше.
- Полный визуальный дифф остальных 131 неизменившихся golden-кадров — не
нужно, `baselines-index.json` показывает, что изменился ровно один хэш.
## Материал раунда
- Ветка: `issue/426-room-hover-tooltip-toggle`
- SHA: `a6eb3d6cfa492ed1e742231800f31efab7c46e4b`
- Диапазон: `origin/dev..HEAD` (6 коммитов, см. «Скоуп»)
## Вердикт
**Зелёный.** High: 0 · Medium: 0. Готово к мержу.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/426-room-hover-tooltip-toggle`, коммит `a6eb3d6cfa49` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `ac93d978f870507113624993ecfa47e429806218`
```
git log --all --format='%H %T' | grep ac93d978f870
```
- ТЗ `docs/specs/426-room-hover-tooltip-toggle.md`, блоб `66fb319f32ef962372bb998256effaf205a7fbb8`
```
git log --all --find-object=66fb319f32ef962372bb998256effaf205a7fbb8 -- docs/specs/426-room-hover-tooltip-toggle.md
```
+197
View File
@@ -0,0 +1,197 @@
# CODE-REVIEW — issue #427 · заход r1
**SHA материала:** `f71de899750dc0a59e176ad0554a09b34927b25f` (HEAD, детач от
`origin/issue/427-decor-large-image-downscale-action`)
**База сравнения:** `origin/dev` = `4cabcbe828e0ec7349414cab4626d43f78d95f88`
**Трек:** `trivial` (короткий трек, ТЗ в теле issue, ревью — комментарий; код-ревью
как обычно)
**Заход:** r1 · блокирующих циклов израсходовано 0 из 2
## Скоуп
Из аудита беты обнаружено, что `renderBackdropGuard` для decor-изображений
(`allowOriginal=false`, файл источника >2 МиБ) гасил **весь** блок действий
диалога-предупреждения вместо одной кнопки «Оставить оригинал». Пользователь не
мог добавить крупное изображение в декор ни оригиналом (запрещено намеренно),
ни уменьшенной копией (должно быть разрешено) — оставалась только «Отмена».
AC (issue body, короткий трек):
1. Для decor-raster >2 МиБ с `probe.kind !== "hard"` guard показывает «Отмена»
и «Загрузить уменьшенную копию», но не «Оставить оригинал»; кнопка активна,
пока не идёт операция.
2. Клик «Загрузить уменьшенную копию» использует существующий
downscale → decor-asset upload, не грузит исходник, закрывает guard после
успеха; `hard` остаётся только с «Отмена»; подложка сохраняет обе кнопки.
3. Targeted production-bundle smoke краснеет на старом условии и различает
decor >2 МиБ / `hard` / обычную подложку; EN/RU User Guide и оба changelog
обновлены.
Продуктовая рамка (`docs/SCOPE.md`): decor-изображения — существующая
принятая функциональность редактора (спецификация `#51`,
`docs/specs/051-custom-decor-images.md`), в аудите excess-functionality не
отмечена к удалению. Это точечный регрессионный фикс уже обещанного сценария
UX, а не новая функция — скоуп-вопросов не возникает.
## Как проверялось
Дельта равна всей задаче (2 коммита от `dev`), r1 — разбор полный, разделы
«Унаследовано из r0» и «Закрытие предыдущего раунда» не нужны.
Прочитано:
- `docs/SCOPE.md`, `AGENTS.md`, `PROCESS.md` (§1, §2.7, §2.10, §5.1, §7.2, §8,
§12).
- Тело issue #427 и три комментария (аналитика, занятие, хендофф).
- `git diff origin/dev...HEAD` полностью (`--stat` + построчно по каждому
текстовому файлу).
- `src/backdrop-pick.ts` целиком (не только диф) — единственный тронутый
продуктовый файл.
- Все три вызова `renderBackdropGuard` (`houseplan-editor-runtime.ts` ×2,
`houseplan-onboarding-runtime.ts` ×1) — проверено, что подложка/онбординг
используют `allowOriginal` по умолчанию `true` и фиксом не задеты.
- `src/decor-image-editor.ts` (`uploadFromInput`, `upload`) — подтверждена
привязка `guardAboveBytes = 2 МиБ` и `replaceSelection` по умолчанию `false`.
- `demo/smoke_backdrop_guard.mjs` целиком, включая новый блок `#427` и
`checkAll`/`check` в `demo/serve.mjs` (семантика: каждый ключ `out.*` должен
быть `true`, иначе смок падает).
- `docs/USER-GUIDE.md`, `docs/USER-GUIDE.ru.md`, оба `CHANGELOG*.md`.
- Минифицированный бандл-чанк `backdrop-pick-*.js` в `custom_components/…` и
`dist/…` — визуально подтверждено, что новый хэш файла и его контент
расходятся со старым (не stale copy).
### Гейты
| Гейт | Статус | Как подтверждено |
|---|---|---|
| `npx tsc --noEmit` (typecheck) | не прогонял повторно | зелёный в Validate на этом же SHA (см. ниже) |
| `npm test` (юниты + мутанты) | не прогонял повторно | зелёный в Validate на этом же SHA |
| `npm run build` + `bundle:sync` (3 копии бандла) | не прогонял повторно | зелёный в Validate на этом же SHA |
| `node scripts/check-docs.mjs` (фингерпринт скриншотов) | не прогонял повторно | коммит `f71de899` — отдельный docs-коммит именно под это; зелёный в том же Validate-прогоне |
| Полный браузерный смок-набор (3 шарда) | не прогонял повторно | все 3 шарда зелёные в том же прогоне (включает `smoke_backdrop_guard`) |
| `golden:verify` | не прогонял повторно | job «Golden-кадры против принятых эталонов» зелёный в том же прогоне |
| `performance_smoke` | не прогонял повторно | job «Перф-смок: бюджет времени кадра» зелёный в том же прогоне |
| `python -m pytest tests_backend -q` | не требуется | diff не трогает `custom_components/**/*.py`; job `Бэкенд` в прогоне — `skipped` (путь-фильтр, ожидаемо) |
| `npm run invariants -- --config …` | не требуется | diff не трогает геометрию (рёбра комнат, толщину стен, `layout`, `marker.space`, `open_spans`) — только UI-кнопки диалога загрузки |
| `scripts/smoke-select.mjs --base 4cabcbe8 --head f71de899` | прогнан | вывод: «НЕОПРЕДЕЛЁННОСТЬ» (0 символов на изменённых строках инлайн-разметки шаблона lit). Не разрешение ничего не прогонять — но полный набор всё равно уже прогнан в CI (все 3 шарда), так что находка инструмента полностью перекрыта фактическим прогоном. |
Проверка, что Validate действительно на этом SHA и действительно зелёный:
`gh run view 33726518377` → `headSha: f71de899…`, `conclusion: success`;
разбивка по job: типы/юниты/бандл — success, три шарда смоков — success, golden —
success, перф-смок — success, hassfest/HACS/backend — skipped (путь-фильтр,
ожидаемо для чисто фронтенд-диффа).
**Одно число — один источник.** Диф не добавляет и не меняет ни одной
пользовательской величины (МиБ файла, целевые размеры уменьшенной копии) —
это существующие вычисления `probe`/`downscaleDimensions`, тронута только
видимость двух кнопок. Пункт неприменим к этой правке.
## Находки
Нет. High/Medium/Low не обнаружено.
## Что проверено и корректно
1. **Корень бага устранён именно так, как описан.** Было:
`hard || !allowOriginal ? null : <оба варианта>` — гасило весь блок кнопок.
Стало: `hard ? null : <"Отмена" всегда рендерится вне условия>` +
`allowOriginal ? <"Оставить оригинал"> : null` перед кнопкой уменьшения,
которая теперь рендерится безусловно внутри `!hard`-ветки
(`src/backdrop-pick.ts:241-247`). Соответствует AC1/AC2 дословно.
2. **`hard`-случай не тронут**: как и раньше, при `probe.kind === 'hard'`
рендерится только «Отмена» — независимо от `allowOriginal`. Прочитано в
коде, не выполнением.
3. **Подложка (backdrop) не затронута**: оба вызова `renderBackdropGuard` для
plan-file (`houseplan-editor-runtime.ts:8565`,
`houseplan-onboarding-runtime.ts:148`) не передают `allowOriginal` →
действует дефолт `true` → обе кнопки остаются, как и до фикса.
4. **`allowOriginal` для decor** по-прежнему `file.size <= 2 МиБ`
(`houseplan-editor-runtime.ts:8563`) — граница не менялась, поменялась
только реакция диалога на `false`.
5. **AC3 (test)**: новый блок в `demo/smoke_backdrop_guard.mjs:113-156`
целенаправленно бьёт именно в починенную ветку — `decorBigFile` собран так,
чтобы `probe.kind` остался `warn` (то же изображение 6200×6200, что и в
существующем AC2-кейсе этого же файла), а `file.size` превысил 2 МиБ через
аппендж «мусорных» байт после JPEG EOI (комментарий в смоке объясняет,
почему Chromium декодирует такой файл штатно). Это разводит ровно три
состояния AC1: decor >2 МиБ/`warn`, decor `hard`, обычная подложка — третье
покрыто уже существующими более ранними секциями того же файла (строки
65-111), которые фикс не трогает и которые заведомо продолжают проходить
(обе кнопки для подложки, дефолт `allowOriginal=true` не менялся).
**Тест умеет падать**: при откате `src/backdrop-pick.ts` к состоянию до
фикса условие `hard || !allowOriginal ? null : …` гасит оба варианта →
`decorButtons.length` было бы `1` (только «Отмена»), а
`out.decorOversizeOffersReducedWithoutOriginal` требует `length === 2` →
`checkAll` роняет смок. Проверено чтением логики `checkAll`/`check`
(`demo/serve.mjs:66-77`: каждый ключ результата обязан быть `true`, иначе
попадает в `_failures` и процесс завершается с ненулевым кодом через
`finish()`).
Дополнительно проверено чтением: очистка моков (`_uploadDecorImage`
восстановлен, `_backdropGuard` и `_decorAssetGuardReplace` сброшены через
`close()`-колбэк decor-ветки `_renderBackdropGuard`) не оставляет состояния,
которое могло бы исказить последующие секции того же смока (alpha-ветка,
AC4 и далее) — фактически это подтверждено тем, что все три браузерных
шарда CI на этом SHA зелёные.
6. **`replaceSelection` в смоке `=== false`** соответствует вызову
`_decorImageUpload(ev)` без `replaceSelection` (по умолчанию `false`,
`houseplan-editor-runtime.ts:5160`) — не «замена выделения», а обычная
загрузка в палитру. Согласовано с кодом `decor-image-editor.ts:135-169`.
7. **Документация (AC3, текстовая часть)**: `docs/USER-GUIDE.md` и `.ru.md`
теперь явно разводят «лимит 2 МиБ относится к сохранённому canonical-файлу»
и «диалог предлагает уменьшенную копию, оригинал недоступен» — точная
формулировка, снимающая расхождение, зафиксированное в самом issue
(«USER-GUIDE описан по факту, а не по ТЗ»). Термины взяты из существующего
текста руководства, не изобретены.
8. **Трейлеры и changelog**: `b87e99f9` несёт `Issue: #427` +
`User-Visible: yes` и правит оба `docs/CHANGELOG*.md` в том же коммите —
соответствует правилу. `f71de899` (`User-Visible: no`, docs-only фингерпринт)
корректно классифицирован как невидимая пользователю правка.
9. **Класс изменений**: только `src/backdrop-pick.ts` — класс A (issue
обязателен, есть); `demo/smoke_backdrop_guard.mjs` — класс B (может
переиспользовать issue задачи — переиспользует); `docs/**`,
`CHANGELOG*` — класс C; `dist/**`, `custom_components/houseplan/frontend/**`
— класс D, синхронно пересобраны (подтверждено зелёным job «синхрон
бандла» на этом SHA, плюс визуальная проверка изменённого чанка
`backdrop-pick-*.js`). Нарушений границ классов нет.
10. **Инвариант локов/декора** (`docs/SCOPE.md`) — правка не затрагивает пути
актуации (`resolveToggleIntent`, `isControllable`, `_cardToggle`); decor-
изображения не являются security-таргетом. Неприменимо, но проверено чтением
diff на предмет случайного расширения actuation-поверхности — такого нет.
## Чего не проверял
- Ручной запуск `npm run typecheck` / `npm test` / `npm run build` /
`node scripts/check-docs.mjs` / полного смок-набора / `golden:verify` /
`performance_smoke` — не требовалось: зелёный Validate уже подтверждён на
точном SHA `f71de899` (`gh run view 33726518377`), включая все три шарда
смоков, golden и перф-смок.
- `python -m pytest tests_backend -q` — diff не трогает `custom_components/**/*.py`.
- `npm run invariants` — diff не трогает геометрию/толщину/`layout`/
`marker.space`/`open_spans`.
- Ручное открытие приложения в браузере (визуальная проверка диалога глазами) —
ревью не включает ручное тестирование по регламенту; вместо этого AC доказаны
чтением кода + падающим-по-конструкции автотестом, плюс независимое
подтверждение зелёным CI-прогоном браузерных смоков на этом же SHA.
- Общий пробел покрытия `upload`/`delete` decor-изображений юнит-тестами
(`tsconfig.test.json` не включает `backdrop-pick.ts`/`decor-image-editor.ts`)
— сам issue называет его отдельно закрытым в `#433`; вне скоупа #427, новый
issue не требуется (уже есть).
## Вердикт
Все три AC доказаны: код читаемо соответствует ожидаемому поведению, целевой
smoke добавлен, показан падающим на до-фиксовом условии, и зелёным в реальном
CI-прогоне на итоговом SHA; документация и оба changelog обновлены в
соответствующих коммитах с корректными трейлерами. Находок нет.
**Вердикт: зелёный · заход r1 · блокирующих циклов 0/2 · High: 0 · Medium: 0**
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/427-decor-large-image-downscale-action`, коммит `f71de899750d` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `cbca87d1f7a12944d5ada9892d1115f1d1b764ec`
```
git log --all --format='%H %T' | grep cbca87d1f7a1
```
+289
View File
@@ -0,0 +1,289 @@
# CODE-REVIEW-428-r1
Issue: [#428](https://github.com/Matysh/houseplan-card/issues/428) — «Экспорт с
недостающей картинкой декора не импортируется — ImportFailure на весь
документ».
Заход: r1 (первый), правила §2.9 о разборе по дельте не применяются — разбор
полный.
Проверяемый HEAD: `32dd4b30abb36403e6ca3a091e96394f1eed7277`
(ветка `issue/428-missing-decor-asset-roundtrip`, слияние от
`bdf53dc4` — `origin/dev`).
Материал: `git log --oneline origin/dev..HEAD`, `git diff origin/dev...HEAD`,
ТЗ `docs/specs/428-missing-decor-asset-roundtrip.md`, зелёное ревью ТЗ
`docs/reviews/SPEC-REVIEW-428-r1.md` (High 0/Medium 0), исходный контракт
`docs/specs/051-custom-decor-images.md` AC10/AC11, тело issue и все
комментарии.
## Скоуп диффа
```
custom_components/houseplan/import_export.py | 39 +++-
docs/CHANGELOG.md | 4 +
docs/CHANGELOG.ru.md | 5 +
docs/CONFIG-COMPATIBILITY.md | 5 +
docs/USER-GUIDE.md | 3 +
docs/USER-GUIDE.ru.md | 4 +
docs/reviews/SPEC-REVIEW-428-r1.md | 188 (артефакт публикации спек-ревью)
docs/specs/428-missing-decor-asset-roundtrip.md | 266 (ТЗ + опечатка)
docs/specs/README.md | 1 +
tests_backend/test_ha_import_export.py | 193 +++
```
Три коммита в диапазоне:
- `bc090c0e` docs(spec) — ТЗ (не предмет этого этапа);
- `80a5a044` docs: review document for #428 — опубликованный артефакт
спек-ревью (не предмет этого этапа);
- `eef7ce12` fix(import): preserve missing decor asset round-trips —
`User-Visible: yes`, продуктовый код + тесты + оба changelog + три доки в
одном коммите;
- `32dd4b30` test(import): account for space id remapping —
`User-Visible: no`, только правка тестового ожидания.
`src/**`, i18n, манифесты, golden, workflows не затронуты — заявление ТЗ
подтверждено диффом.
## Как проверялось
Читал код построчно и сверял с таблицей допустимых значений из ТЗ (раздел
«Контракт manifest и валидации», п.2), а не полагался на слова автора.
### 1. Первопричина и правка `_content_state()`
`custom_components/houseplan/import_export.py:1650-1673`. Новая проверка:
```python
declared_exists = declared.get("exists_at_export")
valid_mime = isinstance(declared_mime, str) and declared_mime in _DECOR_ASSET_MIME_TYPES
missing_mime = declared_exists is False and declared_mime is None
if (declared.get("asset_id") != aid or declared.get("hash") != aid
or type(declared_exists) is not bool
or not (valid_mime or missing_mime)):
raise ImportFailure("invalid_content", ...)
```
Прогнал вручную все шесть строк таблицы ТЗ через эту формулу:
| `exists_at_export` | `mime` | ТЗ | Код |
|---|---|---|---|
| `True` | supported | допустимо | `valid_mime=True` → не падает ✓ |
| `True` | `None`/unsupported | `invalid_content` | `valid_mime=False`, `missing_mime=False` (флаг не `False`) → падает ✓ |
| `False` | supported | допустимо | `valid_mime=True` → не падает ✓ |
| `False` | отсутствует/`None` | допустимо | `missing_mime=True` → не падает ✓ |
| `False` | unsupported, включая `""` | `invalid_content` | оба флага `False` → падает ✓ |
| отсутствует/`None`/`0`/`1`/str/dict/list | любое | `invalid_content` | `type(x) is not bool` истинно для всех перечисленных (в т.ч. `0`/`1`, т.к. `type(0) is int`, не `bool`) → падает ✓ |
Совпадение точное, включая явно названный в ТЗ риск «Python принимает `0` как
`False`» — проверка идёт по `type(...) is bool`, а не по `bool(...)`.
`row["mime"] = declared_mime` сохраняет ровно исходное supplied-значение для
preview; ниже по коду `exists_on_target`/`state` вычисляются заново из
байтов target blob через SHA-256 — supplied MIME/`exists_at_export` не
становится authority для доступности (комментарий в коде это фиксирует,
и это же подтверждено чтением: значение из `declared` нигде не используется
для выбора файла или `state`).
### 2. `content_manifest()` (exporter)
`import_export.py:473-482` — рефакторинг словаря в модульную константу
`_DECOR_ASSET_MIME_BY_SUFFIX`/`_DECOR_ASSET_MIME_TYPES`, поведение не
изменилось (то же `metadata.get("mime") or <lookup by suffix>`). Экспортёр
не тронут в части `exists_at_export`/hash-логики.
### 3. Plan-only проекция (`_project_plan_only_decor`, `_validate_plan_only_document`)
Проверил отдельно, так как это не то место, которое первым приходит в
голову при чтении заголовка issue, но явно названо в ТЗ («Затронутые файлы»)
и обязательно для AC3.
До правки `_DECOR_KIND_FIELDS` не содержал ключ `"image"`, поэтому
`_project_plan_only_decor` для фигуры `kind="image"` копировал только
`_DECOR_COMMON_FIELDS` (`id, kind, color, opacity, width_cm, width`) —
`asset_id` и вся геометрия терялись. Это не косметика: `content_manifest()`
на строке `if not isinstance(aid, str): continue` тогда вообще не создавал
строку манифеста для такого объекта, а спроецированный конфиг оставлял
`decor`-объект `kind: "image"` без `asset_id`/`x`/`y`/`w`/`h` — то есть режим
«Только планировка» был категорически несовместим с любым decor-изображением
в пространстве ещё до этого исправления (не входит в текст issue, но входит
в объявленный ТЗ скоуп и необходимо для AC3).
Правка (`import_export.py:108`) добавляет `"image": ("asset_id", "x", "y",
"w", "h", "angle", "flip_h", "flip_v")` — набор аналогичен по структуре
`"furniture"`. Приватность не расширяется: `asset_id` — content-addressed
SHA-256 без встроенных данных пользователя, а геометрия decor уже
экспортировалась для всех остальных kind (`line`, `rect`, `ellipse`, `text`,
`furniture`) в том же plan-only режиме до этой правки.
`_validate_plan_only_document` (`import_export.py:809-820`) расширяет
допустимые `owner` в supplied `content_manifest` с одного `"space"` до
`"space" | ("decor" && kind=="decor_asset")` — проверил, что условие именно
конъюнкция (`and` внутри `or`), а не широкое разрешение всего `owner=="decor"`:
никакой другой decor-related kind этой веткой не пропускается.
Не-скоуп ТЗ запрещает «изменение... full/space/plan-only projection»
буквально в том же документе, где «Затронутые файлы» прямо называют
«полную image-проекцию plan-only» частью работы. Это внутреннее
противоречие текста ТЗ, а не кода: при спек-ревью (это тот же ревьюер,
зелёный вердикт с этим же диффом функций уже подразумевался разделом
«Затронутые файлы») запрет уже был прочитан как относящийся к UI выбора
режима экспорта и политике detach, а не к содержимому geometry-проекции.
Код реализует ровно то, что назвал раздел «Затронутые файлы», и ничего
сверх этого — расширения не вижу.
### 4. Тесты (`tests_backend/test_ha_import_export.py`)
Прочитал каждый новый тест построчно и убедился, что он умеет падать:
- `test_issue_428_missing_decor_asset_round_trips_in_every_export_mode`
(параметризован `full`/`space`/`plan-only`, реально идёт через
`create_export → create_preview → get_candidate → prepare_apply`, как
требует риск №4 ТЗ) — без правки `_content_state()` первый же вызов
`create_preview()` бросил бы `invalid_content` на этапе построения preview,
тест бы упал на `create_preview`, а не на assert. Дополнительно проверяет
`content_confirmation_required` до подтверждения и точное совпадение
сохранённого объекта (кроме локального `id`, который намеренно
перевыделяется при space-импорте — см. §5 ниже) после подтверждения.
- `test_issue_428_explicitly_missing_asset_accepts_bounded_mime` —
`null`/omitted/`"image/png"` при `exists_at_export: false` дают
`missing_preserved` + `confirmation=True`; без правки `null`/omitted упали
бы на `ImportFailure`.
- `test_issue_428_missing_mime_exception_remains_fail_closed` — 13
параметризованных отрицательных случаев, включая `0`/`1`/`""`/`list`/`dict`
для обоих полей раздельно; до правки часть уже падала (это regression-тест
на существующее поведение), после правки для новых legal-комбинаций
(в этом тесте таких нет — тут только invalid) все раскрываются как
`invalid_content`. Проверил вручную по формуле выше — совпадает.
- `test_issue_428_missing_asset_keeps_hash_identity_strict` — подмена
`asset_id`/`hash` при прочих валидных полях всё равно даёт
`invalid_content`: ослабление не затронуло identity-проверку.
- `test_issue_428_missing_source_reuses_only_exact_target_blob` — существующий
на target файл с точным SHA даёт `available`/`confirmation=False`; при
подмене байтов — `missing_preserved`/`confirmation=True`. Это ровно AC4.
Старый `test_issue_51_missing_decor_asset_stays_as_repairable_geometry`
(соседний случай: source had blob, target doesn't) не тронут и остаётся
зелёным регрессионным тестом — AC6 подтверждён.
Финальный коммит `32dd4b30` — чисто тестовая правка: заменяет
`imported_images == [shape]` на сравнение без ключа `id`, потому что
space-импорт намеренно перевыделяет локальные id (документировано в
`docs/USER-GUIDE.md`: «A space import assigns new internal IDs»). AC2 требует
сохранности `asset_id`, geometry, opacity, flip flags и decor order — `id`
в этот список не входит, тест корректно ослаблен только в этой одной точке,
остальные поля сравниваются на точное равенство.
### 5. Документация и changelog (AC8)
- `docs/CONFIG-COMPATIBILITY.md` — новый абзац описывает `exists_at_export:false`/
`mime:null` как импортируемое во всех трёх режимах и явно называет три
инварианта (`bool`-флаг, exact identity/hash, whitelist непустого MIME) —
соответствует контракту, не расширяет его словами.
- `docs/USER-GUIDE.md`/`.ru.md` — новое предложение продолжает существующий
абзац про «перечёркнутую рамку»/repair; в ru-версии «восстанавливаемую
рамку» — та же сущность, упомянутая двумя предложениями выше, термин не
изобретён. En-версия ссылается на «the existing missing-content
confirmation» — сверил с §20 (строка 966-968 USER-GUIDE.md): «Import first
shows a server-side preview with... content-link state; nothing is written
until confirmation» — тот же механизм, разночтения нет.
- Оба changelog правлены в том же коммите `eef7ce12`, что и продуктовый код —
`git show --stat` подтверждает (см. §«Скоуп диффа» выше); trailer
`User-Visible: yes` на этом коммите корректен.
## Гейты
**Дешёвые гейты подтверждены на этом SHA** (`32dd4b30`), Validate
https://github.com/Matysh/houseplan-card/actions/runs/33730976160 —
`conclusion: success`, проверил `headSha` через `gh run view --json headSha`
и он равен HEAD ветки. Разбор по job:
| Job | Статус на 32dd4b30 | Почему так |
|---|---|---|
| `docs`, `provenance`, `process-gate`, `hacs`, `hassfest` | success | реально выполнены |
| `frontend`, `smoke`, `golden`, `performance_smoke` | skipped | `src/**` не тронут — path-filter `changes`, легитимно (диф подтверждён выше: только backend+docs) |
| `backend` | skipped (переиспользование) | не «пропущено молча»: я прочитал лог job «Переиспользование» — `reuse-backend-<hash>` дал `Cache hit`, т.е. байт-в-байт то же содержимое `import_export.py`+`test_ha_import_export.py`, что и в прогоне `f0d36e8d` (id `33729239357`), где backend **реально выполнялся** и завершился `success`. Проверил `git diff f0d36e8d HEAD -- tests_backend/test_ha_import_export.py custom_components/houseplan/import_export.py` — пусто, разница между этим SHA и HEAD только в `PROCESS.md`/`scripts/gate-reuse.mjs` (rebase на dev), к #428 не относится. Переиспользование корректно. |
Не прогонял `npm run typecheck`, `npm test`, `npm run build` — фронтенд не
тронут, а Validate на этом SHA формально зелёный (`docs`/`provenance` job
покрывают то немногое, что относится к дереву в целом). `python -m pytest
tests_backend -q` не прогонял локально — по правилу AGENTS.md он без HA
молча пропускает `test_ha_*.py` (ровно те тесты, что здесь важны), поэтому
такой прогон ничего бы не доказал; полагаюсь на подтверждённый Linux-CI
прогон `f0d36e8d`.
Не прогонял и не требовались по AC9/ТЗ: browser smokes (`src/**` не тронут),
`golden:verify` (визуальный результат не меняется), `invariants`
(геометрия/`layout`/`marker.space`/`open_spans` не тронуты — диф не
содержит правок стен, рёбер комнат, layout или толщины), performance-профили
(не названы в AC, perf-чувствительные пути не тронуты). `ruff` не
перезапускал — автор сообщил зелёный локальный прогон, а ruff не входит в
список Validate-джобов из AGENTS.md; проверка кода не выявила ничего, что
ruff обычно ловит (неиспользуемые импорты/переменные не вижу).
## Находки
Не найдено. High: 0, Medium: 0, Low: 0.
## Что проверено и корректно
- Матрица валидации `decor_asset` (6 строк ТЗ) реализована в
`_content_state()` буквально, включая различение `bool`/`int` для
`exists_at_export` и включая `""` в список отклоняемых MIME.
- Supplied `mime`/`exists_at_export` не становятся authority для
`state`/`exists_on_target` — эти поля пересчитываются из байтов target
blob по SHA-256 независимо от заявленных значений (AC4, риск №3 ТЗ).
- Три режима экспорта (`full`, `space`, `plan-only`) реально проверены через
`create_export`/`create_preview`/`prepare_apply`, а не только helper'ами
(риск №4 ТЗ снят).
- Ранее принятые строки (`true`+supported MIME, `false`+supported MIME)
не меняют поведения — AC7 подтверждён и построчным разбором, и
параметром `"supported"` в тесте.
- Версии/схемы/миграции не тронуты (`grep` по `VERSION` в диффе пуст) — AC7.
- Оба changelog и обе документации правлены в implementation-коммите с
верным trailer — AC8.
- Побочный дефект plan-only проекции decor-изображений (потеря `asset_id`
и геометрии) исправлен как часть заявленного в ТЗ скоупа, не является
скрытым расширением задачи, и не открывает новую приватность — раздел
«Затронутые файлы» ТЗ его прямо называет.
## Чего не проверял
- Не запускал `npm run typecheck`/`test`/`build` — фронтенд вне диффа,
Validate уже зелёный на точном SHA.
- Не запускал полный HA-harness локально — канон это Linux CI, зелёный на
`f0d36e8d` при байт-идентичном коде backend-файлов.
- Не проверял ручным тестированием в браузере — диф не касается `src/**`,
видимого рендера нет.
- Не проверял decor order при нескольких image-объектах одновременно (тест
использует один объект на пространство) — это пред-существующая глубина
покрытия теста #51/#428, не регресс этого диффа, и не входит ни в один
из шести Not-a-bug рисков ТЗ.
## Вывод
Реализация точно соответствует контракту ТЗ #428 (матрица допустимых
значений, target-reuse, три режима экспорта, совместимость), тесты
осмысленны и способны падать, документация и changelog обновлены в
правильном коммите с верными трейлерами. Единственный найденный при
спек-ревью Low (пустая строка MIME) устранён уточнением текста ТЗ, как и
обещал автор. Блокеров нет.
**Вердикт: зелёный.**
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/428-missing-decor-asset-roundtrip`, коммит `32dd4b30abb3` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `336693355ea1ce40e79303025743bcc0e41e50b2`
```
git log --all --format='%H %T' | grep 336693355ea1
```
- ТЗ `docs/specs/428-missing-decor-asset-roundtrip.md`, блоб `5018539ec5fd0e9d9ffb737cc0d0fa28e6ca2464`
```
git log --all --find-object=5018539ec5fd0e9d9ffb737cc0d0fa28e6ca2464 -- docs/specs/428-missing-decor-asset-roundtrip.md
```
+171
View File
@@ -0,0 +1,171 @@
# CODE-REVIEW-431-r1
Issue: [#431](https://github.com/Matysh/houseplan-card/issues/431) — `kind:'image'` выпал из канонизации координат
Ветка: `issue/431-image-coordinate-canonicalization`
Материал ревью: `git diff origin/dev...HEAD`, HEAD = `6559679b63095a65c7c44ca4026070b0b2c9a930`
Заход: r1 · блокирующих циклов 0/4
## Скоуп
Бэклог аудита v1.71.0-beta.1 (§3.2 M4): `kind: "image"` — box-декор, добавленный
#51, — не входил в allowlist видов декора, канонизируемых координатным барьером
(#223/#224/#291), ни во фронтенде (`src/coordinate-canonicalization.ts`), ни в
Python-зеркале (`custom_components/houseplan/coordinate_canonicalization.py`).
Следствие: no-op сохранение или повторный Optimize мог давать технический diff
конфига только для изображений — класс дефекта #223/#224/#291 открывался заново.
ТЗ живёт в `docs/specs/431-image-coordinate-canonicalization.md`, ревью ТЗ —
`docs/reviews/SPEC-REVIEW-431-r1.md` (зелёное, r1, без находок). Полный трек
обоснован названным критерием §5 (два независимых рантайма на разных языках).
Изменение строго соответствует заявленному скоупу: единый TS runtime-каталог
`DECOR_BOX_KINDS` (`src/editors/decor/types.ts`), его использование через один
predicate `isDecorBoxKind` в обоих местах фронтенда (сбор отчёта и реальная
канонизация), точное Python-зеркало `DECOR_BOX_KINDS`, расширенная общая
fixture, новые тесты обеих рантайм-веток и два новых постоянных
mutation-witness. Никакой новый writer, схема, миграция, UI, i18n не появились
— соответствует «Не-скоупу» ТЗ.
## Как проверялось
Материал — диапазон `git diff origin/dev...HEAD` (32 файла: продуктовый код,
тесты, документация, сгенерированный бандл класса D). Продуктовая правка
ограничена четырьмя файлами: `src/coordinate-canonicalization.ts`,
`src/editors/decor/types.ts`, `custom_components/houseplan/coordinate_canonicalization.py`,
`scripts/mutation-gate.mjs` (новые defensive witness) — плюс тесты/фикстура и
документация.
### Гейты — что прогнано и почему
| Гейт | Статус | Примечание |
|---|---|---|
| `npx tsc --noEmit` / `npm test` / `npm run build` (сверка бандла) | **не перегонялись заново** | Validate зелёный на точном HEAD `6559679b` (проверено: `gh run view 33734051066` → `conclusion: success`, `headSha: 6559679b…`, совпадает с материалом). Дешёвый набор уже подтверждён на этом SHA — повторный прогон не даёт новой информации (правило соразмерности гейтов) |
| `node scripts/check-docs.mjs` | **прогнан** | diff трогает `src/**` → обязателен. `Documentation checks passed (7 files, 10 external links)` |
| `node scripts/no-new-any.mjs --base origin/dev --head HEAD` | **прогнан** | `Новых any нет` (17 добавленных строк в 2 файлах) |
| `node scripts/model-invariants.mjs` | **не требуется** | diff не трогает рёбра комнат, `layout`, `marker.space`, `open_spans`, записи толщины — только `decor.kind: image` box-геометрию; инварианты модели этой поверхности не касаются |
| `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | **прогнан** | вывод: `НЕОПРЕДЕЛЁННОСТЬ`, символы `DECOR_BOX_KINDS`, `DecorBoxKind`, `DecorKind`, `isDecorBoxKind` ни в одном смоке не встречаются. Решение ревьюера: **браузерные смоки не нужны** — изменение целиком внутри чистого geometry-transform (`canonicalizeConfigGeometryInPlace`/`latticeCanonicalizationReport`), не задевает рендер, DOM, drag/resize, Optimize-UI; near-node значения снапаются к тем же координатам, видимого сдвига нет (заявлено в ТЗ и не опровергнуто чтением кода — канонизация не меняет числовое значение вне порога `LATTICE_NOISE_STEPS`). Полная матрица смоков — предрелизный гейт, не гейт ревью |
| `npm run golden:verify` | **не прогонялся** | diff не может изменить видимый результат: канонизация — числовое округление внутри порога snap, не геометрическое преобразование; screenshots/golden явно вне-скоупа по ТЗ, `demo/golden/baselines/**` в diff отсутствует |
| `python -m pytest tests_backend -q` | **не прогонялся штатно** (нет `homeassistant` в окружении: `test_coordinate_canonicalization.py` весь модуль пропускает через `importorskip`) — **проверено чтением и прямым исполнением модуля напрямую** (см. ниже) | `coordinate_canonicalization.py` не импортирует HA, поэтому логику можно исполнить в обход pytest |
| Мутационные свидетели AC4 (frontend + backend) | **прогнаны лично, оба** | см. раздел «Защитные AC» ниже |
## Защитные AC — таблица «чем краснеет» (#435)
| AC | Чем доказан | Чем краснеет — воспроизведено ревьюером |
|---|---|---|
| AC4 frontend completeness | `test/coordinate-canonicalization.test.mjs` → `decor box catalog canonicalizes every box kind…` (targeted `node --test --test-name-pattern="decor box catalog"`) | Применил патч мутанта `image-box-frontend-canonicalization-omitted` (искл. `'image'` из `isDecorBoxKind`) → пересобрал test-build (`npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs`) → тест **упал**: `image.x is canonical … expected: 0.5, actual: 0.5000000004`. Патч отменён, тест снова зелёный, `git status` чист |
| AC4 backend completeness | `tests_backend/test_coordinate_canonicalization.py` → `test_decor_box_catalog_matches_shared_contract`, зарегистрирован как `image-box-python-canonicalization-omitted` в `scripts/mutation-gate.mjs` (backend/HA-гейт, ревьюер не воспроизводит второй раз — правило §2.7) | HA недоступна локально, поэтому вместо pytest модуль `coordinate_canonicalization.py` (чистый Python без HA-импортов) загружен напрямую (`importlib`) и прогнан на shared fixture: (1) позитив — `canonicalize_config_geometry` даёт `image.x/y/w/h/angle` из `configExpected` и сохраняет `asset_id/opacity/flip_h/flip_v/future`; (2) применил ту же мутацию, что и в `mutation-gate.mjs` (`DECOR_BOX_KINDS = ("rect","ellipse","furniture")`) → `image.x` осталось `0.5000000004` вместо `0.5` — **сравнение с ожидаемым падает**, эквивалент красного pytest |
| AC5 allowlist boundary | unit `future-box` case (в том же frontend-тесте) + backend `future` field preservation | не мутировался отдельно; проверено чтением — `isDecorBoxKind`/`DECOR_BOX_KINDS in`-проверка применяется только к перечисленным видам, неизвестный `kind: 'future-box'` не попадает ни в одну ветку `if/elif`, объект возвращается `deepEqual` со входом (подтверждено прогоном теста, зелёный) |
Мутационная регистрация также прошла дешёвую проверку реестра:
`node scripts/mutation-gate.mjs --check` — оба новых id (`image-box-frontend-canonicalization-omitted`,
`image-box-python-canonicalization-omitted`) в списке `ok`; `node --test test/mutation-gate.test.mjs`
— 10/10 (структура реестра, отсутствие устаревших якорей, покрытие шардов).
## AC — разбор
- **AC1 (frontend unit)** — доказано и воспроизведено: `image.x/y/w/h` дают тот
же lattice-результат, `angle` — тот же scalar, что параллельный `furniture` в
той же fixture (побитовое сравнение `configExpected`, тест прогнан выше).
- **AC2 (отчёт + идемпотентность, unit)** — тест `lattice report includes image
box coordinates` проверяет near-node/far классификацию для image
(canonicalized=2, far=1); идемпотентность обеспечена тем, что оба места
фронтенда используют один и тот же `isDecorBoxKind` (устраняет риск №1 ТЗ
«исправлен writer, но не отчёт») — проверено чтением: `grep -n
isDecorBoxKind src/coordinate-canonicalization.ts` даёт ровно два вызова, оба
через общий predicate.
- **AC3 (backend mirror, backend)** — Python `DECOR_BOX_KINDS` идентичен по
порядку и составу `fixture.boxKinds`; `CONFIG_SCHEMA` в `validation.py`
строка 1388 уже валидировала `kind: "image"` с полями `x/y/w/h/angle`
(добавлено в #51, этой задачей не тронуто) — проверено чтением, схема не в
diff. Backend-логика подтверждена прямым исполнением модуля (см. таблицу
выше), это эквивалентно `unit`-доказательству для чистого Python без HA.
- **AC4 (полнота набора, unit+backend+mutation)** — доказано и лично
воспроизведено оба отрицательных прогона (см. таблицу выше).
- **AC5 (поля вне геометрии)** — доказано, тест прогнан; `future`
extension-поле и presentation-поля (`asset_id/opacity/flip_h/flip_v`)
проходят обе канонизации без изменений.
- **AC6 (совместимость)** — проверено чтением и прогоном: `validation.py` не в
diff (схема не менялась), `import_export.py` не в diff, версии/миграции не
тронуты. Полный файл тестов `test/coordinate-canonicalization.test.mjs`
прогнан целиком локально — 11/11 зелёных, включая существующие тесты
#224/#248/#291 (регрессий нет).
- **AC7 (документация/release)** — `docs/CONFIG-COMPATIBILITY.md` называет
`image` в box-каталоге; `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` получили
парную запись в том же implementation-коммите `58efb6a1`
(`User-Visible: yes`) — проверено чтением diff, оба файла в одном коммите.
- **AC8 (гейты)** — Validate зелёный на точном SHA (см. таблицу гейтов); ручные
targeted-прогоны (frontend unit, оба мутанта, `check-docs`, `no-new-any`)
прогнаны лично и задокументированы выше.
## Что проверено и корректно
- Единственный источник каталога box-видов на фронтенде: `DECOR_BOX_KINDS`
экспортируется из `src/editors/decor/types.ts`, реэкспортируется из
`coordinate-canonicalization.ts`, используется через `isDecorBoxKind` в обоих
местах (сбор отчёта и запись) — не осталось параллельного списка сравнений.
- Python-зеркало `DECOR_BOX_KINDS` — точный exact-set и порядок совпадают с
shared fixture `boxKinds`, используется через `in`-проверку на месте прежней
явной цепочки сравнений.
- `DecorImage extends DecorBoxBase` (уже было в #51) — подтверждает, что box
контракт `x/y/w/h/angle` для image структурно корректен, это не новое
допущение, а факт типовой системы.
- Трейлеры коммитов: `58efb6a1` и `6559679b` несут `Issue: #431`; в
implementation-коммите `User-Visible: yes` с правками обоих changelog в том
же коммите — соответствует правилу.
- Не-скоуп соблюдён: не тронуты `validation.py` (схема), `import_export.py`,
UI/рендер декора, i18n, миграции, версии.
- Бандл: `initialViewGzipBytes` вырос на 15 байт (290950→290965), далеко в
пределах бюджета `INITIAL_VIEW_GZIP_BUDGET = 300000` — не находка.
- «Одно число — один источник»: в этом diff нет новой пользовательски видимой
величины, отображаемой дважды (канонизация — служебное округление хвостов
float, не новое значение в UI); правило не применимо к этому изменению.
## Чего не проверял
- Полный `python -m pytest tests_backend -q` c реальным HA-харнессом — модуль
недоступен в этом окружении (`ModuleNotFoundError: No module named
'homeassistant'`); заменено прямым исполнением чистого Python-модуля на
shared fixture (позитив и мутация), что покрывает содержательную часть AC3 и
AC4-backend без HA-обвязки. Полный pytest с HA канонично прогнан в Linux CI
на точном SHA `6559679b` (Validate: `success`).
- Полная матрица браузерных смоков (215 файлов) и `golden`/`performance_smoke`
— не запускались; обоснование в таблице гейтов (`smoke-select.mjs` дал
`НЕОПРЕДЕЛЁННОСТЬ`, ревьюер решил не гонять: чистый geometry-transform без
рендер-поверхности). Оба уже зелёные в Validate на точном SHA.
- `npm run docs:accept`/пересъёмка скриншотов — не требовалась, `check-docs`
зелёный, `docs/images/screenshots.json` в diff содержит только обновлённый
`sourceFingerprint`, кадры не менялись (заявлено автором, косвенно
подтверждено отсутствием файлов `demo/docs/**` в diff).
## Находки
Нет. High: 0, Medium: 0, Low: 0.
## Материал раунда
- SHA материала: `6559679b63095a65c7c44ca4026070b0b2c9a930` (= `origin/issue/431-image-coordinate-canonicalization`, = HEAD на момент ревью).
- Дерево материала: `git diff origin/dev...HEAD` (32 файла, продукт: 4 файла).
- Валидация: `gh run view 33734051066` → `status: completed`, `conclusion: success`, `headSha: 6559679b…`.
- Первый заход (r1) на код-ревью — раздел «Унаследовано» не применяется.
## Вердикт
Зелёный. Изменение узкое, полностью соответствует ТЗ и его AC, оба defensive-AC
воспроизведены лично (не только заявлены автором), гейты, покрывающие diff,
прогнаны или обоснованно пропущены с указанием причины. Находок нет.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/431-image-coordinate-canonicalization`, коммит `6559679b6309` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `131d031470b6191aee0ccafd4b06181a35e4f378`
```
git log --all --format='%H %T' | grep 131d031470b6
```
- ТЗ `docs/specs/431-image-coordinate-canonicalization.md`, блоб `357572246be1e0649b607bc3bf3d59232310ff58`
```
git log --all --find-object=357572246be1e0649b607bc3bf3d59232310ff58 -- docs/specs/431-image-coordinate-canonicalization.md
```
+239
View File
@@ -0,0 +1,239 @@
# CODE-REVIEW-432-r1
- Issue: https://github.com/Matysh/houseplan-card/issues/432
- ТЗ: `docs/specs/432-asset-resolve-authorization-cache.md` (SPEC-REVIEW-432-r1: зелёный)
- Материал ревью: SHA `d8e67f530c33c1b3178a60afb33a110cf5194bb2` (HEAD ветки
`issue/432-asset-resolve-authorization-cache` на момент ревью, коммит
`test(assets): isolate HA asset fixtures`), диапазон `origin/dev...HEAD`
(коммиты `17a1c10b` docs, `f3c32fb2` fix, `d8e67f53` test)
- Заход: r1 · блокирующих циклов израсходовано 0 из 4 (полный трек, лимит 4)
- Вердикт: **зелёный**
## Скоуп ревью
Первый заход код-ревью для issue #432: ограничение прав `houseplan/assets/resolve`
(read-only user видит только referenced-assets, writer — весь каталог) и общий
bounded/single-flight integrity-verifier для WS resolve и HTTP asset GET вместо
полного `read_bytes()+SHA-256` на каждый вызов. Класс изменений — A (Python backend)
+ B (тесты, `scripts/mutation-gate.mjs`) + C (документация); `src/**` не тронут.
Диапазон материала — весь диапазон `origin/dev...HEAD` (три коммита ветки), это
первый заход код-ревью, «Унаследовано из r0» не применяется.
## Как проверялось
1. `docs/SCOPE.md`, `PROCESS.md` §2.7, §7.1, §8, `AGENTS.md` — формат ревью, классы
файлов, обязательность таблицы «AC · чем доказан · чем краснеет» (#435).
2. Тело issue #432 и все комментарии (аналитика, вопрос/ответ владельца Q1, ТЗ на
ревью, зелёное SPEC-REVIEW-432-r1, handoff «Реализация готова») прочитаны целиком.
3. ТЗ `docs/specs/432-asset-resolve-authorization-cache.md` (§7–§16, AC1–AC11,
таблица §14) сверено построчно с фактическим кодом на SHA `d8e67f53`.
4. Полный `git diff origin/dev...HEAD` прочитан целиком:
- `custom_components/houseplan/asset_integrity.py` (новый файл, 140 строк) —
`AssetIntegrityVerifier`, LRU-кеш, single-flight, потоковый SHA-256;
- `custom_components/houseplan/decor_assets.py` — `read_asset()` (точечный lookup
одного sidecar) и общий `_read_catalog_row()`, которым теперь пользуются
и `read_catalog()`, и `read_asset()`;
- `custom_components/houseplan/websocket_api.py` — `ws_assets_resolve()`:
`_runtime()` до любого I/O, read-only membership filter под `write_lock`,
прямой `read_asset()` вместо `read_catalog()`, `verifier.verify()` вместо
инлайн-хеширования;
- `custom_components/houseplan/http_api.py` — `HouseplanContentView.get()`:
assets используют `verifier.verify()`, plans/files остались на `path.is_file()`;
- `custom_components/houseplan/auth.py` — не менялся, `may_write()` сверен как
существующий источник истины (fail-closed, admin_only-семантика);
- `tests_backend/test_decor_assets.py` (+195 строк) — чистые unit-тесты cache
hit/miss, LRU 256/257 границы, потокового ридера, single-flight, mid-read
инвалидации, direct lookup без сканирования каталога;
- `tests_backend/test_ha_websocket.py` (+139 строк) — HA-тесты readonly-фильтра
(со шпионом `read_asset`), `admin_only:false` writer-контракта, `not_ready`
до I/O (со шпионом на `Path`), общего hash-счётчика WS↔HTTP;
- `scripts/mutation-gate.mjs` (+52 строки) — 4 новых постоянных мутанта;
- `docs/ARCHITECTURE.md`, `docs/CONFIG-COMPATIBILITY.md`,
`docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md` — access/cost-контракт описан,
явно подтверждено отсутствие schema/capability/URL миграции.
5. Трейлеры коммитов проверены: `f3c32fb2` — `Issue: #432` / `User-Visible: yes`,
оба changelog изменены в этом же коммите (`git show --stat`); `d8e67f53` —
`Issue: #432` / `User-Visible: no`, только `tests_backend/conftest.py`
(класс B, повторного changelog не требует).
6. Каждый мутант из п.4 (`scripts/mutation-gate.mjs`) мысленно применён к
соответствующей строке `asset_integrity.py`/`websocket_api.py` и прослежен по
логике кода до конкретного assert, который он обязан сломать (таблица ниже);
структурная валидность патчей подтверждена командой (см. «Гейты»).
7. Проверена история CI ветки (`gh run list`/`gh run view`): коммит `f3c32fb2`
получил **красный** прогон Validate (job `Бэкенд: pytest в Home Assistant` —
failure), следующий коммит `d8e67f53` («isolate HA asset fixtures») —
точечный фикс утечки фикстуры (`tests_backend/conftest.py` теперь чистит и
`houseplan/assets`, не только `plans`/`files`), и на нём Validate зелёный
(run 33741146772, `Бэкенд: pytest в Home Assistant` — success). Это
единственная содержательная находка процесса разработки данной задачи, и она
закрыта третьим коммитом того же issue — не находка ревью.
8. Отдельно проверено, что на этом же прогоне (33741146772) job
`Фронтенд: типы, юниты, мутанты, синхрон бандла` — **skipped**, а не «уже
проверен»: путь-фильтр `changes` классифицирует `frontend` по regex, не
включающему `scripts/**`/`custom_components/**`, и весь диапазон коммитов
`origin/dev..HEAD` не тронул ни одного файла, попадающего под этот regex.
Значит фактическое утверждение задания «Validate зелёный, дешёвые гейты
подтверждены» верно для CI в целом (frontend-job там законно не участвует —
`src/**` не менялся), но `npm test`/`tsc`/`build` для этого диффа **не были
исполнены ни разу ни в одном прогоне этой ветки**. Прогнал их сам (см. «Гейты»).
## Проверка AC1–AC11
| AC | Что требует ТЗ | Где в коде | Вердикт |
|---|---|---|---|
| AC1 | read-only видит referenced saved asset, обе карточки без writer-only зависимости | `websocket_api.py:1140-1169`; `src/**` не менялся | доказано тестом + чтением |
| AC2 | unreferenced id → `missing`, referenced → `assets`; forbidden id не читает metadata/stat/blob; writer/`admin_only:false` — полный контракт | `websocket_api.py:1143-1166` (`allowed = requested & referenced` под `_check_write`) | доказано тестом (шпион) |
| AC3 | без runtime — `not_ready` до FS | `websocket_api.py:1140-1142` (`_runtime()` до `root = Path(...)`) | доказано тестом (шпион на `Path`) |
| AC4 | resolve читает только уникальные разрешённые id, не сканирует остальное; согласовано с `read_catalog()` | `decor_assets.py`: `read_asset()`/`_read_catalog_row()` — общий helper | доказано тестом (`Path.glob` запрещён) |
| AC5 | неизменившийся blob хешируется 1 раз для обоих транспортов | `asset_integrity.py:79-129`; оба вызывающих — `get_asset_integrity_verifier(hass)` | доказано HA-тестом (hash-counter WS↔HTTP) |
| AC6 | N параллельных проверок одного key = 1 hash; разные paths независимы; без «зависшего» in-flight | `asset_integrity.py:92-129` (`_inflight`, `finally: flight.event.set()`) | доказано тестом (barrier/ThreadPoolExecutor) |
| AC7 | смена сигнатуры инвалидирует; corrupt → `missing`/404; mid-read change не кешируется; повторный corrupt не перечитывает | `asset_integrity.py:92-129` (`stable = _signature(path) == before`) | доказано pure+HA тестами |
| AC8 | ≤256 entries, LRU eviction, без bytes, chunked reader | `asset_integrity.py:16-17,51-57,120-125` | доказано тестом (`Path.read_bytes` запрещён, 257-я запись) |
| AC9 | authenticated/signed GET сохраняют body/headers/`FileResponse`; plans/files вне verifier | `http_api.py:180-186,211-214` | доказано существующим + расширенным HA-тестом, подтверждено чтением |
| AC10 | payload/capability/config/i18n/URL не меняются; docs описывают контракт | `const.py`, i18n — 0 diff; `docs/ARCHITECTURE.md`, `docs/CONFIG-COMPATIBILITY.md`, оба changelog обновлены | проверено чтением (diffstat: 0 изменений в `const.py`, `manifest.json`, `src/i18n/**`) |
| AC11 | mutation-gate свидетели для дорогих защит | `scripts/mutation-gate.mjs` — 4 новых entries | см. таблицу «чем краснеет» ниже |
## Таблица защитных доказательств (правило #435)
| AC | Чем доказан | Чем краснеет (мутация → эффект) |
|---|---|---|
| AC2 | `test_decor_asset_resolve_readonly_is_limited_to_referenced_ids` (`tests_backend/test_ha_websocket.py`) — шпион на `read_asset`, `looked_up == [referenced_id]` | mutation-gate `asset-resolve-readonly-membership-removed`: `allowed = requested & referenced` → `allowed = requested`. Прочитано и прослежено: unreferenced id снова попадёт в `allowed`, `read_asset()` вызовется для него, `looked_up` тест-шпион поймает лишний id → assert падает |
| AC3 | `test_decor_asset_resolve_requires_runtime_before_io` — `monkeypatch.setattr(hp_ws, "Path", forbidden_path)`, ожидание `not_ready` | нет отдельного mutation-gate entry (чистый порядок операторов); снятие `if rt is None: return` эквивалентно удалению самого guard-а — сразу ловится тем же тестом (`Path` вызывается → `AssertionError` до отправки `not_ready`). Адресный red proof не требуется отдельно: тест уже устроен как ловушка на любой FS-вызов до ответа |
| AC4 | `test_direct_asset_lookup_never_scans_or_accepts_mismatched_sidecars` — `monkeypatch.setattr(Path, "glob", no_scan)` | замена `read_asset()` на `read_catalog(root)` немедленно попадает в `no_scan` → `AssertionError`. Проверено чтением (нет отдельного mutation-gate entry, но witness детерминирован и не требует HA) |
| AC5 | `test_integrity_cache_reuses_digest_and_caches_corrupt_signature` (pure) + HA-тест `test_decor_asset_list_resolve_delete_and_signed_content` (`hash_calls` считает вызовы `AssetIntegrityVerifier.verify` через инструментированный `hasher`, растёт только при реальной смене файла, WS после HTTP не увеличивает счётчик) | mutation-gate `asset-integrity-cache-hit-disabled`: `if cached is not None and ...` → `if False and ...`. Прослежено: cache-hit branch никогда не срабатывает → второй `verify()` того же файла снова становится owner → `calls == 2` вместо `1` → assert падает. Тот же код используется обоими транспортами, поэтому мутация ломает и кросс-транспортное свойство |
| AC6 | `test_integrity_cache_single_flights_same_path_and_releases_after_error` — `ObservedEvent` фиксирует, что второй вызов реально дождался владельца; `test_integrity_checks_for_different_paths_do_not_share_a_hash_lock` | mutation-gate `asset-integrity-single-flight-disabled`: `flight = self._inflight.get(key)` → `flight = None`. Прослежено: оба потока становятся владельцами, `waiter_joined.wait(2)` в тестовом hasher никогда не будет установлен вторым потоком → `coordinated()` виснет/не получает join → assert «the concurrent caller never joined the flight» падает, либо `calls == 2` |
| AC7 | `test_integrity_cache_invalidates_changed_signature_and_rejects_mid_read_change` — `mutating()` hasher переписывает файл во время чтения, ожидается `not unstable.verify(...)` и `not unstable._cache` | mutation-gate `asset-integrity-post-read-signature-ignored`: `stable = _signature(path) == before` → `stable = True`. Прослежено: `verify()` вернёт `True` (digest совпадёт с ожидаемым, т.к. hasher хэширует ещё старые байты), `assert not unstable.verify(...)` падает, `_cache` получит запись — второй assert тоже падает |
| AC8 | `test_integrity_cache_is_bounded_lru_and_stream_reader_avoids_read_bytes` — `monkeypatch.setattr(Path, "read_bytes", forbidden_read_bytes)`, 257 записей | адресный red proof (без отдельного mutation-gate entry, чистый unit, разрешено правилом #435 «для чистого AC8 допустим адресный red proof»): замена `_stream_sha256` на `hashlib.sha256(path.read_bytes())` немедленно ловится `forbidden_read_bytes`; удаление `while len(...) > max: popitem` ловится проверкой `len(verifier._cache) == 256` и отсутствием `paths[1]` |
| AC9 | расширенный `test_decor_asset_list_resolve_delete_and_signed_content` — статусы 200/404, `Content-Type`, `X-Content-Type-Options`, тело `== png` | проверено чтением: `http_api.py:185` (`elif not await ... path.is_file`) оставляет `plans`/`files` вне verifier структурно — ветка `if kind == "assets"` физически не выполняется для другого `kind`, отдельного мутанта не заводили, риск минимален (условие на `kind`, не на данных) |
Правило #435 требует непустой третий столбец для каждого защитного AC — заполнен
для всех семи (AC2–AC8); AC9 и часть AC3/AC4 доказаны детерминированным
white-box unit-тестом с шпионом, что процесс прямо признаёт достаточным для
«чистых» AC без отдельного мутанта.
## Гейты
Задание сообщило, что Validate на точном SHA `d8e67f53` зелёный
(run 33741146772) — проверено (`gh run view`), включая `Бэкенд: pytest в Home
Assistant` (success). Но `Фронтенд: типы, юниты, мутанты, синхрон бандла` на
этом прогоне **skipped** (путь-фильтр: диапазон `origin/dev..HEAD` не касается
`src/**`/`test/**`/`package.json` — `scripts/mutation-gate.mjs` под этот regex
не подпадает вовсе), то есть `typecheck`/`npm test`/`npm run build` в CI на этой
ветке не выполнялись ни разу. Прогнал сам:
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | зелёный, 0 ошибок |
| Unit (frontend) | `npm test` | `# tests 1793 / pass 1792 / fail 0 / skipped 1` |
| Build | `npm run build` | зелёный, `dist/` пересобран |
| Sync бандла | `cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` | совпадают (ожидаемо: `src/**` не менялся) |
| Структура mutation-gate | `node scripts/mutation-gate.mjs --check` | `ok` на всех 60 записях, включая 4 новых |
| Backend pytest (Linux/HA) | не прогонял локально — `homeassistant` не установлен в песочнице ревьюера | подтверждено CI на точном SHA (run 33741146772, job success); AGENTS.md: «чистое подмножество без HA даёт зелёный результат, который ничего не доказывает» — поэтому не подменял локальным прогоном без HA |
| `node scripts/check-docs.mjs` | не требуется | `src/**` не менялся |
| `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | прогнал | «Исполняемого frontend-диффа нет… Browser-smoke этим диффом не выбираются… Тронуто файлов: 15» — согласуется с ТЗ §11/§15.8 (browser/golden не требуются) |
| `npm run golden:verify` | не требуется | рендер/визуал не менялись |
| `node scripts/model-invariants.mjs` | не требуется | геометрия/`layout`/толщина не тронуты |
## Что проверено и корректно
- Полный код `asset_integrity.py` прочитан построчно; блокировка (`self._lock`)
удерживается только на bookkeeping (проверка кеша/inflight), сам `self._hasher(path)`
выполняется **вне** лока — соответствует §9.2 ТЗ («не держать один глобальный lock
на протяжении всех чтений»), подтверждено тестом на независимость разных путей.
- `finally: flight.event.set()` гарантирует, что исключение в hasher всё равно
разбудит ожидающих и снимет in-flight запись — нет вечного зависания (AC6).
- `read_asset()`/`_read_catalog_row()` — общий helper для `assets/list` и `resolve`,
что и требует §9.3 ТЗ («не разойдутся две копии validation»); добавленная
проверка `path.stem != aid` дополнительно исключает подмену sidecar под чужим
именем файла — усиление, а не регресс (протестировано отдельно, `read_catalog()`
на существующих валидных данных не меняет поведение: имя sidecar у
легитимно созданных записей всегда равно `asset_id` по построению
`asset_meta_path()`).
- `HouseplanContentView.get()`: `plans`/`files` остаются на `path.is_file()`,
verifier применяется только при `kind == "assets"` — контракт AC9/§8 ТЗ не
нарушен, CSP/`nosniff`/`immutable`/`FileResponse(chunk_size=...)` не тронуты.
- `may_write()` (`auth.py`) не менялся — переиспользован как единственный источник
writer/read-only семантики, соответствует §7.1 ТЗ.
- Трейлеры и changelog корректны для обоих коммитов класса A/B; `d8e67f53` —
точечная починка утечки тестовой фикстуры (`houseplan/assets` не чистился между
тестами, из-за чего `f3c32fb2` получил красный backend-job), закрыта в рамках
того же issue тем же коммитом с `User-Visible: no` — это ожидаемая часть работы
над задачей, а не находка ревью.
- Все 4 новых постоянных mutation-gate мутанта структурно применимы
(`--check` → `ok`) и при чтении логики каждый действительно ломает assert
того теста, который его сторожит (прослежено построчно, таблица выше).
- Read-only membership snapshot берётся под `rt.write_lock` и отпускается **до**
файлового I/O — соответствует §7.3 ТЗ («Lock не удерживается во время metadata
I/O или хеширования»).
- `_runtime()` вызывается синхронно до всякого обращения к `Path`/файловой системе
(`get_data()` — чтение `hass.data`, без I/O) — AC3 подтверждается и структурно,
не только тестом-шпионом.
- Публичный контракт (payload `{assets, missing}`, `DECOR_ASSETS_API_VERSION`,
URL-схема, i18n, config schema) не тронут — `git diff` по `const.py`,
`manifest.json`, `src/i18n/**`, `src/**` пуст.
## Находки
Нет находок уровня High или Medium.
**Low (не блокирует, зафиксировано без правки).**
1. AC9 (streaming/headers для `assets`) и часть AC3/AC4 доказаны только чтением
и детерминированным white-box unit-тестом (шпион), без отдельной записи в
`scripts/mutation-gate.mjs`. Это разрешено правилом #435 для AC, не требующих
дорогого гейта (backend/HA здесь не обязателен именно для этой мутации:
отделение веток `if kind == "assets"` / `elif` тривиально и не зависит от
concurrency или HA-специфики). Снимается без правки — расширять реестр ради
тривиальной ветки было бы ритуалом, который правило прямо исключает.
2. `docs/CONFIG-COMPATIBILITY.md` добавляет абзац о #432 в середину раздела про
отдельный более старый compatibility-кейс (downgrade изображений), а не
отдельным подзаголовком. Контент корректен и полон, это вопрос структуры
документа. Снимается без правки.
## Чего не проверял
- Backend/HA pytest не исполнял локально: в песочнице ревьюера нет модуля
`homeassistant` и `.venv-backend`; полагаюсь на зелёный Linux CI job
«Бэкенд: pytest в Home Assistant» на точном SHA `d8e67f53` (run 33741146772),
включающий все новые HA-тесты из `tests_backend/test_ha_websocket.py`.
- Полный `node scripts/mutation-gate.mjs` (без `--check`, реальный прогон 4 новых
мутантов) не выполнял: он требует backend-гейт (`backend-test-guard.mjs` → pytest
→ HA), которого в песочнице ревьюера нет, а канонически такой прогон — часть
предрелизного `.github/workflows/mutation-gate.yml`, не гейта код-ревью
(это явно задокументировано в самом `scripts/mutation-gate.mjs`: «прогон дорогой,
его место — перед стабильным релизом»). Корректность каждого мутанта проверена
чтением и прослеживанием логики до конкретного assert (таблица выше), автор
отдельно заявил в handoff, что все четыре пойманы red в WSL/Linux.
- Browser smoke, `golden:verify`, `model-invariants` — не прогонял: ТЗ §11/§15.8
явно исключает их (рендер/геометрия/`layout` не меняются), и
`scripts/smoke-select.mjs` подтверждает отсутствие исполняемого frontend-диффа.
- Производительность «в бою» (реальная нагрузка HA-инстанса с сотнями ассетов)
не измерялась — вне возможностей этого ревью; оценка по коду: I/O теперь
O(число уникальных разрешённых id) вместо O(каталог), повторный blob — 0 байт
чтения при валидном cache-hit, что соответствует §16 ТЗ, подтверждено тестом
с hash-counter.
- Не проверял поведение при недоступной файловой системе (permission denied,
диск в read-only режиме) сверх штатного пути `OSError` → `except OSError:` в
`_signature()`/verify() → fail-dark; отдельного теста на этот конкретный
сценарий нет, но код структурно идентичен уже покрытому «missing file» случаю
(тот же `except OSError` перехватывает оба).
## Вердикт
Вердикт: зелёный · заход r1 · блокирующих циклов 0/4 · High: 0 · Medium: 0 → в задаче
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/432-asset-resolve-authorization-cache`, коммит `d8e67f530c33` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `3cba0a6a5cde3b9dcccba5c8bb7711c0fe92550c`
```
git log --all --format='%H %T' | grep 3cba0a6a5cde
```
- ТЗ `docs/specs/432-asset-resolve-authorization-cache.md`, блоб `8593bd54ad7d7e8a4d6459949fbb960c34ccddc9`
```
git log --all --find-object=8593bd54ad7d7e8a4d6459949fbb960c34ccddc9 -- docs/specs/432-asset-resolve-authorization-cache.md
```
+315
View File
@@ -0,0 +1,315 @@
# CODE-REVIEW-434-r1
- Issue: #434 — «Полиш аудита v1.71.0-beta.1»
- Этап: код-ревью (PROCESS.md §2.7), полный трек
- Заход: r1 · блокирующих циклов израсходовано 0/4
- SHA материала: `dbda64e89d6b76cabfe57c21f281cb6d5132c257` (HEAD в момент разбора; сверено `git rev-parse HEAD` непосредственно перед выводом)
- Диапазон: `origin/dev...HEAD` (5 коммитов: `8a75c53e` спека, `f383d8fd`/`4190db54` документы спек-ревью r1/r2, `cba13621` реализация, `dbda64e8` довесок-мутант)
- Ветка ребейзнута конвейером на 1 коммит dev (`a71531ba → dbda64e8`) — разбор проведён полностью, не по дельте, как и требуется для r1.
## Скоуп
ТЗ закрывает девять подтверждённых разрывов аудита v1.71.0-beta.1: физический
учёт decor-blobs независимо от sidecar (AC1), recovery orphan-загрузки с честным
`reused` (AC2), точное явное удаление (AC3), недостающий негативный тест
sidecar-без-blob (AC4), capability guard в `houseplan-space-card` + revision-scoped
resolve cache (AC5, AC6), честный «текущий» locale gate для danger confirmation
(AC7), негативный witness Area snapshot cleanup (AC8), bounded smoke timeouts
(AC9), отзыв support-токена из непринятого превью (AC10). Все относятся к Core
user jobs `docs/SCOPE.md` (надёжность View/редактора, честность данных, CI-гигиена
для Contributor); продуктовой рамки, требующей отдельного вопроса владельцу, не
нашёл.
## Как проверялось
### Дешёвые гейты (прогнаны лично, SHA выше)
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | 0 ошибок |
| Unit (JS) | `npm test` | 1813 тестов, 1812 pass, 1 skipped, 0 fail |
| no-new-any | `node scripts/no-new-any.mjs --base origin/dev --head HEAD` | «Новых any нет» (60 добавленных строк в 5 файлах) |
| Build + 3 копии бандла | `npm run build && cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js && npm run bundle:sync` | совпадают байт-в-байт; `git status` после ребилда чист |
| check-docs (diff трогает `src/**`) | `node scripts/check-docs.mjs` | «Documentation checks passed (7 files, 10 external links)» |
| backend pure | `python3 -m pytest tests_backend/test_decor_assets.py -q` | 52 passed |
| model-invariants | не запускал | diff не трогает геометрию (rooms/walls/`layout`/`marker.space`/`open_spans`) — не применимо |
### Отбор смоков (`node scripts/smoke-select.mjs --base origin/dev --head HEAD`)
Матрица 216 смоков, «широкий» символ — порог >43 смоков. Вывод инструмента:
21 «прямое совпадение», 35 «слабая связь», 1 «зарегистрированная связь»
(`smoke_french_locale` ← `languageRenderGate`, не запускал — French locale
сценарий не задет этой задачей, связь регистрируется как «эта функция широко
переиспользуется», не как прямое следствие правки).
Прогнал целенаправленно:
- `demo/smoke_danger_confirm_branches.mjs` — прямое совпадение, единственный
файл, названный в AC7 и изменённый в диффе. **OK**, все 38 полей `true`.
- `demo/smoke_space_card_decor_capability.mjs` (новый файл, AC5) — **OK**,
4/4 поля `true`.
- `demo/smoke_support_feedback.mjs` (новый файл, AC10) — **OK**, 29/29 полей `true`.
Остальные 20 «прямых» и 35 «слабых» совпадений — реакция на широко
переиспользуемые символы (`_cfgRev`, `_snap`, `_config`, `_confirmDanger`),
не специфичные для этой задачи логике; полный прогон всей матрицы — предрелизный
гейт (PROCESS §8), не гейт ревью. Golden не запускал — спека прямо говорит
«Golden не требуется» (визуальный кадр не меняется), проверено чтением: diff не
трогает ни один шаблон рендера кроме `_renderRoot`/`_renderDangerConfirm`,
геометрию которых golden не покрывает по замыслу задачи.
### Backend HA harness — НЕ прогонял (честно, гейт пропущен намеренно)
`tests_backend/test_ha_websocket.py` содержит новые/изменённые тесты для AC2 и
AC3 (`test_decor_asset_upload_deduplicates_and_rejects_mime_spoofing` расширен,
`test_decor_asset_delete_removes_exact_orphans_only` новый). Окружение этого
ревью — Python 3.12, `pytest-homeassistant-custom-component==0.13.357` требует
Python ≥3.14 (проверил: `pip install` дал `ResolutionImpossible`/no matching
distribution), `.venv-backend` отсутствует. Это ограничение среды ревью, не
находка по коду (AGENTS.md: «только облачные агенты держат харнесс»).
**AC2 и AC3 доказаны чтением, не исполнением** — см. ниже.
### Мутационные свидетели — прогнаны выборочно (`--changed` затронул 109/380, полный прогон непропорционален; проверил вручную 8 новых/изменённых записей, относящихся к этой задаче)
`node scripts/mutation-gate.mjs --changed --check` — все 109 применимых
патчей легли на текущий код (`ok` по каждому, 0 `FAIL`). Живой прогон (патч →
пересборка → гард) сделал для мутантов этой задачи:
| Мутант / AC | Гард | Результат живого прогона |
|---|---|---|
| `decor-physical-inventory-follows-sidecars` (AC1) | `pytest -k physical_inventory` | применил патч руками → **тест покраснел** (`assert [] == [...]`) |
| `decor-catalog-accepts-sidecar-without-blob` (AC4) | `pytest -k valid_shaped_sidecar_without_blob` | применил патч руками → **тест покраснел** (`row is not None`) |
| `danger-confirm-uses-last-rendered-language-gate` (AC7) | `smoke_danger_confirm_branches` | применил патч руками → **смок покраснел** на `warmToReadyAllowsBeforeRender` |
| `danger-confirm-warm-transition-cancel-removed` (AC7) | `smoke_danger_confirm_branches` | применил патч руками → **смок покраснел** на 3 полях (`readyToWarmCancelsOpenConfirmation`, `warmLanguageGateKeepsControllerEmpty`, `warmLanguageGateRemovesDecisionSurface`) |
| `area-cleanup-keeps-candidate-outside-current-snapshot` (AC8) | `node --test -k "absent from the current snapshot"` | применил патч руками → **тест покраснел** (`true !== false`) |
| `support-invalid-response-leaks-issued-token` (AC10) | `smoke_support_feedback` | применил патч руками → **смок покраснел** на `invalidPreviewTokenIsDiscardedExactlyOnce` |
После каждого прогона дерево восстановлено (`git status` чист, `cp` бэкапа
исходника обратно + ребилд). Не прогонял живьём: `decor-orphan-repair-runs-after-quota`,
`decor-orphan-repair-claims-reuse`, `decor-delete-skips-orphan-blobs` — их гард
использует `tests_backend/test_ha_websocket.py`, недоступный в этой среде
(см. выше); патчи прошли `--check` (якорь находится ровно один раз в текущем
коде), сама логика разобрана чтением ниже.
## Разбор по AC
**AC1 — physical inventory.** `physical_asset_blobs()`/`physical_asset_usage()`
(`decor_assets.py`) считают файлы через `stat(follow_symlinks=False)` +
`S_ISREG` + `suffix in ASSET_EXTENSIONS` + `ASSET_ID_RE.fullmatch(stem)` —
директории, symlink, `.json`, неизвестные расширения и prefix-совпадения не
входят; новый тест `test_physical_inventory_counts_exact_promoted_blobs_not_sidecars`
проверяет все эти случаи одним фикстуром. **Доказано**: pytest прогнан, мутация
красит.
**AC2 — upload recovery.** `http_api.py`: recovery-ветка (`if blob.exists(): ...`)
идёт строго ДО вызова `physical_asset_usage()`/проверки квоты — repair не требует
свободного слота, как того требует контракт. `existing = read_asset(...)` (valid
sidecar + verified blob) возвращает `reused:true` без изменений; orphan (blob без
принимаемого catalog row) хешируется, атомарно создаёт sidecar через
`tempfile.mkstemp` + `os.replace`, возвращает `reused:false`; digest mismatch
кидает `invalid_image` до любой записи. Тест `test_decor_asset_upload_deduplicates_and_rejects_mime_spoofing`
покрывает repair-при-полной-квоте (`monkeypatch MAX_DECOR_ASSETS_COUNT=1`) и
digest-mismatch (проверяет, что повреждённый blob и отсутствие sidecar
остаются нетронутыми, `rejected.status==507`). **Проверено чтением, не
исполнением** (харнесс недоступен); тест и код логически согласованы построчно.
**AC3 — explicit delete.** `websocket_api.py::ws_assets_delete._delete()`
перебирает `ASSET_EXTENSIONS` по точному `<aid><ext>`, плюс sidecar; `in_use`
проверяется ДО вызова `_delete()` под `write_lock+upload_lock`, так что ничего
не удаляется при отказе. Новый тест `test_decor_asset_delete_removes_exact_orphans_only`
кладёт orphan `.png`+`.svg`+битый `.json`, соседний `.gif` (неизвестное
расширение), `aid+"0".png` (prefix) и директорию `aid.webp` — все три «не
трогать» кейса проверяются явно. **Проверено чтением, не исполнением** (харнесс
недоступен); код и тест согласованы построчно, включая заявленный `removed`.
**AC4 — regression sidecar-без-blob.** Продуктовый код не менялся (защита
`blob.is_file()` уже была в `_read_catalog_row` на `dev`) — задача добавляла
только пропущенный отрицательный тест, ровно как описано в причине #4. **Доказано**:
pytest прогнан, мутация (удаление `or not blob.is_file()`) красит новый тест.
**AC5 — capability guard.** `config-store.ts`: `decorAssetsApi` — только точное
`=== 1`, localStorage-seed всегда `null`; unit-тесты `config-store.test.mjs`
проверяют апгрейд/даунгрейд/`localStorage`. `space-card.ts`: resolve вызывается
только при `snap.decorAssetsApi === DECOR_ASSETS_API_VERSION`; при отсутствии —
`_decorAssets` безусловно очищается, resolve не вызывается — подтверждено живым
смоком (`oldBackendSkippedResolve`, `downgradeRevokesWithoutResolve`). **Доказано**
смоком + unit.
Отдельно проверил утверждение «принимает capability-only изменение snapshot
даже при неизменных config/layout/vLights fingerprints»
(`decorAssetsCapabilityChanged` в OR-условии замены `this._snap`, space-card.ts:732)
— см. находку Low-1 ниже: код корректен, но заявленный сценарий не
верифицирован тем инструментом, который на него ссылается.
**AC6 — resolve cache.** `resolveDecorAssets()`: `key = JSON.stringify([configEpoch, unique])`,
кэш пишется только после успешного цикла (до `resolveCache.set` включая throw —
исключение прерывает функцию раньше). Три новых/изменённых unit-теста
(`decor-assets.test.mjs`) прогнаны в `npm test`, логика проверена чтением —
мутация «убрать epoch из key» ловится тестом «resolve cache is scoped by
authoritative config epoch» (прочитан, не мутировал вручную — тривиально
следует из кода). **Доказано** unit-тестами.
**AC7 — danger confirmation.** `_dangerConfirmLocaleGate` стал геттером
(никогда не кэшируется), `_confirmDanger` проверяет его на каждый вызов;
`willUpdate` отменяет открытый confirm при переходе в `warm` СИНХРОННО до
`render()`; `render()`/`_renderRoot()` используют один и тот же call site
шаблона, чтобы вложенный `noChange` для тела уживался с независимым удалением
`hp-confirm`. Смок прогнан живьём (зелёный, 38/38), и я вручную применил и
откатил обе новые мутации реестра — оба раза целевые поля красятся именно так,
как заявлено в `because`. **Доказано** смоком + двумя живыми мутациями.
**AC8 — Area snapshot negative witness.** Продуктовый код не менялся (защита
`snapshotBindings.has(binding)` уже была на `dev`) — только новый unit-тест.
**Доказано**: тест прогнан, мутация красит именно этот тест.
**AC9 — bounded smoke execution.** `validate.yml`: job `smoke` получил
`timeout-minutes: 20`; каждый файл — `timeout --kill-after=10s 180s node "$f"`,
статус 124 печатает отдельную диагностическую строку, `continue` внутри цикла
файлов не встречается — падение одного файла не останавливает шард (существующий
код, не тронут). `smoke_danger_confirm_branches.mjs` обернул оба
German-ожидания (`germanStarted`, `germanCompleted`) в `Promise.race` с 1000 мс.
Новый `test/smoke-exception-guard.test.mjs` проверяет все три границы текстовым
соответствием YAML — прогнан, зелёный. Живой смок подтверждает, что German-путь
укладывается в границы (никакого зависания на реальном прогоне). **Доказано**
текстовым контрактным тестом + живым прогоном смока; сам workflow на GitHub
Actions не выполнялся (не гейт ревью, а CI-инфраструктура).
**AC10 — support token cleanup.** `_buildSupportPreview()`: `issuedToken`
фиксируется сразу после парсинга ответа (валидация формы `^[0-9a-f]{48}$`), ДО
общей проверки hash/format/version/size/spaces/expires/text, которая кидает
`support_rejected`; `catch`-блок discard'ит `issuedToken`, если он не был
обнулён успешным путём. Malformed/отсутствующий токен никогда не попадает в
`issuedToken`. Живой смок зелёный (29/29), живая мутация (снятие discard в
catch) красит ровно `invalidPreviewTokenIsDiscardedExactlyOnce`. **Доказано**
смоком + живой мутацией.
**AC11 — совместимость/документация.** Grep диффа не находит изменений схемы,
версии API (`DECOR_ASSETS_API_VERSION` остался `1`), i18n-ключей или визуальных
шаблонов вне `_renderRoot`/`_renderDangerConfirm` (сам рендер тех же узлов, что и
раньше, просто перегруппированный). `docs/ARCHITECTURE.md`, `CONFIG-COMPATIBILITY.md`,
`SUPPORT-PRIVACY.md`, `TESTING.md`, оба `CHANGELOG*` изменены в одном коммите
`cba13621` с трейлерами `Issue: #434` / `User-Visible: yes`. **Проверено
чтением**: diff review + `check-docs` зелёный.
**AC12 — гейты.** Typecheck/unit/build/bundle-sync/no-new-any/check-docs —
зелёные (таблица выше). Backend pure — зелёный. Backend HA — не прогонял
(ограничение среды, см. выше, отражено честно). Отобранные смоки — зелёные.
Мутационные свидетели — 6/9 новых прогнаны живьём и красят корректно, 3/9
(HA-зависимые) проверены `--check` + чтением. Golden/full performance —
предрелизные, не гейт ревью (спека сама это утверждает).
## Находки
### Low-1 — `decorAssetsCapabilityChanged` в `space-card.ts:711-732` не влияет на
наблюдаемое поведение и не верифицируется смоком, на который ссылается спека
`this._snap.decorAssetsApi` пишется в двух местах (`space-card.ts:733` и `:736-737`
через частичное обновление) и читается **только** в вычислении самого
`decorAssetsCapabilityChanged` (`:711-712`) — больше нигде в проекте
(`grep -n "decorAssetsApi" src/*.ts` даёт ровно 6 строк, все либо запись, либо
сравнение с только что полученным `snap`, а не с `this._snap`). Реальная
семантика AC5 — вызов/невызов `resolve` и очистка `_decorAssets` — читает
**свежий** `snap.decorAssetsApi` напрямую (`:713`, `:740`), а не `this._snap`.
Поэтому замена `this._snap` целиком против частичного обновления `rev`/`layoutRev`
не производит никакой разницы, которую можно было бы пронаблюдать.
**Как проверил.** Убрал `|| decorAssetsCapabilityChanged` из условия на
`:732`, пересобрал бандл, прогнал `smoke_space_card_decor_capability.mjs`
(остался зелёным, 4/4) и отдельным зондом (`page.evaluate`, три `_load()`
подряд: null→1→null) убедился, что `_snap.decorAssetsApi` меняется
`null → 1 → null` **даже без строки** — потому что `virtualLightsChanged`
в этой фикстуре (и, по прослеживанию кода, в любом реальном сценарии, где
`fetchFresh()` вообще выполняется — `cache` в `config-store.ts` всегда `null`
на входе в `fetchFresh`, значит `virtualLightSnapshot()` каждый раз строит
новый объект) уже безусловно `true` ровно тогда, когда снимок вообще меняется.
`test/space-card-audit-lows.test.mjs` проверяет присутствие строки
`decorAssetsCapabilityChanged` в исходнике текстовым `assert.match` — это ловит
буквальное удаление термина, но не поведенческую регрессию, и не то же самое,
что «тест умеет падать» в смысле PROCESS §2.7 (сам мутационный реестр не
содержит записи для этой строки).
**Не блокирует.** Строка не создаёт неверного поведения (она безвредно
избыточна), реальный контракт AC5 (запрет resolve на старом backend, очистка
map при даунгрейде, кэш по epoch) доказан отдельно и надёжно. Снимаю как Low с
запиской: если авторы захотят вычистить, это чистое упрощение (убрать термин
из OR и из текстового теста, ничего не потеряв); можно и оставить как
задел на случай, если `_snap.decorAssetsApi` найдёт читателя в будущем.
### Low-2 — `_dangerConfirmLocaleGate` геттер может вызвать `languageRenderGate()`
дважды за один цикл обновления, пока открыт danger confirm
`languageRenderGate()` (`src/i18n/language-runtime.ts:105`) не чистая функция:
мутирует `pendingHosts`/`committedHosts`, дёргает `host.setAttribute`, и — что
существеннее — при `state === 'pending'` безусловно подписывает новый
`.then()` на `runtime.ensure(code)` при **каждом** вызове, не только при первом
вхождении в pending. `willUpdate()` (`houseplan-card.ts:4192-4194`) читает
геттер, когда `this._dangerConfirm` истинен, и `_renderBody()` (`:11239`) читает
его снова безусловно — при открытом danger confirm это два вызова за один
рендер-цикл вместо одного до этой правки (когда значение кэшировалось полем).
**Не блокирует.** Оба вызова происходят синхронно в одном тике (между
`willUpdate` и `render()` нет await), поэтому `runtime.state(code)` не может
измениться между ними — итоговое значение геттера согласовано на всём проходе.
Лишняя подписка `.then()` на уже существующий (мемоизированный) промис
`runtime.ensure()` добавляет не более одного лишнего `requestUpdate()` при
разрешении — Lit схлопывает такие вызовы. Открытый danger confirm — не самое
частое состояние карточки. Снимаю как Low без правки.
## Унаследовано / повторная проверка
Не применимо — это первый заход код-ревью (r1) по этому issue; разделы
«Закрытие r(N-1)» и «Унаследовано из r(N-1)» не ведутся согласно PROCESS §2.10.
(Два документа `SPEC-REVIEW-434-r1.md`/`-r2.md` в диффе относятся к
предыдущему этапу — ревью ТЗ, а не коду; их вердикт не переоткрывал.)
## Чего не проверял и почему
- **`tests_backend/test_ha_websocket.py` (полный HA-харнесс)** — среда ревью
Python 3.12, пин `pytest-homeassistant-custom-component==0.13.357` требует
≥3.14, `.venv-backend` недоступен. AC2/AC3 доказаны чтением построчного
соответствия теста и кода (см. разбор выше), не исполнением.
- **Полный `npm run golden:verify`** — спека прямо утверждает «Golden не
требуется» (нулевая визуальная дельта в устойчивых состояниях); чтением
подтвердил, что изменённые рендер-точки (`_renderRoot`) рисуют тот же набор
DOM-узлов, что и раньше, в другой обёртке.
- **`npm run golden:capture`/`performance_smoke`/полный набор `demo/smoke_*.mjs` (216 файлов)**
— предрелизный гейт (PROCESS §8), непропорционален объёму этой задачи; выбрал
3 смока, прямо названных в AC и физически изменённых в диффе.
- **Полный `node scripts/mutation-gate.mjs`(без `--changed`) — 380 мутантов** —
предрелизный гейт по определению самого скрипта («прогон дорогой... его
место — перед стабильным релизом»); ограничился `--changed --check` (все 109
затронутых патчей применимы) плюс живым прогоном 6 из 9 новых/изменённых
записей, относящихся именно к этой задаче.
- **`node scripts/model-invariants.mjs`** — diff не касается геометрии
(rooms/walls/layout/marker.space/open_spans), гейт неприменим.
- **French locale smoke (`smoke_french_locale.mjs`)** — «зарегистрированная
связь» через `languageRenderGate`, но сценарий (network/lazy German chunk)
не задет этой задачей; решил не гонять.
- **35 «слабых» совпадений smoke-select** — общие символы (`_cfgRev`, `_config`
и т.п.), не специфичные для этой правки; не гонял, полагаясь на unit/typecheck
как более узкое доказательство отсутствия регрессии в этих поверхностях.
## Итог
High: 0. Medium: 0. Low: 2, обе сняты с запиской (см. выше), правки не требуют.
Все 12 AC доказаны — либо зелёным тестом/смоком с подтверждённой (в 8 из 10
защитных случаев — лично прогнанной) способностью падать, либо честной пометкой
«проверено чтением, не исполнением» там, где харнесс недоступен в среде ревью.
Документация, changelog и трейлеры в порядке. Вердикт: **зелёный**.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/434-v171-polish-audit`, коммит `a71531ba30d2` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `840317519dfa1ae59a2ecf7317ce53bd819c4504`
```
git log --all --format='%H %T' | grep 840317519dfa
```
- ТЗ `docs/specs/434-v171-polish-audit.md`, блоб `e235fcef2bcd817c39c0c5a9134fd2ec8f6eb2f1`
```
git log --all --find-object=e235fcef2bcd817c39c0c5a9134fd2ec8f6eb2f1 -- docs/specs/434-v171-polish-audit.md
```
+136
View File
@@ -0,0 +1,136 @@
# CODE-REVIEW-434-r2
- Issue: #434 — «Полиш аудита v1.71.0-beta.1»
- Этап: код-ревью (PROCESS.md §2.7)
- Заход: r2 · блокирующих циклов израсходовано 0/4
- SHA материала: `c706f8a744a9406b0ce9a1bc49dfd65fd0b681a2` (сверено `git rev-parse HEAD`)
- Предыдущий вердикт: зелёный, r1, SHA `dbda64e89d6b76cabfe57c21f281cb6d5132c257`,
документ `docs/reviews/CODE-REVIEW-434-r1.md`.
- Диапазон дельты: `git diff dbda64e8..c706f8a7`.
## Почему это r2, а не r1 заново
r1 прошёл зелёным на `dbda64e8` и был автоматически смёржен в `dev`. Догоняющий
Validate на смёрженном SHA (`bdba9f48`) упал на `ruff F401`: неиспользуемый
импорт `read_catalog` в `custom_components/houseplan/http_api.py`, оставшийся
после реализации #434. Автор вернул задачу в `S6-in-progress`, внёс
однострочное исправление (`c706f8a7`) и снова отправил на код-ревью — это и
есть материал r2.
## Дельта r1→r2
```
git diff dbda64e8..c706f8a7 --stat
custom_components/houseplan/http_api.py | 1 -
docs/reviews/CODE-REVIEW-434-r1.md | 315 +++++++++++++++++++
```
Продуктовый код: одна строка — удалена запись `read_catalog,` из блока
`from .decor_assets import (...)` в `http_api.py`. Второй файл —
артефакт публикации документа r1, не код, ревью не подлежит (он и есть
предыдущий вердикт).
Коммит `c706f8a7`: `fix: remove stale decor catalog import`, трейлеры
`Issue: #434`, `User-Visible: no`. Trailers корректны: удаление мёртвого
импорта не имеет наблюдаемого пользователем эффекта, changelog не требуется —
и он действительно не тронут в диффе, что согласовано с `User-Visible: no`.
Дельта локальна: один файл, одна строка, не геометрия, не контракт поведения,
не новая подсистема. Полный повторный разбор всех 12 AC из r1 не требуется —
ни один из них не описывает поведение `http_api.py`-импортов; сокращение
объёма разбора обосновано §2.9.
## Проверка правки по существу
`read_catalog` определена в `decor_assets.py:425`, используется в
`websocket_api.py:60,1124` (`ws_assets_list` читает каталог через
`hass.async_add_executor_job(read_catalog, root)`). В `http_api.py` (файл
загрузки/удаления blob) её реального использования нет — grep по всему
дереву не находит ни одного места, где `http_api.read_catalog` бы
реэкспортировался или вызывался (`grep -rn "from .http_api import\|from
custom_components.houseplan.http_api import"` — три хита, ни один не называет
`read_catalog`). Удаление корректно и безопасно: символ был мёртвым импортом
именно в этом файле, не переносом функциональности.
## Гейты, прогнанные лично на SHA `c706f8a7`
Диапазон не трогает `src/**`, геометрию, `demo/**` — большая часть набора
гейтов из r1 неприменима к этой дельте по определению; тронут ровно один
python-файл вне тестов. Прогнал:
| Гейт | Команда | Результат |
|---|---|---|
| ruff (сам источник падения CI) | `python3 -m ruff check custom_components/houseplan` (ruff доустановлен в среде ревью, `pip install ruff`) | `All checks passed!` |
| Typecheck | `npx tsc --noEmit` | 0 ошибок |
| Unit (JS) | `npm test` | 1813 тестов, 1812 pass, 1 skipped, 0 fail — совпадает с r1, регрессии нет |
| Build + сверка 3 копий бандла | `npm run build && cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js && npm run bundle:sync` | байт-в-байт совпадение; `git status` после пересборки чист |
| backend pure (файл, соседний с правкой) | `python3 -m pytest tests_backend/test_decor_assets.py -q` (pytest доустановлен) | 50 passed, 2 skipped — тесты, требующие `pytest-homeassistant-custom-component`, скипаются в этой среде (тот же известный разрыв среды, что и в r1); ни одного fail |
| Отбор смоков | `node scripts/smoke-select.mjs --base dbda64e8 --head c706f8a7` | «Исполняемого frontend-диффа нет (`src/**/*.ts` не тронут). Browser-smoke этим диффом не выбираются — выбирать нечего» |
### Не прогонял и почему
- **`node scripts/check-docs.mjs`** — условие запуска («diff трогает `src/**`»)
не выполнено, дельта касается только `custom_components/houseplan/http_api.py`
и файла документа ревью.
- **Полный HA-harness (`tests_backend/test_ha_websocket.py` и др. с реальным
HA)** — среда ревью не поднимает `pytest-homeassistant-custom-component`
(тот же разрыв среды, что зафиксирован в r1); ruff и `test_decor_assets.py`
достаточны, чтобы доказать конкретно эту правку — она не меняет поведение,
только убирает неиспользуемый символ. Живой Linux CI на смёрженном SHA —
канонический прогон харнесса.
- **`npm run golden:verify`, `mutation-gate.mjs`, `model-invariants.mjs`,
browser-смоки** — дельта не меняет ни одной строки поведения (ни
продуктового кода, кроме удаления мёртвого импорта, ни рендера, ни
геометрии); эти гейты релевантны логике #434, которую r1 уже проверил
индивидуально по каждому AC и не переоткрывается.
## Закрытие раунда r1
| Находка/повод возврата r1 | Чем закрыта | Где видно |
|---|---|---|
| Догоняющий Validate: `ruff F401` на неиспользуемом `read_catalog` в `http_api.py` (обнаружено автором после автомёржа r1, не ревью-находка) | Импорт удалён | `git diff dbda64e8..c706f8a7 -- custom_components/houseplan/http_api.py`; `ruff check` зелёный на `c706f8a7` |
Собственных находок r1 (Low-1, Low-2) в этом раунде не переоткрываю — ниже.
## Унаследовано из r1
Документ `docs/reviews/CODE-REVIEW-434-r1.md`, SHA `dbda64e89d6b76cabfe57c21f281cb6d5132c257`,
зелёный, 0 High / 0 Medium. Дельта r2 не задевает ни один из 12 AC и ни один
файл, упомянутый в разборе r1, кроме самого `http_api.py` (и там — только
блок импортов, не логику). Принимаю без повторной проверки:
- Все 12 AC (physical inventory, upload recovery, explicit delete, sidecar
regression test, capability guard, resolve cache, danger confirmation,
Area snapshot witness, bounded smoke, support token cleanup, совместимость,
гейты) — доказательства из r1 не устарели, код, который они проверяют, не
менялся.
- Low-1 (`decorAssetsCapabilityChanged` в `space-card.ts` не влияет на
поведение) — файл не в дельте.
- Low-2 (`_dangerConfirmLocaleGate` может вызвать `languageRenderGate()`
дважды за цикл) — файл не в дельте.
- Отбор и прогон смоков/мутационных свидетелей r1 — предмет не менялся.
## Итог
High: 0. Medium: 0. Дельта r1→r2 — точечное, верно нацеленное исправление
ровно того дефекта, который остановил CI после автомёржа r1 (мёртвый импорт,
без изменения поведения). Ruff, typecheck, unit, build+bundle-sync и целевой
backend-тест зелёные на `c706f8a7`; grep подтверждает, что символ не
использовался и не реэкспортировался из `http_api.py`. Трейлеры `Issue`/
`User-Visible` соответствуют характеру правки. Вердикт: **зелёный**.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/434-v171-polish-audit`, коммит `c706f8a744a9` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `aeb1b1d451adce7d3cb151a1716717bc68271873`
```
git log --all --format='%H %T' | grep aeb1b1d451ad
```
- ТЗ `docs/specs/434-v171-polish-audit.md`, блоб `e235fcef2bcd817c39c0c5a9134fd2ec8f6eb2f1`
```
git log --all --find-object=e235fcef2bcd817c39c0c5a9134fd2ec8f6eb2f1 -- docs/specs/434-v171-polish-audit.md
```
+171
View File
@@ -0,0 +1,171 @@
# SPEC-REVIEW-426-r1
- **Issue:** https://github.com/Matysh/houseplan-card/issues/426
- **Этап:** spec (ревью ТЗ, PROCESS.md §2.4)
- **Заход:** r1 · блокирующих циклов израсходовано 0 из 4
- **Материал:** `docs/specs/426-room-hover-tooltip-toggle.md` на ветке
`issue/426-room-hover-tooltip-toggle`, SHA `587c20699c4bc1ada405c5c7f9775d34b2b78a39`
(совпадает с ревизией, заявленной автором в issue).
- **Трек:** полный (не `small`) — аналитик назвал нарушенные критерии §5
корректно: новое compatibility-поле в `config.settings` и новый публичный
UX-контракт, изменение проходит через frontend и backend.
## Скоуп
Один глобальный переключатель «Показывать окно с информацией при наведении на
комнату» в «Общих настройках», управляющий только room-tooltip в View
(включая kiosk с мышью); hover-подсветка комнаты и все прочие тултипы не
меняются. Новое optional `settings.show_room_tooltip: boolean`, default `true`
материализуется отсутствием ключа.
## Как проверялось
Ревью ТЗ на этапе `spec` не запускает автотесты/гейты (кода ещё нет) — задача
ревьюера здесь одна: убедиться, что каждое AC однозначно, доказуемо, и что
факты о текущем коде, на которые опирается контракт, не являются
не помеченной догадкой. Поэтому весь разбор — очный аудит соответствия текста
ТЗ реальному состоянию репозитория `587c2069`:
1. `docs/SCOPE.md`, `PROCESS.md`, `AGENTS.md` — трек, обязательные разделы
§7.1, класс изменений, шаблон вердикта.
2. Тело issue #426 и оба комментария (аналитика, занятие, готовность ТЗ) —
сверка с тем, что владелец зафиксировал сам (место настройки, точная
формулировка, default) против того, что ТЗ выдаёт за решённое.
3. `docs/USER-GUIDE.ru.md` (строка 203, 1871) — терминология «подсказка»/
«наведение», состав room-tooltip (название, чистая площадь, температура,
влажность, LQI) — совпадает с описанием AC4.
4. `docs/UX-MODES.md`, `docs/TOUCH-SUPPORT.md` (pointer modality, hover
ownership) — независимость room hover-подсветки от tooltip и контракт
touch/pen уже существуют и не меняются этим ТЗ.
5. Построчная проверка каждого фактического утверждения ТЗ о текущем коде:
- `src/houseplan-card.ts:11578-11589` — локальный `tip`-замыкание на
`pointermove` комнаты, guard `if (this._mode !== 'view') return;`,
безусловный вызов `_showTip()` — подтверждает раздел «Проблема».
- `src/houseplan-card.ts:7282-7294` — `_showTip()` общий для комнаты и
устройства, проверяет `hoverEnabled`/`drag`, ничего не знает про
room-specific флаг — подтверждает риск «не трогать общий guard».
- `src/houseplan-card.ts:1032, 9542, 9659, 9668, 11626-11677` — `_hoverRoom`
и `room-hover-fill-layer`/`outline-layer` независимы от `tip`,
устанавливаются на `pointerenter` — подтверждает AC5.
- `src/houseplan-card.ts:12463-12472` — device tooltip гейтится
`_mode !== 'view' && _mode !== 'devices'`, использует тот же `_showTip()`
— подтверждает «Device tooltip... в View и Редакторе устройств».
- `src/houseplan-editor-runtime.ts:9023-9039, 10213-10253` —
`_openSettingsDialog`/`_saveSettingsDialog` уже хранят `sunRays` по схеме
«явный `true` пишется, falsy удаляется» через `settings: any = {...cfg.settings}`
— подтверждает, что план для `show_room_tooltip` (обратная схема: default
`true` → удаление ключа, `false` → явная запись) технически реализуем тем
же путём, а не гипотетичен.
- `src/houseplan-editor-runtime.ts:10537-10538` — `gs.hint` действительно
первая строка тела диалога — подтверждает «сразу после вводного текста».
- `src/houseplan-editor-runtime.ts:10622-10626` — `<label class="srcrow">`
оборачивает `_boolInput()` и `<span>` для `sunRays` — подтверждает
«доступное имя из видимой строки» как существующий, а не изобретённый
паттерн.
- `custom_components/houseplan/validation.py:1917-1968` — `CONFIG_SCHEMA.settings`
уже принимает `sun_rays: bool` рядом с `extra=vol.ALLOW_EXTRA` —
подтверждает «explicit boolean для нового известного ключа» как
существующий шаблон.
- `custom_components/houseplan/support_package.py:139-140` — `_global_settings`
— явный allowlist `_copy_keys(...)`, куда `show_room_tooltip` предстоit
добавить — подтверждает claim о privacy-проекции.
- `src/space-card.ts:2` — докстринг «READ-ONLY, static schematic» —
подтверждает «`houseplan-space-card` не меняется».
- `scripts/config-field-registry.mjs:26-29` — `sun_rays` (обычное
экспонированное поле, не compatibility-долг) в реестре не числится, только
`show_all`; отсутствие записи о новом поле в `CONFIG-COMPATIBILITY.md`
реестре — верное решение по прецеденту, не пропуск.
- `src/i18n/{en,de,fr,ru}.json` — ровно четыре синхронных словаря,
подтверждает «четыре» без лишнего/недостающего языка.
6. Дубликаты: `gh issue view` для #79 (closed, регрессия заливки), #154
(closed, touch hover sticking), #196 (closed, содержимое тултипа), #152
(open, но про клик/тап-зум, не про информационное окно) — ни один не
пересекается с «отключить только информационное окно».
Материал полный, дельты нет — это первый заход (r1), раздел «Унаследовано из
r0» не применим.
## Находки
Нет High. Нет Medium. Нет Low.
Все девять AC (AC1–AC9) сформулированы как проверяемые утверждения с указанным
способом доказательства (`unit`/`smoke`/`backend`/`commands`), не пересекаются
по ответственности и в сумме покрывают контракт из разделов 1–3: UI/default,
persistence, tooltip off, default parity, hover/device independence,
pointer/mode parity, compatibility/privacy, i18n/docs/release, гейты/бюджет.
План автотестов называет конкретные мутационные пробы для каждого AC (замена
resolver на `Boolean(value)`, guard внутри общего `_showTip()`, скрытие
`_hoverRoom`, хранение `true` вместо удаления ключа, отсутствие очистки
`.tip` при Save) — то есть тесты, которые способны упасть, а не тавтологичные
проверки. Раздел «Принятые предположения» отделяет то, что решил владелец
(место, формулировка, default), от того, что решает автор технически
(ключ конфига, точное место переключателя в вёрстке диалога, отсутствие
per-space override) — ни одна догадка не выдана за факт без пометки.
Продуктовая рамка (SCOPE.md job J1, View как продукт для двух персон из трёх,
инвариант «не трогать другие интерактивные эффекты комнаты» из тела issue)
соблюдена явно разделами «Скоуп»/«Не-скоуп»/«Контракт поведения» и не
противоречит ни одному пункту `docs/UX-MODES.md`/`docs/TOUCH-SUPPORT.md`.
## Что проверено и корректно
- Обязательные разделы §7.1 присутствуют все, включая оба продуктовых
(сценарий; что человек увидит до/после) первыми.
- AC пронумерованы, у каждого явно назван способ доказательства.
- i18n: ключ `gs.show_room_tooltip`, все четыре словаря, формулировки заданы.
- Совместимость: optional boolean, `ALLOW_EXTRA` уже покрывает старый
backend/frontend в обе стороны, миграции store/model version нет и не
требуется — согласуется с `docs/CONFIG-COMPATIBILITY.md` (эта настройка не
compatibility-долг, а обычное новое поле, как `sun_rays`).
- Откат описан явно и без требования флага Labs (persisted boolean — сам себе
выключатель), с уточнением, что произойдёт при вынужденном отступлении.
Требование `docs/PROCESS.md` §2.5 «откат: как выключить или вернуть назад»
выполнено содержательно, а не формальной строкой.
- Release-артефакты называют оба changelog, User Guide EN/RU,
`UX-MODES.md`/`TOUCH-SUPPORT.md`/`CONFIG-COMPATIBILITY.md`, осведомлённость
о доокументационном скриншотном гейте (`check-docs.mjs`/source fingerprint)
и обоснованный отказ от нового golden-сценария (default View визуально не
меняется, диалог общих настроек не входит в принятые golden-сценарии).
- Риски перечислены с конкретной привязкой к AC, который их снимает —
не общие фразы.
## Чего не проверял
- Существование и корректность самого кода реализации — на этапе `spec` кода
ещё нет по правилу (ТЗ пишется до кода), проверять нечего.
- Гейты `typecheck`/`test`/`build`/`no-new-any`/`check-docs`/backend pytest —
не запускались: это гейты реализации (`S6-in-progress`) и код-ревью
(`S7-code-review`), а не ревью ТЗ. Zeленый Validate на `587c2069`
(https://github.com/Matysh/houseplan-card/actions/runs/33720335555)
проверяет только документационный коммит этого SHA, не имеет отношения к
ненаписанному коду issue #426.
- Мутационные пробы плана автотестов не выполнялись физически (кода нет) —
оценена только их логическая способность различить дефект от корректной
реализации, что и требуется от ревью ТЗ.
## Материал раунда
- Ветка: `issue/426-room-hover-tooltip-toggle`
- SHA: `587c20699c4bc1ada405c5c7f9775d34b2b78a39`
- ТЗ: `docs/specs/426-room-hover-tooltip-toggle.md` (та же ревизия)
## Вердикт
**Зелёный.** Готово к переходу в `S5-ready`. High: 0 · Medium: 0.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/426-room-hover-tooltip-toggle`, коммит `587c20699c4b` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `ed72e6cb924cd5e6167c04ba95a73fc0f0bf649d`
```
git log --all --format='%H %T' | grep ed72e6cb924c
```
- ТЗ `docs/specs/426-room-hover-tooltip-toggle.md`, блоб `35a3ad7ff77cf1597f004b94b0799844b56f29f1`
```
git log --all --find-object=35a3ad7ff77cf1597f004b94b0799844b56f29f1 -- docs/specs/426-room-hover-tooltip-toggle.md
```
+188
View File
@@ -0,0 +1,188 @@
# SPEC-REVIEW-428-r1
Issue: [#428](https://github.com/Matysh/houseplan-card/issues/428) — «Экспорт с
недостающей картинкой декора не импортируется — ImportFailure на весь документ».
Материал: тело issue #428, комментарии (аналитика + автор ТЗ), файл
`docs/specs/428-missing-decor-asset-roundtrip.md` на коммите `85ba7a5f`
(HEAD ветки `issue/428-missing-decor-asset-roundtrip`), исходный контракт
`docs/specs/051-custom-decor-images.md` (AC10/AC11 и раздел «Import/export и
совместимость»), текущий код `custom_components/houseplan/import_export.py`,
существующий тест `tests_backend/test_ha_import_export.py`.
Заход: r1 (первый), правила §2.10 о разборе по дельте не применяются — разбор
полный.
## Скоуп
Полный трек (метка `small` не выставлена; аналитика явно называет нарушенный
критерий §5 — «сложность и риск ≤ 3» не выполнен, изменение затрагивает
fail-closed границу export/import). ТЗ лежит в `docs/specs/`, как и требуется
для не-`small` задачи. Задача — точечное ослабление одной проверки в
`_content_state()`: строка `decor_asset` с `exists_at_export: false` и
`mime: null` должна проходить импорт вместо `ImportFailure("invalid_content")`
на весь документ.
## Как проверялось
Проверка велась состязательно: не поверил на слово авторскому «подтверждено
исполнением» из тела issue, а самостоятельно прочитал код и воспроизвёл вывод.
1. **Первопричина независимо подтверждена чтением кода**, не только текстом
issue/ТЗ:
- `content_manifest()` (`import_export.py:418-479`) — при отсутствии и blob,
и `.json`-sidecar `metadata.get("mime")` пусто, `blob` равен `None`,
`.get(blob.suffix if blob else "")` → `.get("")` → `None`. Значит
`mime: null` в манифесте при `exists_at_export: false` — реальный, не
гипотетический случай.
- `_content_state()` (`import_export.py:1643-1649`) безусловно требует
`declared.get("mime") in {"image/png", "image/jpeg", "image/webp",
"image/svg+xml"}` для *любой* строки `decor_asset`, независимо от
`exists_at_export`. При `mime: null` это всегда `ImportFailure`. Баг
воспроизводится чтением, эквивалентен тому, что показал субагентский
прогон автора.
- Существующий тест `test_issue_51_missing_decor_asset_stays_as_repairable_geometry`
(`tests_backend/test_ha_import_export.py:55-80`) действительно строит
только случай «blob был у источника (`exists_at_export: True`, MIME
известен из sidecar), отсутствует у target» — заявление ТЗ о непокрытом
случае C подтверждено, тест не проверяет `mime: null`.
2. **Источник контракта — не выдумка автора.** Сверил ссылку на ТЗ #51: AC11
(«Import принимает v1/v2, fail-closed проверяет manifest… а после
подтверждения сохраняет missing image placeholder») и раздел «Import/export
и совместимость» (`051-custom-decor-images.md:339-361`) действительно
объявляют missing-at-export легальным восстановимым состоянием с
confirmation + repair-placeholder. Новое ТЗ не придумывает продуктовое
поведение, а восстанавливает уже принятый контракт, который код нарушает.
3. **Проверка регрессионной матрицы (AC5/AC6) на реализуемость.** Строка `row
["exists_at_export"] = declared.get("exists_at_export")` в текущем коде
вообще не проверяет тип поля — значит требование AC5/AC6 «строгий
`type(x) is bool`» — это новая, а не восстанавливаемая проверка;
она согласована с разделом «Риски» (`0`/`1` как под-класс `int`) и не
конфликтует с уже существующими данными: JSON `true/false` парсится
Python'ом только как `bool`, так что регресс для валидных прежних
экспортов исключён.
4. **Использование `mime` вне этой проверки.** Проверил, что декларированный в
манифесте `mime` — не источник истины ни для чего, кроме этой валидации:
`decor_assets.py` определяет и проверяет MIME отдельно, по фактическим
байтам загруженного файла (`_validate_asset`, строки ~312-350), а
`config`-запись decor-объекта вообще не хранит `mime` — только `asset_id`.
Ослабление проверки поля `mime` в манифесте не открывает MIME-confusion:
реальная доступность строки по-прежнему определяется пересчётом SHA-256 по
байтам кандидата на target (`import_export.py:1650-1659`), а не
декларацией источника. Раздел «Безопасность и privacy» ТЗ обоснован, не
декларативен.
5. Проверил соответствие процессу: аналитика правильно называет нарушенный
критерий лёгкого трека; артефакт лежит по правильному пути
`docs/specs/428-missing-decor-asset-roundtrip.md`; `docs/specs/README.md`
получил строку с рабочей ссылкой; коммит `85ba7a5f` несёт `Issue: #428` и
`User-Visible: no` — корректно для docs-only коммита ТЗ.
6. Сверил обязательные разделы §7.1: сценарий, «что человек увидит до/после»
(таблица), проблема («Подтверждённая причина»), скоуп/не-скоуп, контракт
поведения («Контракт manifest и валидации»), совместимость/миграция,
touch/i18n/perf, затронутые файлы, AC1–AC9 с доказательствами, план
автотестов, риски, откат, release-артефакты, принятые предположения — все
присутствуют по содержанию (раздел «UX» не выделен отдельным заголовком, но
его содержание — «нового диалога, текста ошибки или элемента управления
нет» — прямо сказано в тексте; см. находку Low ниже).
## Находки
### Low — формулировка граничного значения `mime: ""` в таблице раздела 2 неполна
Таблица «Допустимые строки при импорте» (раздел «Контракт manifest и
валидации», п.2) описывает ветку `exists_at_export: false` тремя строками:
«поддерживаемая строка» → допустимо; «отсутствует или `null`» → допустимо;
«неподдерживаемая **непустая** строка либо значение другого типа» →
`ImportFailure`. Пустая строка `mime: ""` не входит буквально ни в одну из
трёх формулировок: она не «отсутствует или `null`», но и не «непустая».
Проверил, ломает ли это реализуемость: естественная реализация из кода
(`declared.get("mime") not in SUPPORTED and declared.get("mime") is not
None` при `exists_at_export is False`) отклоняет `""` тем же путём, что и
любую другую неподдерживаемую строку — то есть содержательного разночтения в
поведении нет, реализация детерминирована. Дефект чисто в формулировке
таблицы («непустая» лишнее слово), не в контракте. Снимаю находку как
**Low, не блокирует**: замечание оставлено с записью для точности документа,
править не обязательно, так как план автотестов (п.5, «параметризовать
`exists_at_export` и `mime` по таблице») в любом случае может включить `""`
как один из «unsupported non-null MIME» без противоречия итоговому коду.
## Что проверено и корректно
- Первопричина бага реальна и подтверждена независимо (не только доверием к
тексту автора) — см. «Как проверялось» п.1.
- Контракт-источник (#51 AC11) реален, процитирован точно, новое ТЗ его не
меняет, а восстанавливает.
- Скоуп узкий и не расширяется: не задета `EXPORT_FORMAT_VERSION`,
config/model schema, UI подтверждения, upload/delete/replace, frontend —
всё явно перечислено в «Не-скоуп» и это согласуется с «Затронутые файлы»
(только backend + backend-тесты + доки + changelog).
- Таблица допустимых/недопустимых значений (раздел 2) в остальном
исчерпывающая и корректно закрывает найденный класс уязвимости («risk 1» —
«слишком широкое ослабление manifest») точной формулировкой инвариантов,
которые остаются обязательными (exact `asset_id`/`hash`, identity полей,
повторная проверка target blob по байтам).
- AC1–AC9 пронумерованы, каждый с указанным способом доказательства (backend
/ docs gate / ревью кода / commands + Linux CI), формулировки однозначны,
не пересекаются по ответственности.
- Риски названы предметно (широкое ослабление, `bool`/`int` в Python,
supplied-metadata как authority, helper vs настоящий export/import) и у
каждого явно назван снимающий его механизм в контракте/AC.
- «Принятые предположения» оформлены как предположения, а не факты, и
ревьюер с ними согласен по итогам независимой проверки кода — не
гадание, выданное за решение.
- Откат описан и достаточен (revert коммита, без миграции данных).
- Track/процесс: причина полного трека названа явно (критерий §5 не
выполнен), путь артефактов и трейлер коммита ТЗ соответствуют PROCESS.md.
- Продуктовых вопросов владельцу в ТЗ нет — обоснованно: видимое поведение
уже зафиксировано принятым контрактом #51, разбираемый вопрос был
технический (валидация fail-closed границы) и решён автором, а не вынесен.
## Чего не проверял
- Реализация ещё не написана (стадия ТЗ) — код-ревью, автотесты и прогон
гейтов (`typecheck`/`test`/`build`/backend pytest) не в скоупе этого этапа
и будут выполнены на код-ревью по факту диффа.
- Не проверял golden/smoke/performance — задача не трогает frontend/визуал
(сама ТЗ явно это утверждает и обоснование подтверждено чтением: правки
ограничены `custom_components/houseplan/import_export.py` и бэкенд-тестами).
- Не проверял точный будущий текст правок `docs/USER-GUIDE.md`,
`docs/USER-GUIDE.ru.md`, `docs/CONFIG-COMPATIBILITY.md` и changelog — они
ещё не написаны; AC8 корректно называет их обязательными и привязывает к
тому же `User-Visible: yes` коммиту, этого на этапе ТЗ достаточно.
- Не проверял поведение с `space_id`/single-space export код-путём построчно
за пределами того, что нужно для оценки AC3 (проверил только то, что
`content_manifest()` вызывается на уже спроецированный `config` во всех
трёх режимах, включая `plan_only`, и что `asset_id` не выпадает из
plan-only проекции decor-объекта).
## Вердикт
Зелёный. High: 0. Medium: 0. Единственная находка — Low, снята с запиской
(см. выше), автор ничего чинить не обязан.
## Материал раунда
- SHA материала: `85ba7a5f304f9121815b947c9234c545bfaad65e`
(`origin/issue/428-missing-decor-asset-roundtrip`, идентичен HEAD на момент
ревью).
- Дерево: `docs/specs/428-missing-decor-asset-roundtrip.md`,
`docs/specs/README.md`.
- Поиск при необходимости: `git log --all --format='%H %T' | grep <дерево>`;
`git log --all --find-object=<блоб> -- docs/specs/428-missing-decor-asset-roundtrip.md`.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/428-missing-decor-asset-roundtrip`, коммит `85ba7a5f304f` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `413e5407d2d52959155b011df38118f15fc31c0c`
```
git log --all --format='%H %T' | grep 413e5407d2d5
```
- ТЗ `docs/specs/428-missing-decor-asset-roundtrip.md`, блоб `9271c0a82f4072229c39c408330cb1dfb9ca4fe2`
```
git log --all --find-object=9271c0a82f4072229c39c408330cb1dfb9ca4fe2 -- docs/specs/428-missing-decor-asset-roundtrip.md
```
+180
View File
@@ -0,0 +1,180 @@
# SPEC-REVIEW #431 · r1
Issue: [#431](https://github.com/Matysh/houseplan-card/issues/431) — `kind:'image'` выпал из
канонизации координат.
Документ ТЗ: `docs/specs/431-image-coordinate-canonicalization.md`
Материал: коммит `260af7bfd200bb40beb2323af2ad5232e2686325` (`docs(spec): define image
coordinate canonicalization`, `Issue: #431 · User-Visible: no`), совпадает с `HEAD`.
Трек: полный (не `small`) — причина названа автором в S2-analysis и в ТЗ и подтверждена
ниже.
## Скоуп ревью
Первый заход. Проверено: соответствие продуктовой рамке `docs/SCOPE.md`, полнота
разделов §7.1, однозначность и доказуемость AC1–AC8, отсутствие непомеченных догадок,
корректность трек-классификации (полный vs `small`), трассируемость issue ↔ ТЗ.
## Как проверялось
Ревью читало ТЗ состязательно, без пояснений автора, и сверяло каждое фактическое
утверждение с текущим деревом (не с описанием ТЗ):
- прочитаны `docs/SCOPE.md`, `PROCESS.md` (§1–§10.4), `AGENTS.md` целиком;
- прочитан текст issue #431 и все три комментария (аналитика, занятие, «ТЗ готово»);
- сверены `src/coordinate-canonicalization.ts` (обе точки: `visitLatticeCoordinates`
строка 158 и `canonicalizeConfigGeometryInPlace` строка 338) и
`custom_components/houseplan/coordinate_canonicalization.py` строка 153 — оба
действительно перечисляют `("rect", "ellipse", "furniture")` без `image`, как
заявлено в issue и ТЗ;
- сверен `src/editors/decor/types.ts` — `DecorImage extends DecorBoxBase` с полями
`x/y/w/h/angle` подтверждён, `DecorKind` содержит `'image'`;
- сверена `test/fixtures/coordinate-canonicalization.json` и
`test/coordinate-canonicalization.test.mjs` — фикстура содержит `line/rect/
ellipse/text/furniture`, `image` действительно отсутствует;
- проверено, что `custom_components/houseplan/validation.py:1909` (`CONFIG_SCHEMA`)
вызывает тот же `canonicalize_config_geometry` внутри `vol.All(...)`, то есть
Python не имеет отдельной ветки для схемы — правка одной функции закрывает оба
пути, как утверждает AC3;
- проверено, что все вызовы `canonicalizeConfigGeometry(InPlace)` /
`latticeCanonicalizationReport` во фронтенде (`houseplan-card.ts`,
`houseplan-editor-runtime.ts`, `plan-optimizer.ts`, `wall-segment-model.ts`) идут
через единый модуль — второй путь канонизации, которого ТЗ могло бы не заметить,
отсутствует;
- проверено, что перечисления `rect/ellipse/furniture` в `houseplan-editor-runtime.ts`
и `houseplan-card.ts` относятся к UI-логике редактора (заливка, диалоги), не к
канонизации, и там `image` уже присутствует, где это нужно — подтверждает, что
ТЗ верно провело границу не-скоупа;
- проверено, что `tests_backend/test_coordinate_canonicalization.py` требует
`pytest.importorskip("homeassistant")`, то есть локально без HA-харнесса тихо
скипается — подтверждает, что заявленный в AC4 backend-mutation-gate witness
оправдан правилом §2.7 («дорогой гейт, ревьюер не воспроизведёт отрицательный
прогон второй раз»), а не избыточная предосторожность;
- проверено наличие существующих записей `coordinate_canonicalization.py` /
`coordinate-canonicalization.test.mjs` в `scripts/mutation-gate.mjs` (строки
2276–2430) — механизм добавления нового witness-мутанта в этот файл уже
существует и используется для смежных контрактов, расширение реалистично;
- сверен `docs/CONFIG-COMPATIBILITY.md` (раздел «Custom decor images and export v2
(#51)») — `image` там не упомянут как часть box-контракта канонизации, что
подтверждает необходимость правки AC7;
- сверена трассируемость: коммит `260af7bf` правит и сам файл ТЗ, и
`docs/specs/README.md` (новая строка со ссылкой на issue и файл) в одном
коммите с верными трейлерами.
Гейты не гонялись: класса A/B изменений на этом SHA нет (только `docs/specs/**`,
класс C), а полный `typecheck`/`test`/`build` уже зелёный на этом же SHA
(`https://github.com/Matysh/houseplan-card/actions/runs/33732448117`). Продуктового
кода к ревью нет — оценивать нечего гейтами.
## Проверка трек-классификации
Автор в S2-analysis назвал критерий §5, который задача не проходит: «одна
поверхность (один диалог, один модуль, один эндпоинт)» — нарушен, потому что
исправление обязано синхронно и доказуемо менять TypeScript-модуль и Python-зеркало.
Это соответствует факту: правка действительно охватывает два независимых рантайма на
разных языках с раздельными тестовыми наборами (`test/` и `tests_backend/`) плюс
`scripts/mutation-gate.mjs`. Классификация «полный трек» обоснована корректно, файл
`docs/specs/NN-*.md` создан, как и требуется вне `small`.
## Проверка §7.1
Все обязательные разделы присутствуют по содержанию (частично объединены заголовками,
что не является нарушением — угроза объёма шаблона не в счёт): сценарий; что человек
увидит до/после; проблема («Подтверждённая причина»); скоуп/не-скоуп; контракт
поведения; UX/touch/i18n/производительность; модель данных и миграция
(«Совместимость и миграция»); AC1–AC8 с методом доказательства у каждого; план
автотестов, включая таблицу защитных свидетелей в формате §2.7 (три столбца: AC ·
чем доказан · чем обязан краснеть) — авторское решение оформить её уже на этапе ТЗ
облегчает будущее код-ревью и не требуется, но полезно; риски; откат;
release-артефакты.
## Проверка AC на однозначность и доказуемость
AC1–AC8 пронумерованы, у каждого указан способ доказательства
(unit/backend/mutation/review-code/gates), формулировки конкретны (какие именно
поля, какие функции, какой ожидаемый результат). AC4 и AC5 явно требуют прогона
каждого вида через контракт, а не сравнения списков строк — учтён риск №4,
названный автором самим же («тест проверяет список, но не поведение»). AC6 явно
фиксирует границы не-скоупа (формула, пороги, версии, writer inventory, UI, i18n не
меняются) и требует зелёности регрессионных наборов #224/#248/#291 — это защищает
именно тот класс регрессии, которого стоит опасаться при трогании общего модуля.
## Проверка на непомеченные догадки
Утверждения о поведении (девять знаков после запятой для `angle`, точка отсечения
lattice-шума, что `latticeCanonicalizationReport` считает near-node как
`canonicalized`, что `CONFIG_SCHEMA` пропускает конфиг через `canonicalize_config_
geometry`) все сверены с действующим кодом и не являются догадками — это описание
существующего контракта, который расширяется на новый вид, а не изобретается заново.
Раздел «Принятые предположения» корректно маркирует то, что реально является
техническим решением автора (расположение единого каталога, трактовка `flip_h/
flip_v`, независимость fixture от рантайма). Единственный пункт на грани
продукт/техника — квалификация коммита как `User-Visible: yes` при отсутствии
видимого визуального кадра. Оценка: это не продуктовый вопрос из списка §7.1
(«что человек видит или делает», «объём видимого изменения в issue») — объём
изменения уже зафиксирован issue целиком, а `User-Visible` лишь описывает, что
фикс меняет наблюдаемое поведение конфига (частота ревизий/диффов), что описано в
таблице «Что человек увидит до и после». Решение разумное и не создаёт риска даже
если бы было неверным (худший случай — лишняя запись в changelog, не блокирующая
задачу). Эскалации не требует.
## Находки
Нет ни High, ни Medium, ни Low. Задача демонстрирует образцовую точность: каждое
фактическое утверждение о коде проверяется прямым чтением исходников, границы
скоупа проведены по реальным точкам вызова, а не по предположению, риски названы
автором заранее и закрыты соответствующими AC.
## Что проверено и корректно
- Соответствие `docs/SCOPE.md`: попадает в J6 («Keep the plan true as the home
evolves») — устранение шумовых диффов персистентной геометрии.
- Классификация полного трека обоснована названным критерием §5.
- Все §7.1-разделы присутствуют по содержанию.
- AC1–AC8 однозначны, каждому назначен метод доказательства.
- Защитные AC (AC4) уже содержат таблицу «чем доказан / чем краснеет» с корректным
выбором mutation-gate именно там, где гейт дорогой (backend требует HA).
- Фактические утверждения о коде (номера строк, сигнатуры функций, поведение
`CONFIG_SCHEMA`, состав фикстуры) подтверждены чтением текущего дерева.
- Трассируемость issue ↔ ТЗ ↔ `docs/specs/README.md` в одном коммите с верными
трейлерами (`Issue: #431 · User-Visible: no`, коммит только класса C).
- Продуктовых вопросов владельцу нет, и это верно: контракт box-геометрии уже
зафиксирован #223/#224/#291/#51, добавление `image` — не новое решение, а
возврат к уже принятому контракту.
## Чего не проверял
- Гейты `typecheck`/`test`/`build`/`check-docs` не перегонялись самостоятельно:
на этом SHA нет продуктового кода (только `docs/specs/**`), а зелёный Validate
на этом же SHA уже подтверждён ссылкой в задании ревью.
- Реализация (код, тесты, mutation-gate witness) не существует и не проверялась —
предмет этого этапа только ТЗ.
- Не проверялось поведение редактора/рендера изображений в браузере — вне скоупа
задачи и вне этапа spec.
## Вердикт
Зелёный. ТЗ выполнимо, каждый AC проверяем и снабжён способом доказательства,
догадок под видом фактов не найдено, трек-классификация обоснована.
---
**Материал раунда:** SHA `260af7bfd200bb40beb2323af2ad5232e2686325`, дерево —
рабочая копия на момент ревью, blob ТЗ —
`docs/specs/431-image-coordinate-canonicalization.md` в этом же коммите.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/431-image-coordinate-canonicalization`, коммит `260af7bfd200` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `00fc18d8eda6f680117f99866f8e4063cdbb200c`
```
git log --all --format='%H %T' | grep 00fc18d8eda6
```
- ТЗ `docs/specs/431-image-coordinate-canonicalization.md`, блоб `664b75d910be1fcf8ddf2034daec437b2c05430f`
```
git log --all --find-object=664b75d910be1fcf8ddf2034daec437b2c05430f -- docs/specs/431-image-coordinate-canonicalization.md
```
+194
View File
@@ -0,0 +1,194 @@
# SPEC-REVIEW-432-r1
- Issue: https://github.com/Matysh/houseplan-card/issues/432
- ТЗ: `docs/specs/432-asset-resolve-authorization-cache.md`
- Материал ревью: SHA `17a1c10bef67ecd6235d36e324416e58142f3e11` (HEAD ветки на момент ревью, коммит `docs(spec): define bounded asset resolution`, дерево ветки `issue/432-asset-resolve-authorization-cache`)
- Заход: r1 · блокирующих циклов израсходовано 0 из 4 (лимит для полного трека — 4; лёгкий/короткий трек не применяется, трек полный)
- Вердикт: **зелёный**
## Скоуп ревью
Первый заход ревью ТЗ для issue #432 (security/performance баг: `houseplan/assets/resolve`
без проверки прав и без ограничения стоимости хеширования; тот же дефект стоимости у
`HouseplanContentView.get()`). Аналитика зафиксировала полный трек (два endpoint/модуля,
публичный контракт доступа и стоимость файловых операций меняются — критерии `small`
не выполняются, это явно названо в комментарии аналитики). Владелец ответил на
единственный продуктовый вопрос (Q1: что видит non-admin при `admin_only`) до написания
ТЗ; ТЗ фиксирует принятый Default. Ревью — по `PROCESS.md` §2.4 и §7.1, разбор полный
(первый заход, раздел «Унаследовано» не применяется).
## Как проверялось
1. `docs/SCOPE.md` — сценарий и персоны сверены с J1 (живой обзор), J4 (онбординг/каталог)
и J6 (устойчивость интеграции); особо — «View mode is the product for two of the three
personas», что прямо мотивирует контракт non-admin в ТЗ.
2. `AGENTS.md`, `PROCESS.md` §1, §2.3–2.4, §5, §7.1, §7.2 — формат ТЗ, класс изменений
(класс C, документ, `Issue:#432`/`User-Visible: no` в коммите `17a1c10b` — сверено
`git show --stat`), обязательные разделы, лимит циклов, формат вердикта.
3. Тело issue #432 и все 4 комментария (аналитика, вопрос Q1, решение владельца по Q1,
хендофф ТЗ на ревью) прочитаны целиком.
4. Код на этом SHA прочитан против каждого фактического утверждения ТЗ, не поверх:
- `custom_components/houseplan/websocket_api.py:1127–1161` — `ws_assets_resolve`
подтверждён: нет `_check_write`, нет `_runtime()`, полный `read_catalog(root)` +
`path.read_bytes()` + SHA-256 на совпавшую строку каталога;
- `custom_components/houseplan/http_api.py:157–216` — `HouseplanContentView.get()`
подтверждён: полный `read_bytes()` + SHA-256 на каждый GET `assets`, `immutable`
заголовок не ограничивает повторные запросы;
- `custom_components/houseplan/auth.py:16–31` — `may_write()` подтверждает точную
семантику writer/read-only, которую ТЗ использует в AC1–AC3;
- `custom_components/houseplan/decor_assets.py:353–408` — `asset_refs()`,
`read_catalog()`, `public_asset()` существуют и имеют заявленную сигнатуру;
`asset_refs()` действительно покрывает единственное место использования
`asset_id` в конфиге (перепроверено по `import_export.py`, `validation.py` —
других держателей `asset_id` в config нет);
- `custom_components/houseplan/const.py` — квоты 200 файлов / 256 МиБ / 2 МиБ и
`DECOR_ASSETS_API_VERSION = 1` подтверждены, совпадают с заявленным в ТЗ §6/§10;
- `custom_components/houseplan/store.py:78–92` — `write_lock`/`upload_lock`
существуют на `HouseplanData`, паттерн `async with rt.write_lock` уже используется
для похожего authoritative snapshot в `ws_assets_list` — контракт §7.3 технически
реализуем без изобретения нового примитива.
5. Сверены смежные документы: `docs/specs/051-custom-decor-images.md:323` — оригинальный
контракт `houseplan/assets/resolve` действительно зафиксирован как `authenticated
read` (не writer-only); `docs/specs/131-readonly-cold-start.md` — подтверждает, что
read-only View обязан быть визуально полным, что обосновывает Default-решение по Q1.
`docs/CONFIG-COMPATIBILITY.md:170` — запись про #432 добавлена и указывает на верный
файл ТЗ.
6. Проверено использование `resolveDecorAssets()` (`src/decor-assets.ts`) обеими
поверхностями — `src/houseplan-card.ts` и `src/space-card.ts` — что подтверждает
заявление ТЗ §11 о parity full/space card и наличие существующего frontend unit
теста `test/decor-assets.test.mjs`, на который ТЗ ссылается как на доказательство
для read-only View (AC1 покрывается backend-контрактом + этим тестом, а не новым
frontend-тестом).
7. `scripts/mutation-gate.mjs` — подтверждено, что реестр уже содержит мутанты для
`custom_components/houseplan/websocket_api.py` с backend pytest guard'ами (например,
строки 146–179), то есть план ТЗ §14/AC11 зарегистрировать постоянных свидетелей для
backend-защит — не изобретение нового механизма, а использование существующего.
8. Проверены обязательные разделы §7.1 PROCESS.md построчно (см. таблицу ниже) и
однозначность/доказуемость каждого AC1–AC11.
9. Дешёвые гейты не перегонялись: коммит `17a1c10b` — чистый docs-diff (`docs/specs/
432-asset-resolve-authorization-cache.md` + одна строка в `docs/specs/README.md`),
подтверждено `git show --stat`; Validate на этом SHA зелёный (см. ссылку в задании).
Для документа спецификации без изменений в `src/**`/`custom_components/**/*.py`
`typecheck`/`test`/`build`/`check-docs`/инварианты модели не относятся к предмету
ревью этого этапа — само содержимое ещё не код, а его читаемость и доказуемость.
## Проверка §7.1 (обязательные разделы) и однозначность AC
| Раздел §7.1 | Есть в ТЗ | Где |
|---|---|---|
| Сценарий (персона/поверхность/момент) | ✅ | §1 |
| Что человек увидит до/после | ✅ | §2 |
| Проблема | ✅ | §3, подтверждена кодом (см. выше) |
| Скоуп / не-скоуп | ✅ | §5 / §6 |
| Контракт поведения | ✅ | §7–§10 (доступ WS, GET, cache, ошибки/совместимость) |
| UX | ✅ | §11 — явно «новых контролов, текстов… нет» |
| Модель данных и миграция | ✅ (кратко, по существу — миграции нет) | §10 «Ошибки и совместимость», §20 (cache не persisted) |
| i18n | ✅ | §11 |
| AC1…ACn с доказательством | ✅ | §13, каждый AC помечен способом доказательства (`backend/HA`, `backend/unit`, `review/docs`, `mutation gate`) |
| План автотестов | ✅ | §15, 8 пунктов, включая явный список implementation-гейтов |
| Риски | ✅ | §17, 6 рисков со смягчением |
| Откат | ✅ | §18 |
| Release-артефакты | ✅ | §19 |
Раздел «Модель данных и миграция» не вынесен отдельным заголовком, а распределён между
§10 и §20 — содержательно раздел закрыт (нет schema/capability migration, cache
memory-only и не persisted), структурно это Low, не блокирует (см. «Находки»).
Обязательная по правилу #435 таблица защитных доказательств присутствует (§14),
третий столбец «чем краснеет» заполнен для каждой строки конкретной мутацией и
наблюдаемым эффектом — не общей фразой.
## Проверка отсутствия непомеченных догадок
Каждое фактическое утверждение о текущем поведении кода в ТЗ (§3, §7.1, §9.3, ссылки на
`may_write`, `asset_refs`, `read_catalog`, `write_lock`, квоты, capability-версию,
контракт #51 «authenticated read», обязательность read-only View по #131) сверено с
реальным кодом/документами выше и подтвердилось. Технические решения, для которых
однозначного prior art нет (например, точный состав cache signature `size + mtime_ns +
ctime_ns`, выбор между fail-dark и одной повторной попыткой, место хранения cache —
`hass.data` либо runtime-сервис), явно вынесены в §20 «Принятые технические
предположения» с пометкой «ревьюер вправе оспорить» — ни одно не выдано за факт.
Продуктовый вопрос (Q1) задан владельцу отдельно и заранее, до написания ТЗ, что и
требует правило «не бывает сложной задачи без единого открытого вопроса» — вопрос был,
он закрыт до этапа ревью, что для ревью ТЗ корректно (открытых продуктовых вопросов
к моменту сдачи ТЗ быть не должно).
## Находки
Нет находок уровня High или Medium.
**Low (не блокирует, зафиксировано без правки).**
1. Раздел «модель данных и миграция» из обязательного списка §7.1 PROCESS.md не выделен
отдельным заголовком, а распределён по §10/§20. Содержание присутствует и
исчерпывающее (нет миграции, cache не persisted), поэтому это вопрос структуры
документа, а не пропущенное решение. Снимается без правки: следующий автор того же
ТЗ увидит прецедент, что содержание важнее буквального оглавления, когда факт «нет
миграции» явно закрыт в другом месте того же документа.
## Что проверено и корректно
- Полная grounding-проверка технических утверждений ТЗ против фактического кода
(`websocket_api.py`, `http_api.py`, `auth.py`, `decor_assets.py`, `const.py`,
`store.py`) — расхождений не найдено.
- Product-рамка: сценарий и «что человек увидит» отвечают на оба обязательных
продуктовых вопроса, персона и поверхность названы, соответствие J1/J4/J6 по
`docs/SCOPE.md` подтверждено, включая явную ссылку на инвариант read-only View (#131).
- Решение владельца по Q1 корректно перенесено в контракт (§4, §7.3) без искажения:
read-only видит только referenced-assets, writer — полный каталог, GET не меняется.
- AC1–AC11 однозначны, у каждого назван способ доказательства; для защитных AC (AC2,
AC3, AC5, AC6, AC7, AC9) заполнена обязательная по #435 таблица «чем доказан / чем
краснеет» с конкретной мутацией, а не общей фразой.
- Не-скоуп (§6) корректно отделяет эту задачу от смежных: quota/upload-валидация,
writer-only GET, config schema migration, frontend/i18n, общий cache для других
типов файлов — явно исключены и не проросли в контракт.
- Откат (§18) явно запрещает «тихо» отключать security/performance защиту через флаг —
соответствует духу standing rule о необратимых действиях.
- Release-артефакты (§19) требуют оба changelog, обновление ARCHITECTURE.md и
CONFIG-COMPATIBILITY.md, что уже подтверждено записью в README ТЗ на этом SHA.
- Трейлеры коммита `17a1c10b` (`Issue: #432`, `User-Visible: no`) корректны для
docs-only спецификации; `docs/specs/README.md` содержит обратную ссылку на issue.
## Чего не проверял
- Реализацию — код ещё не написан, это этап ревью ТЗ, не код-ревью.
- Полный набор гейтов (`typecheck`/`test`/`build`/`golden`/backend pytest/browser
smoke/`model-invariants`) — не относится к предмету этого этапа: диапазон материала
этого раунда — только `docs/specs/432-*.md` и тело issue, изменений в `src/**` или
`custom_components/**/*.py` в этом коммите нет. Дешёвые гейты на SHA `17a1c10b`
подтверждены зелёным Validate (ссылка в задании), поэтому не перегонялись повторно.
- Осуществимость точной реализации bounded single-flight (потокобезопасность между
executor-потоками HA) — это техническое решение, оставленное автору по правилу §7.1
PROCESS.md («всё, чего пользователь не наблюдает, агенты решают сами»); будет
предметом код-ревью через AC6 и его mutation witness.
- Полноту `scripts/mutation-gate.mjs` записей для AC2/AC5/AC6/AC7 — их ещё нет (ТЗ
только планирует их появление в §14/AC11), поэтому проверять на этом этапе нечего;
это станет предметом код-ревью.
## Материал раунда
- SHA: `17a1c10bef67ecd6235d36e324416e58142f3e11`
- Дерево: `docs/specs/432-asset-resolve-authorization-cache.md`,
`docs/specs/README.md` (запись про #432)
- Ветка: `issue/432-asset-resolve-authorization-cache`
- Первый заход — раздел «Унаследовано из r0» не применяется.
## Вердикт
Вердикт: зелёный · заход r1 · блокирующих циклов 0/4 · High: 0 · Medium: 0 → в задаче
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/432-asset-resolve-authorization-cache`, коммит `17a1c10bef67` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `efc2aa18b50775a262a444d5ea1544c588646eba`
```
git log --all --format='%H %T' | grep efc2aa18b507
```
- ТЗ `docs/specs/432-asset-resolve-authorization-cache.md`, блоб `8593bd54ad7d7e8a4d6459949fbb960c34ccddc9`
```
git log --all --find-object=8593bd54ad7d7e8a4d6459949fbb960c34ccddc9 -- docs/specs/432-asset-resolve-authorization-cache.md
```
+186
View File
@@ -0,0 +1,186 @@
# SPEC-REVIEW-434-r1
- Issue: https://github.com/Matysh/houseplan-card/issues/434
- Этап: ревью ТЗ (PROCESS.md §2.4)
- ТЗ: `docs/specs/434-v171-polish-audit.md`, проверяемый SHA автора `5566d6f9898e3c6d21f3e92a2ddf94536c86edc4`
- Заход: r1 · блокирующих циклов израсходовано 0 из 4 (полный трек, лимит 4)
- Вердикт: **жёлтый**
## Скоуп
Follow-up к аудиту v1.71.0-beta.1 (`AUDIT-2026-09-03-v1710beta1.md` §3.3): девять
независимых мелких дефектов на нескольких поверхностях — физический учёт/удаление
orphan decor-blob'ов, capability guard в `houseplan-space-card`, revision-scoped
resolve cache, честный `reused`, актуальность locale gate в danger confirmation и
симметричная отмена, отрицательный свидетель Area-snapshot cleanup, bounded
smoke-выполнение (per-route и per-file timeout), отзыв support preview token.
Маршрут — полный (обоснование в ТЗ: несколько независимых контрактов, хранение
пользовательских файлов, rolling compatibility, асинхронный safety lifecycle,
несколько независимых гейтов — сложность выше лимита `small`); обоснование
корректно, критерий лёгкого трека действительно не проходит.
## Как проверялось
Прочитаны: `docs/SCOPE.md`, `AGENTS.md`, `PROCESS.md` §1–§8, §12; тело issue #434
и оба комментария (аналитика + хендофф автора); ТЗ целиком (511 строк);
`docs/CONFIG-COMPATIBILITY.md`, `docs/ARCHITECTURE.md` (разделы про
content-addressed decor store), `docs/SUPPORT-PRIVACY.md`; связанные issue #417,
#419, #432 (включая финальный код-ревью #432 для контекста integrity-кэша) и
issue #435 (правило «таблица чем краснеет»).
Поскольку ТЗ на 90% состоит из утверждений о **текущем** поведении кода
(«Подтверждённые причины»), а не только из предложений на будущее, каждое из
девяти утверждений было сверено построчно с `origin/dev` — своей и параллельным
агентом (независимая перепроверка, без пересечения выводов до сравнения):
| № | Утверждение ТЗ | Файл:строка на `origin/dev` | Результат |
|---|---|---|---|
| 1 | `read_catalog()` обходит только `*.json`, `_read_catalog_row()` требует `blob.is_file()` | `decor_assets.py:407`, `:388` | подтверждено дословно |
| 2 | `HpConfigSnapshot` не переносит `decor_assets_api`; `space-card.ts` вызывает resolve безусловно; `houseplan-card.ts:4306` — с гардом | `config-store.ts:21-29` (нет поля); `space-card.ts:732`; `houseplan-card.ts:4306-4312` | подтверждено дословно |
| 3 | `resolveCache` — одна пара id-set→Map на connection, без ревизии config | `decor-assets.ts:23,88-99` | подтверждено дословно |
| 4 | `test_catalog_ignores_missing_or_malformed_sidecars` не проверяет «валидный sidecar, blob отсутствует» | `tests_backend/test_decor_assets.py:318-325` | подтверждено, кейс в файле отсутствует |
| 5 | Recovery-ветка возвращает `reused:true` без предшествующей catalog-записи | `http_api.py:305-316` (условие `if blob.exists()` → `return row, True`) | подтверждено; фактический `return` на 5 строк ниже цитируемого диапазона — не искажает смысл |
| 6 | `_dangerConfirmLocaleGate` — снимок прошлого рендера; открытый confirm не отменяется при переходе в `warm` | `houseplan-card.ts:2161` (поле), `:2183` (чтение), `:11228` (запись только в `_renderBody()`); ни один из `_cancelDangerConfirm()` call-sites (`:1607,2794,4188,7385,7446`) не привязан к смене locale gate | подтверждено; номера строк ТЗ приблизительные (±1–3), сама механика верна и явно помечена в ТЗ как ориентировочная |
| 7 | `snapshotBindings.has(binding)` в `resolveAreaSnapshotCleanup()` не имеет отдельного отрицательного теста | `device-area-relocation.ts:171`; `test/device-area-relocation.test.mjs` | подтверждено, все существующие тесты либо берут binding из того же snapshot, либо используют пустой снапшот |
| 8 | `germanStarted`/`germanCompleted` асимметричны; отдельный smoke-файл ограничен лишь job-таймаутом | `demo/smoke_danger_confirm_branches.mjs:79-84` — подтверждено дословно | **см. находку ниже** — цифра «20 минут» в самой ТЗ неверна |
| 9 | `_buildSupportPreview()` бросает `support_rejected` до `_discardSupportPreview()` при валидном token, но невалидном другом поле | `houseplan-editor-runtime.ts:9190-9199` (throw), `:9226-9233` (catch без discard) | подтверждено дословно |
Дополнительно проверено: `docs/specs/README.md` — двусторонняя ссылка issue ↔ ТЗ
на месте (строка 183); `scripts/mutation-gate.mjs` уже содержит мутанты для
Python-файлов бэкенда (прецедент для AC1–AC4); `test/validate-workflow.test.mjs`
— прецедент text-based контрактного теста над `.github/workflows/*.yml` (годится
для AC9); `docs/CONFIG-COMPATIBILITY.md` раздел «Custom decor images…» подтверждает,
что #432 не менял схему/URL/capability — согласуется с разделом ТЗ «Модель
данных»; `docs/ARCHITECTURE.md` подтверждает content-addressed модель
(`<64 hex>.<ext>`, sidecar JSON), на которой строится вся глава AC1–AC4; `reused`
нигде не читается в `src/**` — уточнение его семантики действительно не является
изменением публичного/видимого контракта.
## Находки
### [Medium, в скоупе] Неверная цифра «20-минутный timeout» job `smoke` — фактическая ошибка, а не предположение
**Где:** ТЗ, «Подтверждённые причины» п.8; раздел «Контракт поведения» §7
(«Глобальный `timeout-minutes: 20`, детерминированное разбиение… не меняются»);
раздел «Производительность» («не уменьшает 20-минутный общий бюджет job»);
раздел «Риски» («сохраняет глобальные 20 минут»).
**В чём дефект:** в `.github/workflows/validate.yml` `timeout-minutes: 20`
принадлежит **другой** job — `performance_smoke` (строка 715). Job `smoke`
(объявлена на строке 486, шаг цикла `for f in demo/smoke_*.mjs` — строка 554)
**не имеет собственного `timeout-minutes` вообще** — в файле ровно одно
вхождение слова `timeout`, и это не она. Без явного значения GitHub Actions
использует дефолт 360 минут, а не 20. Сам issue #434 в исходной формулировке
пункта (з) написан точно: «у smoke-job в `validate.yml` своего
`timeout-minutes` тоже нет» — то есть корректный факт был в issue, а при
переносе в ТЗ он превратился в конкретную (неверную) цифру, не помеченную как
предположение.
**Почему это находка, а не мелочь:** это утверждение — не проходной
комментарий, а часть контракта, который ТЗ прямо объявляет неизменным
(«не меняются», «не уменьшает»). Реализатор, доверяющий тексту ТЗ, будет
считать, что job уже ограничена 20 минутами, и не задаст себе вопрос, нужно ли
явно выставить `timeout-minutes` на job `smoke` в рамках этой же задачи (item
8/AC9 «bounded execution» — ровно про то, чтобы ни один smoke не мог удерживать
раннер бесконечно). Сейчас после фикса по-прежнему не будет верхней границы на
уровне job — только на уровне отдельного файла (180 c + 10 c kill grace,
максимум ~63 файла), что для 3 шардов и текущего числа смоков даёт время
исполнения, которое ТЗ не оценивает и не ограничивает.
**Воспроизведение:** `grep -n timeout .github/workflows/validate.yml` →
единственное совпадение на строке 715 внутри job `performance_smoke` (строки
705–781); job `smoke` — строки 486–586, `timeout-minutes` в её теле нет.
**Что нужно исправить:** либо (a) явно написать в ТЗ, что у job `smoke`
сейчас нет собственного ограничения (дефолт GitHub 360 минут), и explicit
решить/зафиксировать — фиксируется ли `timeout-minutes` на уровне job этой же
задачей, либо остаётся полагаться только на per-file guard; либо (b) если
решение «job-level timeout не трогаем» осознанное — убрать из «Контракта» и
«Рисков» формулировки, которые ссылаются на несуществующие «текущие 20 минут»
как на неизменную величину. Это техническое решение (§7.1: «где хранится
состояние» — техническое, «что видит пользователь» — нет), поэтому чинится
автором ТЗ без обращения к владельцу.
Без High-находок это жёлтый вердикт: находка в скоупе задачи (это тот же раздел
7/AC9, который задача и меняет), правится в этом же ТЗ, повторный цикл — код не
пишется до зелёного ревью ТЗ.
## Что проверено и корректно
- Обязательные разделы §7.1 присутствуют все: сценарий, что человек увидит
до/после, проблема («Подтверждённые причины»), скоуп и не-скоуп, контракт
поведения (8 подпунктов), UX/accessibility/touch/kiosk/i18n, модель данных и
совместимость, критерии приёмки AC1–AC12 с указанием способа доказательства,
план автотестов (10 шагов), риски, откат, release-артефакты.
- Продуктовые первые два раздела отвечают на оба обязательных вопроса:
персона/поверхность/момент (Home admin, оба редактора и обе карточки,
штатная эксплуатация + редкий аварийный останов HA) и что видно до/после —
без терминов реализации.
- Из девяти пунктов «Подтверждённые причины» восемь с половиной проверены
дословно точным построчным совпадением с `origin/dev` (см. таблицу выше);
ни одна из них не оказалась догадкой, выданной за факт. Автор явно и честно
пометил номера строк как ориентировочные там, где они действительно немного
разошлись (п.6), и отдельным блоком «Принятые технические предположения» —
все решения, которые не являются продуктовыми и не требуют владельца.
- Таблица «чем краснеет» (#435) заполнена для всех десяти AC без пустых
третьих столбцов; для AC5/AC6 (чистые фронтенд-юниты) корректно применена
льгота §2.7 «для чистых юнитов достаточно прогона со снятой защитой» вместо
обязательного постоянного мутанта — автор явно прочитал и применил именно
эту оговорку, а не общее правило.
- Не-скоуп корректно исключает смежные, но более крупные работы: полный
рефакторинг LanguageRuntime/support pipeline/smoke-шардирования, повышение
`decor_assets_api`/schema/export version, изменение видимого текста/UI.
Это не даёт задаче расползтись за пределы девяти найденных дефектов.
- Изменение семантики `reused` не является изменением видимого/публичного
контракта: поле нигде не читается в `src/**` (проверено grep), так что
уточнение не требует продуктового решения владельца и не ломает
`docs/CONFIG-COMPATIBILITY.md`.
- Явное удаление orphan-blob'ов (AC3) не противоречит standing rule
`docs/SCOPE.md` «никогда не удалять файл по предположению»: причиной
остаётся явный вызов `houseplan/assets/delete` с точным asset id, а не
вывод из отсутствия ссылок — ТЗ прямо проговаривает это соответствие.
- AC1–AC12 однозначны и у каждого назван способ доказательства
(`backend/unit`, `backend/HA`, `unit/smoke`, `browser smoke`,
`unit/CI contract`, `review/docs`, `gates`); ни один не описывает решение
расплывчато настолько, чтобы реализация могла разойтись с намерением.
- Технический прецедент для новых механик подтверждён по репозиторию:
мутанты для Python-файлов уже есть в `scripts/mutation-gate.mjs` (AC1–AC4
реализуемы тем же способом); `test/validate-workflow.test.mjs` — рабочий
образец YAML-контрактного теста без внешней зависимости (годится для AC9).
- Раздел «Откат» корректно называет границы: миграции нет, восстановленные
sidecar остаются обычными валидными записями и безопасны при откате.
## Чего не проверял
- Не проверялся сам код реализации — его ещё нет, это ревью ТЗ, не код-ревью;
гейты (`typecheck`/`test`/`build`/backend pytest) не запускались, так как
диапазон `origin/dev...HEAD` для этой ветки — это документация (только
ТЗ и README, класс C), продуктовый код не менялся.
- Не проверялась точность построчных ссылок за пределами девяти утверждений
из «Подтверждённые причины» (например, конкретные номера в «Затронутые
модули») — они не заявлены как проверяемые факты, а как ожидаемый список
файлов, ТЗ прямо говорит «выделение чистых helpers допустимо».
- Не оценивалось время исполнения per-file timeout (180 c × число смоков ×
3 шарда) относительно фактического суммарного бюджета CI — сама находка
выше означает, что этот бюджет ТЗ пока не называет корректно; оценка того,
сколько это должно быть в минутах, — предмет исправления ТЗ, не этого
ревью.
- Не проверялся код `#432` дальше, чем нужно для контекста (не переисследовал
его собственное код-ревью по существу — оно уже принято зелёным на своём
цикле).
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/434-v171-polish-audit`, коммит `5566d6f9898e` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `1e1861969610794ffa6a9d8458b52ef3695496b8`
```
git log --all --format='%H %T' | grep 1e1861969610
```
- ТЗ `docs/specs/434-v171-polish-audit.md`, блоб `e0229c0cd6550a1c44b978335c52bd2b264b1c52`
```
git log --all --find-object=e0229c0cd6550a1c44b978335c52bd2b264b1c52 -- docs/specs/434-v171-polish-audit.md
```
+182
View File
@@ -0,0 +1,182 @@
# SPEC-REVIEW-434-r2
- Issue: https://github.com/Matysh/houseplan-card/issues/434
- Этап: ревью ТЗ (PROCESS.md §2.4)
- ТЗ: `docs/specs/434-v171-polish-audit.md`
- Проверяемый SHA: `904a989af70481eb7f34c1e3cfc99de115ba869f` (= `git rev-parse HEAD`,
совпадает с SHA, названным автором в хендоффе r2)
- Заход: r2 · блокирующих циклов израсходовано 1 из 4 (полный трек, лимит 4)
- Вердикт: **зелёный**
## Скоуп раунда
Повторный раунд по §2.9/2.10: предмет разбора — дельта ТЗ между материалом r1
(`5566d6f9898e3c6d21f3e92a2ddf94536c86edc4`) и текущим `904a989a`, а не задача
целиком. Дельта проверена командой:
```
git diff 5566d6f9898e3c6d21f3e92a2ddf94536c86edc4..904a989af70481eb7f34c1e3cfc99de115ba869f -- docs/specs/434-v171-polish-audit.md
```
`git diff --stat` на этом же диапазоне подтверждает, что дельта не касается
ничего, кроме файла ТЗ, — второй изменённый файл в диапазоне,
`docs/reviews/SPEC-REVIEW-434-r1.md`, это публикация документа предыдущего
раунда конвейером (класс C, не предмет разбора).
Дельта локальна: она правит ровно одну тему — контракт job-level
`timeout-minutes` для job `smoke` (пункт 8 / AC9 / раздел 7.7 / «Производительность»
/ «Риски» / таблица «чем краснеет» / тест-план / «Принятые предположения»).
Условия «разбор остаётся полным» (ребейз на ушедший вперёд `dev`, смена
контракта поведения, новая подсистема, объём дельты сопоставим с исходной
задачей) не выполнены: ни ребейза, ни новой подсистемы, дельта — 40 строк
правки внутри уже существующего AC9, без изменения продуктового контракта
(изменение чисто CI/gate, не видимое пользователю). Сокращённый объём разбора
обоснован.
## Закрытие раунда r1
| Находка r1 | Чем закрыта | Где это видно |
|---|---|---|
| [Medium, в скоупе] ТЗ утверждало неизменный `timeout-minutes: 20` у job `smoke`, хотя эти 20 минут принадлежат `performance_smoke`, а у `smoke` собственного лимита нет вовсе (дефолт GitHub 360 мин) | Текст ТЗ переписан по всем перечисленным в находке местам: п.8 «Подтверждённые причины», раздел 7.7 «Bounded smoke execution», «Производительность», «Риски», AC9, «чем краснеет» (AC9), тест-план (шаг 8), «Принятые технические предположения». Факт теперь сформулирован верно, и **в скоуп задачи добавлено решение**: job `smoke` получает собственный `timeout-minutes: 20` — новая независимая граница, отдельная от `performance_smoke` | `docs/specs/434-v171-polish-audit.md:73-76` («сама job `smoke` не имеет `timeout-minutes` и потому наследует 360-минутный default… Единственные 20 минут в workflow относятся к другой job — `performance_smoke`»); `:247-250` («Job `smoke` получает собственный `timeout-minutes: 20`: это отдельная граница, которой сейчас нет…»); `:384-389` (AC9 текст); `:420` (таблица «чем краснеет», мутация «удаляет `smoke.timeout-minutes`»); `:446-448` (тест-план п.8); `:470-472` (риски); `:517-519` (принятые предположения) |
Факт перепроверен независимо от заявления автора: `grep -n timeout
.github/workflows/validate.yml` на актуальном HEAD даёт ровно одно
совпадение — строка 715, job `performance_smoke` (заголовок job на строке 705,
`timeout-minutes: 20` на 715). Job `smoke` (заголовок строка 486, цикл
`for f in demo/smoke_*.mjs` строка 554) действительно не содержит
`timeout-minutes` нигде в своём теле. Формулировка ТЗ после правки точна.
## Проверка дельты по существу (не только «текст поменялся»)
Так как дельта меняет именно AC9 (появляется новое обязательство — job-level
timeout), доказательство которого дельта задевает, AC9 разобран заново, а не
унаследован:
- **Согласованность формулировок.** Все шесть затронутых мест (см. таблицу
выше) описывают одну и ту же границу одинаково: 20 минут, независимая от
`performance_smoke`, покрывает весь shard «при системном зависании либо
серии отдельных отказов» (`:470-472`), а не заменяет per-file timeout.
Противоречий между разделами не найдено.
- **Реалистичность значения 20 минут.** Комментарий в самом
`.github/workflows/validate.yml:492-494` фиксирует, что последовательный
прогон всех смоков занимал ~7.5 минут до шардирования на 3 части, то есть
штатный шард сейчас укладывается в ~2.5 минуты — у 20-минутного лимита
восьмикратный запас на холодный раннер, сопоставимый с обоснованием того же
значения у `performance_smoke` (`:711-713`, «15 минут не хватало... холодный
кэш»). Патологический сценарий, где job-level timeout прервёт shard до того,
как последовательно доработают все per-file timeout (215 файлов / 3 шарда
≈ 72 на шард × 190 c ≈ 228 минут в предельном случае «все зависли») —
ТЗ его не скрывает, а называет прямо как ожидаемое поведение защиты «при
серии отдельных отказов», а не как гарантию довести до конца каждый файл.
Технической ошибки или недосказанности здесь нет.
- **Проверяемость AC9 после правки.** «Чем краснеет» (`:420`) называет два
независимых свидетеля — мутацию per-file wrapper (`plain node "$f"`) и
отдельно мутацию `smoke.timeout-minutes` (удаление поля) — и требует, чтобы
workflow-контрактный тест реагировал на оба независимо
(«не находит одну из двух независимых границ»). Это не создаёт пустого
столбца и не путает test-time симуляцию (per-file probe) со статическим
YAML-контрактом (job-level timeout, аналог которому — `test/validate-workflow.test.mjs`,
подтверждённый как рабочий прецедент ещё в r1). Инженерно реализуемо тем же
способом, что и остальной AC9.
- **Затронутые модули учитывают файл workflow.** `docs/specs/434-v171-polish-audit.md:339-340`
прямо называет `.github/workflows/validate.yml` в списке затронутых модулей —
правка AC9 не «повисает» без соответствующего файла в скоупе.
Новых дефектов дельта не вносит.
## Унаследовано из r1
Всё, что не задето дельтой, принимается без повторной проверки — документ
`docs/reviews/SPEC-REVIEW-434-r1.md` (материал: ветка `issue/434-v171-polish-audit`,
коммит `5566d6f9898e`, дерево `1e1861969610794ffa6a9d8458b52ef3695496b8`,
блоб ТЗ `e0229c0cd6550a1c44b978335c52bd2b264b1c52`):
- Обязательные разделы §7.1 присутствуют полностью; продуктовые «Сценарий» и
«Что человек увидит до и после» отвечают на оба обязательных вопроса
(персона/поверхность/момент; видимое изменение без терминов реализации) —
и эти разделы дельтой r2 не тронуты (см. diff — правки только в §7.7,
«Производительность», «Риски», AC9, таблице и тест-плане).
- Восемь из девяти пунктов «Подтверждённые причины» (все, кроме п.8, который
и есть предмет дельты) построчно сверены с `origin/dev` двумя независимыми
агентами и подтверждены дословно, включая: `read_catalog`/`blob.is_file()`
(п.1), отсутствие capability-гарда в `space-card.ts:732` (п.2), кэш по
id-set без ревизии config (п.3), отсутствующий негативный тест «sidecar без
blob» (п.4), `reused:true` без catalog-записи (п.5), locale gate снимок
прошлого рендера (п.6, номера строк помечены как приблизительные), отсутствие
отдельного witness у `snapshotBindings.has(binding)` (п.7), throw до discard
в support preview (п.9).
- AC1–AC8, AC10–AC12 однозначны, способ доказательства назван для каждого;
таблица «чем краснеет» (#435) заполнена без пустых столбцов для всех, кроме
AC9, которую r2 перепроверил заново (см. выше).
- Маршрут (full) обоснован верно — критерий лёгкого трека не проходит из-за
нескольких независимых поверхностей и подсистем.
- Не-скоуп корректно исключает соседние более крупные рефакторинги
(LanguageRuntime/support pipeline/шардирование, изменения schema/API version,
видимого текста/UI).
- Изменение семантики `reused` не является изменением видимого контракта —
поле нигде не читается в `src/**`.
- AC3 (удаление orphan-блобов) не противоречит правилу SCOPE.md «никогда не
удалять файл по предположению»: причина удаления — явный вызов
`houseplan/assets/delete` с точным id, а не вывод из отсутствия ссылок.
- Раздел «Откат» корректно называет границы (миграции нет, восстановленные
sidecar остаются валидными записями); эти строки дельтой не менялись.
- Технические прецеденты (`scripts/mutation-gate.mjs` уже содержит Python-мутанты,
`test/validate-workflow.test.mjs` — рабочий образец YAML-контрактного теста)
подтверждены на `origin/dev` в r1 и остаются в силе — AC9 после правки
использует тот же прецедент.
## Что проверено в r2 (сверх наследования)
- Дельта ТЗ (`docs/specs/434-v171-polish-audit.md`, r1→r2) построчно прочитана
целиком.
- Факт из находки r1 перепроверен заново на актуальном HEAD:
`grep -n timeout .github/workflows/validate.yml` (1 совпадение, строка 715,
job `performance_smoke`); контекст job `smoke` (строки 486–586) и
`performance_smoke` (строки 705–720) прочитан целиком, `timeout-minutes` в
теле `smoke` действительно отсутствует.
- Реалистичность выбранного значения 20 минут сверена с комментарием в самом
workflow-файле (фактическое время шардированного прогона) и числом реальных
smoke-файлов (`ls demo/smoke_*.mjs | wc -l` → 215).
- Согласованность формулировки новой границы across всех шести затронутых
разделов ТЗ.
- `git diff --stat` между SHA r1 и r2 — подтверждено, что кроме ТЗ и
публикации документа r1 больше ничего не менялось (продуктовый код не
затронут, разбор кода в этом раунде не требуется).
## Чего не проверял
- Гейты (`typecheck`/`test`/`build`/backend pytest) не запускались: диапазон
правок — документация класса C (ТЗ + документ предыдущего ревью), кода нет.
- Не проверялся сам код реализации — его по-прежнему нет, это ревью ТЗ.
- Разделы, не затронутые дельтой (см. «Унаследовано из r1»), не разбирались
заново по существу — они наследуются от r1 согласно §2.10.
- Не оценивался точный сценарий частичного отказа (например, ровно 6–7
зависших файлов из 72 в шарде) на предмет того, какие именно файлы успеют
отработать до срабатывания job-level timeout — ТЗ прямо не гарантирует
«довести шард до конца при серии отказов», и это осознанно названо
принятым поведением защиты, а не заявленным AC.
## Материал раунда
- Ветка: `issue/434-v171-polish-audit`, SHA `904a989af70481eb7f34c1e3cfc99de115ba869f`.
- Дельта: `git diff 5566d6f9898e3c6d21f3e92a2ddf94536c86edc4..904a989af70481eb7f34c1e3cfc99de115ba869f -- docs/specs/434-v171-polish-audit.md`.
- Предыдущий раунд: `docs/reviews/SPEC-REVIEW-434-r1.md`, материал —
ветка `issue/434-v171-polish-audit`, коммит `5566d6f9898e`, дерево
`1e1861969610794ffa6a9d8458b52ef3695496b8`, блоб ТЗ
`e0229c0cd6550a1c44b978335c52bd2b264b1c52`.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/434-v171-polish-audit`, коммит `904a989af704` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `37d24151e91b534280e7ce0d4a437cd082afdc30`
```
git log --all --format='%H %T' | grep 37d24151e91b
```
- ТЗ `docs/specs/434-v171-polish-audit.md`, блоб `e235fcef2bcd817c39c0c5a9134fd2ec8f6eb2f1`
```
git log --all --find-object=e235fcef2bcd817c39c0c5a9134fd2ec8f6eb2f1 -- docs/specs/434-v171-polish-audit.md
```
+313
View File
@@ -0,0 +1,313 @@
# Issue #426 — отключение информационного окна комнаты при наведении
- **Issue:** https://github.com/Matysh/houseplan-card/issues/426
- **Приоритет / тип:** P2 · feature / polish
- **Область:** Общие настройки, View/киоск, room hover, global config,
backend validation/privacy projection, i18n, документация и QA
- **Связи:** #79 (room hover), #154 (pointer modality), #196 (содержимое
тултипа)
- **Ревизия:** 2 (2026-09-03; размещение editor-only строк уточнено по
результату bundle-budget без изменения поведения)
## Сценарий
Администратор использует насыщенный план, на котором информационное окно
комнаты при движении мыши перекрывает полезную часть сцены или просто не нужно.
В **Общих настройках** он выключает опцию **«Показывать окно с информацией при
наведении на комнату»**. После сохранения комнаты по-прежнему подсвечиваются
при наведении, но окно с названием, площадью, температурой, влажностью и LQI не
появляется. Настройка действует на все пространства этой конфигурации и на
других экранах после загрузки конфигурации.
## Что человек увидит до и после
**До:** mouse-hover комнаты всегда показывает информационное окно; отключить
его отдельно от hover-подсветки нельзя.
**После:** в начале диалога «Общие настройки» есть включённый по умолчанию
переключатель. В выключенном состоянии исчезает только информационное окно
комнаты. Подсветка комнаты, тултипы устройств и все действия остаются прежними.
## Проблема и подтверждение по коду
Комната создаёт локальный обработчик `tip` в `src/houseplan-card.ts`; каждое из
пяти представлений геометрии комнаты (`path`/`polygon`/`rect`) передаёт его в
`@pointermove`. Обработчик в View безусловно вычисляет clean-floor area и
вызывает общий `_showTip()`. Сам `_showTip()` правильно проверяет mouse-hover,
pointer modality и drag, но не различает пользовательское намерение показать
или скрыть room-tooltip.
Hover-подсветка уже имеет независимое состояние `_hoverRoom` и отдельные SVG
слои `_renderRoomHoverFill()` / `_renderRoomHoverOutline()`. Поэтому требование
можно выполнить без отключения подсветки и без изменения геометрии комнаты.
Диалог общих настроек и его сохранение принадлежат lazy
`src/houseplan-editor-runtime.ts`; соответствующего draft/config-поля сейчас
нет.
## Скоуп
- Один глобальный переключатель с заданной владельцем русской строкой в
«Общих настройках» и эквивалентами EN/DE/FR.
- Optional boolean `settings.show_room_tooltip` с default `true` при отсутствии
или невалидном legacy/future значении.
- Отключение только room-tooltip во всех пространствах полного
`houseplan-card`, включая View в kiosk-конфигурации на hover-capable экране.
- Сохранение прежней hover-подсветки комнаты и всех некомнатных тултипов.
- Backend validation, безопасная support-package проекция, compatibility docs,
пользовательские руководства, changelog и тестовый контракт.
- Немедленное исчезновение уже показанного room-tooltip после успешного
сохранения выключенного значения.
## Не-скоуп
- Отключение или изменение hover-подсветки комнаты.
- Отключение тултипов устройств, проёмов, кнопок, Help affordance или иных
поверхностей.
- Изменение состава, порядка строк, позиции или оформления информационного
окна комнаты.
- Перенос настройки на уровень пространства/комнаты, локальное значение на
экран или отдельная настройка для kiosk.
- Изменение touch/pen-контракта: на этих указателях transient hover и сейчас не
появляется.
- Интерактивность `houseplan-space-card`: эта карточка остаётся статической и
уже не имеет hover/tooltips.
- Миграция store/model version или переписывание существующих конфигураций при
чтении.
## Контракт поведения
### 1. Общие настройки
1. Сразу после существующей вводной строки диалога находится обычная строка с
переключателем **«Показывать окно с информацией при наведении на комнату»**.
Новая секция, help-иконка и дополнительное предупреждение не нужны.
2. При открытии диалога значение draft равно `true`, если
`settings.show_room_tooltip` не является точным boolean `false`; точный
`false` открывается выключенным.
3. Переключение меняет только draft. Cancel, Escape и закрытие по правилам
диалога не меняют серверную конфигурацию и текущее поведение.
4. Save с выключенной опцией записывает точный
`settings.show_room_tooltip: false`. Save с включённой опцией удаляет ключ,
материализуя default отсутствием, а не `true`.
5. После успешного Save результат действует сразу, без reload. Ошибка записи
оставляет диалог открытым и не выдаёт несохранённый draft за применённое
значение по существующему контракту общих настроек.
### 2. Room hover в View
1. Единственный resolver читает настройку так: только точный `false` означает
«не показывать»; отсутствие, `null`, строка, число и будущая повреждённая
форма fail-safe проецируются в нынешнее `true`.
2. При effective `true` mouse-hover комнаты побайтово/семантически сохраняет
текущий путь: title, clean-floor area, temperature, humidity, LQI, позиция и
обновление вслед за указателем не меняются.
3. При effective `false` pointer enter/move по комнате не создаёт и не рисует
`.tip`, а также не выполняет отложенное вычисление `_roomArea()` только ради
скрытого окна.
4. Независимый `_hoverRoom` продолжает устанавливаться на pointer enter;
`room-hover-fill-layer` и `room-hover-outline-layer` остаются видимыми по
прежним правилам. Pointer leave и все lifecycle/mode/space очистки transient
hover не меняются.
5. Device tooltip продолжает использовать общий `_showTip()` в View и
Редакторе устройств независимо от `show_room_tooltip`. Настройка не может
стать глобальным запретом `.tip`.
6. Успешное сохранение `false` очищает возможное текущее room-tooltip. Повторное
включение не создаёт окно само: оно появляется при следующем настоящем
mouse move над комнатой.
### 3. Режимы и указатели
- Контракт применяется только к комнате в View. Plan, Devices и Background не
получают нового hover-поведения.
- На hover-capable desktop и в kiosk с мышью используется одно global значение.
- Touch/pen и compatibility mouse events продолжают подавляться
`PointerModalityController` независимо от настройки.
- Pan, pinch, drag, click/tap, room-card link, device actions и keyboard paths
не меняются.
## UX и i18n
Добавить ключ `gs.show_room_tooltip` во все четыре синхронизированных lazy
editor-словаря `src/i18n/support/{en,ru,de,fr}.json`. Существующую editor-only
строку `gs.hint` перенести туда же, чтобы новый control не увеличивал initial
View graph:
- RU: `Показывать окно с информацией при наведении на комнату`;
- EN: `Show the room information window on hover`;
- DE: `Rauminformationen beim Darüberfahren anzeigen`;
- FR: `Afficher les informations de la pièce au survol`.
Используется существующий `_boolInput()` и класс строки общих настроек. Control
имеет доступное имя из видимой строки; отдельные tooltip/help и aria-only ключи
не добавляются. Порядок остальных контролов не меняется.
## Модель данных, миграция и совместимость
### Frontend
- `ServerConfig.settings` получает optional
`show_room_tooltip?: boolean`.
- Pure resolver (рабочее имя `showRoomTooltipOf`) является единственным
источником default для runtime и draft диалога.
- `_settingsDialog` получает boolean `showRoomTooltip`; draft не читается
напрямую из UI DOM при Save.
- Model/store version не меняется. Загрузка не материализует default и не
создаёт запись.
### Backend и support package
- `CONFIG_SCHEMA.settings` явно принимает только boolean для нового известного
ключа. `extra=ALLOW_EXTRA` сохраняется для forward compatibility остальных
полей.
- Privacy projection support package переносит только нормализованный boolean
`show_room_tooltip`, без новых пользовательских данных.
- Полный backup/export/import уже переносит global settings как часть config;
отдельный remap или envelope field не нужен.
### Mixed version и downgrade
| Frontend | Backend | Поведение |
|---|---|---|
| old | new | Новый известный boolean игнорируется старой карточкой; room-tooltip показывается, конфиг не повреждается |
| new | old | Старый backend сохраняет поле через существующий `ALLOW_EXTRA`; новая карточка применяет `false` |
| new | new | Выключенное значение сохраняется и подавляет только room-tooltip |
При downgrade пользователь временно снова увидит room-tooltip, но значение
`false` остаётся в конфиге и восстановит поведение после возврата новой версии.
Это мягкая деградация; data migration и блокировка смешанных версий не нужны.
## Затронутые файлы и модули
- `src/types.ts`, `src/logic.ts`, `src/houseplan-card.ts`,
`src/houseplan-editor-runtime.ts`.
- `src/i18n/support/{en,ru,de,fr}.json`, `src/i18n/support.ts`.
- `custom_components/houseplan/validation.py`,
`custom_components/houseplan/support_package.py`.
- `test/logic.test.mjs`, settings/source contract tests,
`tests_backend/test_validation.py`, support-package tests и целевой browser
smoke для room-tooltip/general settings.
- `docs/USER-GUIDE.md`, `docs/USER-GUIDE.ru.md`, `docs/UX-MODES.md`,
`docs/TOUCH-SUPPORT.md`, `docs/CONFIG-COMPATIBILITY.md`, при необходимости
`docs/TESTING.md`, оба changelog.
- Собранные `dist/**` и `custom_components/houseplan/frontend/**` по обычному
bundle-контракту.
## Критерии приёмки
- **AC1 — UI и default (unit + smoke).** В общих настройках есть ровно один
переключатель с локализованным именем; новый и legacy config без поля
открывает его включённым, точный `false` — выключенным.
- **AC2 — persistence (unit + smoke + backend).** Save выключенного draft
записывает boolean `false`; reopen/reload сохраняют его. Save включённого
draft удаляет ключ. Cancel/Escape не пишут, backend принимает оба canonical
состояния и отклоняет не-boolean новое значение.
- **AC3 — tooltip off (unit + smoke).** При `false` настоящий mouse move над
комнатой не создаёт `.tip`, не вызывает `_roomArea()`, и уже видимое окно
исчезает после успешного Save.
- **AC4 — default parity (smoke).** При absent/`true` room-tooltip сохраняет
нынешние title, area, temperature, humidity, LQI, positioning и движение за
указателем.
- **AC5 — hover/device independence (smoke).** В обоих состояниях остаются
room fill/outline hover layers; при `false` device tooltip в View/Devices
по-прежнему появляется и содержит прежние данные.
- **AC6 — pointer/mode parity (unit + smoke).** Touch/pen и synthetic
compatibility mouse не создают room-tooltip; View/kiosk с настоящей мышью
следуют опции; editor modes, pan/pinch/click и room-card действия не меняются.
- **AC7 — compatibility/privacy (backend + unit).** Full config round-trip и
support projection сохраняют нормализованный boolean; отсутствие ключа не
материализуется; mixed-version/downgrade ведут себя по таблице без изменения
model/store version.
- **AC8 — i18n/docs/release (unit + docs gate).** EN/RU/DE/FR имеют parity,
оба руководства и UX/touch/compatibility docs описывают границу опции, а оба
changelog получают пользовательскую запись в том же коммите.
- **AC9 — гейты и бюджет (commands).** В цикле реализации проходят
`npx tsc --noEmit`, `npm test`, `npm run build`, целевой backend pytest,
`no-new-any`, docs check и выбранный browser smoke. Default-кадры golden не
меняются; initial/editor gzip остаются в текущих бюджетах.
## План автотестов
- Табличный unit для resolver: missing/`undefined`/`null`/invalid/`true` →
`true`, только boolean `false` → `false`.
- Source/dialog contract: draft инициализируется resolver-ом; `false` пишется,
`true` удаляется; ключ UI присутствует во всех локалях.
- Backend pytest: `false`/`true` проходят, строка/число отклоняются; support
projection содержит boolean и не копирует невалидную форму.
- Новый/расширенный Playwright smoke: открыть общие настройки, проверить
default, Cancel, сохранить `false`, reopen/reload, подвигать реальную мышь над
комнатой и устройством, проверить room hover layers и `.tip`, вернуть `true`
и проверить восстановление текущего содержимого room-tooltip.
- Мутационные доказательства: заменить resolver на `Boolean(value)` — падает
AC1/AC4; поставить guard внутри общего `_showTip()` — падает device-часть
AC5; скрыть `_hoverRoom` вместе с tooltip — падает AC5; хранить `true` —
падает AC2/AC7; не очищать текущее окно при Save — падает AC3.
## Release-артефакты
- Пользовательская запись в `docs/CHANGELOG.md` и
`docs/CHANGELOG.ru.md` в product-коммите.
- Обновлённые EN/RU User Guide, `docs/UX-MODES.md`,
`docs/TOUCH-SUPPORT.md` и `docs/CONFIG-COMPATIBILITY.md`.
- Изменение нового control доказывает целевой browser smoke; отдельный новый
golden не нужен, потому что default View визуально не меняется и диалог
общих настроек не является принятым golden-сценарием.
- Любая правка `src/**` обновляет source fingerprint документационных
скриншотов через каноническую приёмку; неожиданный raster diff блокирует
завершение задачи.
- Performance/security artifacts не добавляются: один boolean resolver на
room pointermove не создаёт frame-loop или сетевого пути; штатные bundle
budget и prerelease performance gates остаются обязательными.
## Производительность и безопасность
Resolver выполняет одну строгую boolean-проверку до вычисления площади; при
выключенной опции работа на pointermove уменьшается. Он не входит в render loop
и не меняет геометрию, кэши или сетевые запросы. Новый boolean не содержит
персональных данных; support package переносит его только как allowlisted
presentation preference.
## Риски
- **Случайно выключить device tooltip.** `_tip` общий для комнат и устройств;
guard в `_showTip()` был бы слишком широким. Снимается room-specific guard и
независимой проверкой device tooltip в AC5.
- **Сломать default старых конфигов.** `Boolean(undefined)` дал бы `false`.
Снимается pure resolver-ом «только точный false выключает» и таблицей AC1.
- **Показать несохранённый draft как live-настройку.** Диалог редактирует копию,
поэтому runtime читает только server config, а не `_settingsDialog`; Cancel и
failure проверяются AC2.
- **Оставить уже видимый tooltip после выключения.** Пассивный pointer может не
дать нового события. Успешный Save явно очищает transient tip, AC3 фиксирует
это поведение.
- **Потерять `false` в mixed-version цикле.** Старый backend сохраняет unknown
settings, а old frontend не должен реконструировать весь settings-объект без
spread. Compatibility-таблица и round-trip AC7 делают границу явной.
- **Добавить скрытую стоимость pointermove.** Guard ставится до `_roomArea()` и
climate/LQI чтений; bundle/performance gates подтверждают отсутствие роста
горячего пути.
## Откат
Feature flag не нужен: persisted boolean уже сам является выключателем. Для
аварийного продуктового отката удаляются строка UI и room-specific runtime
guard, но backend acceptance/type и support projection временно сохраняются.
Старый runtime безопасно вернёт прежний всегда-включённый tooltip, а сохранённые
`false` не повредятся и не заблокируют config writes.
Если поле требуется убрать окончательно, отдельная проверяемая data-fix удаляет
`settings.show_room_tooltip` из сохранённых конфигураций; только после этого
можно убрать явный schema/support contract. Повышать model/store version или
переписывать все конфиги для обычного rollback нельзя.
## Принятые предположения
- «Общие настройки» означает одно server-persisted значение для всей
конфигурации, а не per-space/per-room/localStorage.
- Отключается только информационное окно комнаты; hover wash/outline остаются.
- Опция действует и в kiosk при наличии настоящей мыши; touch-only kiosk уже не
показывает hover по текущему контракту.
- Переключатель расположен сразу после вводного текста диалога, без новой
секции, help и предупреждения.
- Канонический ключ — `settings.show_room_tooltip`; default хранится отсутствием,
а явное значение требуется только для `false`.
- `houseplan-space-card` не меняется, потому что уже не имеет интерактивности.
@@ -0,0 +1,266 @@
# ТЗ #428 — round-trip экспорта с отсутствующей картинкой декора
Issue: [#428](https://github.com/Matysh/houseplan-card/issues/428)
Статус документа: ТЗ на ревью.
Источник контракта: [ТЗ #51](051-custom-decor-images.md), раздел
«Import/export и совместимость», AC10 и AC11.
## Сценарий
1. В конфигурации пространства сохранён `decor`-объект `kind: image` с
корректным 64-символьным SHA-256 `asset_id`.
2. Соответствующего blob и metadata sidecar уже нет в
`<config>/houseplan/assets/`.
3. Пользователь экспортирует полный дом, одно пространство либо только
планировку, а затем пытается импортировать полученный JSON.
Сейчас exporter честно записывает для объекта `exists_at_export: false` и
`mime: null`, но importer требует MIME из белого списка для любой строки
`decor_asset`. Поэтому House Plan отклоняет весь собственный экспорт как
`invalid_content`, хотя #51 определяет missing asset как легальное,
восстанавливаемое состояние.
После исправления документ проходит preview, сообщает об отсутствующем файле,
требует действующее явное подтверждение и сохраняет image-объект вместе с его
геометрией как repair-placeholder. Остальной импорт не меняется.
## Что человек увидит до и после
| Состояние | Сейчас | После исправления |
|---|---|---|
| Файл картинки отсутствовал уже при экспорте | Импорт всего JSON завершается ошибкой | Preview открывается, показывает missing content и требует подтверждение |
| Пользователь подтверждает импорт без файла | До подтверждения невозможно дойти | Объект и его геометрия сохраняются; во View не рисуется, в Background editor доступен для замены |
| Файл с тем же exact hash уже есть на целевой системе | Документ всё равно отклоняется из-за `mime: null` | Локальный blob проверяется по SHA-256 и переиспользуется как `available` |
Нового диалога, текста ошибки или элемента управления нет.
## Подтверждённая причина
- `custom_components/houseplan/import_export.py::content_manifest()` получает
MIME из metadata sidecar либо расширения найденного blob. Если оба файла
отсутствуют, результат — `None`; флаг `exists_at_export` при этом равен
`False`.
- `_content_state()` повторно строит ожидаемые ссылки из payload, но затем
безусловно требует у supplied `decor_asset` MIME из множества
`image/png`, `image/jpeg`, `image/webp`, `image/svg+xml`.
- `test_issue_51_missing_decor_asset_stays_as_repairable_geometry` покрывает
только соседний случай: blob был у источника и потому MIME был известен, но
blob отсутствует на target.
## Скоуп
- ограниченно скорректировать валидацию `decor_asset` в `_content_state()`;
- сохранить строгую сверку manifest с image-ссылками, заново выведенными из
payload;
- покрыть настоящий round-trip «export при отсутствующем blob/sidecar → import
preview» и отрицательную матрицу manifest;
- уточнить контракт missing decor asset в `docs/CONFIG-COMPATIBILITY.md`,
`docs/USER-GUIDE.md` и `docs/USER-GUIDE.ru.md`;
- добавить пользовательскую запись в оба changelog.
## Не-скоуп
- встраивание blob/base64 в JSON;
- восстановление, загрузка, перенос или автоматическое удаление asset-файлов;
- угадывание MIME из `asset_id`: content-addressed id не содержит расширение;
- изменение формата export, `EXPORT_FORMAT_VERSION`, config/model schema либо
storage layout;
- изменение UI подтверждения, placeholder, счётчиков preview, delete/replace,
full/space/plan-only projection или политики внешних файлов;
- ослабление проверки любых manifest-строк, кроме строго описанного ниже
missing `decor_asset`.
## Контракт manifest и валидации
### 1. Канонический экспорт
Exporter продолжает выдавать одну extension-neutral строку `decor_asset` на
каждый image record. Поля `asset_id` и `hash` равны canonical lowercase SHA-256,
`exists_at_export` всегда имеет настоящий тип `bool`.
- Если verified source blob существует, `exists_at_export` равно `true`, а
`mime` обязательно входит в поддерживаемый белый список.
- Если verified source blob отсутствует либо не проходит exact hash,
`exists_at_export` равно `false`. `mime` может быть поддерживаемой строкой,
когда её сохранил валидный metadata sidecar, либо JSON `null`, когда MIME
достоверно неизвестен.
Exporter не восстанавливает MIME эвристикой и не добавляет bytes.
### 2. Допустимые строки при импорте
До определения локального состояния target importer проверяет supplied
`decor_asset` по следующей матрице:
| `exists_at_export` | `mime` | Результат |
|---|---|---|
| literal `true` | поддерживаемая строка | допустимо |
| literal `true` | отсутствует, `null` или неподдерживаемая строка | `ImportFailure("invalid_content")` |
| literal `false` | поддерживаемая строка | допустимо |
| literal `false` | отсутствует или `null` | допустимо: это исправляемый missing asset |
| literal `false` | любая неподдерживаемая строка, включая `""`, либо значение другого типа | `ImportFailure("invalid_content")` |
| поле отсутствует, `null`, `0`, `1`, строка, объект или массив | любое | `ImportFailure("invalid_content")` |
Во всех допустимых строках остаются обязательными:
- exact equality supplied `asset_id` и `hash` с SHA-256, выведенным из payload;
- exact identity строки (`kind`, `owner`, `owner_id`, `field`, `url`) и отсутствие
лишних/дублированных/пропущенных строк;
- повторная проверка target blob чтением bytes и сравнением SHA-256.
Поддерживаемый MIME — только `image/png`, `image/jpeg`, `image/webp` или
`image/svg+xml`. Неподдерживаемый указанный MIME нельзя маскировать
`exists_at_export: false`.
### 3. Локальное состояние target
Источник не определяет доступность на целевой системе:
- target blob с exact hash → `state: available`, `exists_on_target: true`,
отдельное подтверждение для этой строки не требуется;
- target blob отсутствует, нечитаем или hash не совпадает →
`state: missing_preserved`, `exists_on_target: false`, общий preview получает
`confirmation_required: true`;
- после подтверждения `_detach_missing()` не удаляет image record: `asset_id`,
geometry, opacity, flips и decor order сохраняются по контракту #51.
Значение `exists_at_export` в нормализованной preview-строке остаётся значением
из supplied manifest. `mime: null` не превращается в MIME найденного либо
предполагаемого файла и не становится authority.
## Совместимость и миграция
- Миграции config/model/storage нет.
- Номер export format не меняется: исправленный importer принимает ранее
сгенерированный самим House Plan v2 документ, который уже соответствовал
заявленному контракту `exists_at_export: false`.
- Все документы, которые принимались раньше, продолжают приниматься.
- Старый importer может по-прежнему отвергнуть такой JSON; исправление не может
сделать уже установленную старую версию совместимой вперёд.
- Отсутствующий `exists_at_export` не трактуется как legacy default: exporter
v2 всегда записывает поле, а fail-closed поведение защищает границу доверия.
## Безопасность и privacy
Manifest остаётся описательным, не авторитетным. Payload определяет полный набор
ссылок, а target bytes — фактическую доступность. Исключение для отсутствующего
MIME связано одновременно с exact image identity и literal false; оно не даёт
подсунуть внешний URL, пропустить ссылку, объявить другой hash/MIME или обойти
проверку файла. Новых данных в export и новых путей к файловой системе нет.
## Touch, accessibility, i18n и производительность
- Touch/View/kiosk не меняются: это backend round-trip до существующего preview.
- Новых текстов и ключей i18n нет; используются действующие missing-content
confirmation и repair-placeholder.
- На каждый asset остаются тот же один bounded поиск кандидатов и, при наличии
blob, один SHA-256 проход. Новых обходов, сетевых запросов и frontend bundle
кода нет; performance budgets не меняются.
## Затронутые файлы и модули
- `custom_components/houseplan/import_export.py` — bounded validation
`decor_asset` в `_content_state()`, полная image-проекция plan-only и её
ограниченный manifest allowlist;
- `tests_backend/test_ha_import_export.py` — положительный round-trip и
отрицательная матрица;
- `docs/CONFIG-COMPATIBILITY.md` — точное значение missing MIME;
- `docs/USER-GUIDE.md`, `docs/USER-GUIDE.ru.md` — пользовательское правило
повторного экспорта/import missing image;
- `docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md` — release note;
- этот файл и `docs/specs/README.md` — трассируемость ТЗ.
Frontend `src/**`, i18n JSON, version files, screenshots/golden и workflow не
затрагиваются.
## Критерии приёмки
- **AC1 — настоящий missing round-trip (backend).** Full export, созданный при
отсутствии blob и sidecar, содержит `exists_at_export: false, mime: null`;
его import preview не падает, выдаёт `missing_preserved` и требует
подтверждение.
- **AC2 — сохранение объекта (backend).** После подтверждённой подготовки
импорта image record сохраняет exact `asset_id`, geometry, opacity, flip flags
и decor order; `_detach_missing()` его не удаляет.
- **AC3 — три режима экспорта (backend).** Общая validation path доказана для
full, single-space и plan-only export: missing image row во всех режимах
импортируема по одному контракту, а projection/privacy каждого режима не
меняются.
- **AC4 — target reuse (backend).** Для строки
`exists_at_export: false, mime: null` existing target blob переиспользуется
только после exact SHA-256 проверки и получает `available` без ложного
missing confirmation.
- **AC5 — fail-closed MIME/flag (backend).** Параметризованный отрицательный
тест доказывает матрицу: null/omitted MIME допустим только при literal false;
unsupported non-null MIME отклоняется и при false; отсутствующий и любой
не-bool `exists_at_export` отклоняются.
- **AC6 — identity invariants (backend).** Mismatched `asset_id`/`hash`, лишняя,
пропущенная или дублированная manifest row по-прежнему дают
`invalid_content`; существующее покрытие остаётся зелёным.
- **AC7 — совместимость (backend + ревью кода).** Ранее допустимые строки
`exists_at_export: true` с поддерживаемым MIME и `false` с поддерживаемым MIME
не меняют результат; config/model/export version не повышается.
- **AC8 — документация и release (docs gate + ревью кода).** Оба User Guide и
compatibility doc описывают импортируемый missing round-trip; оба changelog
обновлены в том же `User-Visible: yes` implementation commit.
- **AC9 — гейты (commands + Linux CI).** В цикле реализации зелёные
`npm run typecheck`, `npm test`, `npm run build` и targeted backend tests;
полный HA harness остаётся каноническим Linux CI. Golden, smoke и performance
не требуются до команды на бету, поскольку визуальный/frontend output не
меняется.
## План автотестов
1. Добавить helper fixture с image record и отсутствующим source asset.
2. Параметризовать `kind=full`, `kind=space` и plan-only variant; создавать
документ через `create_export()`, не конструировать только вручную.
3. Для полного пути пропустить JSON через `create_preview()` на отдельном
пустом target root и проверить content state, confirmation и сохранённый
candidate payload.
4. Создать exact target blob без sidecar и доказать `available`; затем заменить
bytes и доказать `missing_preserved`.
5. Параметризовать `exists_at_export` и `mime` по таблице, включая Python
`False` отдельно от `0`, потому что `bool` — подкласс `int`.
6. Не заменять существующий тест #51: он остаётся регрессией для known MIME при
missing target.
## Release-артефакты
- В `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` — одна парная запись о том,
что повторно экспортированный план с отсутствующей пользовательской картинкой
снова импортируется с подтверждением и без потери объекта.
- В обоих User Guide — короткое уточнение рядом с missing image/import rules.
- В `docs/CONFIG-COMPATIBILITY.md` — нормативная truth table в компактной форме.
- Screenshots/golden не обновляются: видимый рендер и UI не меняются.
- Release/version/tag не входят в задачу; issue остаётся открытой в S8 до беты.
## Риски
1. **Слишком широкое ослабление manifest.** Снимается точной проверкой
`type(exists_at_export) is bool`, белым списком непустого MIME и неизменной
exact identity/hash validation.
2. **Python принимает `0` как `False`.** Проверка должна быть по типу и identity,
а тест содержит `0` и `1` как отрицательные значения.
3. **Source metadata становится authority.** Нельзя использовать supplied MIME
или availability для выбора target файла; target hash проверяется как раньше.
4. **Проверен helper, но не настоящий export/import.** AC1 и AC3 требуют
документы от `create_export()` и хотя бы один путь через `create_preview()`.
## Откат
Откат — revert implementation commit: importer снова потребует supported MIME
у каждой строки `decor_asset`. Данных и миграций откатывать не нужно; уже
импортированные image records остаются валидными по схеме #51. Цена отката —
возврат исходной невозможности импортировать собственный export с missing asset.
## Принятые предположения
- `exists_at_export: false` означает только подтверждённое exporter-ом отсутствие
verified source blob; оно не обещает отсутствие exact blob на target.
- `mime: null` — единственное корректное представление неизвестного MIME,
которое пишет текущий exporter; отсутствие ключа принимается эквивалентно
только в той же строго missing-ветке для устойчивости JSON producers.
- Восстановимый MIME нельзя получить из SHA-256 `asset_id` без blob/sidecar,
поэтому исправляется importer, а не вводится недостоверное значение exporter-а.
@@ -0,0 +1,237 @@
# ТЗ #431 — канонизация координат `kind: image`
Issue: [#431](https://github.com/Matysh/houseplan-card/issues/431)
Статус документа: реализовано.
Источники контракта: [ТЗ #51](051-custom-decor-images.md),
[ТЗ #224](224-config-coordinate-canonicalization.md) и
[ТЗ #291](291-lattice-coordinate-write-barrier.md).
## Сценарий
Автор плана добавляет собственное изображение в редакторе Подложки, двигает,
масштабирует или поворачивает его и сохраняет конфигурацию. Позже он повторно
сохраняет план без геометрических изменений либо запускает «Оптимизировать
планы».
Сейчас `kind: image` проходит frontend- и backend-барьеры записи вне списка
box-декора. Незаметные хвосты чисел с плавающей точкой сохраняются, поэтому
повторный no-op жест или оптимизация способен снова дать технический diff и
лишнюю ревизию.
После исправления изображение следует тому же контракту `x/y/w/h/angle`, что
`rect`, `ellipse` и `furniture`: следующая штатная запись или Optimize приводит
его координаты к канонической форме, а повтор операции является no-op.
## Что человек увидит до и после
| Ситуация | Сейчас | После исправления |
|---|---|---|
| Сохранение изображения после drag/resize/rotate | План выглядит правильно, но в конфиг могут попасть шумовые float-координаты | Геометрия сохраняется канонически без визуального сдвига |
| Повторный no-op жест или Optimize | Может появиться ещё одно изменение/ревизия того же плана | Повторная операция не создаёт нового геометрического diff |
| Старый план с шумовыми координатами изображения | Шум остаётся после обычной записи | Нормализуется при следующей записи или явном Optimize |
Новых кнопок, сообщений, настроек и визуальных состояний нет.
## Подтверждённая причина
- `DecorKind` уже содержит `image`, а `DecorImage` использует box-поля
`x/y/w/h/angle`.
- `src/coordinate-canonicalization.ts` не включает `image` ни в сбор значений
для `latticeCanonicalizationReport()`, ни в реальную канонизацию config.
- `custom_components/houseplan/coordinate_canonicalization.py` содержит то же
неполное зеркало.
- Общая fixture перечисляет остальные четыре вида декора, но не `image`, поэтому
frontend и backend согласованно подтверждают один и тот же дефект.
## Скоуп
- включить `image` в box-контракт frontend-сбора статистики и канонизации;
- включить `image` в Python-зеркало канонизации;
- сделать полный набор box-видов явным и проверяемым, чтобы обходы не содержали
независимые цепочки сравнений;
- расширить shared fixture и оба runtime-набора тестов;
- добавить отрицательные доказательства, что выпадение вида из frontend либо
backend краснит соответствующий тест;
- уточнить compatibility-документацию и добавить парную changelog-запись.
## Не-скоуп
- изменение формата `DecorImage`, asset API, загрузки, рендера или редактора;
- новая миграция, повышение model/config/export version либо запись при чтении;
- изменение точности, порога lattice snap или формулы канонизации;
- рекурсивное округление неизвестных числовых полей;
- канонизация `opacity`, `width_cm`, `flip_h`, `flip_v`, `asset_id` или других
presentation/content-полей;
- изменение поведения неизвестных и будущих `decor.kind` без отдельной
классификации их геометрии.
## Контракт поведения
### 1. Каталог геометрических классов декора
Frontend имеет один runtime-каталог box-видов:
```text
rect · ellipse · furniture · image
```
Тип `DecorKind` обязан получать эти варианты из того же каталога, а не повторять
отдельный независимый список. Сбор статистики и фактическая канонизация используют
один predicate/каталог. Python объявляет точное зеркало box-набора.
Shared contract перечисляет ожидаемый box-набор. Frontend unit и backend test
сверяют с ним свои runtime-каталоги exact-set сравнением и прогоняют одинаковую
геометрию для каждого вида. Удаление одного вида из любого runtime-каталога либо
рассинхронизация shared contract обязаны дать красный тест.
### 2. Поля и числовой контракт
Для каждого box-вида, включая `image`:
- `x`, `y`, `w`, `h` проходят существующую lattice-канонизацию относительно
`1/240` с действующим порогом;
- `angle` проходит существующую scalar-канонизацию до девяти десятичных знаков;
- near-node значения учитываются в `latticeCanonicalizationReport()` как
`canonicalized`, а намеренно off-grid значения — как `far` без snap;
- повторная канонизация результата byte-equivalent и идемпотентна.
Все остальные поля image record сохраняются без изменений. Невалидные,
нечисловые и non-finite значения продолжают обрабатываться действующей схемой;
эта задача не меняет её политику валидации.
### 3. Пути записи и Optimize
Новые специальные writer-ветки не добавляются. Исправление действует через
существующие общие барьеры:
- frontend config candidate до `houseplan/config/set`;
- backend config schema и storage helper;
- предварительную и финальную канонизацию Optimize;
- сбор отчёта Optimize о lattice-изменениях.
Существующие route guards #291 остаются без изменений: задача исправляет полноту
данных внутри барьера, а не инвентарь writer-ов.
## Совместимость и миграция
- Новых полей и миграции нет; model/config/export versions не меняются.
- Старые конфиги читаются byte-for-byte как раньше. Image geometry становится
канонической только при следующей штатной записи или явном Optimize.
- Уже канонические изображения не меняются.
- Старые версии House Plan продолжают читать результат как обычный
`kind: image`; downgrade не требует обратной миграции.
## Touch, accessibility, i18n и производительность
- Touch/View/kiosk и доступность не меняются: жесты и рендер остаются прежними.
- Новых строк и ключей i18n нет.
- Новых обходов config нет. Четырёхэлементный membership-check заменяет текущую
цепочку сравнений внутри уже существующих обходов; бюджеты производительности
не меняются.
- Security/privacy и сетевые поверхности не затрагиваются.
## Затронутые файлы и модули
- `src/editors/decor/types.ts` — единый runtime box-каталог и производные типы;
- `src/coordinate-canonicalization.ts` — использование каталога при сборе и
канонизации;
- `custom_components/houseplan/coordinate_canonicalization.py` — Python-зеркало;
- `test/fixtures/coordinate-canonicalization.json` — shared набор и image row;
- `test/coordinate-canonicalization.test.mjs` — frontend completeness,
idempotency и preservation;
- `tests_backend/test_coordinate_canonicalization.py` — backend mirror и schema;
- `scripts/mutation-gate.mjs` — постоянный свидетель backend-защиты;
- `docs/CONFIG-COMPATIBILITY.md` — явный image box-контракт;
- `docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md` — release note;
- этот файл и `docs/specs/README.md` — трассируемость.
User Guide, i18n, screenshots/golden, smoke и performance fixtures не меняются.
## Критерии приёмки
- **AC1 — frontend image canonicalization (unit).** `image.x/y/w/h` получают
тот же lattice-результат, а `image.angle` тот же scalar-результат, что
эквивалентный `furniture`; immutable и in-place API дают одинаковый результат.
- **AC2 — отчёт и идемпотентность (unit).** Image near-node/off-grid значения
правильно входят в `latticeCanonicalizationReport()`; повторная
канонизация/Optimize не создаёт изменений.
- **AC3 — backend mirror (backend).** Python helper и `CONFIG_SCHEMA` дают для
image record точный shared expected result и сохраняют его идемпотентно.
- **AC4 — полнота box-набора (unit + backend + mutation).** Runtime-каталоги TS
и Python exact-set равны shared contract `rect/ellipse/furniture/image`, а
каждый вид реально прогоняется через `x/y/w/h/angle`. Удаление `image` из TS
краснит targeted unit; удаление из Python краснит targeted backend test через
зарегистрированный mutation-gate witness.
- **AC5 — поля вне геометрии (unit + backend).** `asset_id`, `opacity`,
`flip_h`, `flip_v` и неизвестное extension-поле переживают обе канонизации без
изменений; неизвестный `decor.kind` не начинает округляться рекурсивно.
- **AC6 — совместимость (ревью кода).** Формула, пороги, версии, схемы данных,
writer inventory, UI и i18n не меняются; действующие тесты #224/#248/#291
остаются зелёными.
- **AC7 — документация и release (docs gate + ревью кода).** Compatibility doc
называет `image` среди box-видов; оба changelog обновлены в том же
`User-Visible: yes` implementation commit.
- **AC8 — гейты (commands + Linux CI).** Зелёные `npm run typecheck`,
`npm test`, `npm run build`, targeted backend test и оба отрицательных
свидетеля. Полный HA harness каноничен в Linux CI.
## План автотестов и таблица защитных свидетелей
1. Добавить в shared fixture `boxKinds` и representative `image` с шумом во
всех пяти геометрических полях и отдельными полями, которые менять нельзя.
2. В frontend unit сравнить runtime-каталог с `boxKinds`, затем
параметризованно проверить каждый вид и image preservation.
3. В backend test сравнить Python-зеркало с тем же `boxKinds`, проверить helper
и `CONFIG_SCHEMA` на том же expected output.
4. Добавить Optimize/no-op проверку для image: первый прогон очищает измеримый
шум, второй возвращает отсутствие persisted changes.
5. Выполнить отрицательные прогоны до передачи на код-ревью:
| Защитный AC | Чем доказан | Чем обязан краснеть |
|---|---|---|
| AC4 frontend completeness | targeted `coordinate-canonicalization` unit | удалить `image` из TS box-каталога → unit fail |
| AC4 backend completeness | targeted backend test | mutation-gate: удалить `image` из Python box-каталога → backend fail |
| AC5 allowlist boundary | unit + backend preservation cases | заменить box-ветку рекурсивным округлением/задеть extension field → preservation fail |
## Release-артефакты
- В `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` — парная запись: координаты
пользовательских изображений теперь проходят общий стабильный барьер записи и
не создают повторный технический diff.
- В `docs/CONFIG-COMPATIBILITY.md` — `image` явно включён в действующий
`x/y/w/h/angle` box-контракт.
- Screenshots/golden и User Guide не обновляются: визуал и пользовательский поток
не меняются.
- Release/version/tag не входят в задачу; issue остаётся открытой в S8 до беты.
## Риски
1. **Исправлен writer, но не отчёт.** Один predicate обязан использоваться обоими
TS-обходами; AC2 проверяет счётчик Optimize.
2. **Frontend и backend снова расходятся одинаково незаметно.** Exact-set
сравнение обоих runtime-каталогов с одной fixture и backend-мутант делают
удаление наблюдаемым.
3. **Случайно канонизированы presentation/content-поля.** AC5 фиксирует
allowlist и неизвестное extension-поле.
4. **Тест проверяет список, но не поведение.** AC4 требует прогнать каждый вид,
а не ограничиваться сравнением строк каталога.
## Откат
Откат — revert implementation commit. Новых полей и миграций нет; уже
канонизированные image-координаты остаются валидными. Цена отката — возврат
floating-point шума для следующих записей изображений.
## Принятые предположения
- Box-геометрия определяется структурой `x/y/w/h/angle`; текущий полный набор —
`rect`, `ellipse`, `furniture`, `image`.
- `angle` остаётся scalar, а не lattice-полем; `flip_h/flip_v` не кодируются
отрицательными размерами и не канонизируются.
- Shared fixture является языконезависимым тестовым контрактом; продуктовый
runtime не читает fixture с диска.
- Исправление считается пользовательским bugfix (`User-Visible: yes`), хотя
визуальный кадр не меняется: оно устраняет наблюдаемые лишние сохранения и
повторные Optimize-изменения.
@@ -0,0 +1,371 @@
# ТЗ #432 — Ограниченный resolve и единая проверка целостности изображений
- Issue: https://github.com/Matysh/houseplan-card/issues/432
- Приоритет / тип: P2 · bug · security
- Трек: полный — меняются два backend endpoint, публичный контракт доступа и
стоимость файловых операций; критерии `small` из `PROCESS.md` не выполняются
- Связано: #51 (custom decor images), #131 (полный View read-only-пользователя),
#421/#430 (исполняемые отрицательные доказательства)
- Решение владельца: Default по Q1 принят в issue 2026-09-03
## 1. Сценарий
**Персона:** домочадец без права редактирования либо администратор, открывающий
полный House Plan / отдельную карточку пространства. На плане есть загруженные
растровые или SVG-изображения декоративного слоя.
При загрузке View frontend разрешает сохранённые `asset_id`, подписывает URL и
рисует изображения. Параллельно прямой либо ошибочный клиент может многократно
вызывать `houseplan/assets/resolve` и GET тех же файлов. Проверка целостности не
должна превращать обычную загрузку или злоупотребление API в сотни мегабайт
повторного чтения с диска.
Задача обслуживает J1/J4/J6: View остаётся полным для household members,
пользовательский файл проверяется до показа, а интеграция остаётся устойчивой.
## 2. Что человек увидит до и после
**До:** сохранённые изображения отображаются, но каждый resolve/GET заново
читает файл целиком и считает SHA-256. Повторные или параллельные обращения могут
нагружать диск и задерживать Home Assistant. Попытка закрыть дыру обычной
write-проверкой, наоборот, убрала бы изображения у read-only-пользователя.
**После:** те же сохранённые изображения без новых сообщений и настроек видны
администратору, домочадцу, в full card и space card. Неизменившийся blob
хешируется один раз и переиспользуется обеими transport-поверхностями;
read-only-пользователь не может использовать resolve как просмотр всего
внутреннего asset-каталога.
## 3. Подтверждённая проблема
1. `ws_assets_resolve()` принимает до 200 id, сканирует каталог и для каждого
совпавшего blob выполняет `path.read_bytes()` + SHA-256. При лимите 2 МиБ на
файл это до 400 МиБ чтения за один вызов и снова столько же за следующий.
2. `HouseplanContentView.get()` перед каждым GET asset повторяет тот же полный
`read_bytes()` + SHA-256. Заголовок `immutable` не защищает от прямого клиента
и не объединяет full card со space card.
3. Общего cache/single-flight нет: два одновременных запроса могут независимо
хешировать один и тот же blob.
4. `assets/resolve` не требует готового runtime и не различает writer и
read-only user.
5. Действующее ТЗ #51 намеренно называет resolve `authenticated read`; обе
карточки вызывают его в View. Поэтому безусловный `_check_write()` нарушит
#131 и целевую персону из `docs/SCOPE.md`.
## 4. Решение владельца
Принят Default:
- non-admin при `admin_only` продолжает видеть сохранённые декоративные
изображения;
- такой пользователь может разрешать только `asset_id`, на которые ссылается
текущий сохранённый config;
- writer может разрешать любой существующий asset для редакторских сценариев;
- authenticated/signed GET точного content URL сохраняется;
- WS resolve и HTTP GET разделяют один ограниченный cache/single-flight по пути
и файловой сигнатуре.
## 5. Скоуп
В задачу входят:
1. готовый runtime как обязательная предпосылка `assets/resolve`;
2. least-privilege фильтр requested ids для read-only connection;
3. сохранение полного resolve-контракта для `may_write == true`, включая случай
`admin_only: false`;
4. прямое чтение metadata только для разрешённых requested ids вместо полного
сканирования каталога;
5. один общий для WS и HTTP bounded integrity verifier;
6. cache по каноническому пути и файловой сигнатуре, включая размер и точные
timestamps; cache хранит вычисленный digest, а не bytes;
7. single-flight для параллельной проверки одной файловой версии;
8. потоковый SHA-256 ограниченными chunks без `Path.read_bytes()`;
9. invalidation при изменении файловой сигнатуры, bounded eviction и fail-dark
при исчезновении, I/O error, смене файла во время чтения или неверном hash;
10. backend/HA tests и постоянные mutation-witness для дорогих защит;
11. уточнение архитектурной и compatibility-документации, changelog RU/EN.
## 6. Не-скоуп
- изменение форматов PNG/JPEG/WebP/SVG, upload validation или лимита 2 МиБ;
- изменение namespace-квоты 200 файлов / 256 МиБ;
- новые rate limits, user-visible ошибки, repair, diagnostics или настройки;
- скрытие сохранённых изображений от household members;
- превращение content GET в writer-only endpoint;
- изменение signed URL, срока подписи, URL-формата, CSP, MIME или streaming
`FileResponse`;
- удаление файлов, сборка мусора либо пересмотр standing rule из `SCOPE.md`;
- config/schema migration, новые persisted/compatibility-поля;
- frontend batching/cache, рендер, редакторы, touch-жесты и i18n;
- общий cache для plans, manuals, export/import и других файлов House Plan.
## 7. Контракт доступа к `houseplan/assets/resolve`
### 7.1. Предпосылки
- HA WebSocket authentication остаётся внешней обязательной границей.
- Handler первым получает runtime через действующий fail-closed путь. Если
интеграция не готова, возвращается `not_ready`; каталог и blobs не читаются.
- `may_write(hass, connection.user)` остаётся единственным определением writer:
admin при `admin_only: true` либо любой authenticated user при
`admin_only: false`.
### 7.2. Writer
Writer может запросить любой корректный `asset_id` в пределах существующего
лимита сообщения. Для каждого id сервер напрямую читает одноимённую metadata
запись и проверяет соответствующий blob. Существующий ответ сохраняется:
валидный asset входит в `assets`, отсутствующий/невалидный/повреждённый — в
`missing`; дубликат присутствует не более одного раза.
### 7.3. Read-only user
Под `runtime.write_lock` берётся короткий coherent snapshot сохранённого config
и из него существующим `asset_refs()` строится множество разрешённых id. Lock
не удерживается во время metadata I/O или хеширования.
- Запрошенный id из множества используется так же, как у writer.
- Запрошенный id вне множества сразу попадает в `missing` и не вызывает чтение
его metadata, stat либо blob.
- Ответ не различает «не существует», «повреждён» и «не разрешён». Это сохраняет
partial resolve и не создаёт existence oracle.
- Один запрещённый id не отменяет разрешённые элементы той же пачки.
Config может измениться сразу после snapshot; это допустимая read-consistency.
Следующий resolve увидит новую сохранённую ревизию. Файл не удаляется на одном
факте исчезновения ссылки.
## 8. Контракт content GET
`GET /api/houseplan/content/assets/_/<hash>.<ext>` сохраняет существующие два
пути доступа: authenticated request либо валидная HA-подпись. Membership в
текущем config повторно не проверяется: подписанный URL обязан работать, а
content-addressed hash практически не перебирается.
До `FileResponse` asset проходит тот же integrity verifier, что WS. Неверный
digest, исчезновение или ошибка чтения дают прежний 404. Valid response
сохраняет exact MIME, CSP для SVG, `nosniff`, immutable private cache header и
потоковую отдачу. Plans/files этой задачей не меняются.
## 9. Integrity cache и ограничение стоимости
### 9.1. Identity
Cache key включает resolved canonical path; запись содержит файловую сигнатуру
и фактический SHA-256. Сигнатура включает как минимум `size`, `mtime_ns` и
`ctime_ns` (либо документированную точную платформенную замену). Ожидаемый hash
сравнивается с digest, а не становится единственным доказательством cache hit.
Перед использованием hit выполняется `stat`. Несовпадение сигнатуры означает
miss. После холодного чтения выполняется повторный `stat`; если файл изменился
во время вычисления, результат не публикуется и запрос fail-dark либо делает не
более одной повторной стабильной попытки. Бесконечного retry нет.
### 9.2. Стоимость и память
- Blob читается фиксированными chunks; полные bytes не сохраняются в памяти.
- Неизменившаяся файловая версия хешируется один раз на жизнь cache независимо
от того, пришёл первый запрос через WS или HTTP.
- Одновременные проверки одного key/signature выполняют ровно одно чтение;
остальные ждут тот же результат. Ошибка также будит ожидающих и не оставляет
key навсегда in-flight.
- Разные файлы не обязаны выполняться последовательно; реализация не должна
держать один глобальный lock на протяжении всех чтений.
- Cache ограничен не более чем 256 entries и вытесняет least-recently-used либо
эквивалентно детерминированный старый entry.
- Cached digest/negative integrity result применим только к той же сигнатуре.
Missing path не кешируется бессрочно без файловой сигнатуры.
- Cache memory-only, не входит в config/diagnostics/export/backup и очищается при
перезапуске HA. Persisted invalidation или миграция не нужны.
### 9.3. Прямой metadata lookup
Resolve не вызывает полный `read_catalog(root)`. Для каждого уникального
разрешённого id читается только `<asset_id>.json`; запись проходит те же проверки
формы, extension, id и наличия blob, что каталог. Shared helper обязан оставлять
`assets/list` и resolve согласованными, чтобы две копии validation не разошлись.
## 10. Ошибки и совместимость
- Public success payload `{assets, missing}` и metadata row не меняются.
- `not_ready` — единственная новая наблюдаемая ошибка для вызова в момент, когда
config entry не загружена; это тот же lifecycle-контракт остальных WS-команд.
- Read-only forbidden id становится `missing`, не `unauthorized`.
- I/O/JSON/stat/hash failures не содержат disk path или exception в ответе.
- Existing valid configs, exports/imports и image records читаются без миграции.
- Новый frontend со старым backend и старый frontend с новым backend продолжают
работать в пределах контракта #51; capability version не повышается.
## 11. UX, accessibility, touch и i18n
Новых контролов, текстов, focus/keyboard semantics и переводов нет. Full card и
space card рисуют тот же image либо существующий missing-placeholder. View,
kiosk, phone и tablet обязаны сохранить parity для read-only user; редакторы
остаются доступны только по действующему `can_write`.
Golden и browser smoke не требуются: рендер и frontend не меняются. Read-only
View доказывается backend permission-контрактом плюс существующими frontend
unit tests вызова resolve; код-ревью отдельно проверяет, что frontend не получил
writer-only зависимость.
## 12. Затронутые модули
Ожидаемый набор; имена нового helper могут быть уточнены без изменения
контракта:
- `custom_components/houseplan/decor_assets.py` либо новый чистый модуль рядом —
direct metadata lookup и bounded single-flight integrity cache;
- `custom_components/houseplan/websocket_api.py` — runtime/access filter и
использование общего verifier;
- `custom_components/houseplan/http_api.py` — тот же verifier перед asset
`FileResponse`;
- `custom_components/houseplan/__init__.py` / runtime helper — один cache на HA
instance с корректным lifecycle;
- `tests_backend/test_decor_assets.py` — чистые cache/direct-lookup тесты;
- `tests_backend/test_ha_websocket.py` — HA permission, WS/HTTP и shared-cache
integration tests;
- `scripts/mutation-gate.mjs` — постоянные отрицательные свидетели;
- `docs/ARCHITECTURE.md`, `docs/CONFIG-COMPATIBILITY.md`, changelog RU/EN.
`src/**`, frontend bundle и i18n не должны меняться, если реализация не обнаружит
отдельный, заранее согласованный compatibility blocker.
## 13. Критерии приёмки
- **AC1 (backend/HA).** При `admin_only: true` read-only user успешно разрешает
сохранённый referenced asset; full и space View не получают writer-only
зависимости.
- **AC2 (backend/HA, security).** Тот же user получает unreferenced id в
`missing`, тогда как referenced id из той же пачки остаётся в `assets`;
metadata/stat/blob запрещённого id не читаются. Writer разрешает оба, а при
`admin_only: false` обычный authenticated user имеет writer-контракт.
- **AC3 (backend/HA, lifecycle).** Без loaded runtime resolve отвечает
`not_ready` до любых filesystem operations.
- **AC4 (backend/unit).** Resolve читает metadata только уникальных разрешённых
requested ids и не сканирует остальные catalog rows; malformed/mismatched row
fail-dark и согласована с `read_catalog()`.
- **AC5 (backend/HA, performance).** Последовательные WS resolve и HTTP GET
одного неизменившегося valid blob в любом порядке вызывают одно потоковое
вычисление SHA-256 на общую файловую версию.
- **AC6 (backend/unit, performance).** N параллельных проверок одного
path/signature выполняют один loader/hash, получают одинаковый результат и не
оставляют in-flight state после success или exception. Проверки разных paths
могут продвигаться независимо.
- **AC7 (backend/unit/HA, integrity).** Изменение signature инвалидирует hit;
заменённый corrupt blob становится `missing` в WS и 404 в HTTP. Смена файла во
время чтения не кеширует неподтверждённый digest. Повторный запрос той же
corrupt signature не перечитывает blob.
- **AC8 (backend/unit, budget).** Cache хранит не более 256 entries, вытесняет
старые, не хранит bytes и вычисляет digest chunks без `Path.read_bytes()`.
- **AC9 (backend/HA, compatibility).** Authenticated и signed valid GET сохраняют
body, MIME/security/cache headers и streaming `FileResponse`; plans/files
остаются вне нового verifier.
- **AC10 (review/docs).** Payload, capability, config schema, imports/exports,
frontend, i18n и URL не меняются; architecture/compatibility docs и оба
changelog описывают новый access/cost contract.
- **AC11 (mutation gate).** Для дорогих защит зарегистрированы и исполнены
постоянные свидетели: снятие read-only membership filter краснит AC2; отключение
cache hit/single-flight краснит AC5/AC6; принятие digest после смены signature
краснит AC7. Штатное дерево проходит те же guards зелёным.
## 14. Таблица защитных доказательств
Эта таблица обязательна для handoff и code-review по правилу #435; точные имена
могут быть уточнены, но третий столбец не может исчезнуть.
| AC | Чем доказан | Чем обязан покраснеть |
|---|---|---|
| AC2 | HA test `test_decor_asset_resolve_readonly_is_limited_to_referenced_ids` | мутант удаляет membership filter до metadata lookup; unreferenced id появляется в `assets` либо вызывает I/O |
| AC3 | HA test `test_decor_asset_resolve_requires_runtime_before_io` | мутант удаляет `_runtime()`/ранний return; filesystem probe фиксирует обращение |
| AC4 | pure/HA test direct lookup со сторонними catalog rows | мутант возвращает `read_catalog(root)`; sentinel metadata вне request читается |
| AC5 | HA test WS → HTTP и HTTP → WS с hash counter | мутант всегда объявляет cache miss; counter становится больше 1 |
| AC6 | pure threaded single-flight test с управляемым barrier/loader | мутант удаляет in-flight coordination; loader вызывается N раз |
| AC7 | pure + HA test смены signature и mid-read mutation | мутант игнорирует signature/post-read stat; старый/нестабильный digest принимается |
| AC8 | pure LRU/chunk-reader tests | мутант снимает eviction либо заменяет chunk loop на `read_bytes()`; size/reader sentinel нарушается |
| AC9 | существующий и расширенный signed-content HA test | мутант обходит verifier для HTTP либо меняет headers/FileResponse; corrupt body отдаётся или contract assertions падают |
AC2/AC5/AC6/AC7, которые зависят от HA либо concurrency и не гарантированно
воспроизводятся локально у ревьюера, получают persistent entries в
`scripts/mutation-gate.mjs`. Для чистого AC8 допустим адресный red proof с
выводом в документе ревью.
## 15. План автотестов
1. Расширить #51 HA fixture двумя assets: один referenced, второй нет; выполнить
resolve read-only и writer connections при обоих значениях `admin_only`.
2. Подменить direct metadata/stat/hash seams счётчиками и доказать, что forbidden
id не достигает файловой системы, а unrelated catalog row не сканируется.
3. Вызвать handler без runtime и проверить `not_ready` + нулевые I/O counters.
4. Чисто протестировать hit/miss, LRU boundary 256/257, changed size/timestamps,
cached corrupt digest и bounded retry при изменении во время чтения.
5. Через управляемые threads/barriers одновременно проверить один и разные keys;
тест не использует sleep и имеет bounded join/timeout только как защиту от
deadlock.
6. В HA test последовательно вызвать WS и signed HTTP (затем обратный порядок)
и проверить единый hash counter, 200/404 и неизменные headers/body.
7. Запустить каждый mutation witness: исправное дерево зелёное, мутированное
падает именно целевым assertion, а не import/timeout ошибкой.
8. Implementation gate: `npm run typecheck`, `npm test`, `npm run build`;
backend HA-harness — Linux CI. Browser/golden/performance smoke не выбираются,
если `smoke-select` не обнаружит расширение frontend/visible surface.
## 16. Производительность и безопасность
- Повторный неизменившийся blob: 0 прочитанных content bytes для hash; допустим
один `stat` и bounded cache lookup.
- Холодный blob: не более его фактического размера, читаемого chunks; параллельные
запросы одной версии не умножают bytes.
- Память cache: O(256) metadata/digests/in-flight records, без blob bytes.
- Resolve I/O: O(число уникальных разрешённых requested ids), а не O(весь каталог).
- Read-only user не получает metadata unreferenced asset и не может заставить
verifier прочитать его через WS resolve.
- Секреты, локальные пути и причины fail-dark не входят в transport response.
## 17. Риски
- **Stale cache скроет повреждение.** Смягчение: precise signature до hit и
повторный stat после чтения; AC7 с отрицательным witness.
- **Single-flight deadlock после исключения.** Смягчение: cleanup/notify в
`finally`, детерминированный concurrent error test.
- **Глобальный lock сериализует разные images.** Смягчение: in-flight ownership
по key, AC6 отдельно запускает два paths.
- **Read-only View случайно станет writer-only.** Смягчение: referenced success
закреплён AC1 для обеих карточек как blocking compatibility invariant.
- **Partial batch выдаст existence oracle.** Смягчение: forbidden id неотличим от
missing/corrupt и не отменяет разрешённые rows.
- **Config меняется между auth snapshot и resolve.** Смягчение: snapshot короткий,
file deletion по inference запрещено, следующий load пересинхронизирует View.
## 18. Откат
Откат — revert backend helper и его вызовов, возврат прежних resolve/GET путей.
Persisted state, config, assets и migration rollback отсутствуют. Security и
performance защиты не имеют runtime-флага: временное отключение cache не должно
молча отключать membership guard или integrity check.
## 19. Release-артефакты
- `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md`: кратко описать сохранение
read-only View и устранение повторного хеширования (`User-Visible: yes`);
- `docs/ARCHITECTURE.md`: access matrix resolve/GET и shared verifier lifecycle;
- `docs/CONFIG-COMPATIBILITY.md`: отсутствие schema/capability migration и
rolling compatibility;
- `scripts/mutation-gate.mjs`: security/performance/integrity witnesses;
- user guide, i18n, screenshots/golden: без изменений;
- handoff содержит точный SHA, HA test names, hash/I/O counters и результаты
каждого отрицательного witness.
## 20. Принятые технические предположения
- Forbidden read-only id возвращается как `missing`, а не ошибкой всего вызова:
это сохраняет partial batching и не раскрывает существование файла.
- Cache принадлежит HA instance и лениво доступен обоим endpoint; конкретное
место хранения (`hass.data` либо эквивалентный runtime service) не является
persisted контрактом.
- Лимит cache 256 покрывает максимальные 200 promoted assets с небольшим
служебным запасом и остаётся явной тестируемой константой.
- Exact cache signature включает `ctime_ns` сверх предложенных issue
path/mtime/size: это усиливает invalidation без изменения пользовательского
контракта.
- Ссылки на строки ориентировочны; реализация привязывается к символам и
поведению, если `dev` сдвинется до начала разработки.
+521
View File
@@ -0,0 +1,521 @@
# ТЗ #434 — Полиш аудита v1.71.0-beta.1
- Issue: https://github.com/Matysh/houseplan-card/issues/434
- Приоритет: P3, `bug` / `polish` / `tests`
- Маршрут: full; задача меняет Python- и TypeScript-продукт, хранение
пользовательских файлов, rolling compatibility, асинхронный safety lifecycle
и несколько независимых гейтов, поэтому не проходит лимит сложности и одной
поверхности лёгкого трека
- Связанные контракты: #51 (пользовательские изображения), #417 (ветви
подтверждения опасных действий), #418/#423 (support preview), #419 (Area
snapshot cleanup), #432 (resolve и целостность assets)
## Сценарий
Home admin пользуется изображениями в Редакторе подложки и отдельной карточкой
пространства, обновляет frontend и интеграцию через HACS, меняет язык либо
отправляет обращение в поддержку. В редком аварийном состоянии Home Assistant
может остановиться между записью blob и sidecar. Одновременно разработчик
рассчитывает, что защитные тесты действительно краснеют при снятии проверок, а
один зависший browser smoke не удерживает весь CI job до глобального timeout.
## Что человек увидит до и после
**До:** аварийно оставшийся файл изображения не входит в лимит и не удаляется
явным API-вызовом, повторная загрузка неверно выглядит как переиспользование;
static card шумит ошибкой на старом backend, а восстановленное изображение может
остаться `missing` до переподключения. В переходе языка подтверждение опасного
действия может зависнуть или быть ошибочно отклонено, а невалидный ответ support
preview удерживает временный слот десять минут.
**После:** физический store честно учитывает аварийные blobs, повторная загрузка
безопасно восстанавливает каталог, явное удаление действительно удаляет точный
asset; обе карточки соблюдают capability и revision cache. Подтверждение всегда
либо показано, либо немедленно и безопасно отменено, а неиспользованный support
token отзывается. Видимые контролы, тексты и обычные успешные сценарии не
меняются.
## Цель
Закрыть девять подтверждённых разрывов аудита v1.71.0-beta.1 и закрепить для
каждой защитной ветки отрицательный свидетель:
1. физический учёт и явное удаление orphan decor blobs;
2. capability guard в `houseplan-space-card`;
3. cache epoch resolve по ревизии config;
4. точный тест наличия blob для catalog row;
5. честный `reused` при восстановлении sidecar;
6. актуальный locale gate и симметричная отмена danger confirmation;
7. свидетель принадлежности кандидата текущему Area snapshot;
8. локальные timeout для German wait и каждого CI smoke-файла;
9. отзыв валидного support token из непринятого ответа.
## Подтверждённые причины
1. `read_catalog()` обходит только `*.json`, а `_read_catalog_row()` принимает
строку лишь при существующем blob. Поэтому blob без корректного sidecar не
виден ни каталогу, ни quota, а delete сначала ищет ту же невидимую строку.
2. `HpConfigSnapshot` не переносит `decor_assets_api`, и `_load()` static card
безусловно вызывает `houseplan/assets/resolve`.
3. `resolveCache` хранит одну пару `набор id → Map` на connection и не включает
принятую серверную ревизию config; `missing` тем самым становится бессрочным
для прежнего набора id.
4. Тест с названием `test_catalog_ignores_missing_or_malformed_sidecars`
создаёт blob для единственной валидной строки и не проверяет обратный случай
«валидный sidecar, blob отсутствует».
5. Upload при уже существующем exact blob дописывает sidecar и возвращает
`reused: true`, хотя до операции promoted catalog entry не существовала.
6. `_confirmDanger()` читает `_dangerConfirmLocaleGate` прошлого render. Между
изменением языка и render это даёт ложное разрешение, а между завершением
загрузки языка и render — ложный отказ. Переход в `warm/noChange` не отменяет
уже открытый controller request и оставляет его в inert DOM.
7. Условие `snapshotBindings.has(binding)` в `resolveAreaSnapshotCleanup()` не
имеет кейса, в котором все соседние проверки истинны, а binding отсутствует
только в текущем snapshot.
8. `germanStarted` ограничен одной секундой, соседний `germanCompleted` ожидается
без границы; CI запускает каждый `demo/smoke_*.mjs` простым `node`, а сама job
`smoke` не имеет `timeout-minutes` и потому наследует 360-минутный default
GitHub Actions. Единственные 20 минут в workflow относятся к другой job —
`performance_smoke`.
9. `_buildSupportPreview()` извлекает token, но при невалидности другого поля
бросает `support_rejected` до `_discardSupportPreview()`.
## Скоуп
В скоупе:
- независимый физический inventory promoted decor blobs для quota/delete;
- восстановление missing/broken sidecar повторной exact загрузкой;
- точная семантика `reused` для valid catalog hit и repaired orphan;
- передача runtime capability в shared config snapshot и fail-closed guard
static card;
- revision-scoped positive/negative resolve cache в full и static card;
- актуальная проверка locale readiness при каждом danger request и отмена
открытого request при переходе в неотрисовываемый `warm`;
- недостающие unit/backend/smoke cases и постоянные mutation witnesses;
- bounded ожидание German route, per-file timeout и самостоятельный job timeout
smoke-шарда в Validate;
- best-effort discard каждого корректного support token, ответ которого не был
принят в состояние диалога;
- техническая документация и оба changelog.
## Не-скоуп
- palette/файловый менеджер для отображения orphan blobs, автоматическая сборка
мусора, age-based cleanup либо удаление по отсутствию ссылки;
- восстановление metadata без повторной загрузки exact canonical bytes;
- новый формат sidecar, asset id, URL, capability version, quota либо config;
- повышение `decor_assets_api`, support protocol или export format;
- изменение разрешений list/resolve/upload/delete и content GET;
- изменение визуала decor image, static card, danger dialog или support form;
- новое пользовательское сообщение о repaired orphan либо о техническом
discard;
- изменение двухподтверждённого Area-cleanup контракта #419;
- полный рефакторинг LanguageRuntime, support pipeline или smoke sharding.
## Контракт поведения
### 1. Physical inventory и quota
Promoted blob — обычный файл в decor-assets root с точным именем
`<64 lowercase hex><allowed extension>`, где extension входит в действующий
allowlist PNG/JPEG/WebP/SVG. Temporary `.asset-*`, sidecars, каталоги, prefix-
совпадения и неизвестные расширения не являются promoted blobs.
Quota до создания нового blob считается по физическому inventory, а не по
доверенным sidecar rows:
- file count — число promoted blob-файлов;
- bytes — сумма их фактических `stat().st_size`;
- blob учитывается даже при отсутствующем, битом, несовпавшем либо лживом
sidecar и даже при неверном digest содержимого;
- отсутствующий blob при сохранившемся sidecar не занимает file/byte quota;
- каждый реально лежащий allowed-extension файл учитывается один раз; возможные
несколько расширений одного hash — несколько физических файлов.
Catalog/list/resolve при этом не становятся менее строгими: без корректного
sidecar и существующего совпадающего blob orphan остаётся невидимым и не
рендерится. Это разделяет доверенный каталог и физическое потребление диска.
### 2. Upload recovery и `reused`
Под `upload_lock` backend до отказа по count/byte quota проверяет точный путь
`<validated asset_id><validated extension>`:
- valid catalog row + подтвердившийся exact blob сохраняет прежний idempotent
результат `reused: true` и не меняет имя/created_at;
- exact blob без принимаемого catalog row хешируется и сравнивается с id;
совпадение атомарно создаёт новый sidecar из текущей validated загрузки и
возвращает `reused: false`, потому что promoted catalog entry создана этой
операцией;
- recovery не требует свободного file slot/bytes: физический blob уже вошёл в
inventory и новых promoted bytes операция не добавляет;
- несовпавший digest не перезаписывается и не удаляется по предположению;
upload fail-closed возвращает действующую безопасную ошибку целостности;
- broken/mismatched sidecar заменяется только при exact hash match того же id;
ни один другой blob/sidecar не затрагивается.
Обычная новая загрузка после recovery check применяет прежние 200 assets /
256 МиБ / low-disk границы и атомарную запись. Ошибка не оставляет новый blob,
sidecar или temporary.
### 3. Явное удаление orphan
`houseplan/assets/delete` по-прежнему требует write permission, корректный exact
asset id и нулевой authoritative refcount под `write_lock + upload_lock`.
Успешно проверенный явный запрос удаляет:
- `<asset_id>.json`, если он существует независимо от валидности;
- каждый обычный файл `<asset_id><allowed extension>` из фиксированного
allowlist.
Он не использует пользовательскую строку как glob, не удаляет directories,
temporary, неизвестные расширения или prefix-совпадения. `removed: true`, если
удалён хотя бы один exact sidecar/blob; `false`, если ни одного target не было.
Ошибка `in_use` ничего не удаляет, даже если sidecar повреждён. Это не нарушает
standing rule: причиной удаления является явное действие с точным asset id, а
не вывод из отсутствия ссылок.
### 4. Capability static card и resolve cache
`HpConfigSnapshot` получает runtime-only нормализованное поле capability. Только
exact safe integer `decor_assets_api === 1` означает поддержку; отсутствующее,
нецелое, иное либо malformed значение становится `null`. Каждый успешный fresh
`config/get` авторитетен и может отозвать ранее принятую capability даже при
неизменном config fingerprint. LocalStorage snapshot capability не доверяет и
не превращает в persisted permission: до свежего ответа значение `null`.
`houseplan-space-card`:
- при exact capability вызывает resolve и рисует разрешённые assets;
- без неё не вызывает `houseplan/assets/resolve`, очищает runtime map assets и
использует прежнее fail-closed missing-поведение;
- принимает capability-only изменение snapshot даже при неизменных config,
layout и virtual-light fingerprints;
- не показывает toast/raw WS error и не добавляет новый UI.
`resolveDecorAssets()` получает обязательный opaque config epoch от вызывающей
карточки. Для серверного snapshot epoch включает как минимум принятую числовую
ревизию; full и static card передают ревизию одного и того же accepted config.
Cache key равен `owner connection + epoch + sorted unique valid ids`:
- одинаковые owner/epoch/id-set возвращают тот же `Map` без нового WS;
- новый epoch вызывает новый resolve даже при том же id-set, включая повторную
проверку прежних `missing`;
- разные owner не делят cache;
- failed call не кэшируется;
- batching 200 и validation ответа не меняются.
### 5. Danger confirmation и текущий язык
Решение о возможности открыть `hp-confirm` принимается по текущей конфигурации
языка и текущему состоянию `LANGUAGE_RUNTIME` в момент request/update, а не по
полю, записанному предыдущим render.
- Если текущая ветка `warm → noChange` не может добавить confirmation, новый
`_confirmDanger()` сразу возвращает `false`, ничего не регистрирует в
controller и не ждёт следующего render.
- Если язык уже перешёл из pending в ready/fallback до render, новый request не
получает ложный отказ из-за прошлого `warm` и проходит обычный путь.
- Уже открытый request при переходе в `warm` разрешается `false` до потери
decision source. `hp-confirm` перестаёт быть действующим/доступным элементом,
тогда как последний стабильный body сохраняется по семантике `noChange`, а
host остаётся `inert` до готовности локали.
- Onboarding, fixed-floor pending/invalid, lost-space guard, cold first-load и
ready main branch сохраняют контракт #402/#417; количество подтверждений —
не более одного, согласие никогда не переносится между языковыми состояниями.
### 6. Area snapshot negative witness
Поведение #419 не меняется. Previous cleanup candidate переносится в следующий
проход только если его binding одновременно валиден, revision конечна и binding
присутствует в текущем `marker_area_snapshot`. Тест создаёт случай, где первые
два условия истинны, registries допустимы, но snapshot binding отсутствует;
кандидат обязан исчезнуть. Отдельная мутация снятия membership-check обязана
покраснеть именно на этом assertion.
### 7. Bounded smoke execution
- И `germanStarted`, и `germanCompleted` в
`demo/smoke_danger_confirm_branches.mjs` ожидаются через локальную границу
1000 мс с разными диагностическими ошибками. Отсутствие completion не может
превратить целевой smoke в бесконечное ожидание.
- Validate smoke shard запускает каждый `demo/smoke_*.mjs` через GNU
`timeout --kill-after=10s 180s`. Exit 124 считается обычным падением файла,
его лог печатается и остальные назначенные shard-файлы продолжают выполняться;
итог shard остаётся красным.
- Job `smoke` получает собственный `timeout-minutes: 20`: это отдельная граница,
которой сейчас нет, а значение соседней `performance_smoke` не считается её
защитой. Детерминированное разбиение, browser install, exception guard и log
artifacts не меняются.
### 8. Support preview token cleanup
Любой ответ `houseplan/support/preview`, содержащий token формы ровно 48
lowercase hex, владеет временным backend slot. Если frontend не принимает этот
ответ как текущий valid preview по любой причине, token best-effort отзывается
ровно один раз через `houseplan/support/preview/discard`:
- malformed hash/format/version/size/spaces/expires/text при валидном token;
- ответ устаревшего generation/draft либо снятого consent;
- отказ применить уже проверенный preview в текущее состояние.
Malformed/отсутствующий token не отправляется в discard. Cleanup выполняется
независимо от того, актуален ли уже UI request; только изменение видимого error
state зависит от current generation. Ошибка discard не маскирует исходный
`support_rejected`, не раскрывается пользователю и оставляет backend TTL
последней защитой. Valid current preview, успешный submit и уже существующие
close/uncheck paths не получают лишнего discard.
## Модель данных, API и совместимость
- Persisted config/layout, model version, sidecar schema, asset id, export/import
и support package не меняются; миграции нет.
- `decor_assets_api: 1` уже существует в `config/get`; меняется только перенос
и потребление существующей runtime capability в shared snapshot.
- Новых WebSocket/HTTP endpoint и полей response нет. Уточняется смысл
существующего upload `reused`: `true` означает, что valid catalog entry уже
существовала до запроса; repaired orphan возвращает `false`.
- Старый frontend игнорирует additive backend поведение. Новый frontend со
старым backend fail-closed не вызывает неизвестный asset resolve из static
card и продолжает рисовать остальные слои.
- Orphan inventory вычисляется с диска и нигде не сохраняется. Repair sidecar
использует действующую schema; downgrade не требует rollback данных.
## UX, accessibility, touch, kiosk и i18n
- Новых кнопок, сообщений, focus targets, жестов, ARIA и переводов нет.
- Full View, static View, kiosk, desktop и touch получают один capability/cache
контракт; редактор остаётся desktop-first.
- При несовместимом backend static card просто не рисует недоступное custom
image, как для `missing`; остальные стены, комнаты, устройства и decor
остаются видимы.
- Danger confirmation сохраняет прежний текст, focus и alertdialog semantics;
изменение только исключает зависший/устаревший dialog во время смены языка.
- Support form сохраняет текущую ошибку `support_rejected`; cleanup не добавляет
новый status либо toast.
- Golden не требуется: ожидаемый визуальный кадр в устойчивых состояниях не
меняется. Docs screenshots должны подтвердить нулевую pixel-дельту; при
изменении `src/**` обновляется их source fingerprint по штатному workflow.
## Производительность, хранение и безопасность
- Quota inventory — один bounded scan директории под уже существующим
`upload_lock` на upload; `assets/list/resolve` не получают дополнительный
полный scan. Стоимость O(число entries в asset root), физически ограниченном
quota 200 promoted blobs плюс служебные файлы.
- Повторный resolve в рамках одного config epoch остаётся O(1) cache hit; новый
epoch делает не более одного batched WS-прохода на exact id-set.
- Capability guard убирает заведомо отклоняемый WS-вызов на старом backend.
- Per-file timeout ограничивает один smoke 180 секундами; самостоятельный
`timeout-minutes: 20` ограничивает весь shard при системном зависании либо
серии отдельных отказов. Это не меняет timeout соседней `performance_smoke`.
- Quota не доверяет sidecar bytes/count, delete не использует glob и всегда
повторно проверяет refs/permission. Digest-mismatch никогда не перезаписывается
автоматически.
- Support discard принимает только уже выданный token строгой формы и не
добавляет данные к package/relay. Ошибка cleanup не раскрывает token, path или
backend exception.
## Затронутые модули
Ожидаемый набор; выделение чистых helpers допустимо без изменения контракта:
- `custom_components/houseplan/decor_assets.py` — physical inventory/catalog
seams;
- `custom_components/houseplan/http_api.py` — quota и orphan upload recovery;
- `custom_components/houseplan/websocket_api.py` — exact orphan delete;
- `src/config-store.ts`, `src/space-card.ts`, `src/houseplan-card.ts`,
`src/decor-assets.ts` — capability, epoch cache и danger lifecycle;
- `src/houseplan-editor-runtime.ts` — support token cleanup;
- `src/device-area-relocation.ts` меняется только если потребуется тестируемый
seam; сам контракт cleanup не меняется;
- `test/decor-assets.test.mjs`, `test/device-area-relocation.test.mjs`, новый
либо существующий config/static-card contract test;
- `tests_backend/test_decor_assets.py`, `tests_backend/test_ha_websocket.py` —
inventory/upload/delete cases;
- `demo/smoke_danger_confirm_branches.mjs`,
`demo/smoke_support_feedback.mjs` — transition/cleanup integration;
- `.github/workflows/validate.yml`, workflow contract test,
`scripts/mutation-gate.mjs` — timeouts и постоянные свидетели;
- `docs/ARCHITECTURE.md`, `docs/CONFIG-COMPATIBILITY.md`, `docs/TESTING.md`,
`docs/SUPPORT-PRIVACY.md`, оба changelog и docs screenshot fingerprint.
## Критерии приёмки
- **AC1 (backend/unit, storage).** Blob без sidecar и blob с malformed/
mismatched sidecar отсутствуют в catalog/list/resolve, но каждый входит в
physical count и actual-byte quota; sidecar без blob не входит в catalog и не
расходует blob quota. **Доказательство:** pure backend matrix с границами
count/bytes ±1.
- **AC2 (backend/HA, recovery).** Exact повторная загрузка valid orphan до
проверки новой quota атомарно создаёт sidecar, возвращает `reused:false` и
делает asset доступным list/resolve; последующая identical upload возвращает
`reused:true`. Digest mismatch не меняет ни один файл.
**Доказательство:** upload endpoint test с full-quota fixture и hash asserts.
- **AC3 (backend/HA, explicit delete).** Writer удаляет unreferenced exact
blobs всех allowed extensions и sidecar даже при missing/broken metadata;
`removed` отражает наличие удалённого target. `in_use`, prefix, unknown ext,
temp и directory остаются нетронутыми. **Доказательство:** WebSocket access /
filesystem matrix.
- **AC4 (backend/unit, regression).** Valid-shaped sidecar без matching blob
отбрасывается общим catalog/direct-read validator; снятие `blob.is_file()`
краснит точный тест. **Доказательство:** расширенный
`test_catalog_ignores_missing_or_malformed_sidecars` + mutation gate.
- **AC5 (unit/smoke, compatibility).** Fresh shared snapshot нормализует exact
capability, localStorage seed имеет `null`; static card не вызывает resolve
без exact v1, очищает asset map при downgrade и вызывает resolve после
capability-only upgrade при том же config. **Доказательство:** shared-store
unit + static-card network-counter smoke/contract.
- **AC6 (unit, performance).** Одинаковые owner/epoch/sorted-id-set дают один
resolve и один `Map`; при следующем config epoch тот же положительный либо
negative-cached set вызывает новый resolve. Failed call не кэшируется,
batching остаётся 200. **Доказательство:** `decor-assets` call-count matrix.
- **AC7 (browser smoke, safety).** В обоих окнах ready→warm и warm→ready новый
danger request принимает решение по текущему runtime; уже открытый dialog при
переходе в warm резолвится `false`, исчезает как действующий decision source,
controller пуст, stable body не заменён, host inert. Остальные ветви #417
остаются зелёными. **Доказательство:** расширенный branch smoke с bounded
promise races и двумя отдельными mutants.
- **AC8 (unit, regression).** Previous Area cleanup candidate, отсутствующий в
текущем snapshot, не переносится при otherwise-valid данных; снятие
`snapshotBindings.has(binding)` краснит только этот кейс.
**Доказательство:** targeted unit + mutation gate.
- **AC9 (unit/CI contract, liveness).** `germanCompleted` падает с собственной
диагностикой не позднее 1000 мс; каждый CI smoke имеет 180-секундный TERM и
10-секундный KILL guard, timeout помечает shard красным, сохраняет лог и не
пропускает последующие файлы; сама job `smoke` имеет независимый
`timeout-minutes: 20`. **Доказательство:** completion-timeout probe + workflow
unit с поочерёдно удалёнными per-file wrapper и job-level timeout.
- **AC10 (browser smoke, privacy/lifecycle).** Valid token из invalid, stale или
неприменённого preview response отзывается ровно один раз; malformed token не
отзывается; current valid preview и success не получают преждевременный
discard. UI сохраняет исходный error/current draft. **Доказательство:**
support smoke с WS call counters + mutation gate.
- **AC11 (review/docs, compatibility).** Нет schema/API version/i18n/visual
изменений; standing no-inference deletion, write/ref guards и partial asset
resolve сохранены. Architecture/compatibility/testing/privacy docs и оба
changelog согласованы. **Доказательство:** diff review, docs checks и
compatibility fixtures.
- **AC12 (gates).** Typecheck, unit, build/bundle sync, selected frontend
smokes, backend tests, no-new-any, docs, workflow contracts и все новые
mutation witnesses зелёные на exact SHA. Golden и full performance остаются
предрелизными, Linux CI — канон полного HA harness.
## Таблица защитных доказательств
Точные имена тестов можно уточнить при реализации, но каждая строка сохраняет
отдельный отрицательный witness по правилу #435.
| AC | Чем доказан | Чем обязан покраснеть |
|---|---|---|
| AC1 | backend quota/catalog matrix | inventory снова строится из `read_catalog()` либо доверяет sidecar bytes; orphan не меняет count/bytes и boundary upload ложно проходит |
| AC2 | HA upload recovery test | recovery выполняется после quota либо возвращает `reused:true`; full-quota repair отказывается или assertion response падает |
| AC3 | HA delete filesystem matrix | delete снова зависит только от valid catalog row либо удаляет glob/prefix; orphan остаётся или sentinel исчезает |
| AC4 | exact sidecar-without-blob test | mutation удаляет `blob.is_file()`; missing blob появляется в catalog |
| AC5 | config-store/static-card call counters | mutation удаляет exact capability guard/revocation; старый backend получает resolve либо downgrade сохраняет map |
| AC6 | resolve cache call-count unit | mutation удаляет epoch из key; второй epoch не делает WS и прежний missing остаётся |
| AC7 | danger branch smoke | mutations возвращают cached render gate либо удаляют warm-transition cancel; promise зависает/ложно отклоняется или controller/DOM остаётся действующим |
| AC8 | Area cleanup targeted unit | mutation удаляет `snapshotBindings.has(binding)`; отсутствующий binding переносится |
| AC9 | timeout probe + workflow contract | mutation делает plain `await germanCompleted`, plain `node "$f"` либо удаляет `smoke.timeout-minutes`; probe превышает границу или contract assertion не находит одну из двух независимых границ |
| AC10 | support smoke с exact discard counters | mutation переставляет validation throw до cleanup/удаляет cleanup; token не отзывается либо отзывается дважды |
Для AC1–AC4/AC7/AC8/AC10, где полный backend/browser прогон дорог для ручного
повтора, добавляются persistent entries в `scripts/mutation-gate.mjs`. Для AC5,
AC6 и workflow contract допустим адресный unit red-run, если его команда и вывод
внесены в code-review; пустого третьего столбца быть не может.
## План автотестов
1. Создать backend filesystem matrix: valid row, missing blob, missing sidecar,
malformed/mismatched sidecar, wrong digest, duplicate allowed extensions,
unknown extension, temp и directory; независимо проверить trusted catalog и
physical inventory.
2. На count и byte limit проверить обычный отказ, exact orphan repair при уже
полном store и честную последовательность `false → true` для `reused`.
3. Через HA WebSocket проверить delete для orphan/broken/valid metadata, writer/
read-only, in-use race и sentinels, которые не совпадают exact id+allowlist.
4. Расширить `decor-assets` unit матрицей owner × epoch × id order × missing ×
rejected call × 200/201 ids.
5. Проверить shared snapshot для fresh exact/missing/malformed capability,
localStorage seed и capability-only upgrade/downgrade без смены config body.
6. В danger branch smoke удерживать locale request: отдельно открыть request до
warm transition, вызвать request до следующего render после смены языка и
завершить locale до следующего render. Все ожидания имеют локальный timeout.
7. Добавить exact Area candidate кейс и выполнить его с удалённым membership
condition.
8. Добавить German completion timeout probe и статический workflow contract;
timeout fixture обязан оставить следующий synthetic command выполненным, но
итоговый status — failure; отдельная проверка отличает 20-минутный timeout
`smoke` от одноимённого поля соседней `performance_smoke`.
9. В support smoke вернуть ответы с valid token и отдельно испорченными hash,
format, size и current generation; посчитать discard по каждому token.
10. Запустить штатное дерево и каждый mutation witness: падение должно происходить
по целевому assertion, а не по parse/import/внешнему глобальному timeout.
## Риски
- **Физический scan примет посторонний файл за asset.** Смягчение: exact hash
filename + фиксированный allowlist; это консервативно для quota, но никогда не
делает файл доверенным catalog asset.
- **Recovery перезапишет чужие bytes.** Смягчение: exact path и полный SHA-256;
mismatch только отказывает и требует явного delete.
- **Delete расширит область удаления.** Смягчение: fixed paths из allowlist,
no glob, permission/ref locks и sentinel tests.
- **Capability-only snapshot потеряется из-за fingerprint optimization.**
Смягчение: capability включена в adoption decision и имеет отдельный тест без
config/layout delta.
- **Danger fix нарушит `noChange` и мигнёт stable body.** Смягчение: smoke
сравнивает body identity/содержимое отдельно от удаляемого confirm outlet.
- **Per-file timeout окажется слишком коротким на cold runner.** Смягчение:
180 секунд значительно выше обычного отдельного smoke, job получает отдельные
20 минут на установку и весь shard; timeout печатает точное имя и лог для
пересмотра числа.
- **Best-effort discard сам упадёт.** Смягчение: исходная ошибка остаётся
основной, TTL backend сохраняется как финальная защита.
## Откат
Откат — единый revert implementation commit. Persisted schema и migration
отсутствуют. Уже восстановленные valid sidecars остаются обычными корректными
catalog rows и безопасны для старой версии. Возврат старой quota/cache/confirm/
cleanup логики допустим только вместе с возвратом соответствующих тестов и
changelog; пользовательские blobs при откате автоматически не удаляются.
## Release-артефакты
- `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md`: orphan recovery/quota/delete,
static capability/cache retry и безопасные async cleanup fixes;
- `docs/ARCHITECTURE.md`: trusted catalog против physical inventory и asset
recovery/delete boundaries;
- `docs/CONFIG-COMPATIBILITY.md`: runtime-only capability static card и cache
epoch без повышения API;
- `docs/TESTING.md`: 180-секундный per-smoke guard и negative witness policy;
- `docs/SUPPORT-PRIVACY.md`: token из непринятого ответа также отзывается;
- `scripts/mutation-gate.mjs`: постоянные backend/browser witnesses;
- docs screenshot workflow: подтверждение нулевой pixel-дельты и обновлённый
fingerprint из-за `src/**`;
- i18n, user guide, golden baselines и performance profiles: без изменений;
- implementation commit — `User-Visible: yes`, оба changelog в том же коммите.
## Принятые технические предположения
Эти решения не меняют продуктовый замысел и могут быть свободно скорректированы
ревьюером до S5:
- orphan остаётся невидимым в palette до exact повторной загрузки: без sidecar
нельзя достоверно показать имя, MIME и размеры;
- quota считает фактические allowed-extension files, а не уникальные hash:
каждое физическое потребление диска должно быть ограничено;
- broken sidecar repair получает metadata/имя/created_at текущей загрузки и
`reused:false`; прежним недоверенным полям sidecar не следуем;
- explicit delete по hash охватывает все allowed extensions этого exact hash,
потому что пользователь назвал content identity, а не одно недостоверное
расширение из sidecar;
- config cache epoch передаётся явно в `resolveDecorAssets`, а capability в
localStorage не сохраняется; свежий server response — единственный authority;
- 1000 мс для route-completion, 180 секунд + 10 секунд kill grace для файла и
20 минут для job `smoke` — технические liveness budgets, не пользовательские
таймауты;
- ссылки на строки из аудита ориентировочны: реализация привязывается к символам
и проверяемому поведению на актуальном `dev`.
+5
View File
@@ -84,6 +84,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
| [#314](https://github.com/Matysh/houseplan-card/issues/314) Атомарная запись v8 drafts и независимой геометрии | [314-v8-draft-write-regression.md](314-v8-draft-write-regression.md) |
| [#306](https://github.com/Matysh/houseplan-card/issues/306) Нулевые стены вместо виртуальных границ | [306-zero-thickness-walls.md](306-zero-thickness-walls.md) |
| [#348](https://github.com/Matysh/houseplan-card/issues/348) Полная немецкая локализация | [348-german-localization.md](348-german-localization.md) |
| [#428](https://github.com/Matysh/houseplan-card/issues/428) Round-trip экспорта с отсутствующей картинкой декора | [428-missing-decor-asset-roundtrip.md](428-missing-decor-asset-roundtrip.md) |
## P2
@@ -164,6 +165,9 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
| [#294](https://github.com/Matysh/houseplan-card/issues/294) Esc завершает текущую цепочку стен без удаления геометрии | [294-wall-esc-detach.md](294-wall-esc-detach.md) |
| [#419](https://github.com/Matysh/houseplan-card/issues/419) Безопасная уборка Area-снапшота при пустом или усечённом HA-реестре | [419-area-snapshot-roster-guard.md](419-area-snapshot-roster-guard.md) |
| [#421](https://github.com/Matysh/houseplan-card/issues/421) Отрицательные доказательства для трёх защитных проверок | [421-negative-test-proofs.md](421-negative-test-proofs.md) |
| [#426](https://github.com/Matysh/houseplan-card/issues/426) Отключение информационного окна комнаты при наведении | [426-room-hover-tooltip-toggle.md](426-room-hover-tooltip-toggle.md) |
| [#431](https://github.com/Matysh/houseplan-card/issues/431) Канонизация координат пользовательских изображений | [431-image-coordinate-canonicalization.md](431-image-coordinate-canonicalization.md) |
| [#432](https://github.com/Matysh/houseplan-card/issues/432) Ограниченный resolve и единая проверка целостности изображений | [432-asset-resolve-authorization-cache.md](432-asset-resolve-authorization-cache.md) |
## P3
@@ -176,6 +180,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
| [#340](https://github.com/Matysh/houseplan-card/issues/340) Обязательная ревизия повторной записи config/set | [340-config-set-revision.md](340-config-set-revision.md) |
| [#372](https://github.com/Matysh/houseplan-card/issues/372) Компактное верхнее кадрирование static card без заголовка | [372-space-card-empty-title.md](372-space-card-empty-title.md) |
| [#423](https://github.com/Matysh/houseplan-card/issues/423) Полиш support pipeline и защитных инструментов v1.70.0 | [423-v170-polish.md](423-v170-polish.md) |
| [#434](https://github.com/Matysh/houseplan-card/issues/434) Полиш аудита v1.71.0-beta.1 | [434-v171-polish-audit.md](434-v171-polish-audit.md) |
## P3
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "houseplan-card",
"version": "1.71.0-beta.1",
"version": "1.71.0-beta.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "houseplan-card",
"version": "1.71.0-beta.1",
"version": "1.71.0-beta.2",
"license": "MIT",
"dependencies": {
"lit": "^3.1.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.71.0-beta.1",
"version": "1.71.0-beta.2",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
+28 -8
View File
@@ -42,13 +42,39 @@ export const INITIAL_VIEW_GZIP_BUDGET = 300_000;
* средние фичи до стены, а не после неё.
*/
export const LOW_HEADROOM_WARNING_BYTES = 15_000;
export const SUPPORT_LAZY_INITIAL_BASELINE_BYTES = 291_046;
/**
* Числового храповика здесь больше нет, и это решение, а не упущение (#429).
*
* До #429 функция бросала при `initialViewGzipBytes >= 291 046` — «граф не стал
* больше, чем был на момент закрытия #423». На бете 1.71.0 запас до этого
* порога составлял пятнадцать байт, на момент правки — сто четыре. Пятнадцать
* байт gzip меньше одной строки локали: первый же посторонний коммит получил бы
* красный CI с сообщением про копирайт формы поддержки, к которому не имеет
* отношения.
*
* Гейт, обвиняющий не ту задачу, — худший вид гейта: его выключают, не
* разбираясь, и вместе с ним выключают проверку владения графом, которая как
* раз долговечна. Поэтому число снято, а проверка владения осталась.
*
* Что именно было снято по существу: «граф не вырос» — это критерий приёмки на
* момент задачи, а не свойство продукта. Свойство продукта охраняет общий
* бюджет (`INITIAL_VIEW_GZIP_BUDGET`) и предупреждение о запасе; они судят
* размер целиком и не привязаны к чужому issue.
*/
export const SUPPORT_LAZY_MARKERS = [
'Contact details (email/tg/WhatsApp), optional.',
'Контакт для связи (email/tg/WhatsApp), необязательно.',
];
/** Keep form-only support copy in the lazy editor graph (#423). */
/**
* Форма поддержки живёт только в ленивом графе редактора (#423).
*
* Функция судит ВЛАДЕНИЕ, а не размер: маркеры формы обязаны отсутствовать в
* `initialViewFiles` и присутствовать в `lazyEditorFiles`. Размер охраняют
* `assertBundleBudget` и `lowHeadroomWarning` — им для этого не нужен чужой
* номер issue (#429).
*/
export function assertSupportBundleOwnership(
manifest,
root = 'dist',
@@ -67,12 +93,6 @@ export function assertSupportBundleOwnership(
throw new Error(`support form copy missing from lazy editor graph: ${marker}`);
}
}
if (manifest.initialViewGzipBytes >= SUPPORT_LAZY_INITIAL_BASELINE_BYTES) {
throw new Error(
`initial View graph ${manifest.initialViewGzipBytes} B gzip did not improve on #423 baseline `
+ `${SUPPORT_LAZY_INITIAL_BASELINE_BYTES} B`,
);
}
}
/** Тревога о запасе: `null`, пока его хватает. */
+4
View File
@@ -407,6 +407,10 @@
"opaque": "<function _north_deg>",
"required": false
},
"config.settings.show_room_tooltip": {
"required": false,
"type": "bool"
},
"config.settings.sun_rays": {
"required": false,
"type": "bool"
+22 -1
View File
@@ -34,8 +34,29 @@ import { sourceFingerprint } from './source-fingerprint.mjs';
*/
export const HARNESS = {
smoke: {
// Всё, что job «Смоки в браузере» ИСПОЛНЯЕТ, а не только сами смоки (#430).
//
// До этой задачи ключ держал ровно `demo/smoke_*.mjs`. Из него выпадали:
// `demo/serve.mjs` — сама оснастка, включая гард исключений; `demo/guard/**`
// — отрицательные пробы этого гарда и запускающий их `verify-guard.mjs`;
// benchmark, который одна из проб запускает с `--guard-probe`.
//
// Стоило это ровно того, чего и должно было. Прогон #2371 (ee678352)
// добавлял в `verify-guard.mjs` пробу гарда benchmark — и job со смоками
// была ПРОПУЩЕНА как переиспользованная: правка файла, который исполняется
// только в ней, её ключ не меняла. Проба уехала в `dev`, ни разу не
// запустившись. Пропущенная проверка выглядит точно как пройденная — и это
// тот же дефект, против которого заведён весь #430.
//
// Benchmark'и берутся все, а не по имени: любой из них может быть подключён
// к пробам позже, а платить за лишний прогон дешевле, чем за молчание.
// `demo/fixtures/**` в ключ не входит — он в корпусе `sourceFingerprint`,
// который уже подмешан в ключ строкой выше.
roots: ['demo'],
keep: (rel) => /^demo\/smoke_[^/]+\.mjs$/.test(rel),
keep: (rel) => /^demo\/smoke_[^/]+\.mjs$/.test(rel)
|| rel === 'demo/serve.mjs'
|| /^demo\/guard\//.test(rel)
|| /^demo\/benchmark_[^/]+\.mjs$/.test(rel),
},
golden: {
// demo/golden/** целиком: и сценарии, и эталоны — эталон тоже вход
+377 -2
View File
@@ -1048,8 +1048,14 @@ const MUTANT_DEFINITIONS = [
+ 'its only decision source (#417 AC1)',
patches: [{
file: 'src/houseplan-card.ts',
find: " if (this._dangerConfirm && this._dangerConfirmMissingSpace()) {\n this._cancelDangerConfirm();\n }",
replace: " void this._dangerConfirm;\n void this._dangerConfirmMissingSpace;",
find: ' if (this._dangerConfirm && (\n'
+ ' this._dangerConfirmMissingSpace() || this._dangerConfirmLocaleGate === \'warm\'\n'
+ ' )) {\n'
+ ' this._cancelDangerConfirm();\n'
+ ' }',
replace: ' if (this._dangerConfirm && this._dangerConfirmLocaleGate === \'warm\') {\n'
+ ' this._cancelDangerConfirm();\n'
+ ' }',
}],
},
{
@@ -1063,6 +1069,49 @@ const MUTANT_DEFINITIONS = [
replace: " void this._dangerConfirmLocaleGate;",
}],
},
{
id: 'danger-confirm-uses-last-rendered-language-gate',
guard: 'node demo/smoke_danger_confirm_branches.mjs',
because: 'ready -> warm and warm -> ready both have a window before render; consulting the '
+ 'last painted branch can admit an unrenderable request or reject a renderable one (#434 AC7)',
patches: [{
file: 'src/houseplan-card.ts',
find: " /** Current language branch; never cached from a previous render. */\n"
+ ' private get _dangerConfirmLocaleGate(): LanguageRenderGate {\n'
+ " if (!this._config || !this.hass) return 'ready';\n"
+ ' return languageRenderGate(\n'
+ ' this, LANGUAGE_RUNTIME, langOf(this.hass, this._config.language),\n'
+ ' );\n'
+ ' }',
replace: " private _dangerConfirmLocaleGate: LanguageRenderGate = 'ready';",
}, {
file: 'src/houseplan-card.ts',
find: ' const localeGate = this._dangerConfirmLocaleGate;\n'
+ " if (localeGate === 'cold') return languageLoadingTemplate();",
replace: ' const localeGate = languageRenderGate(\n'
+ ' this, LANGUAGE_RUNTIME, langOf(this.hass, this._config.language),\n'
+ ' );\n'
+ ' this._dangerConfirmLocaleGate = localeGate;\n'
+ " if (localeGate === 'cold') return languageLoadingTemplate();",
}],
},
{
id: 'danger-confirm-warm-transition-cancel-removed',
guard: 'node demo/smoke_danger_confirm_branches.mjs',
because: 'a dialog opened while ready must settle false and lose its actionable DOM owner '
+ 'when a new language moves the committed card into the warm noChange branch (#434 AC7)',
patches: [{
file: 'src/houseplan-card.ts',
find: ' if (this._dangerConfirm && (\n'
+ ' this._dangerConfirmMissingSpace() || this._dangerConfirmLocaleGate === \'warm\'\n'
+ ' )) {\n'
+ ' this._cancelDangerConfirm();\n'
+ ' }',
replace: ' if (this._dangerConfirm && this._dangerConfirmMissingSpace()) {\n'
+ ' this._cancelDangerConfirm();\n'
+ ' }',
}],
},
{
id: 'furniture-edge-handles-steal-the-corner',
guard: 'node demo/smoke_furniture_polish.mjs',
@@ -2335,6 +2384,32 @@ const MUTANT_DEFINITIONS = [
replace: ' const candidate = this._serverCfg;',
}],
},
{
id: 'image-box-frontend-canonicalization-omitted',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="decor box catalog" '
+ 'test/coordinate-canonicalization.test.mjs',
because: 'the decor type can know about an image while a handwritten traversal silently '
+ 'leaves its x/y/w/h outside the canonical write barrier (#431 AC4)',
patches: [{
file: 'src/coordinate-canonicalization.ts',
find: ' && (DECOR_BOX_KINDS as readonly string[]).includes(value);',
replace: " && value !== 'image'\n"
+ ' && (DECOR_BOX_KINDS as readonly string[]).includes(value);',
}],
},
{
id: 'image-box-python-canonicalization-omitted',
guard: 'node scripts/backend-test-guard.mjs decor_box_catalog_matches_shared_contract '
+ 'tests_backend/test_coordinate_canonicalization.py',
because: 'the Python schema and storage boundary must mirror the complete frontend box '
+ 'catalog instead of accepting image floating-point tails from older clients (#431 AC4)',
patches: [{
file: 'custom_components/houseplan/coordinate_canonicalization.py',
find: 'DECOR_BOX_KINDS = ("rect", "ellipse", "furniture", "image")',
replace: 'DECOR_BOX_KINDS = ("rect", "ellipse", "furniture")',
}],
},
{
id: 'quantization-hits-allowlist',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
@@ -4638,6 +4713,19 @@ const MUTANT_DEFINITIONS = [
replace: ' this._devicePositionHistory.clear();',
}],
},
{
id: 'area-cleanup-keeps-candidate-outside-current-snapshot',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="absent from the current snapshot" '
+ 'test/device-area-relocation.test.mjs',
because: 'finite two-frame evidence belongs to one current snapshot binding; retaining a '
+ 'candidate after that binding was removed can later delete unrelated provenance (#434 AC8)',
patches: [{
file: 'src/device-area-relocation.ts',
find: ' if (validBinding(binding) && Number.isFinite(revision) && snapshotBindings.has(binding)) {',
replace: ' if (validBinding(binding) && Number.isFinite(revision)) {',
}],
},
{
id: 'support-stale-preview-response-revives-consent',
guard: 'node demo/smoke_support_feedback.mjs',
@@ -4651,6 +4739,22 @@ const MUTANT_DEFINITIONS = [
replace: ' return true;',
}],
},
{
id: 'support-invalid-response-leaks-issued-token',
guard: 'node demo/smoke_support_feedback.mjs',
because: 'a syntactically valid token is already an allocated backend slot even when the '
+ 'rest of the preview response is invalid; it must be discarded independently of UI '
+ 'currentness instead of lingering until TTL (#434 AC10)',
patches: [{
file: 'src/houseplan-editor-runtime.ts',
find: ' if (issuedToken) {\n'
+ ' const token = issuedToken;\n'
+ " issuedToken = '';\n"
+ ' void this._discardSupportPreview(token);\n'
+ ' }',
replace: ' void issuedToken;',
}],
},
{
id: 'support-edited-retry-reuses-old-idempotency-key',
guard: 'node demo/smoke_support_feedback.mjs',
@@ -4683,6 +4787,277 @@ const MUTANT_DEFINITIONS = [
+ " errorCode: '',",
}],
},
{
id: 'decor-raster-full-decode-skipped',
guard: 'python3 -m pytest tests_backend/test_decor_assets.py -q -p no:cacheprovider',
because: 'a valid-looking PNG whose IDAT is not a zlib stream must be refused before it '
+ 'enters the authenticated store; header parsing answers w/h/mime and cannot answer '
+ 'whether the raster decodes at all (#51 AC, аудит #430 п.1)',
patches: [{
file: 'custom_components/houseplan/decor_assets.py',
find: ' with Image.open(BytesIO(data)) as image:\n'
+ ' image.load()\n'
+ ' if image.size != (width, height):\n'
+ ' raise DecorAssetError("invalid_image", "Image dimensions are inconsistent")\n'
+ ' if getattr(image, "is_animated", False):\n'
+ ' raise DecorAssetError("unsupported_image", "Animated images are unsupported")',
replace: ' _ = (Image, BytesIO)',
}],
},
{
id: 'decor-physical-inventory-follows-sidecars',
guard: 'python3 -m pytest tests_backend/test_decor_assets.py -q -p no:cacheprovider '
+ '-k physical_inventory',
because: 'quota is a physical-storage boundary: malformed/missing sidecars cannot make '
+ 'promoted blobs invisible, while sidecars without blobs consume no blob quota (#434 AC1)',
patches: [{
file: 'custom_components/houseplan/decor_assets.py',
find: ' for path in root.iterdir():\n'
+ ' if (\n'
+ ' path.suffix in ASSET_EXTENSIONS',
replace: ' for path in root.glob("*.json"):\n'
+ ' if (\n'
+ ' path.suffix in ASSET_EXTENSIONS',
}],
},
{
id: 'decor-catalog-accepts-sidecar-without-blob',
guard: 'python3 -m pytest tests_backend/test_decor_assets.py -q -p no:cacheprovider '
+ '-k valid_shaped_sidecar_without_blob',
because: 'catalog/list/resolve remain strict projections; a valid-looking sidecar must not '
+ 'materialise an image whose matching blob is absent (#434 AC4)',
patches: [{
file: 'custom_components/houseplan/decor_assets.py',
find: ' or not blob.is_file()\n',
replace: '',
}],
},
{
id: 'decor-orphan-repair-runs-after-quota',
guard: 'python3 -m pytest tests_backend/test_ha_websocket.py -q -p no:cacheprovider '
+ '-k decor_asset_upload_deduplicates',
because: 'an exact digest-proven orphan adds no physical bytes and must repair its sidecar '
+ 'even when physical count quota is already full (#434 AC2)',
patches: [{
file: 'custom_components/houseplan/http_api.py',
find: ' if blob.exists():\n'
+ ' if not blob.is_file() or hashlib.sha256(blob.read_bytes()).hexdigest() != aid:',
replace: ' if False and blob.exists():\n'
+ ' if not blob.is_file() or hashlib.sha256(blob.read_bytes()).hexdigest() != aid:',
}],
},
{
id: 'decor-orphan-repair-claims-reuse',
guard: 'python3 -m pytest tests_backend/test_ha_websocket.py -q -p no:cacheprovider '
+ '-k decor_asset_upload_deduplicates',
because: 'reused:true means a valid catalog entry predated the request; rebuilding a lost or '
+ 'broken sidecar creates that entry and must report false (#434 AC2)',
patches: [{
file: 'custom_components/houseplan/http_api.py',
find: ' os.replace(meta_temp, meta)\n'
+ ' return row, False\n'
+ ' finally:',
replace: ' os.replace(meta_temp, meta)\n'
+ ' return row, True\n'
+ ' finally:',
}],
},
{
id: 'decor-delete-skips-orphan-blobs',
guard: 'python3 -m pytest tests_backend/test_ha_websocket.py -q -p no:cacheprovider '
+ '-k decor_asset_delete_removes_exact_orphans_only',
because: 'explicit delete is the only bounded recovery path for invisible orphan blobs; '
+ 'depending on catalog metadata leaves quota permanently occupied (#434 AC3)',
patches: [{
file: 'custom_components/houseplan/websocket_api.py',
find: ' for extension in ASSET_EXTENSIONS:\n',
replace: ' for extension in ():\n',
}],
},
{
id: 'decor-svg-canonical-bytes-discarded',
guard: 'python3 -m pytest tests_backend/test_decor_assets.py -q -p no:cacheprovider',
because: 'the stored SVG must be the re-serialised canonical form, not the upload: keeping '
+ 'the original bytes silently reinstates whatever the parser dropped — prologue, '
+ 'comments, exotic spelling of the same tree (#51 ТЗ §3, аудит #430 п.2)',
patches: [{
file: 'custom_components/houseplan/decor_assets.py',
find: 'return ValidatedAsset(canonical, "image/svg+xml"',
replace: 'return ValidatedAsset(data, "image/svg+xml"',
}],
},
{
id: 'decor-svg-canonical-size-unchecked',
guard: 'python3 -m pytest tests_backend/test_decor_assets.py -q -p no:cacheprovider',
because: 'canonicalisation can grow the document fourfold by escaping text, so the 2 MiB '
+ 'limit must be re-applied to the canonical bytes: a 1.84 MiB upload otherwise lands '
+ 'as 7.35 MiB in the store (аудит #430 п.2)',
patches: [{
file: 'custom_components/houseplan/decor_assets.py',
find: ' _check_size(canonical)\n',
replace: '',
}],
},
{
id: 'decor-svg-external-url-guard-off',
guard: 'python3 -m pytest tests_backend/test_decor_assets.py -q -p no:cacheprovider',
because: 'javascript:, data:, http:, https: and // inside an allowed attribute of an allowed '
+ 'tag are caught by this rule alone; every "external" case of the original corpus was '
+ 'caught by tag or attribute allowlists instead (аудит #430 п.3)',
patches: [{
file: 'custom_components/houseplan/decor_assets.py',
find: 'if any(token in low for token in ("javascript:", "data:", "http:", "https:", "//")):',
replace: 'if False:',
}],
},
{
id: 'decor-image-flip-v-ignored',
guard: 'node --test test/decor-assets.test.mjs',
because: 'vertical flip is half of the image projection contract and had no witness of its '
+ 'own: the single #51 case set flip_h only, so dropping flip_v stayed green (#51 AC3, '
+ 'аудит #430 п.4)',
patches: [{
file: 'src/decor-assets.ts',
find: '${shape.flip_v ? -1 : 1}',
replace: '1',
}],
},
{
id: 'decor-image-opacity-ignored',
guard: 'node --test test/decor-assets.test.mjs',
because: 'the projection must carry the shape opacity; the only case asserted opacity 2 → 1, '
+ 'an expectation indistinguishable from hardcoding 1 (#51 AC4, аудит #430 п.4)',
patches: [{
file: 'src/decor-assets.ts',
find: 'const opacity = clamp01(shape.opacity, 1);',
replace: 'const opacity = 1;',
}],
},
{
id: 'decor-asset-id-shape-unchecked',
guard: 'node --test test/decor-assets.test.mjs',
because: 'the catalog row must prove its own asset_id shape: the malformed row of #51 kept '
+ 'the url of a real asset, so the url comparison caught it and the id regex could be '
+ 'deleted unnoticed (аудит #430 п.4)',
patches: [{
file: 'src/decor-assets.ts',
find: " if (!DECOR_ASSET_ID_RE.test(String(row.asset_id || '')) || row.url !== expectedUrl",
replace: ' if (row.url !== expectedUrl',
}],
},
{
id: 'asset-resolve-readonly-membership-removed',
guard: 'node scripts/backend-test-guard.mjs '
+ 'decor_asset_resolve_readonly_is_limited_to_referenced_ids '
+ 'tests_backend/test_ha_websocket.py',
because: 'a read-only household member needs referenced images for View but must not use '
+ 'assets/resolve to probe or hash arbitrary catalog ids (#432 AC2)',
patches: [{
file: 'custom_components/houseplan/websocket_api.py',
find: ' allowed = requested & referenced\n',
replace: ' allowed = requested\n',
}],
},
{
id: 'asset-integrity-cache-hit-disabled',
guard: 'node scripts/backend-test-guard.mjs '
+ 'integrity_cache_reuses_digest_and_caches_corrupt_signature '
+ 'tests_backend/test_decor_assets.py',
because: 'unchanged valid and corrupt files must reuse the actual digest instead of '
+ 're-reading the blob for every WS resolve or HTTP GET (#432 AC5)',
patches: [{
file: 'custom_components/houseplan/asset_integrity.py',
find: ' if cached is not None and cached.signature == before:\n',
replace: ' if False and cached is not None and cached.signature == before:\n',
}],
},
{
id: 'asset-integrity-single-flight-disabled',
guard: 'node scripts/backend-test-guard.mjs '
+ 'integrity_cache_single_flights_same_path_and_releases_after_error '
+ 'tests_backend/test_decor_assets.py',
because: 'parallel requests for one file version must share one streaming hash and wake '
+ 'all waiters after success or failure (#432 AC6)',
patches: [{
file: 'custom_components/houseplan/asset_integrity.py',
find: ' flight = self._inflight.get(key)\n',
replace: ' flight = None\n',
}],
},
{
id: 'asset-integrity-post-read-signature-ignored',
guard: 'node scripts/backend-test-guard.mjs '
+ 'integrity_cache_invalidates_changed_signature_and_rejects_mid_read_change '
+ 'tests_backend/test_decor_assets.py',
because: 'a digest computed while the blob changes must fail dark and never become a '
+ 'trusted cache entry for either transport (#432 AC7)',
patches: [{
file: 'custom_components/houseplan/asset_integrity.py',
find: ' stable = _signature(path) == before\n',
replace: ' stable = True\n',
}],
},
{
id: 'decor-oversize-hides-the-downscale-action',
guard: 'node --test test/decor-image-upload.test.mjs',
because: 'a decor source above the 2 MiB asset limit must still be offered as a reduced '
+ 'copy: the flag forbids keeping the ORIGINAL, and gating the whole action block on it '
+ 'is #427 — a bug that survived four review rounds because this path had no test at all',
patches: [{
file: 'src/backdrop-pick.ts',
find: ' ${hard ? null : html`',
replace: ' ${hard || !allowOriginal ? null : html`',
}],
},
{
id: 'decor-upload-loses-the-replace-flag',
guard: 'node --test test/decor-image-upload.test.mjs',
because: 'the guard dialog must upload into whichever slot the caller asked for: losing the '
+ 'replaceSelection flag silently turns "replace this image" into "arm the palette" and '
+ 'leaves the selected shape pointing at the old asset (#51 AC1, #433)',
patches: [{
file: 'src/decor-image-editor.ts',
find: ' this.hooks.setGuardReplace(replaceSelection);',
replace: ' this.hooks.setGuardReplace(false);',
}],
},
{
id: 'decor-upload-error-codes-collapse',
guard: 'node --test test/decor-image-upload.test.mjs',
because: 'each backend refusal code carries its own message: collapsing too_large into the '
+ 'generic io_error tells the user "something went wrong" where the product knows exactly '
+ 'what went wrong and how to fix it (#433)',
patches: [{
file: 'src/decor-image-editor.ts',
find: " too_large: 'backdrop.too_large_title',\n",
replace: '',
}],
},
{
id: 'pure-backend-test-pulls-home-assistant',
guard: 'python3 -m pytest tests_backend/test_backend_quality.py -q -p no:cacheprovider',
because: 'a pure test module that imports an HA-dependent backend module must be caught '
+ 'statically: pytest aborts on collection, so ALL pure tests stop running and the '
+ 'output looks nothing like a normal failure (#436, the #389 pattern)',
patches: [{
file: 'tests_backend/test_projection.py',
find: 'import copy\nimport importlib.util\nimport os',
replace: 'import copy\nimport importlib.util\nimport os\n'
+ 'from custom_components.houseplan.store import async_save_config_state',
}],
},
{
id: 'benchmark-page-verdict-unwatched',
guard: 'node demo/guard/verify-guard.mjs',
because: 'the page benchmark of #423 must register its page with watchPage, and that must be '
+ 'proven by running it: the previous proof asked a regexp whether it still finds the '
+ 'substring the same test had just deleted (аудит #430 п.5)',
patches: [{
file: 'demo/benchmark_backdrop_decode.mjs',
find: 'const page = watchPage(await (await browser.newContext()).newPage());',
replace: 'const page = await (await browser.newContext()).newPage();',
}],
},
];
const mutationCardSource = readFileSync(join(repoRoot, 'src/houseplan-card.ts'), 'utf8');
+7
View File
@@ -27,6 +27,13 @@
/** @type {SmokeLink[]} */
export const SMOKE_LINKS = [
{
symbols: ['showRoomTooltipOf'],
smokes: ['smoke_room_tooltip_toggle.mjs'],
because: 'the production-bundle scenario proves the global-settings draft and persistence, '
+ 'room-only suppression before area work, immediate stale-room-tip cleanup, preserved room '
+ 'highlight/device tooltip and default-on restoration (#426)',
},
{
symbols: [
'applyDevicePlacement', 'devicePlacement', 'sameDevicePlacement',
+4 -3
View File
@@ -238,9 +238,10 @@ export function renderBackdropGuard(
<button class="btn ghost" ?disabled=${guard.busy} @click=${() => dismiss()}>
${host._t('btn.cancel')}</button>
<span class="spacer"></span>
${hard || !allowOriginal ? null : html`
<button class="btn ghost" ?disabled=${guard.busy} @click=${() => original()}>
${host._t('backdrop.keep_original')}</button>
${hard ? null : html`
${allowOriginal ? html`
<button class="btn ghost" ?disabled=${guard.busy} @click=${() => original()}>
${host._t('backdrop.keep_original')}</button>` : null}
<button class="btn on" ?disabled=${guard.busy} @click=${() => reduced()}>
${guard.busy ? host._t('backdrop.reducing') : host._t('backdrop.use_downscaled')}
</button>`}
+4
View File
@@ -21,6 +21,8 @@ const LS_CFG = 'houseplan_card_cfg_v1';
export interface HpConfigSnapshot {
config: any | null;
rev: number;
/** Runtime-only: absent until a fresh config/get advertises exact v1. */
decorAssetsApi: number | null;
configFingerprint: string;
layout: Record<string, any>;
layoutRev: number;
@@ -50,6 +52,7 @@ export function cachedSnapshot(): HpConfigSnapshot | null {
return {
config: c.config,
rev: c.rev || 0,
decorAssetsApi: null,
configFingerprint: c.config_fingerprint || contentFingerprint(c.config),
layout,
layoutRev: c.layout_rev || 0,
@@ -80,6 +83,7 @@ async function fetchFresh(hass: any, generation: number): Promise<HpConfigSnapsh
const snapshot: HpConfigSnapshot = {
config: cfgResp?.config ?? null,
rev: configRev,
decorAssetsApi: cfgResp?.decor_assets_api === 1 ? 1 : null,
configFingerprint: contentFingerprint(cfgResp?.config ?? null),
layout: layResp?.layout ?? {},
layoutRev: layResp?.rev ?? 0,
+14 -2
View File
@@ -5,6 +5,13 @@
* Keep the precision, lattice formula and field allow-list in lockstep.
*/
import {
DECOR_BOX_KINDS,
type DecorBoxKind,
} from './editors/decor/types';
export { DECOR_BOX_KINDS };
export const COORDINATE_DECIMALS = 9;
export const COORDINATE_FACTOR = 10 ** COORDINATE_DECIMALS;
export const LATTICE_GRID_N = 240;
@@ -84,6 +91,11 @@ function records(value: unknown): JsonRecord[] {
: [];
}
function isDecorBoxKind(value: unknown): value is DecorBoxKind {
return typeof value === 'string'
&& (DECOR_BOX_KINDS as readonly string[]).includes(value);
}
function scalarFields(item: JsonRecord, names: readonly string[]): void {
for (const name of names) {
if (Object.prototype.hasOwnProperty.call(item, name)) {
@@ -155,7 +167,7 @@ function visitLatticeCoordinates(
for (const opening of records(space.openings)) fieldValues(opening, ['x', 'y']);
for (const decor of records(space.decor)) {
if (decor.kind === 'line') fieldValues(decor, ['x1', 'y1', 'x2', 'y2']);
else if (decor.kind === 'rect' || decor.kind === 'ellipse' || decor.kind === 'furniture') {
else if (isDecorBoxKind(decor.kind)) {
fieldValues(decor, ['x', 'y', 'w', 'h']);
} else if (decor.kind === 'text') fieldValues(decor, ['x', 'y']);
}
@@ -335,7 +347,7 @@ export function canonicalizeConfigGeometryInPlace<T>(config: T): T {
for (const decor of records(space.decor)) {
if (decor.kind === 'line') latticeFields(decor, ['x1', 'y1', 'x2', 'y2']);
else if (decor.kind === 'rect' || decor.kind === 'ellipse' || decor.kind === 'furniture') {
else if (isDecorBoxKind(decor.kind)) {
latticeFields(decor, ['x', 'y', 'w', 'h']);
scalarFields(decor, ['angle']);
} else if (decor.kind === 'text') {
+2 -1
View File
@@ -82,10 +82,11 @@ export async function resolveDecorAssets(
connection?: object;
},
assetIds: readonly string[],
configEpoch: number | string,
): Promise<Map<string, DecorAsset>> {
const unique = [...new Set(assetIds.filter((id) => DECOR_ASSET_ID_RE.test(id)))].sort();
const owner = hass.connection || hass;
const key = unique.join(',');
const key = JSON.stringify([configEpoch, unique]);
const cached = resolveCache.get(owner);
if (cached?.[0] === key) return cached[1];
const out = new Map<string, DecorAsset>();
+3 -1
View File
@@ -5,7 +5,9 @@
* presentation (`width_cm`) is deliberately separate from geometry: resizing
* an object must never make its outline thicker.
*/
export type DecorKind = 'line' | 'rect' | 'ellipse' | 'text' | 'furniture' | 'image';
export const DECOR_BOX_KINDS = ['rect', 'ellipse', 'furniture', 'image'] as const;
export type DecorBoxKind = (typeof DECOR_BOX_KINDS)[number];
export type DecorKind = 'line' | 'text' | DecorBoxKind;
export interface DecorBase {
id: string;
+45 -48
View File
@@ -39,7 +39,7 @@ import {
spaceDisplayOf, resolveEffectiveRoomFill, fillColorsOf, DEFAULT_FILL_COLORS,
customFillOf, roomCustomFillOf, DEFAULT_CUSTOM_FILL,
type FillColors, type FillColorEntry, type ResolvedRoomFill, runServiceFor, RUN_TARGET_DOMAINS,
DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY, stageBgOf,
DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY, stageBgOf, showRoomTooltipOf,
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
referencedContentUrls,
DISPLAY_MODES, TAP_ACTIONS, SPACE_FILL_UI_MODES, ROOM_FILL_MODES,
@@ -378,7 +378,7 @@ import {
} from './space-order';
import { applyOpeningMoves, mergeCollinearPartitions, spaceMergeGeometry } from './wall-merge';
const CARD_VERSION = '1.71.0-beta.1';
const CARD_VERSION = '1.71.0-beta.2';
const ENTRY_BUILD_FINGERPRINT = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
const EDITOR_RETRY_ASSET = '__HOUSEPLAN_EDITOR_RETRY_ASSET__';
@@ -1026,6 +1026,7 @@ export class HouseplanCard extends LitElement {
lqi?: number | null;
temp?: number | null;
hum?: number | null;
room?: boolean;
} | null = null;
/** Room whose physical perimeter is highlighted in View. The explicit
* overlay is needed because thick wall bodies paint above room shapes. */
@@ -2156,8 +2157,13 @@ export class HouseplanCard extends LitElement {
} | { kind: 'run'; text: string; exec: () => void } | null = null;
/** One eager confirmation owner shared by View, onboarding and lazy editors. */
private _dangerConfirm: HpConfirmState | null = null;
/** Last language branch painted by `_renderBody`; `warm` cannot accept new dialogs. */
private _dangerConfirmLocaleGate: LanguageRenderGate = 'ready';
/** Current language branch; never cached from a previous render. */
private get _dangerConfirmLocaleGate(): LanguageRenderGate {
if (!this._config || !this.hass) return 'ready';
return languageRenderGate(
this, LANGUAGE_RUNTIME, langOf(this.hass, this._config.language),
);
}
private readonly _dangerConfirmController = new HpConfirmController((state) => {
this._dangerConfirm = state;
});
@@ -2219,6 +2225,7 @@ export class HouseplanCard extends LitElement {
colors: FillColors; glowRadius: number; bgColor: string | null;
/** sun on the plan (docs/SUN.md) */
northDeg: number | null; bgMode: 'static' | 'daynight'; sunRays: boolean;
showRoomTooltip: boolean;
busy: boolean;
} | null = null;
private _supportDialog: SupportDialogState | null = null;
@@ -4182,7 +4189,9 @@ export class HouseplanCard extends LitElement {
this._syncEmptySpaceState();
// #417: losing the only renderable space while a decision is pending must
// settle it before render() clears hp-confirm with `nothing`.
if (this._dangerConfirm && this._dangerConfirmMissingSpace()) {
if (this._dangerConfirm && (
this._dangerConfirmMissingSpace() || this._dangerConfirmLocaleGate === 'warm'
)) {
this._cancelDangerConfirm();
}
if (changed.has('hass') && this.hass) {
@@ -4307,7 +4316,7 @@ export class HouseplanCard extends LitElement {
this._decorAssets = new Map();
return;
}
const resolved = await resolveDecorAssets(this.hass, decorAssetIds(cfg));
const resolved = await resolveDecorAssets(this.hass, decorAssetIds(cfg), this._cfgRev);
if (token !== this._decorAssetSyncToken) return;
this._decorAssets = resolved;
this._resign();
@@ -7286,11 +7295,12 @@ export class HouseplanCard extends LitElement {
lqi?: number | null,
temp?: number | null,
hum?: number | null,
room = false,
): void {
this._notePointer(ev);
if (!this._pointerModality.hoverEnabled) return;
if (this._drag || this._deviceDrag) return;
this._tip = { x: ev.clientX, y: ev.clientY, title, meta, lqi, temp, hum };
this._tip = { x: ev.clientX, y: ev.clientY, title, meta, lqi, temp, hum, room };
}
// ================= ROOM MARKUP EDITOR =================
@@ -11206,23 +11216,24 @@ export class HouseplanCard extends LitElement {
</hp-confirm>`;
}
/** One stable Lit template site lets a nested `noChange` retain the body
* while the independent confirmation child is removed during a warm gate. */
private _renderRoot(body: TemplateResult | typeof noChange): TemplateResult {
return html`${body}${this._renderDangerConfirm()}`;
}
protected render(): TemplateResult | typeof nothing | typeof noChange {
const body = this._renderBody();
// `noChange` is Lit's «do not touch the DOM» signal and `nothing` means the
// card is not initialised yet — neither may be wrapped in a template. In
// both, the card draws nothing at all, so a confirmation cannot be shown:
// `_confirmDanger` refuses such a request outright instead of leaving it
// pending (ТЗ §Граница). The user has pressed nothing at that point.
if (body === noChange || body === nothing) return body;
return html`${body}${this._renderDangerConfirm()}`;
// `nothing` is the only root that has no decision surface. `noChange` is
// deliberately nested: it preserves the committed body while allowing the
// sibling hp-confirm to settle/cancel on a ready -> warm transition.
if (body === nothing) return body;
return this._renderRoot(body);
}
private _renderBody(): TemplateResult | typeof nothing | typeof noChange {
if (!this._config || !this.hass) return nothing;
const localeGate = languageRenderGate(
this, LANGUAGE_RUNTIME, langOf(this.hass, this._config.language),
);
this._dangerConfirmLocaleGate = localeGate;
const localeGate = this._dangerConfirmLocaleGate;
if (localeGate === 'cold') return languageLoadingTemplate();
if (localeGate === 'warm') return noChange;
const onboardingRuntimeRequested = !!this._importDialog
@@ -11577,6 +11588,10 @@ export class HouseplanCard extends LitElement {
let areaText: string | null | undefined;
const tip = (e: PointerEvent) => {
if (this._mode !== 'view') return;
if (!showRoomTooltipOf(this._settings)) {
if (this._tip?.room) this._tip = null;
return;
}
if (areaText === undefined) areaText = this._roomArea(r);
this._showTip(
e,
@@ -11585,8 +11600,15 @@ export class HouseplanCard extends LitElement {
showLqi ? this._roomLqi(r.area) : null,
this._roomTemp(r),
this._roomHum(r),
true,
);
};
const enterRoom = (event: PointerEvent) => {
this._notePointer(event);
if (this._pointerModality.hoverEnabled) {
this._hoverRoom = { space: space.id, room: r };
}
};
const myPoly = polyOf(r);
// A room's ordinary solid stroke must not run beneath its zero
// wall overlay — suppress it and draw a trimmed outline. This
@@ -11623,57 +11645,32 @@ export class HouseplanCard extends LitElement {
? svg`<path class="${cls}" style="${style}" fill-rule="evenodd"
data-hp="room" data-id=${hpId} data-area=${hpArea}
d="${[obstaclePath, ...holes.map(pathD)].join(' ')}"
@pointerenter=${(event: PointerEvent) => {
this._notePointer(event);
if (this._pointerModality.hoverEnabled) {
this._hoverRoom = { space: space.id, room: r };
}
}}
@pointerenter=${enterRoom}
@pointermove=${tip}
@pointerleave=${() => this._clearTransientHover()}></path>`
: holes.length && fillPoly
? svg`<path class="${cls}" style="${style}" fill-rule="evenodd"
data-hp="room" data-id=${hpId} data-area=${hpArea}
d="${[fillPoly, ...holes].map(pathD).join(' ')}"
@pointerenter=${(event: PointerEvent) => {
this._notePointer(event);
if (this._pointerModality.hoverEnabled) {
this._hoverRoom = { space: space.id, room: r };
}
}}
@pointerenter=${enterRoom}
@pointermove=${tip}
@pointerleave=${() => this._clearTransientHover()}></path>`
: fillPoly && fillPoly !== myPoly
? svg`<polygon class="${cls}" style="${style}" points="${fillPoly.map((p) => p.join(',')).join(' ')}"
data-hp="room" data-id=${hpId} data-area=${hpArea}
@pointerenter=${(event: PointerEvent) => {
this._notePointer(event);
if (this._pointerModality.hoverEnabled) {
this._hoverRoom = { space: space.id, room: r };
}
}}
@pointerenter=${enterRoom}
@pointermove=${tip}
@pointerleave=${() => this._clearTransientHover()}></polygon>`
: r.poly
? svg`<polygon class="${cls}" style="${style}" points="${r.poly.map((p) => p.join(',')).join(' ')}"
data-hp="room" data-id=${hpId} data-area=${hpArea}
@pointerenter=${(event: PointerEvent) => {
this._notePointer(event);
if (this._pointerModality.hoverEnabled) {
this._hoverRoom = { space: space.id, room: r };
}
}}
@pointerenter=${enterRoom}
@pointermove=${tip}
@pointerleave=${() => this._clearTransientHover()}></polygon>`
: svg`<rect class="${cls}" style="${style}"
data-hp="room" data-id=${hpId} data-area=${hpArea}
x="${r.x}" y="${r.y}" width="${r.w}" height="${r.h}" rx="${Math.min(r.w!, r.h!) * 0.03}"
@pointerenter=${(event: PointerEvent) => {
this._notePointer(event);
if (this._pointerModality.hoverEnabled) {
this._hoverRoom = { space: space.id, room: r };
}
}}
@pointerenter=${enterRoom}
@pointermove=${tip}
@pointerleave=${() => this._clearTransientHover()}></rect>`;
const trimmed = edgeCuts.length && myPoly
+33 -7
View File
@@ -33,7 +33,7 @@ import {
spaceDisplayOf, resolveEffectiveRoomFill, fillColorsOf, DEFAULT_FILL_COLORS,
customFillOf, roomCustomFillOf, DEFAULT_CUSTOM_FILL,
type FillColors, type FillColorEntry, type ResolvedRoomFill, runServiceFor, RUN_TARGET_DOMAINS,
DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY, stageBgOf,
DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY, stageBgOf, showRoomTooltipOf,
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
referencedContentUrls,
DISPLAY_MODES, TAP_ACTIONS, SPACE_FILL_UI_MODES, ROOM_FILL_MODES,
@@ -358,7 +358,7 @@ import {
} from './space-order';
import { applyOpeningMoves, mergeCollinearPartitions, spaceMergeGeometry } from './wall-merge';
const CARD_VERSION = '1.71.0-beta.1';
const CARD_VERSION = '1.71.0-beta.2';
type ResizeLiveLabel = {
kind: 'length';
@@ -1112,8 +1112,8 @@ export interface HouseplanEditorHostPort {
_sentPos: Map<string, DeviceLayout[string] | null>;
_serverCfg: ServerConfig | null;
_serverStorage: boolean;
_settings: { exclude_integrations?: string[]; group_lights?: boolean; show_all?: boolean; filter_seeded?: boolean; icon_rules?: { pattern: string; icon: string; }[]; };
_settingsDialog: { colors: FillColors; glowRadius: number; bgColor: string | null; northDeg: number | null; bgMode: "static" | "daynight"; sunRays: boolean; busy: boolean; } | null;
_settings: { exclude_integrations?: string[]; group_lights?: boolean; show_all?: boolean; filter_seeded?: boolean; icon_rules?: { pattern: string; icon: string; }[]; show_room_tooltip?: boolean; };
_settingsDialog: { colors: FillColors; glowRadius: number; bgColor: string | null; northDeg: number | null; bgMode: "static" | "daynight"; sunRays: boolean; showRoomTooltip: boolean; busy: boolean; } | null;
_supportDialog: SupportDialogState | null;
_showAll: boolean;
_showHidden: boolean;
@@ -1134,7 +1134,7 @@ export interface HouseplanEditorHostPort {
_t: (key: I18nKey, vars?: Record<string, string | number>) => string;
_undoDevicePosition: () => void;
_thickWallCuts: () => number[][];
_tip: { x: number; y: number; title: string; meta: string; lqi?: number | null; temp?: number | null; hum?: number | null; } | null;
_tip: { x: number; y: number; title: string; meta: string; lqi?: number | null; temp?: number | null; hum?: number | null; room?: boolean; } | null;
_toggleConfirmationLines: (intent: ResolvedToggleIntent) => string[];
_toggleConfirmationStateText: (target: ResolvedToggleTarget) => string;
_toggleIntent: (device: DevItem, devices?: readonly DevItem[]) => ResolvedToggleIntent | null;
@@ -9034,6 +9034,7 @@ public _openSettingsDialog = (): void => {
northDeg: northDegOf(this.host._settings, {}),
bgMode: bgModeOf(this.host._settings, {}),
sunRays: sunRaysOn(this.host._settings, {}),
showRoomTooltip: showRoomTooltipOf(this.host._settings),
busy: false,
};
};
@@ -9169,6 +9170,7 @@ private async _buildSupportPreview(draftId: string): Promise<void> {
|| !supportApiCompatible(this.host._haSupportApi)) return;
const generation = ++this._supportPreviewGeneration;
this._supportPatch(draftId, { status: 'building', errorCode: '' });
let issuedToken = '';
try {
const response: unknown = await this.host.hass.callWS({
type: 'houseplan/support/preview',
@@ -9187,6 +9189,7 @@ private async _buildSupportPreview(draftId: string): Promise<void> {
const expiresIn = Number(payload.expires_in);
const spaces = Number(payload.spaces);
const token = String(payload.token || '');
if (/^[0-9a-f]{48}$/.test(token)) issuedToken = token;
const sha256 = String(payload.sha256 || '');
const version = Number(payload.version);
const format = String(payload.format || '');
@@ -9209,6 +9212,7 @@ private async _buildSupportPreview(draftId: string): Promise<void> {
preparedAt: now,
};
if (!this._supportPreviewRequestIsCurrent(draftId, generation)) {
issuedToken = '';
void this._discardSupportPreview(token);
return;
}
@@ -9218,11 +9222,21 @@ private async _buildSupportPreview(draftId: string): Promise<void> {
errorCode: '',
rawOpen: false,
})) {
issuedToken = '';
void this._discardSupportPreview(token);
return;
}
issuedToken = '';
this._scheduleSupportExpiry(draftId, preview);
} catch (error: unknown) {
// The backend has allocated every syntactically valid token it returns.
// Cleanup is independent from UI currentness: invalid, stale or locally
// unadoptable responses must not occupy a slot until TTL.
if (issuedToken) {
const token = issuedToken;
issuedToken = '';
void this._discardSupportPreview(token);
}
if (!this._supportPreviewRequestIsCurrent(draftId, generation)) return;
if (!this._supportPatch(draftId, {
status: 'error',
@@ -10233,12 +10247,15 @@ public _updateDecorStyle(next: DecorStyle): void {
settings.bg_mode = d.bgMode;
if (d.sunRays) settings.sun_rays = true;
else delete settings.sun_rays;
if (d.showRoomTooltip) delete settings.show_room_tooltip;
else settings.show_room_tooltip = false;
// Legacy compatibility: old configs may still contain this accepted
// field, but weather no longer affects sunlight and the UI no longer
// exposes it. Saving general settings cleans the obsolete value up.
delete settings.weather_entity;
this.host._serverCfg = { ...cfg, settings };
await this._saveConfigNow();
if (!d.showRoomTooltip && this.host._tip?.room) this.host._tip = null;
this.host._settingsDialog = null;
this.host.requestUpdate();
this.host._showToast(this.host._t('gs.saved'));
@@ -10534,7 +10551,16 @@ public _renderSettingsDialog(): TemplateResult {
return html`<hp-dialog .hass=${this.host.hass} .title=${this.host._t('gs.title')} icon="mdi:cog-outline" wide
@hp-close=${() => (this.host._settingsDialog = null)}>
<div class="body">
<div class="rhint">${this.host._t('gs.hint')}</div>
<div class="rhint">${supportT(
langOf(this.host.hass, this.host._config?.language), 'gs.hint',
)}</div>
<label class="srcrow">
${this._boolInput(this.host._settingsDialog!.showRoomTooltip, (v) =>
(this.host._settingsDialog = { ...this.host._settingsDialog!, showRoomTooltip: v }))}
<span>${supportT(
langOf(this.host.hass, this.host._config?.language), 'gs.show_room_tooltip',
)}</span>
</label>
<label class="dispsection">${this.host._t('gs.light_group')}</label>
${this._renderColorRow('light_on', 'gs.light_on')}
${this._renderColorRow('light_off', 'gs.light_off')}
@@ -10660,7 +10686,7 @@ public _renderSettingsDialog(): TemplateResult {
</div>
<div class="row" slot="footer">
<button class="btn ghost" @click=${() =>
(this.host._settingsDialog = { ...this.host._settingsDialog!, colors: JSON.parse(JSON.stringify(DEFAULT_FILL_COLORS)), glowRadius: this.host._imperial ? 9.8 : 3, bgColor: null, northDeg: null, bgMode: 'daynight', sunRays: false })}>
(this.host._settingsDialog = { ...this.host._settingsDialog!, colors: JSON.parse(JSON.stringify(DEFAULT_FILL_COLORS)), glowRadius: this.host._imperial ? 9.8 : 3, bgColor: null, northDeg: null, bgMode: 'daynight', sunRays: false, showRoomTooltip: true })}>
${this.host._t('gs.reset')}
</button>
<span class="spacer"></span>
-1
View File
@@ -422,7 +422,6 @@
"marker.sub_entity": "Entität",
"title.general_settings": "Allgemeine Einstellungen",
"gs.title": "Allgemeine Einstellungen",
"gs.hint": "Füllfarben gelten für jeden Bereich; jede Farbe hat ihre eigene Deckkraft. Der Füllmodus wird in den Einstellungen des jeweiligen Bereichs festgelegt.",
"gs.light_group": "Füllung: Licht",
"gs.light_on": "Lichter an",
"gs.light_off": "Alle Lichter aus",
-1
View File
@@ -422,7 +422,6 @@
"marker.sub_entity": "entity",
"title.general_settings": "General settings",
"gs.title": "General settings",
"gs.hint": "Fill colors apply to every space; each color has its own opacity. Which fill mode a space uses is set in that space's dialog.",
"gs.light_group": "Fill: lights",
"gs.light_on": "Lights on",
"gs.light_off": "All lights off",
-1
View File
@@ -422,7 +422,6 @@
"marker.sub_entity": "entité",
"title.general_settings": "Paramètres généraux",
"gs.title": "Paramètres généraux",
"gs.hint": "Les couleurs de remplissage s’appliquent à tous les espaces ; chaque couleur possède sa propre opacité. Le mode de remplissage utilisé par un espace se règle dans la boîte de dialogue de cet espace.",
"gs.light_group": "Remplissage : lumières",
"gs.light_on": "Lumières allumées",
"gs.light_off": "Toutes les lumières éteintes",
-1
View File
@@ -422,7 +422,6 @@
"marker.sub_entity": "сущность",
"title.general_settings": "Общие настройки",
"gs.title": "Общие настройки",
"gs.hint": "Цвета заливок действуют на все пространства; у каждого цвета своя прозрачность. Какой режим заливки использует пространство — задаётся в его диалоге.",
"gs.light_group": "Заливка: освещение",
"gs.light_on": "Свет включён",
"gs.light_off": "Весь свет выключен",
+2 -2
View File
@@ -1,4 +1,4 @@
/** Lazy Help & feedback dictionaries owned by the editor runtime (#423). */
/** Lazy editor-only dictionaries owned by the editor runtime (#423). */
import { subst } from '../logic';
import type { Lang } from './registry';
import de from './support/de.json' with { type: 'json' };
@@ -15,7 +15,7 @@ const SUPPORT_DICTIONARIES: Record<Lang, Record<SupportI18nKey, string>> = {
fr,
};
/** Translate form-only support copy with the same synchronous English fallback. */
/** Translate editor-only copy with the same synchronous English fallback. */
export function supportT(
lang: Lang,
key: SupportI18nKey,
+2
View File
@@ -1,4 +1,6 @@
{
"gs.hint": "Füllfarben gelten für jeden Bereich; jede Farbe hat ihre eigene Deckkraft. Der Füllmodus wird in den Einstellungen des jeweiligen Bereichs festgelegt.",
"gs.show_room_tooltip": "Rauminformationen beim Darüberfahren anzeigen",
"support.about_group": "Über die Karte",
"support.guide_group": "Dokumentation",
"support.guide": "Benutzerhandbuch",
+2
View File
@@ -1,4 +1,6 @@
{
"gs.hint": "Fill colors apply to every space; each color has its own opacity. Which fill mode a space uses is set in that space's dialog.",
"gs.show_room_tooltip": "Show the room information window on hover",
"support.about_group": "About the card",
"support.guide_group": "Documentation",
"support.guide": "User guide",
+2
View File
@@ -1,4 +1,6 @@
{
"gs.hint": "Les couleurs de remplissage s’appliquent à tous les espaces ; chaque couleur possède sa propre opacité. Le mode de remplissage utilisé par un espace se règle dans la boîte de dialogue de cet espace.",
"gs.show_room_tooltip": "Afficher les informations de la pièce au survol",
"support.about_group": "À propos de la carte",
"support.guide_group": "Documentation d’aide",
"support.guide": "Guide utilisateur",

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