# ТЗ #42 — Измеряемое инженерное качество backend - Issue: https://github.com/Matysh/houseplan-card/issues/42 - Приоритет: P2, tests/tech-debt; полный трек (backend class A + видимое поведение ошибок — решение аналитики 2026-08-15) - Ревизия: 6 (2026-08-30) — финальная правка r4 по АРБИТРАЖУ владельца (§4: бюджет циклов исчерпан, решение зафиксировано в issue); механизм + ступень baseline, пороги — последующие trivial - Тип: infra/tests + один видимый пользователю блок (тексты ошибок) ## Замеры ревизии (HEAD, песочница) Pure-coverage 89.1% (2377/258); HA-модули (~1.5К stmts, включая import_export 1065) измеримы только в CI. Ruff E,F,B,I: 333 (E501 — 291, содержательных 42). Ни одного requirements/pyproject в репо; зависимости бэкенд-CI — строка в validate.yml + зеркало в mutation-gate.yml. WS-коды де-факто стабильны и фронт мапит по code (`backup.error.`, 26 ключей en/ru); дыры: сырой английский message в fallback и regex-парсинг message у `invalid_passage_fields`/`invalid_partition_opening_jamb_margin`. ## Сценарий Разработчик ломает покрытие или типы бэкенда — CI краснеет с конкретным модулем и числом. Пользователь получает ошибку от бэкенда — видит локализованный текст по стабильному коду; неизвестный код даёт общий локализованный fallback с кодом, а не сырую английскую фразу. ## Что человек увидит до и после Почти ничего: единственная видимая часть — тексты редких ошибок. **До**: неизвестная ошибка = английская message; два кода парсятся regex'ом из английской строки. **После**: локализованный fallback с кодом; structured JSON-details. Всё остальное — инженерные гейты. ## Скоуп (одна ступень, пять блоков) ### 1. Tooling-фундамент `pyproject.toml` (первый в репо): метаданные не нужны — только конфиг инструментов. Зависимости бэкенд-теста выносятся в `requirements_test.txt` (pinned); validate.yml и зеркало в mutation-gate.yml ставят из него (одна точка правды вместо двух строк). ### 2. Lint (ruff, narrow) Конфиг в pyproject: `select = ["E", "F", "B", "I"]`, `ignore = ["E501"]` (291 длинная строка — НЕ переписываются: «без массового rewrite»), target py313. Чинятся 42 содержательных нарушения: I001/F401/ F841/E731/B905 — механически; **B023 (×17, loop-var в замыкании) — каждый случай разбирается отдельно**: реальная гонка → фикс с юнитом, доказанная синхронность → `# noqa: B023` с причиной в комментарии. CI-джоба `ruff check custom_components/houseplan` в validate.yml (backend). ### 3. Typing (mypy strict, растущий allowlist) `[tool.mypy]` per-module: strict для стартового списка достижимых pure-модулей — `const`, `projection`, `coordinate_canonicalization`, `frontend_asset_manifest`, `junction_limits`, `plans` (+ те из `validation`/`wall_segment_model`/`geometry_migration`, что пройдут без каскадного рефакторинга — финальный список фиксируется по факту зелени и называется в handoff). Список strict-модулей может только РАСТИ — контракт-тест сравнивает конфиг с committed-списком и падает при удалении. HA-boundary модули (websocket/http/store/repairs/…) — вне ступени (нужны stubs HA, CI-итерации) — следующая ступень, зафиксировано здесь. ### 4. Coverage (механизм + baseline-гейт) - validate.yml backend: pytest → `--cov=custom_components/houseplan --cov-branch --cov-report=xml --cov-report=term`; артефакт coverage.xml. - `scripts/backend-coverage-baseline.txt` — одно число (стартовое = фактический общий % CI-прогона pure+harness, снимается первым прогоном ветки); шаг CI сравнивает: ниже baseline − 0.1 п.п. → красный. - Защита от тихого скипа harness: шаг до pytest — `python -c "import homeassistant"` + после collect: количество собранных `tests_backend/test_ha_*` ≥ 50, иначе красный. - Пороги 90% → 95%: последующие trivial-issues, меняющие ОДНО число в baseline-файле (механизм этой ступени их уже enforce'ит). Приёмка issue «≥95%» достигается той лестницей; данная ступень сдаёт механизм + «не ниже baseline», и это отражено в квалификации quality_scale (test-coverage остаётся `todo` с прогресс-ссылкой). ### 5. WS error contract + доки - `const.py`: `ERROR_CODES` (frozenset фиксированных кодов) + `ERROR_CODE_FAMILIES` (префиксы шаблонных). Контракт-тест (pure, скан исходников) обязан покрыть ОБА пути эмиссии (M1 r1): (а) литералы `send_error(..., "", ...)` в websocket_api; (б) коды, читаемые из `err.code` в обработчиках — их источники перечисляются явно и сканируются по месту объявления: `OpeningPassageError`, `PartitionOpeningHostError`, `PartitionOpeningJambMarginError`, `WallModelClientOutdatedError` (validation.py, литеральный class-attr `code = "..."` — извлекается regex'ом), `JunctionLimitError` (junction_limits.py, `f"junction_limit_{rule}"` — семейство `junction_limit_` по списку rules), `MarkerControlError` — два пути (r3): (1) ~15 ЛИТЕРАЛЬНЫХ кодов аргументом конструктора (`duplicate_marker_control`, `invalid_marker_control`, `marker_control_*`, `invalid_value_badge*`, `invalid_value_source*`, `invalid_light_entity`, `invalid_toggle_entity`, `value_badge_source_required` — validation.py:790-999) — сканер извлекает их так (r4, арбитраж): литералы на месте вызова — regex'ом по `MarkerControlError("<код>"`; 6 кодов, передаваемых ЧЕРЕЗ ЛОКАЛЬНЫЕ ПЕРЕМЕННЫЕ (`source_error`/`attribute_error` — условные присваивания validation.py:819-844 → invalid_value_badge_source, invalid_value_source, invalid_value_badge_attribute, invalid_value_source_attribute; `code` из литерального кортежа :900-919 → invalid_light_entity, invalid_toggle_entity) — сканер дополнительно извлекает по regex'у присваиваний этих переменных литералами в том же модуле; каждый из 15 ∈ ERROR_CODES поимённо. (2) f-string-коды с префиксами `value_badge_` / `value_source_` — семейства ∈ ERROR_CODE_FAMILIES. Вызов MarkerControlError с кодом, не извлекаемым ни одним из путей, → красный сканер (fail-closed). Каждый фиксированный код ∈ ERROR_CODES и имеет en-ключ `backup.error.`; каждое семейство ∈ ERROR_CODE_FAMILIES и обслуживается либо своим family-ключом, либо задокументированным общим fallback по коду — тест требует одно из двух. Появление в источниках кода/класса вне обоих списков → красный. - Structured details: `invalid_passage_fields` и `invalid_partition_opening_jamb_margin` шлют message JSON-строкой (`{"space":…,"opening":…,"fields":…}`); фронт парсит JSON.parse с fallback на прежний regex (совместимость со старым бэкендом одной беты). Regex-ветка помечена deprecated-комментарием с датой удаления. - Fallback `_errText` (M2 r1: реальная причина дефекта — ПОРЯДОК проверок, `e.message` раньше кода): порядок меняется на code-first; неизвестный код использует СУЩЕСТВУЮЩИЙ ключ `err.unknown`/`err.code` (уже переведён en/ru/de/fr) — новых i18n-ключей этот блок не вводит; сырой английский `e.message` в UI не показывается (уходит в console.warn). - Доки: USER-GUIDE.ru получает паритетный §Troubleshooting (перевод §22); quality_scale.yaml: `docs-troubleshooting` → done, `docs-examples` → done ТОЛЬКО если текст HA-правила фактически удовлетворён существующими YAML-примерами гайда (проверка по тексту правила; иначе остаётся todo с причиной); `strict-typing` — остаётся todo с прогрессом (ступень). ## Не-скоуп (следующая ступень, зафиксировано) Strict typing HA-boundary модулей; пороги coverage 90/95; формат-проверка всего репо; массовая нормализация E501; перевод остальных секций гайдов. ## i18n Новых ключей НЕТ: неизвестные коды переиспользуют существующие `err.unknown`/`err.code` (переведены во всех 4 словарях); известные коды — существующее пространство `backup.error.`. Если реализация family-ключей (блок 5) потребует 1–2 новых ключа — они добавляются во все 4 словаря и называются в handoff (паритет-гейт словарей ловит пропуск). ## Контракт поведения Tooling не меняет stored data и успешные пользовательские сценарии. Единственное видимое изменение — тексты ошибок (блок 5): коды и структура ответов бэкенда с существующими кодами НЕ меняются (message двух кодов меняет ФОРМАТ на JSON — фронт совместим в обе стороны одну бету). ## Критерии приёмки - **AC1** (CI): джоба backend публикует coverage.xml + summary; подмена baseline на большее число → красный шаг (доказательство прогоном ветки). - **AC2** (CI): удаление homeassistant из шага установки или фильтр test_ha_* → красный ещё до pytest / на collect-пороге. - **AC3** (локально): `ruff check` чист на выбранном наборе; каждый `noqa: B023` несёт объяснение (контракт-тест: noqa без текста запрещён). - **AC4** (локально): mypy strict зелёный на стартовом allowlist; контракт-тест падает при СУЖЕНИИ списка. - **AC5** (юнит): контракт-тест реализует норматив блока 5 ЦЕЛИКОМ — оба пути эмиссии: (а) литералы `send_error` и (б) перечисленные err.code-источники (четыре validation-класса, `JunctionLimitError`, `MarkerControlError`); каждый фиксированный код ∈ ERROR_CODES с en-ключом `backup.error.`, каждое семейство ∈ ERROR_CODE_FAMILIES с family-ключом либо задокументированным fallback; источник вне перечня → красный. В частности `invalid_passage_fields` и `invalid_partition_opening_jamb_margin` обязаны быть доказаны тестом. - **AC6** (юнит фронта): JSON-message двух кодов парсится в structured details; старый regex-формат по-прежнему принимается; неизвестный код → локализованный fallback, английский message не попадает в DOM. - **AC7**: полный гейт; pytest 240/0 pure; бюджет ≈ без изменений (фронт меняет только обработку ошибок). ## План автотестов - Юниты фронта: AC6 (парсер details + fallback) — test/logic или error-текст тесты. - Pure-pytest: AC5-скан; существующие 240 не слабеют. - Контракт-тесты: AC3-noqa, AC4-allowlist (читают pyproject/исходники). - Мутанты: м1 — удалить код из ERROR_CODES → красный AC5; м1b — убрать один err.code-источник из перечня сканера (например `PartitionOpeningJambMarginError`) → красный AC5 (ветка (б) доказана); м1c (r4) — удалить `invalid_light_entity` (код-через-переменную) из ERROR_CODES → красный AC5 (переменная-подветвь доказана); м2 — вернуть regex-first парсинг (сломать JSON-ветку) → красный AC6. - CI-доказательства AC1/AC2 — прогоном ветки, фиксируются в handoff. ## Риски - B023-фиксы — единственные поведенческие: каждый со своим юнитом или обоснованным noqa. - Python 3.10 (песочница) vs 3.13 (CI): ruff/mypy конфиг target 3.13, локальная проверка на 3.10 — синтаксис кода уже совместим. - JSON-message: старый фронт с новым бэком увидит JSON-строку в сыром fallback → в пределах одной беты допустимо (пары версий фронт/бэк обновляются вместе HACS'ом); отмечено в ченджлоге. ## Откат `git revert`: конфиги/гейты исчезают, коды ошибок не менялись, формат message двух кодов возвращается — фронт совместим (regex-ветка ещё жива). Потери данных нет. **DoR-примечания:** миграция/compatibility — только формат message двух кодов (двусторонняя совместимость на бету); touch — не влияет; производительность — не влияет (test/CI-time). ## Release-артефакты - CHANGELOG×2: user-visible коротко (локализованный fallback ошибок), остальное — инженерная запись. - docs/ARCHITECTURE.md: раздел «Backend quality gates» (coverage baseline, ruff, mypy allowlist, ERROR_CODES) со ссылками. - USER-GUIDE.ru §Troubleshooting. ## Принятые предположения - Baseline-число снимается ПЕРВЫМ CI-прогоном ветки и коммитится в неё же до S7 (ревьюер видит фактическое значение). - `backup.error.` — существующее пространство ключей для всех WS-ошибок (не только бэкапов) — так уже используется фронтом; переименование пространства — вне скоупа. - Формат JSON-details фиксируется этим ТЗ как контракт двух кодов; общий механизм details для ВСЕХ кодов — следующая ступень при необходимости.