Compare commits

...
Author SHA1 Message Date
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
99 changed files with 5686 additions and 730 deletions
+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
@@ -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":
+31 -9
View File
@@ -372,20 +372,42 @@ def asset_meta_path(root: Path, asset_id: str) -> Path:
return root / f"{asset_id}.json"
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": "0cb19ee6d556f05dfee84ade0b3ec3f9e06f99985d1fd026d9f3310d29679210",
"entry": "houseplan-card.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-Dwkibvgo.js",
"houseplan-assets/houseplan-card-Bx2y9RQ5.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 291031,
"initialViewGzipBytes": 290965,
"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-NhWEACal.js",
"houseplan-assets/de-BPlkz0Hx.js",
"houseplan-assets/editor-C6k58Bzs.js",
"houseplan-assets/fr-Cv32nFOv.js",
"houseplan-assets/houseplan-editor-runtime-bWrIAGv1.js",
"houseplan-assets/houseplan-onboarding-runtime-B1h4ymeY.js"
],
"lazyGzipBytes": 214972,
"lazyGzipBytes": 215455,
"lazyEditorFiles": [
"houseplan-assets/backdrop-pick-DWD0ZQTc.js",
"houseplan-assets/editor-Dvui5H3b.js",
"houseplan-assets/houseplan-editor-runtime-9AEP51ly.js"
"houseplan-assets/backdrop-pick-NhWEACal.js",
"houseplan-assets/editor-C6k58Bzs.js",
"houseplan-assets/houseplan-editor-runtime-bWrIAGv1.js"
],
"lazyEditorGzipBytes": 159157,
"lazyEditorGzipBytes": 159774,
"lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-DWD0ZQTc.js",
"houseplan-assets/houseplan-onboarding-runtime-CnVK5oOC.js"
"houseplan-assets/backdrop-pick-NhWEACal.js",
"houseplan-assets/houseplan-onboarding-runtime-B1h4ymeY.js"
],
"lazyOnboardingGzipBytes": 14132,
"lazyOnboardingGzipBytes": 14131,
"lazyLocaleFiles": [
"houseplan-assets/de-DJX-vLW1.js",
"houseplan-assets/fr-DVmfM6Kw.js"
"houseplan-assets/de-BPlkz0Hx.js",
"houseplan-assets/fr-Cv32nFOv.js"
],
"lazyLocaleGzipBytes": 48859,
"lazyLocaleGzipBytes": 48727,
"files": [
{
"path": "houseplan-assets/backdrop-pick-DWD0ZQTc.js",
"sha256": "455450cbc7b74f052fb112680af9ba4be145af8c3a1061907ec541b63957f22f",
"rawBytes": 20974,
"gzipBytes": 7176,
"path": "houseplan-assets/backdrop-pick-NhWEACal.js",
"sha256": "27fac5544c482a28f5f4cb05a114202d293da133076c37953bcd4e7643a9574b",
"rawBytes": 20996,
"gzipBytes": 7177,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-Dwkibvgo.js"
"houseplan-assets/houseplan-card-Bx2y9RQ5.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/de-DJX-vLW1.js",
"sha256": "73d198680c17602addf41855748533ed47233e81e9bdc02a786034aeac0e4e63",
"rawBytes": 81798,
"gzipBytes": 24701,
"path": "houseplan-assets/de-BPlkz0Hx.js",
"sha256": "544d69da019bb0c3aa5d1d112f9e09a4278ef77ea61a040aa8417ffd2955f594",
"rawBytes": 81632,
"gzipBytes": 24635,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-Dvui5H3b.js",
"sha256": "11578ded60410e4e879c9342ee74c8a0968477ea2bae98eb18480a0ad02d303c",
"path": "houseplan-assets/editor-C6k58Bzs.js",
"sha256": "aba31c59c20787606d75b3fb196520a55e20b58b510a084f7d7f12f0cfca4a91",
"rawBytes": 3826,
"gzipBytes": 1580,
"gzipBytes": 1581,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-Dwkibvgo.js"
"houseplan-assets/houseplan-card-Bx2y9RQ5.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/fr-DVmfM6Kw.js",
"sha256": "167bed2822ce7e2b38d47e8dc1d73648d10c04d469979bf05e414000b53487b4",
"rawBytes": 83973,
"gzipBytes": 24158,
"path": "houseplan-assets/fr-Cv32nFOv.js",
"sha256": "b77f4c31d7343901e8e17b1377c646e0057574a56875274156517de90b69e5da",
"rawBytes": 83754,
"gzipBytes": 24092,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-Dwkibvgo.js",
"sha256": "d16deb0fc6f591040a2f97d51102e3e355da35cb3d1b69aa79472406c28d3ed2",
"rawBytes": 1033612,
"gzipBytes": 290234,
"path": "houseplan-assets/houseplan-card-Bx2y9RQ5.js",
"sha256": "86231c5d7b42f208e29547b78e1e122eb07dd6374b90298a96fbd68048bb8310",
"rawBytes": 1032927,
"gzipBytes": 290170,
"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-BPlkz0Hx.js",
"houseplan-assets/editor-C6k58Bzs.js",
"houseplan-assets/fr-Cv32nFOv.js",
"houseplan-assets/houseplan-editor-runtime-bWrIAGv1.js",
"houseplan-assets/houseplan-onboarding-runtime-B1h4ymeY.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-9AEP51ly.js",
"sha256": "c1b8f90458afedddbbdde98570c517d6e66dfcee08ad4764b1ac2473bdf89fb4",
"rawBytes": 574225,
"gzipBytes": 150401,
"path": "houseplan-assets/houseplan-editor-runtime-bWrIAGv1.js",
"sha256": "6d689b4b7623b306e9a60f15ddcd54ff2bcafdb7c8af7536e865e1393a6c3ba3",
"rawBytes": 575918,
"gzipBytes": 151016,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-DWD0ZQTc.js",
"houseplan-assets/houseplan-card-Dwkibvgo.js"
"houseplan-assets/backdrop-pick-NhWEACal.js",
"houseplan-assets/houseplan-card-Bx2y9RQ5.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-CnVK5oOC.js",
"sha256": "bd169896116229e76b37243106bde64755589017639890d36dd19282c872db7a",
"path": "houseplan-assets/houseplan-onboarding-runtime-B1h4ymeY.js",
"sha256": "0410abdf58ae271c96a2350948f800177d4095db472dd773487563ea9aa2518b",
"rawBytes": 28088,
"gzipBytes": 6956,
"gzipBytes": 6954,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-DWD0ZQTc.js",
"houseplan-assets/houseplan-card-Dwkibvgo.js"
"houseplan-assets/backdrop-pick-NhWEACal.js",
"houseplan-assets/houseplan-card-Bx2y9RQ5.js"
],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "730163666440793e44be0ee5482f7080931370f94555a1d52072d3a54698447e",
"sha256": "b250ac4f45a25fb4066fec09bd65042803bc8585c487af955532253489b5ec1b",
"rawBytes": 1183,
"gzipBytes": 797,
"gzipBytes": 795,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-Dwkibvgo.js"
"houseplan-assets/houseplan-card-Bx2y9RQ5.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__="0cb19ee6d556f05dfee84ade0b3ec3f9e06f99985d1fd026d9f3310d29679210";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-Bx2y9RQ5.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
@@ -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__="0cb19ee6d556f05dfee84ade0b3ec3f9e06f99985d1fd026d9f3310d29679210";try{await import("./houseplan-assets/houseplan-card-Bx2y9RQ5.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
+5 -9
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,
@@ -176,18 +177,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",
+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
@@ -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.
+18 -9
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,
@@ -54,6 +55,7 @@ from .decor_assets import (
asset_meta_path,
asset_refs,
public_asset,
read_asset,
read_catalog,
)
from .import_export import (
@@ -1135,22 +1137,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)
+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');
+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);
+60 -60
View File
@@ -1,125 +1,125 @@
{
"schema": 1,
"fingerprint": "816ff9c893e23b7441e73210f85024741ff6c5bf8dabd2fb0df9e8b8055bc693",
"fingerprint": "0cb19ee6d556f05dfee84ade0b3ec3f9e06f99985d1fd026d9f3310d29679210",
"entry": "houseplan-card.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-Dwkibvgo.js",
"houseplan-assets/houseplan-card-Bx2y9RQ5.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 291031,
"initialViewGzipBytes": 290965,
"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-NhWEACal.js",
"houseplan-assets/de-BPlkz0Hx.js",
"houseplan-assets/editor-C6k58Bzs.js",
"houseplan-assets/fr-Cv32nFOv.js",
"houseplan-assets/houseplan-editor-runtime-bWrIAGv1.js",
"houseplan-assets/houseplan-onboarding-runtime-B1h4ymeY.js"
],
"lazyGzipBytes": 214972,
"lazyGzipBytes": 215455,
"lazyEditorFiles": [
"houseplan-assets/backdrop-pick-DWD0ZQTc.js",
"houseplan-assets/editor-Dvui5H3b.js",
"houseplan-assets/houseplan-editor-runtime-9AEP51ly.js"
"houseplan-assets/backdrop-pick-NhWEACal.js",
"houseplan-assets/editor-C6k58Bzs.js",
"houseplan-assets/houseplan-editor-runtime-bWrIAGv1.js"
],
"lazyEditorGzipBytes": 159157,
"lazyEditorGzipBytes": 159774,
"lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-DWD0ZQTc.js",
"houseplan-assets/houseplan-onboarding-runtime-CnVK5oOC.js"
"houseplan-assets/backdrop-pick-NhWEACal.js",
"houseplan-assets/houseplan-onboarding-runtime-B1h4ymeY.js"
],
"lazyOnboardingGzipBytes": 14132,
"lazyOnboardingGzipBytes": 14131,
"lazyLocaleFiles": [
"houseplan-assets/de-DJX-vLW1.js",
"houseplan-assets/fr-DVmfM6Kw.js"
"houseplan-assets/de-BPlkz0Hx.js",
"houseplan-assets/fr-Cv32nFOv.js"
],
"lazyLocaleGzipBytes": 48859,
"lazyLocaleGzipBytes": 48727,
"files": [
{
"path": "houseplan-assets/backdrop-pick-DWD0ZQTc.js",
"sha256": "455450cbc7b74f052fb112680af9ba4be145af8c3a1061907ec541b63957f22f",
"rawBytes": 20974,
"gzipBytes": 7176,
"path": "houseplan-assets/backdrop-pick-NhWEACal.js",
"sha256": "27fac5544c482a28f5f4cb05a114202d293da133076c37953bcd4e7643a9574b",
"rawBytes": 20996,
"gzipBytes": 7177,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-Dwkibvgo.js"
"houseplan-assets/houseplan-card-Bx2y9RQ5.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/de-DJX-vLW1.js",
"sha256": "73d198680c17602addf41855748533ed47233e81e9bdc02a786034aeac0e4e63",
"rawBytes": 81798,
"gzipBytes": 24701,
"path": "houseplan-assets/de-BPlkz0Hx.js",
"sha256": "544d69da019bb0c3aa5d1d112f9e09a4278ef77ea61a040aa8417ffd2955f594",
"rawBytes": 81632,
"gzipBytes": 24635,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-Dvui5H3b.js",
"sha256": "11578ded60410e4e879c9342ee74c8a0968477ea2bae98eb18480a0ad02d303c",
"path": "houseplan-assets/editor-C6k58Bzs.js",
"sha256": "aba31c59c20787606d75b3fb196520a55e20b58b510a084f7d7f12f0cfca4a91",
"rawBytes": 3826,
"gzipBytes": 1580,
"gzipBytes": 1581,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-Dwkibvgo.js"
"houseplan-assets/houseplan-card-Bx2y9RQ5.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/fr-DVmfM6Kw.js",
"sha256": "167bed2822ce7e2b38d47e8dc1d73648d10c04d469979bf05e414000b53487b4",
"rawBytes": 83973,
"gzipBytes": 24158,
"path": "houseplan-assets/fr-Cv32nFOv.js",
"sha256": "b77f4c31d7343901e8e17b1377c646e0057574a56875274156517de90b69e5da",
"rawBytes": 83754,
"gzipBytes": 24092,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-Dwkibvgo.js",
"sha256": "d16deb0fc6f591040a2f97d51102e3e355da35cb3d1b69aa79472406c28d3ed2",
"rawBytes": 1033612,
"gzipBytes": 290234,
"path": "houseplan-assets/houseplan-card-Bx2y9RQ5.js",
"sha256": "86231c5d7b42f208e29547b78e1e122eb07dd6374b90298a96fbd68048bb8310",
"rawBytes": 1032927,
"gzipBytes": 290170,
"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-BPlkz0Hx.js",
"houseplan-assets/editor-C6k58Bzs.js",
"houseplan-assets/fr-Cv32nFOv.js",
"houseplan-assets/houseplan-editor-runtime-bWrIAGv1.js",
"houseplan-assets/houseplan-onboarding-runtime-B1h4ymeY.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-9AEP51ly.js",
"sha256": "c1b8f90458afedddbbdde98570c517d6e66dfcee08ad4764b1ac2473bdf89fb4",
"rawBytes": 574225,
"gzipBytes": 150401,
"path": "houseplan-assets/houseplan-editor-runtime-bWrIAGv1.js",
"sha256": "6d689b4b7623b306e9a60f15ddcd54ff2bcafdb7c8af7536e865e1393a6c3ba3",
"rawBytes": 575918,
"gzipBytes": 151016,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-DWD0ZQTc.js",
"houseplan-assets/houseplan-card-Dwkibvgo.js"
"houseplan-assets/backdrop-pick-NhWEACal.js",
"houseplan-assets/houseplan-card-Bx2y9RQ5.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-CnVK5oOC.js",
"sha256": "bd169896116229e76b37243106bde64755589017639890d36dd19282c872db7a",
"path": "houseplan-assets/houseplan-onboarding-runtime-B1h4ymeY.js",
"sha256": "0410abdf58ae271c96a2350948f800177d4095db472dd773487563ea9aa2518b",
"rawBytes": 28088,
"gzipBytes": 6956,
"gzipBytes": 6954,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-DWD0ZQTc.js",
"houseplan-assets/houseplan-card-Dwkibvgo.js"
"houseplan-assets/backdrop-pick-NhWEACal.js",
"houseplan-assets/houseplan-card-Bx2y9RQ5.js"
],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "730163666440793e44be0ee5482f7080931370f94555a1d52072d3a54698447e",
"sha256": "b250ac4f45a25fb4066fec09bd65042803bc8585c487af955532253489b5ec1b",
"rawBytes": 1183,
"gzipBytes": 797,
"gzipBytes": 795,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-Dwkibvgo.js"
"houseplan-assets/houseplan-card-Bx2y9RQ5.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__="0cb19ee6d556f05dfee84ade0b3ec3f9e06f99985d1fd026d9f3310d29679210";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-Bx2y9RQ5.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
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__="0cb19ee6d556f05dfee84ade0b3ec3f9e06f99985d1fd026d9f3310d29679210";try{await import("./houseplan-assets/houseplan-card-Bx2y9RQ5.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)}
+18 -5
View File
@@ -380,10 +380,23 @@ 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.
`removed:true` is a binding tombstone, not a renderable marker. It claims an
HA binding against automatic discovery while intentionally exposing that same
@@ -918,7 +931,7 @@ 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/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 deletion 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 |
+19
View File
@@ -2,6 +2,25 @@
## Unreleased
- 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
+23
View File
@@ -8,6 +8,29 @@
## Не выпущено
- Сохранённые пользовательские картинки по-прежнему видны домочадцам без права
редактирования, но произвольный поиск файлов теперь закрыт; повторные загрузки
карточки и 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
- В редакторе подложки теперь можно загружать и повторно использовать закрытые
+34
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,23 @@ 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.
## Independent-wall opening host (#132)
`space.openings[].host` is an optional discriminated object
+4
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)
+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": "54f08ee5b757a4fca0219018b34137487cd1e19f8f8f691e928973021a229a46",
"captureScriptSha256": "cadb8e1bcab9f1dcdd7d75b3b90ddcbaaeb2b8c2a098f575a21f39ff70f5c59c",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -15,7 +15,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "cb230a51d6b12aed13e4a2db8e08701fcaefed3b54a6e61c4e0cbb1a3483be7d",
"sourceSha256": "54f08ee5b757a4fca0219018b34137487cd1e19f8f8f691e928973021a229a46",
"imageSha256": "80a70361dc18dd0461568df332062e6482c633af5d280954f8b675701418a76d"
},
"view-touch": {
@@ -26,7 +26,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "cb230a51d6b12aed13e4a2db8e08701fcaefed3b54a6e61c4e0cbb1a3483be7d",
"sourceSha256": "54f08ee5b757a4fca0219018b34137487cd1e19f8f8f691e928973021a229a46",
"imageSha256": "4106cc28847047505f46921ff95765d8abdf5b382d9d17d4c5e4ad129dd8f6be"
},
"space-create": {
@@ -37,7 +37,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "cb230a51d6b12aed13e4a2db8e08701fcaefed3b54a6e61c4e0cbb1a3483be7d",
"sourceSha256": "54f08ee5b757a4fca0219018b34137487cd1e19f8f8f691e928973021a229a46",
"imageSha256": "617b51b3648498787b5039980c9f3eceb75ba56ed63a1a20e616bc05bc304362"
},
"room-contour-close": {
@@ -48,7 +48,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "cb230a51d6b12aed13e4a2db8e08701fcaefed3b54a6e61c4e0cbb1a3483be7d",
"sourceSha256": "54f08ee5b757a4fca0219018b34137487cd1e19f8f8f691e928973021a229a46",
"imageSha256": "1dab6cc3b9d1bf7d8c40f0e5137f8c688683c9b7eabc5167da99d41ecfdd5b79"
},
"plan-context-tray": {
@@ -59,7 +59,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "cb230a51d6b12aed13e4a2db8e08701fcaefed3b54a6e61c4e0cbb1a3483be7d",
"sourceSha256": "54f08ee5b757a4fca0219018b34137487cd1e19f8f8f691e928973021a229a46",
"imageSha256": "94ef50762753c0ac6ddc84d2521c4232a3f9ecd89843810d2df61c517592bed7"
},
"device-editor": {
@@ -70,7 +70,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "cb230a51d6b12aed13e4a2db8e08701fcaefed3b54a6e61c4e0cbb1a3483be7d",
"sourceSha256": "54f08ee5b757a4fca0219018b34137487cd1e19f8f8f691e928973021a229a46",
"imageSha256": "7a601769de38aa19c2e280f6ff4cf3695b854b1d1c6d1f8de4e6b5e8f7eb1559"
},
"device-display-preview": {
@@ -81,7 +81,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "cb230a51d6b12aed13e4a2db8e08701fcaefed3b54a6e61c4e0cbb1a3483be7d",
"sourceSha256": "54f08ee5b757a4fca0219018b34137487cd1e19f8f8f691e928973021a229a46",
"imageSha256": "0939875f8631694f4de0ef4fd01032010ede8c7d49fb7c0a857612d4b3afff93"
},
"background-editor": {
@@ -92,7 +92,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "cb230a51d6b12aed13e4a2db8e08701fcaefed3b54a6e61c4e0cbb1a3483be7d",
"sourceSha256": "54f08ee5b757a4fca0219018b34137487cd1e19f8f8f691e928973021a229a46",
"imageSha256": "054170fd9ef45762b602b4d5c9c3b9ea9724858be61af137970c246f485c13bb"
},
"room-card": {
@@ -103,7 +103,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "cb230a51d6b12aed13e4a2db8e08701fcaefed3b54a6e61c4e0cbb1a3483be7d",
"sourceSha256": "54f08ee5b757a4fca0219018b34137487cd1e19f8f8f691e928973021a229a46",
"imageSha256": "2ae4a58853d98e10d12456b2078ec2f6a0b597722c8310d7b016abd2bc42561e"
},
"device-info": {
@@ -114,7 +114,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "cb230a51d6b12aed13e4a2db8e08701fcaefed3b54a6e61c4e0cbb1a3483be7d",
"sourceSha256": "54f08ee5b757a4fca0219018b34137487cd1e19f8f8f691e928973021a229a46",
"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
```
+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
```
+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` сдвинется до начала разработки.
+4
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
+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/** целиком: и сценарии, и эталоны — эталон тоже вход
+191
View File
@@ -2335,6 +2335,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 '
@@ -4683,6 +4709,171 @@ 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-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: '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>`}
+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') {
+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;
+21 -32
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,
@@ -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. */
@@ -2219,6 +2220,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;
@@ -7286,11 +7288,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 =================
@@ -11577,6 +11580,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 +11592,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 +11637,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
+19 -6
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,
@@ -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,
};
};
@@ -10233,12 +10234,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 +10538,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 +10673,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",
+2
View File
@@ -1,4 +1,6 @@
{
"gs.hint": "Цвета заливок действуют на все пространства; у каждого цвета своя прозрачность. Какой режим заливки использует пространство — задаётся в его диалоге.",
"gs.show_room_tooltip": "Показывать окно с информацией при наведении на комнату",
"support.about_group": "О карточке",
"support.guide_group": "Документация",
"support.guide": "Руководство пользователя",
+7
View File
@@ -1351,6 +1351,13 @@ export function stageBgOf(settings: any, disp: { bgColor: string | null }): stri
return safeStoredColor(g, '');
}
/** Global room-hover information preference. Legacy and malformed values keep
* the historical default; only an explicit boolean false disables the window. */
export function showRoomTooltipOf(settings: unknown): boolean {
return (settings as { show_room_tooltip?: unknown } | null | undefined)
?.show_room_tooltip !== false;
}
// ---------------- global fill colors ----------------
export interface FillColorEntry {
+2
View File
@@ -247,6 +247,8 @@ export interface ServerConfig {
/** Internal registry-Area provenance; not a user-facing setting. */
marker_area_snapshot?: MarkerAreaSnapshot;
icon_rules?: { pattern: string; icon: string }[];
/** Only an explicit false hides the room hover information window. */
show_room_tooltip?: boolean;
};
}
+31 -6
View File
@@ -9,7 +9,6 @@ import {
} from '../scripts/bundle-manifest.mjs';
import {
INITIAL_VIEW_GZIP_BUDGET, LOW_HEADROOM_WARNING_BYTES,
SUPPORT_LAZY_INITIAL_BASELINE_BYTES,
assertBundleBudget, assertSupportBundleOwnership, lowHeadroomWarning,
} from '../scripts/bundle-budget.mjs';
import { compareBundleTrees, sha256Bytes, verifyBundleTree } from '../scripts/bundle-tree.mjs';
@@ -93,7 +92,6 @@ test('#423 support form copy belongs only to the lazy editor graph', () => {
const manifest = {
initialViewFiles: ['initial.js'],
lazyEditorFiles: ['editor.js'],
initialViewGzipBytes: SUPPORT_LAZY_INITIAL_BASELINE_BYTES - 1,
};
const markers = ['lazy English marker', 'lazy Russian marker'];
assert.doesNotThrow(() => assertSupportBundleOwnership(manifest, temp, markers));
@@ -105,11 +103,9 @@ test('#423 support form copy belongs only to the lazy editor graph', () => {
writeFileSync(join(temp, 'initial.js'), 'header only');
assert.throws(
() => assertSupportBundleOwnership(
{ ...manifest, initialViewGzipBytes: SUPPORT_LAZY_INITIAL_BASELINE_BYTES },
temp,
markers,
{ initialViewFiles: ['initial.js'], lazyEditorFiles: [] }, temp, markers,
),
/did not improve/,
/missing from lazy editor graph/,
);
} finally {
rmSync(temp, { recursive: true, force: true });
@@ -275,3 +271,32 @@ test('запас на момент рекалибровки выше порог
// Иначе рекалибровка была бы бессмысленной: гейт сразу же начал бы кричать.
assert.ok(INITIAL_VIEW_GZIP_BUDGET - 273_697 > LOW_HEADROOM_WARNING_BYTES);
});
test('#429 проверка владения не судит размер графа', () => {
// Числовой храповик #423 оставлял пятнадцать байт запаса и покрасил бы
// первый же посторонний коммит сообщением про копирайт формы поддержки.
// Гейт, обвиняющий не ту задачу, выключают не разбираясь — вместе с
// долговечной проверкой владения. Здесь закреплено, что размер вернуться в
// эту функцию не может: любое значение проходит, пока владение соблюдено.
const temp = mkdtempSync(join(tmpdir(), 'houseplan-support-size-'));
try {
writeFileSync(join(temp, 'initial.js'), 'header only');
writeFileSync(join(temp, 'editor.js'), 'lazy English marker · lazy Russian marker');
const markers = ['lazy English marker', 'lazy Russian marker'];
const base = { initialViewFiles: ['initial.js'], lazyEditorFiles: ['editor.js'] };
for (const initialViewGzipBytes of [0, 291_046, 10_000_000, undefined]) {
assert.doesNotThrow(
() => assertSupportBundleOwnership({ ...base, initialViewGzipBytes }, temp, markers),
`размер ${initialViewGzipBytes} не должен влиять на проверку владения`,
);
}
// Размер по-прежнему охраняется — но общим бюджетом, а не чужим номером.
assert.match(
lowHeadroomWarning(9058) || '',
/запас бюджета 9058 Б/,
'предупреждение о запасе остаётся единственным честным сигналом о размере',
);
} finally {
rmSync(temp, { recursive: true, force: true });
}
});
+52
View File
@@ -5,6 +5,7 @@ import { readHouseplanProductionSource } from './houseplan-source.mjs';
import {
COORDINATE_DECIMALS,
DECOR_BOX_KINDS,
LATTICE_GRID_N,
LATTICE_NOISE_STEPS,
canonicalizeConfigGeometry,
@@ -39,6 +40,57 @@ test('frontend and backend share the scalar+lattice fixture contract (#291)', ()
assert.equal(Object.is(layout['rl:poly'].x, -0), false, 'negative zero becomes positive');
});
test('decor box catalog canonicalizes every box kind and preserves image metadata (#431)', () => {
assert.deepEqual(DECOR_BOX_KINDS, fixture.boxKinds);
const inputBoxes = fixture.configInput.spaces[0].decor
.filter((item) => fixture.boxKinds.includes(item.kind));
assert.deepEqual(
inputBoxes.map((item) => item.kind).sort(),
[...fixture.boxKinds].sort(),
'the shared fixture covers the exact runtime box catalog',
);
const output = canonicalizeConfigGeometry(fixture.configInput);
const outputById = new Map(output.spaces[0].decor.map((item) => [item.id, item]));
const expectedById = new Map(fixture.configExpected.spaces[0].decor.map((item) => [item.id, item]));
for (const input of inputBoxes) {
const actual = outputById.get(input.id);
const expected = expectedById.get(input.id);
for (const field of ['x', 'y', 'w', 'h', 'angle']) {
assert.equal(actual[field], expected[field], `${input.kind}.${field} is canonical`);
assert.notEqual(actual[field], input[field], `${input.kind}.${field} exercises the barrier`);
}
}
const inputImage = inputBoxes.find((item) => item.kind === 'image');
const actualImage = outputById.get('image');
for (const field of ['asset_id', 'opacity', 'flip_h', 'flip_v', 'future']) {
assert.deepEqual(actualImage[field], inputImage[field], `image.${field} is preserved`);
}
const futureBox = {
id: 'future', kind: 'future-box', x: 0.1000000004, y: 0.2000000006,
w: 0.3000000004, h: 0.4000000006, angle: 12.1234567896,
};
const unknownResult = canonicalizeConfigGeometry({ spaces: [{ decor: [futureBox] }] });
assert.deepEqual(unknownResult.spaces[0].decor[0], futureBox);
});
test('lattice report includes image box coordinates (#431)', () => {
const node = 83 / 240;
const noise = Number(node.toFixed(9));
const report = latticeCanonicalizationReport({ spaces: [{
id: 'floor', title: 'Floor', cell_cm: 5,
decor: [{ id: 'image', kind: 'image', x: noise, y: node, w: noise, h: 0.06 }],
}] });
assert.equal(report.canonicalized, 2);
assert.equal(report.far, 1);
assert.equal(report.spaces.length, 1);
assert.equal(report.spaces[0].canonicalized, 2);
assert.equal(report.spaces[0].far, 1);
});
test('scalar canonicalization is symmetric and never snaps off-grid geometry (#224)', () => {
assert.equal(canonicalizeNumber(1.2345678905), 1.234567891);
assert.equal(canonicalizeNumber(-1.2345678905), -1.234567891);
+42
View File
@@ -34,6 +34,33 @@ test('#51 full and static renderers share one fail-closed image projection', ()
assert.equal(projectDecorImage({ ...shape, w: 0 }, 1000, 500), null);
});
test('#430 проекция читает flip_v, а не только flip_h', () => {
// Единственный кейс #51 задавал flip_h: true и ничего не говорил про flip_v,
// поэтому `${shape.flip_v ? -1 : 1}` → `1` оставляло 7 pass. Вертикальное
// отражение — половина контракта AC3, и своего свидетеля у неё не было.
const shape = { id: 'image', kind: 'image', asset_id: id('d'), x: 0, y: 0, w: 1, h: 1 };
const scaleOf = (extra) => projectDecorImage({ ...shape, ...extra }, 100, 100)[5]
.match(/scale\([^)]*\)/)[0];
assert.equal(scaleOf({}), 'scale(1 1)');
assert.equal(scaleOf({ flip_h: true }), 'scale(-1 1)');
assert.equal(scaleOf({ flip_v: true }), 'scale(1 -1)');
assert.equal(scaleOf({ flip_h: true, flip_v: true }), 'scale(-1 -1)');
});
test('#430 проекция переносит opacity, а не подставляет единицу', () => {
// Кейс #51 задавал opacity: 2 и ждал 1 — ожидание, неотличимое от «opacity
// игнорируется»: мутант `const opacity = 1` проходил. Промежуточное значение
// отличает перенос от заглушки, крайности закрепляют clamp и fallback.
const shape = { id: 'image', kind: 'image', asset_id: id('d'), x: 0, y: 0, w: 1, h: 1 };
const opacityOf = (opacity) => projectDecorImage({ ...shape, opacity }, 100, 100)[4];
assert.equal(opacityOf(0.4), 0.4);
assert.equal(opacityOf(0), 0, 'полностью прозрачная картинка — законное состояние');
assert.equal(opacityOf(-1), 0);
assert.equal(opacityOf(2), 1);
assert.equal(opacityOf(undefined), 1, 'нет значения — непрозрачная');
assert.equal(opacityOf('nonsense'), 1);
});
test('#51 resolve projection rejects malformed catalog rows', () => {
const good = {
asset_id: id('c'), name: 'safe.svg', mime: 'image/svg+xml',
@@ -46,6 +73,21 @@ test('#51 resolve projection rejects malformed catalog rows', () => {
assert.equal(adoptDecorAssets({ assets: [{ ...good, mime: 'image/png' }] }).size, 0);
});
test('#430 форма asset_id проверяется сама, а не через совпадение с url', () => {
// Строка `{ ...good, asset_id: 'bad' }` выше не свидетель формы id: у неё
// остаётся url настоящего ассета, и её отбивает сравнение url. Поэтому
// мутант `!DECOR_ASSET_ID_RE.test(...)` → `false` выживал. Здесь url
// согласован с плохим id — сработать может только сама проверка формы.
const row = (asset_id) => ({
asset_id, name: 'safe.svg', mime: 'image/svg+xml', width: 20, height: 10, bytes: 100,
url: `/api/houseplan/content/assets/_/${asset_id}.svg`,
});
assert.equal(adoptDecorAssets({ assets: [row('bad')] }).size, 0);
assert.equal(adoptDecorAssets({ assets: [row(`${id('a')}a`)] }).size, 0, 'длиннее 64');
assert.equal(adoptDecorAssets({ assets: [row(id('a').replace('a', 'A'))] }).size, 0, 'не hex');
assert.equal(adoptDecorAssets({ assets: [row(id('a'))] }).size, 1, 'корректный id проходит');
});
test('#51 a rotated image contributes its complete visible bounds to framing', () => {
const item = decorBoxItem({ x: 0.1, y: 0.2, w: 0.2, h: 0.1, angle: 90 });
assert.ok(item);
+29
View File
@@ -1,5 +1,6 @@
{
"decimals": 9,
"boxKinds": ["rect", "ellipse", "furniture", "image"],
"configInput": {
"spaces": [
{
@@ -117,6 +118,20 @@
"w": 0.1000000004,
"h": 0.1000000006,
"angle": 90.1234567896
},
{
"id": "image",
"kind": "image",
"asset_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"x": 0.5000000004,
"y": 0.5000000006,
"w": 0.2000000004,
"h": 0.3000000006,
"angle": 45.1234567896,
"opacity": 0.5000000004,
"flip_h": true,
"flip_v": false,
"future": {"numeric": 0.1234567896}
}
],
"room_drafts": [
@@ -318,6 +333,20 @@
"w": 0.1,
"h": 0.1,
"angle": 90.12345679
},
{
"id": "image",
"kind": "image",
"asset_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"x": 0.5,
"y": 0.5,
"w": 0.2,
"h": 0.3,
"angle": 45.12345679,
"opacity": 0.5000000004,
"flip_h": true,
"flip_v": false,
"future": {"numeric": 0.1234567896}
}
],
"room_drafts": [
+32
View File
@@ -176,6 +176,38 @@ test('the CLI prints the key and writes it to GITHUB_OUTPUT', (t) => {
}
});
test('#430 ключ смоков покрывает всё, что эта job исполняет', () => {
// Прогон #2371 (ee678352) добавил в verify-guard.mjs пробу гарда benchmark,
// и job со смоками была пропущена как переиспользованная: файл исполняется
// только там, а в её ключ не входил. Проба уехала в dev, не запустившись ни
// разу. Здесь закреплено, что так больше не выйдет.
const { dir, put } = makeTree();
try {
put('demo/serve.mjs', '// harness\n');
put('demo/guard/verify-guard.mjs', '// probes\n');
put('demo/guard/guard_tail_exception.mjs', '// probe\n');
put('demo/benchmark_backdrop_decode.mjs', '// benchmark\n');
const files = harnessFiles(dir, 'smoke');
for (const rel of [
'demo/serve.mjs',
'demo/guard/verify-guard.mjs',
'demo/guard/guard_tail_exception.mjs',
'demo/benchmark_backdrop_decode.mjs',
]) {
assert.ok(files.includes(rel), `${rel} вне ключа смоков — его правка будет реюзнута`);
}
// И ключ обязан меняться от правки каждого из них: список файлов сам по
// себе ничего не гарантирует, если хэш их не читает.
for (const rel of files) {
const before = reuseKey(dir, 'smoke');
put(rel, '// changed\n');
assert.notEqual(reuseKey(dir, 'smoke'), before, `${rel}: правка не меняет ключ`);
}
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('HARNESS keeps scripts/** out of the keys on purpose', () => {
// Инфраструктурная работа правит scripts/** постоянно. Если бы каталог
// целиком попал в ключ, переиспользование не срабатывало бы никогда — ровно
+2 -2
View File
@@ -126,11 +126,11 @@ test('i18n: every registered dictionary carries the English key set', () => {
}
});
test('i18n: lazy support dictionaries carry matching keys and placeholders', () => {
test('i18n: lazy editor dictionaries carry matching keys and placeholders', () => {
const supportEn = supportDictionaries.get('en');
const keys = Object.keys(supportEn).sort();
const placeholders = (value) => (String(value).match(/\{\w+\}/gu) || []).sort();
assert.equal(keys.length, 42);
assert.equal(keys.length, 44);
assert.equal(Object.keys(en).filter((key) => key.startsWith('support.')).join(','), 'support.title');
for (const { code } of LANGUAGE_REGISTRY) {
const dictionary = supportDictionaries.get(code);
+12
View File
@@ -26,6 +26,7 @@ import {
hassValue, valueWithUnit, decorTextScale, decorTextLines,
LIVE_TEXT_DASH, LIVE_TEXT_VALUE_MAX, DECOR_TEXT_SCALE_MIN, DECOR_TEXT_SCALE_MAX,
quantizeOpeningLightAmount,
showRoomTooltipOf,
} from '../test-build/logic.js';
import {
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
@@ -35,6 +36,17 @@ test('tap action editor choices keep the canonical order', () => {
assert.deepEqual([...TAP_ACTIONS], ['info', 'more-info', 'toggle', 'run', 'none']);
});
test('room tooltip preference is default-on and only exact false disables it', () => {
for (const value of [undefined, null, true, 0, 1, 'false', [], {}]) {
assert.equal(showRoomTooltipOf(value), true);
}
assert.equal(showRoomTooltipOf({ show_room_tooltip: undefined }), true);
assert.equal(showRoomTooltipOf({ show_room_tooltip: null }), true);
assert.equal(showRoomTooltipOf({ show_room_tooltip: true }), true);
assert.equal(showRoomTooltipOf({ show_room_tooltip: 'false' }), true);
assert.equal(showRoomTooltipOf({ show_room_tooltip: false }), false);
});
test('display normalization and alarm-capable metadata share one contract', () => {
assert.equal(normalizeDeviceDisplay(undefined), 'badge');
assert.equal(normalizeDeviceDisplay('ripple'), 'icon_ripple');
+13 -5
View File
@@ -70,16 +70,24 @@ test('пробы вызываются в job с браузером и служа
assert.match(smoke, /if: matrix\.shard == 1/, 'один раз, а не в каждом шарде');
const mutants = read('scripts/mutation-gate.mjs');
for (const id of [
// Правка #404 состоит из двух половин, и мутант на одну оставил бы другую
// недоказанной. Список ведётся руками, и это осознанно: счётчик обязан
// совпадать с ним, поэтому новый мутант на этих пробах нельзя добавить, не
// назвав его здесь (в #430 так добавился четвёртый — гард page-benchmark).
const guarded = [
'smoke-guard-blind-to-tail',
'smoke-guard-forgets-to-register-pages',
'report-page-errors-skips-round-trip',
]) {
'benchmark-page-verdict-unwatched',
];
for (const id of guarded) {
assert.match(mutants, new RegExp(`id: '${id}'`), `мутант ${id} не зарегистрирован`);
}
// Правка состоит из двух половин, и мутант на одну оставил бы другую
// недоказанной.
assert.equal((mutants.match(/node demo\/guard\/verify-guard\.mjs/g) || []).length, 3);
assert.equal(
(mutants.match(/node demo\/guard\/verify-guard\.mjs/g) || []).length,
guarded.length,
'мутант на пробах гарда есть, а в списке выше его нет — список отстал от реестра',
);
});
test('страницы, созданные вне launch(), подписаны общим гардом (#404, Medium-1)', () => {
+26 -13
View File
@@ -86,26 +86,39 @@ test('#421 dedicated guard probe reaches reportPageErrors without finish', () =>
'finish() замаскирует сломанный round-trip внутри reportPageErrors()');
});
test('#423 every Playwright page benchmark has an enforceable pageerror verdict', () => {
test('#423 каждый page-benchmark обязан иметь вердикт и динамическую пробу (#430)', () => {
// Что здесь проверяется, а что — нет.
//
// Этот тест — ОБНАРУЖЕНИЕ: он находит benchmark, открывающий страницу
// Playwright, и требует от него формы гарда и наличия отрицательной пробы.
// Поведение гарда в рантайме он не доказывает и не может: браузера в job
// «Фронтенд» нет. Доказывает запуск — `demo/guard/verify-guard.mjs`.
//
// До #430 здесь стояли две «отрицательные проверки»: результат
// `pageBenchmarkIsGuarded(source.replace('watchPage(', '('))` обязан быть
// false. Но сама функция буквально ищет подстроку `watchPage(` — вырезав её,
// мы спрашивали регулярку, находит ли она то, что мы только что удалили.
// Доказано было, что регулярка не пуста. Ровно тот вид проверки, против
// которого заведён мутационный гейт, и он же — единственная находка аудита
// v1.71.0-beta.1, где тест был циклическим, а не просто слабым.
const benchmarks = readdirSync(DEMO)
.filter((name) => name.startsWith('benchmark_') && name.endsWith('.mjs'))
.filter((name) => /\.newPage\(/.test(read(name)));
assert.deepEqual(benchmarks, ['benchmark_backdrop_decode.mjs']);
const verifier = readFileSync(
new URL('../demo/guard/verify-guard.mjs', import.meta.url), 'utf8',
);
for (const name of benchmarks) {
const source = read(name);
assert.equal(pageBenchmarkIsGuarded(source), true, `${name}: missing pageerror guard`);
assert.equal(
pageBenchmarkIsGuarded(source.replace('watchPage(', '(')),
false,
`${name}: removing watchPage must make the contract red`,
);
assert.equal(
pageBenchmarkIsGuarded(source.replace('reportPageErrors()', 'false')),
false,
`${name}: removing the final verdict must make the contract red`,
assert.match(source, /process\.argv\.includes\('--guard-probe'\)/,
`${name}: нужен режим отрицательной пробы — без него гард не проверить запуском`);
assert.match(source, /setTimeout\(\(\) => \{ throw new Error\(/,
`${name}: проба обязана бросать исключение ВНУТРИ страницы, в хвосте замера`);
assert.ok(
new RegExp(`file: '\\.\\./${name}',\\s*\n\\s*args: \\['--guard-probe'\\]`).test(verifier),
`${name}: нет записи в demo/guard/verify-guard.mjs — проба существует и не вызывается,`
+ ' а это ровно то состояние, в котором #423 прожил до #430',
);
}
const backdrop = read('benchmark_backdrop_decode.mjs');
assert.match(backdrop, /process\.argv\.includes\('--guard-probe'\)/);
assert.match(backdrop, /houseplan backdrop guard probe/);
});
+1 -1
View File
@@ -51,6 +51,6 @@ if HAS_HA:
"""Do not let warm HA harness runs exhaust the shared file quota."""
if "hass" in request.fixturenames:
hass = request.getfixturevalue("hass")
for relative in ("houseplan/plans", "houseplan/files"):
for relative in ("houseplan/plans", "houseplan/files", "houseplan/assets"):
shutil.rmtree(Path(hass.config.path(relative)), ignore_errors=True)
yield
+141
View File
@@ -196,3 +196,144 @@ def test_issue_398_pure_imports_leaves_sys_modules_as_it_found_it():
)
assert dir(again)
assert sorted(k for k in sys.modules if k.startswith("custom_components")) == before
# --- #436: файл, которому нужен Home Assistant, обязан это объявить ---------
def _toplevel_imports(path: Path) -> set[str]:
"""Модули, импортируемые на уровне модуля — то есть при сборке pytest.
Тела функций и классов не считаются: их импорты исполняются при вызове и
сборку не ломают. `if TYPE_CHECKING:` тоже не считается — этот блок не
исполняется никогда (`virtual_lights` так тянет `store` и остаётся чистым).
"""
import ast
names: set[str] = set()
def visit(body: list) -> None:
for node in body:
if isinstance(node, ast.Import):
names.update(alias.name for alias in node.names)
elif isinstance(node, ast.ImportFrom):
base = node.module or ""
if node.level: # from . import store / from .store import x
base = f".{base}" if base else "."
names.add(base)
names.update(f"{base}.{alias.name}".replace("..", ".", 1)
if base == "." else f"{base}.{alias.name}"
for alias in node.names)
elif isinstance(node, ast.If):
if "TYPE_CHECKING" in ast.unparse(node.test):
continue
visit(node.body)
visit(node.orelse)
elif isinstance(node, ast.Try):
visit(node.body)
for handler in node.handlers:
visit(handler.body)
visit(node.orelse)
visit(node.finalbody)
visit(ast.parse(path.read_text(encoding="utf-8")).body)
return names
def _ha_dependent_backend_modules(root: Path | None = None) -> set[str]:
"""Модули интеграции, которые при импорте тянут Home Assistant.
Прямые импортёры плюс замыкание по относительным импортам: `store` тянет HA
сам, а всё, что импортирует `store` на уровне модуля, тянет его через него.
"""
deps: dict[str, set[str]] = {}
for path in sorted((root or BACKEND).glob("*.py")):
imports = _toplevel_imports(path)
deps[path.stem] = {
name.lstrip(".").split(".")[0] for name in imports if name.startswith(".")
}
if any(name.split(".")[0] == "homeassistant" for name in imports):
deps[path.stem].add("homeassistant")
needs = {name for name, own in deps.items() if "homeassistant" in own}
changed = True
while changed: # обычно один проход; цикл — на случай цепочек длиннее одной
changed = False
for name, own in deps.items():
if name not in needs and own & needs:
needs.add(name)
changed = True
return needs
def _needs_home_assistant(path: Path, ha_modules: set[str]) -> bool:
"""Оборвётся ли импорт этого файла без Home Assistant."""
imports = _toplevel_imports(path)
if {name.split(".")[0] for name in imports} & {
"homeassistant", "pytest_homeassistant_custom_component",
}:
return True
touched = set()
for name in imports:
parts = name.split(".")
if parts[:2] == ["custom_components", "houseplan"] and len(parts) > 2:
touched.add(parts[2])
return bool(touched & ha_modules)
def test_issue_436_ha_dependent_test_modules_declare_it():
"""Сборка pytest не имеет права падать из-за отсутствия Home Assistant.
До #436 `conftest.py` отсекал HA-тесты по шаблону имени `test_ha_*.py`, а
`test_coordinate_canonicalization.py` тянул HA через `store` и под шаблон
не попадал: `python3 -m pytest tests_backend/` без HA обрывался на сборке,
и НИ ОДИН из трёх сотен чистых тестов не выполнялся. Признак «нужен ли
файлу HA» был подменён признаком «как файл назван» — та же конструкция,
которая в #389 уронила 85 тестов с голым `assert False`.
Проверка статическая и работает в любом окружении: она читает импорты, а
не исполняет их.
"""
tests_dir = Path(__file__).resolve().parent
ha_modules = _ha_dependent_backend_modules()
# Свидетели самого сканера: без них «ничего не нашёл» выглядело бы как
# «всё в порядке» — ровно тот вид зелёного, против которого заведён #430.
assert "store" in ha_modules, "сканер не видит прямого импортёра HA"
assert "coordinate_canonicalization" not in ha_modules, "чистый модуль помечен как HA"
ha_named = sorted(path for path in tests_dir.glob("test_ha_*.py"))
assert ha_named, "HA-тесты исчезли — у сканера не осталось положительных примеров"
for path in ha_named:
assert _needs_home_assistant(path, ha_modules), (
f"{path.name}: сканер не распознаёт HA-зависимость там, где она заведомо есть"
)
undeclared = [
path.name for path in sorted(tests_dir.glob("test_*.py"))
if _needs_home_assistant(path, ha_modules)
and not path.name.startswith("test_ha_")
and 'importorskip("homeassistant"' not in path.read_text(encoding="utf-8")
]
assert undeclared == [], (
"файлам нужен Home Assistant, но они этого не объявляют: "
f"{undeclared}. Либо имя test_ha_*.py — тогда их отсечёт conftest, — либо "
'pytest.importorskip("homeassistant") ДО импортов, тянущих HA (#436). '
"Иначе прогон tests_backend/ без HA оборвётся на сборке, и не выполнится "
"ни один тест, включая чистые."
)
def test_issue_436_scanner_follows_transitive_ha_dependencies(tmp_path):
"""Замыкание — не украшение: в интеграции есть модуль, зависящий от HA
только через соседа (`import_export` тянет его через `store`). Без
замыкания такой модуль считался бы чистым, и тест, импортирующий его,
снова обрывал бы сборку. Проверяется на синтетическом дереве, чтобы
свидетель не зависел от того, как перетасуют импорты в самой интеграции.
"""
(tmp_path / "direct.py").write_text("from homeassistant.core import HomeAssistant\n")
(tmp_path / "middle.py").write_text("from .direct import HomeAssistant\n")
(tmp_path / "far.py").write_text("from . import middle\n")
(tmp_path / "clean.py").write_text("import json\n")
(tmp_path / "lazy.py").write_text(
"from typing import TYPE_CHECKING\n"
"if TYPE_CHECKING:\n from .direct import HomeAssistant\n"
"def f():\n from .direct import HomeAssistant\n return HomeAssistant\n",
)
assert _ha_dependent_backend_modules(tmp_path) == {"direct", "middle", "far"}
@@ -9,9 +9,30 @@ from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
# Этот файл требует Home Assistant, но называется не `test_ha_*` (#436).
#
# Отсекать HA-тесты по имени файла — конструкция, которая уже стоила #389:
# корректность харнесса держалась на именах в каталоге. Здесь она стоила
# меньшего, но обиднее: `collect_ignore_glob` в conftest этот файл не ловил,
# импорт `store` тянул `homeassistant`, и `python3 -m pytest tests_backend/`
# без HA падал НА СБОРКЕ. То есть чистое подмножество не выполнялось вовсе —
# ни один из 310 тестов, — а документация обещала обратное.
#
# `importorskip` на уровне модуля превращает это в честный скип: pytest
# сообщает «1 skipped» вместо «Interrupted», остальные файлы прогоняются. В
# CI Home Assistant установлен, поэтому там не скипается ничего.
#
# Импорты ниже стоят после этой строки намеренно: они и есть то, что без HA
# не выполнится. Порядок закреплён тестом
# `test_issue_436_ha_dependent_test_modules_declare_it` — он краснеет, если
# такой файл появится без объявления.
pytest.importorskip("homeassistant", reason="модуль тянет HA через store (#436)")
from custom_components.houseplan import virtual_lights
from custom_components.houseplan.coordinate_canonicalization import (
COORDINATE_DECIMALS,
DECOR_BOX_KINDS,
LATTICE_GRID_N,
LATTICE_NOISE_STEPS,
canonicalize_config_geometry,
@@ -30,7 +51,6 @@ from custom_components.houseplan.validation import (
)
from custom_components.houseplan.wall_segment_model import commit_wall_segment_model
FIXTURE = (
Path(__file__).parents[1]
/ "test"
@@ -69,6 +89,32 @@ def test_python_and_frontend_share_the_scalar_lattice_fixture_contract() -> None
assert math.copysign(1.0, layout["rl:poly"]["x"]) == 1.0
def test_decor_box_catalog_matches_shared_contract() -> None:
fixture = _fixture()
assert list(DECOR_BOX_KINDS) == fixture["boxKinds"]
input_decor = fixture["configInput"]["spaces"][0]["decor"]
input_boxes = [item for item in input_decor if item["kind"] in DECOR_BOX_KINDS]
assert sorted(item["kind"] for item in input_boxes) == sorted(fixture["boxKinds"])
result = canonicalize_config_geometry(fixture["configInput"])
output_by_id = {item["id"]: item for item in result["spaces"][0]["decor"]}
expected_by_id = {
item["id"]: item for item in fixture["configExpected"]["spaces"][0]["decor"]
}
for source in input_boxes:
actual = output_by_id[source["id"]]
expected = expected_by_id[source["id"]]
for field in ("x", "y", "w", "h", "angle"):
assert actual[field] == expected[field]
assert actual[field] != source[field]
source_image = next(item for item in input_boxes if item["kind"] == "image")
image = output_by_id["image"]
for field in ("asset_id", "opacity", "flip_h", "flip_v", "future"):
assert image[field] == source_image[field]
def test_scalar_contract_is_symmetric_and_keeps_off_grid_geometry() -> None:
assert canonicalize_number(1.2345678905) == 1.234567891
assert canonicalize_number(-1.2345678905) == -1.234567891
+314 -3
View File
@@ -2,15 +2,28 @@
from __future__ import annotations
import base64
import hashlib
import importlib
import json
import struct
import threading
import zlib
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import pytest
from custom_components.houseplan.asset_integrity import (
ASSET_INTEGRITY_CACHE_ENTRIES,
AssetIntegrityVerifier,
)
from custom_components.houseplan.const import MAX_DECOR_ASSET_BYTES
from custom_components.houseplan.decor_assets import (
DecorAssetError,
asset_meta_path,
asset_refs,
public_asset,
read_asset,
read_catalog,
validate_asset,
)
@@ -51,9 +64,11 @@ def test_declared_mime_must_agree_with_extension_and_bytes() -> None:
(WEBP_VP8L_3X2, "lossless.webp", "image/webp"),
(WEBP_VP8X_3X2, "alpha.webp", "image/webp"),
])
def test_supported_raster_headers_and_full_decode(
def test_supported_raster_headers_and_dimensions(
payload: bytes, filename: str, mime: str,
) -> None:
"""Имя до #430 обещало «and_full_decode», а проверялись w/h/mime — их даёт
header-парсер, и мутация полного декодирования оставалась зелёной."""
asset = validate_asset(payload, filename, "application/octet-stream")
assert (asset.width, asset.height, asset.mime) == (3, 2, mime)
@@ -185,11 +200,121 @@ def test_svg_rejects_one_oversized_attribute_before_tree_use() -> None:
validate_asset(payload, "bounded.svg")
def test_valid_looking_but_truncated_raster_is_rejected_by_full_decode() -> None:
with pytest.raises(DecorAssetError, match="corrupt|decode"):
def test_truncated_raster_is_rejected_by_the_header_parser() -> None:
"""Обрезка на 33 байтах убирает `IEND`, поэтому файл отбивает парсер
заголовка. До #430 этот тест назывался «by_full_decode» и записывался в
доказательство декодирования — при no-op блока Pillow он оставался
зелёным. Настоящий свидетель декодирования — тест ниже."""
with pytest.raises(DecorAssetError, match="corrupt or has the wrong type"):
validate_asset(PNG_1X1[:33], "truncated.png", "image/png")
def _png_with_corrupt_idat() -> bytes:
"""PNG, безупречный для парсера заголовка и мёртвый для декодера.
Сигнатура, IHDR с честными 1x1, IEND на месте, длины и CRC всех чанков
верны — придраться нечему, пока кто-нибудь не попробует распаковать IDAT,
в котором лежит не zlib-поток, а текст.
"""
def chunk(kind: bytes, payload: bytes) -> bytes:
return (struct.pack(">I", len(payload)) + kind + payload
+ struct.pack(">I", zlib.crc32(kind + payload) & 0xFFFFFFFF))
return (b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", struct.pack(">IIBBBBB", 1, 1, 8, 0, 0, 0, 0))
+ chunk(b"IDAT", b"not a zlib stream at all")
+ chunk(b"IEND", b""))
def test_valid_looking_png_is_rejected_by_the_full_pillow_decode() -> None:
"""Свидетель полного декодирования (#430, п.1 аудита v1.71.0-beta.1).
Раньше блок `with Image.open(...) as image: image.load()` можно было
заменить на no-op, и все 35 тестов файла оставались зелёными: заголовок
отвечал на все вопросы, которые они задавали. Здесь спрашивается то, на
что заголовок ответить не может, — распаковывается ли растр вообще.
"""
pytest.importorskip("PIL", reason="полное декодирование делает Pillow")
with pytest.raises(DecorAssetError, match="cannot be decoded"):
validate_asset(_png_with_corrupt_idat(), "corrupt.png", "image/png")
def test_pillow_is_present_wherever_home_assistant_is() -> None:
"""Свидетель выше не имеет права молча пропускаться в каноне.
`validate_asset` глотает `ImportError` осознанно: чистое подмножество
тестов должно работать без всего HA. Цена — пропуск теста там, где Pillow
нет, а пропущенный тест выглядит как пройденный. Поэтому в окружении с
Home Assistant (Linux CI, WSL) отсутствие Pillow — красное само по себе.
"""
pytest.importorskip("homeassistant", reason="чистая песочница без HA")
importlib.import_module("PIL.Image")
def test_svg_asset_carries_canonical_bytes_not_the_upload() -> None:
"""Свидетель канонизации (#430, п.2). ТЗ §3 требует ре-сериализацию, а
`ValidatedAsset(canonical, …)` → `ValidatedAsset(data, …)` не краснило ни
один тест: все проверяли w/h/mime и ни один — сами байты.
"""
raw = (b'<?xml version="1.0" encoding="utf-8"?>\n'
b'<!-- pipeline comment -->\n'
b'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 20" >'
b'<rect x="0" y="0" width="1" height="1"></rect>'
b'<title>a&gt;b</title>'
b'</svg>')
asset = validate_asset(raw, "canonical.svg")
assert asset.data != raw
assert not asset.data.startswith(b"<?xml"), "XML-пролог не переживает канонизацию"
assert b"<!--" not in asset.data, "комментарий не переживает канонизацию"
assert b"<rect " in asset.data and b"</rect>" not in asset.data, "пустой элемент сжимается"
assert asset.data == (
b'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 20">'
b'<rect x="0" y="0" width="1" height="1" />'
b'<title>a&gt;b</title>'
b'</svg>'
)
def test_svg_size_limit_applies_to_canonical_bytes() -> None:
"""Свидетель второй половины п.2: `_check_size(canonical)`.
Экранирование `>` в тексте раздувает документ вчетверо, поэтому загрузка
размером 1.84 МиБ канонизуется в 7.35 МиБ. Проверка размера на входе это
пропускает; снятие `_check_size(canonical)` до #430 не краснило ничего, и
в хранилище уезжал файл вчетверо больше объявленного предела.
"""
payload = (b'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 20">'
+ (b"<desc>" + b">" * 4096 + b"</desc>") * 470
+ b"</svg>")
assert len(payload) < MAX_DECOR_ASSET_BYTES, "загрузка обязана проходить входной контроль"
with pytest.raises(DecorAssetError, match="2 MiB"):
validate_asset(payload, "canonical-bomb.svg")
@pytest.mark.parametrize("value", [
"javascript:alert(1)",
"data:image/png;base64,AAAA",
"http://example.com/x",
"https://example.com/x",
"//example.com/x",
])
def test_svg_external_resource_guard_catches_what_no_other_rule_does(value: str) -> None:
"""Свидетель гарда внешних URL (#430, п.3).
Все три «внешних» кейса корпуса ловились другими правилами: тегом не из
словаря, атрибутом не из словаря, ветвью `name == "href"`. Замена условия
на `if False:` не краснила ничего. Здесь тег и атрибут разрешённые, `url(`
нет, `href` нет — сработать может только сам токен-гард, и сообщение это
подтверждает.
"""
payload = (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 20">'
f'<path fill="{value}" d="M0 0L1 1"/></svg>'
).encode()
with pytest.raises(DecorAssetError, match="External SVG resources are forbidden"):
validate_asset(payload, "external.svg")
def test_catalog_ignores_missing_or_malformed_sidecars(tmp_path) -> None:
aid = "a" * 64
(tmp_path / f"{aid}.png").write_bytes(PNG_1X1)
@@ -207,6 +332,192 @@ def test_catalog_empty_directory_and_metadata_path(tmp_path) -> None:
assert asset_meta_path(tmp_path, aid) == tmp_path / f"{aid}.json"
def test_direct_asset_lookup_never_scans_or_accepts_mismatched_sidecars(
tmp_path, monkeypatch,
) -> None:
payload = b"one"
aid = hashlib.sha256(payload).hexdigest()
other = "d" * 64
(tmp_path / f"{aid}.png").write_bytes(payload)
(tmp_path / f"{aid}.json").write_text(json.dumps({
"asset_id": aid, "ext": ".png", "mime": "image/png",
}), encoding="utf-8")
(tmp_path / f"{other}.json").write_text(json.dumps({
"asset_id": aid, "ext": ".png", "mime": "image/png",
}), encoding="utf-8")
assert [row["asset_id"] for row in read_catalog(tmp_path)] == [aid]
def no_scan(_self, _pattern):
raise AssertionError("direct lookup must not scan the catalog")
monkeypatch.setattr(Path, "glob", no_scan)
assert read_asset(tmp_path, aid)["asset_id"] == aid
assert read_asset(tmp_path, other) is None
def test_integrity_cache_reuses_digest_and_caches_corrupt_signature(tmp_path) -> None:
payload = b"stable-content"
path = tmp_path / "asset.bin"
path.write_bytes(payload)
expected = hashlib.sha256(payload).hexdigest()
calls = 0
def counted(candidate: Path) -> str:
nonlocal calls
calls += 1
return hashlib.sha256(candidate.read_bytes()).hexdigest()
verifier = AssetIntegrityVerifier(hasher=counted)
assert verifier.verify(path, expected)
assert verifier.verify(path, expected)
assert calls == 1
wrong = "0" * 64
assert not verifier.verify(path, wrong)
assert not verifier.verify(path, wrong)
assert calls == 1, "the actual digest also caches a negative comparison"
def test_integrity_cache_invalidates_changed_signature_and_rejects_mid_read_change(
tmp_path,
) -> None:
path = tmp_path / "asset.bin"
first = b"first"
second = b"second-version"
path.write_bytes(first)
calls = 0
def counted(candidate: Path) -> str:
nonlocal calls
calls += 1
return hashlib.sha256(candidate.read_bytes()).hexdigest()
verifier = AssetIntegrityVerifier(hasher=counted)
assert verifier.verify(path, hashlib.sha256(first).hexdigest())
path.write_bytes(second)
assert verifier.verify(path, hashlib.sha256(second).hexdigest())
assert calls == 2
replacement = b"changed-during-read"
def mutating(candidate: Path) -> str:
original = candidate.read_bytes()
candidate.write_bytes(replacement)
return hashlib.sha256(original).hexdigest()
unstable = AssetIntegrityVerifier(hasher=mutating)
path.write_bytes(first)
assert not unstable.verify(path, hashlib.sha256(first).hexdigest())
assert not unstable._cache, "an unstable digest must not become a cache hit"
def test_integrity_cache_single_flights_same_path_and_releases_after_error(tmp_path) -> None:
path = tmp_path / "asset.bin"
payload = b"concurrent"
path.write_bytes(payload)
expected = hashlib.sha256(payload).hexdigest()
waiter_joined = threading.Event()
real_event = threading.Event
class ObservedEvent:
def __init__(self) -> None:
self._event = real_event()
def set(self) -> None:
self._event.set()
def wait(self, timeout=None) -> bool:
waiter_joined.set()
return self._event.wait(timeout)
calls = 0
def coordinated(candidate: Path) -> str:
nonlocal calls
calls += 1
assert waiter_joined.wait(2), "the concurrent caller never joined the flight"
return hashlib.sha256(candidate.read_bytes()).hexdigest()
verifier = AssetIntegrityVerifier(hasher=coordinated, event_factory=ObservedEvent)
with ThreadPoolExecutor(max_workers=2) as pool:
first = pool.submit(verifier.verify, path, expected)
second = pool.submit(verifier.verify, path, expected)
assert first.result(timeout=3) and second.result(timeout=3)
assert calls == 1
attempts = 0
def once_broken(candidate: Path) -> str:
nonlocal attempts
attempts += 1
if attempts == 1:
raise OSError("injected read failure")
return hashlib.sha256(candidate.read_bytes()).hexdigest()
recovered = AssetIntegrityVerifier(hasher=once_broken)
assert not recovered.verify(path, expected)
assert recovered.verify(path, expected)
assert attempts == 2 and not recovered._inflight
def test_integrity_checks_for_different_paths_do_not_share_a_hash_lock(tmp_path) -> None:
first_path = tmp_path / "first.bin"
second_path = tmp_path / "second.bin"
first_path.write_bytes(b"first")
second_path.write_bytes(b"second")
first_started = threading.Event()
release_first = threading.Event()
def coordinated(candidate: Path) -> str:
if candidate == first_path:
first_started.set()
assert release_first.wait(2)
return hashlib.sha256(candidate.read_bytes()).hexdigest()
verifier = AssetIntegrityVerifier(hasher=coordinated)
with ThreadPoolExecutor(max_workers=2) as pool:
first = pool.submit(
verifier.verify, first_path, hashlib.sha256(b"first").hexdigest(),
)
assert first_started.wait(1)
independent = pool.submit(
verifier.verify, second_path, hashlib.sha256(b"second").hexdigest(),
)
assert independent.result(timeout=1)
release_first.set()
assert first.result(timeout=2)
def test_integrity_cache_is_bounded_lru_and_stream_reader_avoids_read_bytes(
tmp_path, monkeypatch,
) -> None:
assert ASSET_INTEGRITY_CACHE_ENTRIES == 256
paths = []
for index in range(ASSET_INTEGRITY_CACHE_ENTRIES + 1):
path = tmp_path / f"{index}.bin"
path.write_bytes(str(index).encode())
paths.append(path)
verifier = AssetIntegrityVerifier()
def forbidden_read_bytes(_self):
raise AssertionError("integrity verification must stream chunks")
monkeypatch.setattr(Path, "read_bytes", forbidden_read_bytes)
for index in range(ASSET_INTEGRITY_CACHE_ENTRIES):
assert verifier.verify(
paths[index], hashlib.sha256(str(index).encode()).hexdigest(),
)
# Refresh zero, then the 257th insert must evict one rather than zero.
assert verifier.verify(paths[0], hashlib.sha256(b"0").hexdigest())
last = ASSET_INTEGRITY_CACHE_ENTRIES
assert verifier.verify(paths[last], hashlib.sha256(str(last).encode()).hexdigest())
assert len(verifier._cache) == ASSET_INTEGRITY_CACHE_ENTRIES
assert str(paths[0].resolve()) in verifier._cache
assert str(paths[1].resolve()) not in verifier._cache
assert str(paths[last].resolve()) in verifier._cache
def test_reference_scan_is_cross_space_and_image_only() -> None:
aid = "b" * 64
refs = asset_refs({"spaces": [
+193
View File
@@ -107,6 +107,199 @@ def _config() -> dict:
return commit_wall_segment_model(legacy)[0]
def _config_with_decor_image(asset_id: str) -> tuple[dict, dict]:
config = _config()
shape = {
"id": "custom-picture", "kind": "image", "asset_id": asset_id,
"x": 0.12, "y": 0.23, "w": 0.34, "h": 0.45,
"angle": 12.5, "opacity": 0.6, "flip_h": True, "flip_v": False,
}
config["spaces"][0]["decor"] = [shape]
return config, shape
def _missing_decor_export(
source: Path, *, kind: str = "full", plan_only: bool = False,
asset_id: str = "a" * 64,
) -> tuple[dict, dict]:
config, shape = _config_with_decor_image(asset_id)
document, _filename = create_export(
SimpleNamespace(instance_id="source-instance"),
{"config": config, "rev": 2},
{"layout": {}, "rev": 3},
kind=kind,
space_id="ground" if kind == "space" else None,
plan_only=plan_only,
card_version="review",
config_root=source,
)
return document, shape
@pytest.mark.parametrize(
("kind", "plan_only"),
[("full", False), ("space", False), ("space", True)],
ids=["full", "space", "plan-only"],
)
def test_issue_428_missing_decor_asset_round_trips_in_every_export_mode(
tmp_path: Path, kind: str, plan_only: bool,
) -> None:
document, shape = _missing_decor_export(
tmp_path / "source", kind=kind, plan_only=plan_only,
)
assert document["content_manifest"] == [{
"kind": "decor_asset",
"owner": "decor",
"owner_id": "ground:custom-picture",
"field": "asset_id",
"url": shape["asset_id"],
"asset_id": shape["asset_id"],
"storage": "internal",
"mime": None,
"hash": shape["asset_id"],
"exists_at_export": False,
}]
runtime = SimpleNamespace(instance_id="target-instance", import_previews={})
response = create_preview(
runtime,
json.dumps(document).encode(),
owner_id="alice",
duplicate_policy="skip",
current_config_data={"config": {"spaces": [], "markers": []}, "rev": 0},
current_layout_data={"layout": {}, "rev": 0},
config_root=tmp_path / "target",
)
assert response["preview"]["confirmation_required"] is True
assert response["preview"]["content"][0]["state"] == "missing_preserved"
candidate = get_candidate(runtime, response["token"], "alice")
with pytest.raises(ImportFailure) as unconfirmed:
prepare_apply(
candidate, {"spaces": [], "markers": []}, {},
confirm_missing_content=False,
)
assert unconfirmed.value.code == "content_confirmation_required"
imported, _layout, _details = prepare_apply(
candidate, {"spaces": [], "markers": []}, {},
confirm_missing_content=True,
)
imported_images = [
item
for space in imported["spaces"]
for item in space.get("decor") or []
if item.get("kind") == "image"
]
assert len(imported_images) == 1
# A one-space import intentionally allocates local object ids. The portable
# asset identity and every visual field must survive that namespace remap.
imported_without_local_id = {
key: value for key, value in imported_images[0].items() if key != "id"
}
source_without_local_id = {
key: value for key, value in shape.items() if key != "id"
}
assert imported_without_local_id == source_without_local_id
@pytest.mark.parametrize(
("mime", "omit_mime"),
[(None, False), (None, True), ("image/png", False)],
ids=["null", "omitted", "supported"],
)
def test_issue_428_explicitly_missing_asset_accepts_bounded_mime(
tmp_path: Path, mime: str | None, omit_mime: bool,
) -> None:
document, _shape = _missing_decor_export(tmp_path / "source")
if omit_mime:
document["content_manifest"][0].pop("mime")
else:
document["content_manifest"][0]["mime"] = mime
rows, confirmation = import_export_api._content_state(
document, False, tmp_path / "target",
)
assert rows[0]["mime"] == mime
assert rows[0]["state"] == "missing_preserved"
assert confirmation is True
@pytest.mark.parametrize(
("exists_at_export", "mime", "remove_exists"),
[
(True, None, False),
(False, "", False),
(False, "text/plain", False),
(False, 0, False),
(False, [], False),
(False, {}, False),
(None, "image/png", True),
(None, "image/png", False),
(0, "image/png", False),
(1, "image/png", False),
("false", "image/png", False),
([], "image/png", False),
({}, "image/png", False),
],
ids=[
"present-null-mime", "empty-mime", "unsupported-mime", "numeric-mime",
"list-mime", "object-mime", "missing-flag", "null-flag", "zero-flag",
"one-flag", "string-flag", "list-flag", "object-flag",
],
)
def test_issue_428_missing_mime_exception_remains_fail_closed(
tmp_path: Path, exists_at_export: Any, mime: Any, remove_exists: bool,
) -> None:
document, _shape = _missing_decor_export(tmp_path / "source")
row = document["content_manifest"][0]
row["exists_at_export"] = exists_at_export
row["mime"] = mime
if remove_exists:
row.pop("exists_at_export")
with pytest.raises(ImportFailure) as invalid:
import_export_api._content_state(document, False, tmp_path / "target")
assert invalid.value.code == "invalid_content"
@pytest.mark.parametrize("field", ["asset_id", "hash"])
def test_issue_428_missing_asset_keeps_hash_identity_strict(
tmp_path: Path, field: str,
) -> None:
document, _shape = _missing_decor_export(tmp_path / "source")
document["content_manifest"][0][field] = "b" * 64
with pytest.raises(ImportFailure) as invalid:
import_export_api._content_state(document, False, tmp_path / "target")
assert invalid.value.code == "invalid_content"
def test_issue_428_missing_source_reuses_only_exact_target_blob(tmp_path: Path) -> None:
data = b"target already has the canonical custom image"
asset_id = hashlib.sha256(data).hexdigest()
document, _shape = _missing_decor_export(
tmp_path / "source", asset_id=asset_id,
)
target_assets = tmp_path / "target" / "houseplan" / "assets"
target_assets.mkdir(parents=True)
blob = target_assets / f"{asset_id}.png"
blob.write_bytes(data)
rows, confirmation = import_export_api._content_state(
document, False, tmp_path / "target",
)
assert rows[0]["mime"] is None
assert rows[0]["exists_at_export"] is False
assert rows[0]["exists_on_target"] is True
assert rows[0]["state"] == "available"
assert confirmation is False
blob.write_bytes(b"different bytes")
rows, confirmation = import_export_api._content_state(
document, False, tmp_path / "target",
)
assert rows[0]["exists_on_target"] is False
assert rows[0]["state"] == "missing_preserved"
assert confirmation is True
def _document(tmp_path: Path, kind: str = "full") -> dict:
runtime = SimpleNamespace(instance_id="instance-a")
document, _filename = create_export(
+139
View File
@@ -1173,6 +1173,26 @@ async def test_not_ready_without_entry(hass: HomeAssistant, hass_ws_client: WebS
assert not resp["success"] and resp["error"]["code"] == "not_ready"
async def test_decor_asset_resolve_requires_runtime_before_io(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, monkeypatch,
) -> None:
"""#432 AC3: lifecycle refusal precedes even construction of a store path."""
from custom_components.houseplan import websocket_api as hp_ws
hp_ws.async_register(hass)
def forbidden_path(*_args, **_kwargs):
raise AssertionError("asset filesystem touched before the runtime gate")
monkeypatch.setattr(hp_ws, "Path", forbidden_path)
client = await hass_ws_client(hass)
await client.send_json_auto_id({
"type": "houseplan/assets/resolve", "asset_ids": ["a" * 64],
})
resp = await client.receive_json()
assert not resp["success"] and resp["error"]["code"] == "not_ready"
async def test_plan_set_validates(hass: HomeAssistant, hass_ws_client: WebSocketGenerator) -> None:
await _setup(hass)
client = await hass_ws_client(hass)
@@ -2122,6 +2142,104 @@ async def test_decor_asset_upload_deduplicates_and_rejects_mime_spoofing(
assert json.loads(spoofed.text)["error"] == "invalid_format"
async def test_decor_asset_resolve_readonly_is_limited_to_referenced_ids(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
hass_read_only_access_token: str,
monkeypatch,
) -> None:
"""#432 AC1/AC2: View keeps its images without exposing the catalog."""
import hashlib
from custom_components.houseplan import websocket_api as wsapi
from custom_components.houseplan.const import ASSETS_DIR
await _setup(hass)
admin = await hass_ws_client(hass)
root = Path(hass.config.path(ASSETS_DIR))
root.mkdir(parents=True, exist_ok=True)
payloads = (b"referenced", b"not-referenced")
asset_ids = []
for index, payload in enumerate(payloads):
aid = hashlib.sha256(payload).hexdigest()
asset_ids.append(aid)
(root / f"{aid}.png").write_bytes(payload)
(root / f"{aid}.json").write_text(json.dumps({
"asset_id": aid, "name": f"{index}.png", "mime": "image/png",
"ext": ".png", "width": 1, "height": 1, "bytes": len(payload),
"created_at": f"2026-01-0{index + 1}T00:00:00Z",
}), encoding="utf-8")
cfg = await _cfg([{"id": "one", "plan_url": None}])
cfg["spaces"][0]["decor"] = [{
"id": "picture", "kind": "image", "asset_id": asset_ids[0],
"x": 0.1, "y": 0.2, "w": 0.3, "h": 0.4,
}]
assert (await _save(admin, cfg, 0))["success"]
looked_up = []
real_read_asset = wsapi.read_asset
def observed_read_asset(asset_root: Path, asset_id: str):
looked_up.append(asset_id)
return real_read_asset(asset_root, asset_id)
monkeypatch.setattr(wsapi, "read_asset", observed_read_asset)
readonly = await hass_ws_client(hass, access_token=hass_read_only_access_token)
await readonly.send_json_auto_id({
"type": "houseplan/assets/resolve", "asset_ids": asset_ids,
})
response = await readonly.receive_json()
assert response["success"]
assert [row["asset_id"] for row in response["result"]["assets"]] == [asset_ids[0]]
assert response["result"]["missing"] == [asset_ids[1]]
assert looked_up == [asset_ids[0]], "forbidden metadata/blob must not be touched"
looked_up.clear()
await admin.send_json_auto_id({
"type": "houseplan/assets/resolve", "asset_ids": asset_ids,
})
response = await admin.receive_json()
assert response["success"] and len(response["result"]["assets"]) == 2
assert set(looked_up) == set(asset_ids)
async def test_decor_asset_resolve_non_admin_is_writer_when_admin_only_is_off(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
hass_read_only_access_token: str,
) -> None:
"""#432 AC2: resolve follows may_write instead of hard-coding admin."""
import hashlib
from custom_components.houseplan.const import ASSETS_DIR
entry = MockConfigEntry(
domain=DOMAIN, title="House Plan", data={}, options={CONF_ADMIN_ONLY: False},
)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
payload = b"writer-by-option"
aid = hashlib.sha256(payload).hexdigest()
root = Path(hass.config.path(ASSETS_DIR))
root.mkdir(parents=True, exist_ok=True)
(root / f"{aid}.png").write_bytes(payload)
(root / f"{aid}.json").write_text(json.dumps({
"asset_id": aid, "name": "writer.png", "mime": "image/png", "ext": ".png",
"width": 1, "height": 1, "bytes": len(payload),
"created_at": "2026-01-01T00:00:00Z",
}), encoding="utf-8")
client = await hass_ws_client(hass, access_token=hass_read_only_access_token)
await client.send_json_auto_id({
"type": "houseplan/assets/resolve", "asset_ids": [aid],
})
response = await client.receive_json()
assert response["success"]
assert response["result"]["assets"][0]["asset_id"] == aid
async def test_decor_asset_list_resolve_delete_and_signed_content(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, hass_client_no_auth,
) -> None:
@@ -2130,6 +2248,7 @@ async def test_decor_asset_list_resolve_delete_and_signed_content(
import hashlib
from custom_components.houseplan.const import ASSETS_DIR, CONTENT_URL
from custom_components.houseplan.asset_integrity import AssetIntegrityVerifier
await _setup(hass)
client = await hass_ws_client(hass)
@@ -2144,6 +2263,16 @@ async def test_decor_asset_list_resolve_delete_and_signed_content(
"asset_id": aid, "name": "pixel.png", "mime": "image/png", "ext": ".png",
"width": 1, "height": 1, "bytes": len(png), "created_at": "2026-01-01T00:00:00Z",
}), encoding="utf-8")
hash_calls = 0
def counted_hash(path: Path) -> str:
nonlocal hash_calls
hash_calls += 1
return hashlib.sha256(path.read_bytes()).hexdigest()
hass.data[DOMAIN]["asset_integrity_verifier"] = AssetIntegrityVerifier(
hasher=counted_hash,
)
cfg = await _cfg([{"id": "one", "plan_url": None}])
cfg["spaces"][0]["decor"] = [{
@@ -2174,11 +2303,21 @@ async def test_decor_asset_list_resolve_delete_and_signed_content(
assert response.status == 200 and await response.read() == png
assert response.headers["Content-Type"].startswith("image/png")
assert response.headers["X-Content-Type-Options"] == "nosniff"
assert hash_calls == 1, "WS and HTTP must share one unchanged-file digest"
(root / f"{aid}.png").write_bytes(b"tampered")
assert (await http.get(signed)).status == 404
assert hash_calls == 2
(root / f"{aid}.png").write_bytes(png)
assert (await http.get(signed)).status == 200
assert hash_calls == 3
await client.send_json_auto_id({
"type": "houseplan/assets/resolve", "asset_ids": [aid],
})
resolved_after_http = await client.receive_json()
assert resolved_after_http["success"]
assert hash_calls == 3, "HTTP and WS must share one unchanged-file digest"
cfg["spaces"][0]["decor"] = []
saved = await _save(client, cfg, saved["result"]["rev"])
assert saved["success"]
+3
View File
@@ -200,6 +200,7 @@ def test_rich_plan_projection_preserves_safe_structure_and_drops_unknown_values(
"model_version": 9,
"settings": {
"north_deg": 30,
"show_room_tooltip": False,
"fill_colors": {"warm": {"c": "#ffaa00", "a": 0.5, "secret": "drop"}},
"decor_default_style": {
"color": "#123456", "width_cm": 2, "secret": "drop",
@@ -290,6 +291,7 @@ def test_rich_plan_projection_preserves_safe_structure_and_drops_unknown_values(
assert package["versions"]["card"] == "unknown"
assert plan["settings"]["fill_colors"] == {"warm": {"a": 0.5, "c": "#ffaa00"}}
assert plan["settings"]["show_room_tooltip"] is False
assert plan["settings"]["decor_default_style"] == {"color": "#123456", "width_cm": 2}
assert space["rooms"][0]["poly"] == [[0, 0], [2, 0]]
assert space["rooms"][0]["settings"]["temp_source_kind"] == "entity"
@@ -334,6 +336,7 @@ def test_projection_helpers_fail_closed_on_malformed_shapes():
assert support_package._points(None) == []
assert support_package._custom_fill(None) is None
assert support_package._global_settings(None) == {}
assert support_package._global_settings({"show_room_tooltip": "false"}) == {}
assert support_package._room_settings(ids, None) == {}
assert support_package._project_layout(ids, None) == {}
assert support_package._summary(None, None)["spaces"] == 0
+13
View File
@@ -479,6 +479,19 @@ def test_sun_settings_global():
v.CONFIG_SCHEMA({"spaces": [], "settings": {"weather_entity": {"e": 1}}})
def test_room_tooltip_global_setting_is_strict_boolean_and_round_trips():
for value in (True, False):
out = v.CONFIG_SCHEMA(
{"spaces": [], "settings": {"show_room_tooltip": value}}
)
assert out["settings"]["show_room_tooltip"] is value
for bad in (None, 0, 1, "false", [], {}):
with pytest.raises(vol.Invalid):
v.CONFIG_SCHEMA(
{"spaces": [], "settings": {"show_room_tooltip": bad}}
)
def test_marker_area_snapshot_is_strict_and_bounded():
"""#126: Area provenance accepts only exact direct bindings and non-empty Areas."""
snapshot = {