# CODE-REVIEW-354-r2 - **Issue:** https://github.com/Matysh/houseplan-card/issues/354 - **Этап:** код-ревью (PROCESS.md §2.7) - **Заход:** r2 · блокирующих циклов израсходовано 1 из 2 (лёгкий трек, лимит 2) - **SHA материала:** `c1356fae1845d04264ed79628c93d3f1598c449f` (`git rev-parse HEAD`) - **SHA предыдущего раунда (r1):** `1397afefd5564abf76cb6ed87b9f5eaee074361b` (не назван в r1-вердикте — см. документ CODE-REVIEW-354-r1.md, шапка) - **Ветка:** `issue/354-language-runtime` - **Вердикт: зелёный** ## Скоуп Разбор по дельте (PROCESS.md §2.9): единственный новый коммит с момента r1 — `c1356fae` («test: prove the failure delivery itself, not only the unsubscription (#354 r1-M1)»), `git diff 1397afef..c1356fae`. Коммит написан явно как правка находки r1-M1 (плюс попутно взята r1-L1) — продуктовый контракт К1/К2/К3 не переоткрывается, ребейза на ушедший вперёд `dev` не было (`1397afef` — прямой родитель `c1356fae`), новая подсистема не задета. Дельта локальна — полный разбор не требуется. Файлы дельты: `src/i18n/registry.ts` (13 строк), `test/i18n-runtime.test.mjs` (23 строки), `scripts/mutation-gate.mjs` (новый мутант, 19 строк), `docs/USER-GUIDE.md`/`docs/USER-GUIDE.ru.md` (по 1 предложению), синхронизированные копии бандла (`dist/**`, `custom_components/houseplan/ frontend/**`) и `docs/images/screenshots.json` + `docs/images/06-device-editor.png` (перегенерированы `check-docs` из-за смены `sourceFingerprint`, т.к. `src/i18n/registry.ts` входит в `src/**`). ## Закрытие раунда r1 | Находка r1 | Чем закрыта | Где это видно | |---|---|---| | **M1** (Medium) — юнит `subscribeLanguageLoadFailures` не проводил код через реальный fan-out, ловил только «отписка молчит» | Fan-out вынесен в экспортируемую `notifyLanguageLoadFailures(code)` (`src/i18n/registry.ts:58-64`); юнит теперь вызывает её напрямую и проверяет доставку двум живым подписчикам, молчание одного после отписки и полную тишину после отписки обоих | `test/i18n-runtime.test.mjs:173-191` (тело теста, см. «Как проверялось»); новый мутант `locale-failure-delivery-cut` в `scripts/mutation-gate.mjs:659-668` воспроизводит именно ту правку, которой r1-ревьюер поймал дыру, и красит юнит — прогнан мной лично, 1/1 | | **L1** (Low) — `docs/USER-GUIDE.ru.md` не упоминал новый тост при отказе локали | Абзац о немецкой локали в обоих гайдах дополнен предложением о тосте | `docs/USER-GUIDE.md:149-153`, `docs/USER-GUIDE.ru.md:155-158` (см. диф `git diff 1397afef..c1356fae -- docs/USER-GUIDE.md docs/USER-GUIDE.ru.md`) | Обе находки закрыты по существу, не текстом коммит-сообщения — проверено чтением диффа и повторным прогоном нового мутанта. ## Унаследовано из r1 Код-контракты, которых дельта не касается, приняты из `docs/reviews/CODE-REVIEW-354-r1.md` (SHA `1397afef`) без повторного разбора, кроме точечных перепроверок ниже, которые дельта затронула косвенно (общий прогон тестов/мутантов/смока и один точечный grep): - **AC1/К1** (`LANGUAGE_RUNTIME` — экземпляр `LanguageRuntime`, рукописный дубль удалён) — сама конструкция `new LanguageRuntime(...)` в дельте переписана только в части четвёртого аргумента (инлайн-стрелка → именованная функция), логика и итоговое поведение не изменились. Перепроверено попутно: мутант `language-runtime-handwritten-duplicate` прогнан мной повторно на `HEAD` — 1/1 поймана. - **AC3/К3** (контракт-юнит + мутант на возврат дубля) — не в дельте, перепроверено попутно тем же прогоном, зелёный. - **К2-периметр** (только `houseplan-card.ts` подписывается и показывает тост; `space-card.ts`, `editor.ts`, `space-editor.ts` — без тост-инфраструктуры) — файлы вне дельты, не тронуты. Перепроверено точечно: `grep -rn "notifyLanguageLoadFailures" src/` — единственное использование внутри `registry.ts` (конструктор рантайма), периметр не расширился. - **i18n-ключ `toast.locale_load_failed`** и его тексты en/ru/de — вне дельты, не тронуты; паритет-тест ключей локалей входит в общий прогон `npm test`, прошёл в его составе. - **Трейлеры и changelog за поведенческую часть** — уже приняты в r1 для `1397afef`. У `c1356fae` — `Issue: #354`, `User-Visible: no`, что верно: дельта не создаёт новое пользовательское поведение (тост уже был User-Visible в `1397afef`), только выносит внутреннюю функцию для тестируемости и правит документацию/тестовую инфраструктуру. Changelog в `c1356fae` не тронут — и не должен быть. ## Как проверялось Зелёного `Validate` на SHA `c1356fae` не найдено — все гейты прогнаны мной лично. | Гейт | Результат | Команда | |---|---|---| | typecheck | зелёный, без вывода | `npx tsc --noEmit` | | unit-тесты (полный набор) | зелёный, 1487 pass / 0 fail / 1 skip (тот же skip, что и в r1, не связан с #354) | `npm test` | | build + сверка 3 копий бандла | зелёный, `git status --porcelain` пуст после `npm run build && npm run bundle:sync` | `npm run build && npm run bundle:sync` | | docs-гейт (скриншоты/фингерпринт) | зелёный, «Documentation checks passed (7 files, 10 external links)» — фингерпринт уже пересчитан автором в самом коммите | `node scripts/check-docs.mjs` | | bundle:budget | зелёный, справочно, 257212 B < 282000 B | `npm run bundle:budget` | | provenance (трейлеры) | зелёный | `node scripts/validate-commit-provenance.mjs` | | мутант М1-фикса `locale-failure-delivery-cut` | поймана 1/1, «тест покраснел, как обязан» | `node scripts/mutation-gate.mjs --id=locale-failure-delivery-cut` | | мутант К1/К3 `language-runtime-handwritten-duplicate` (наследуется, перепроверен попутно) | поймана 1/1 | `node scripts/mutation-gate.mjs --id=language-runtime-handwritten-duplicate` | | мутант К2 `locale-failure-toast-dropped` (наследуется, перепроверен попутно) | поймана 1/1 | `node scripts/mutation-gate.mjs --id=locale-failure-toast-dropped` | | `demo/smoke_german_locale.mjs` (полный прогон) | зелёный, все 9 проверок `true`, включая `failureShowsToast` | `node demo/smoke_german_locale.mjs` | | `node scripts/smoke-select.mjs --base 1397afef --head HEAD` | см. ниже | — | **smoke-select:** дельта касается 1 файла `src/**`, 2 изменённых символа проекта (`languageLoadFailureListeners`, `notifyLanguageLoadFailures`) — оба не встречаются ни в одном из 198 смоков, инструмент вернул **НЕОПРЕДЕЛЁННОСТЬ**. Решение: новый браузерный смок не нужен. Оба символа — чисто внутренняя разводка (замыкание вынесено в именованную функцию для юнит-тестируемости), наблюдаемое поведение через них не меняется — оно уже доказано `demo/smoke_german_locale.mjs` (`failureShowsToast`), который является зарегистрированной связью для контракта К2 и прогнан целиком выше, зелёный. ## Находки Нет. Обе находки r1 закрыты по существу (см. «Закрытие раунда r1»), новых дефектов дельта не вносит. ## Что проверено и корректно - **AC4, часть 2 (юнит на `subscribeLanguageLoadFailures`)** — ранее единственный незакрытый пункт АС4. Текст ТЗ требует «подписка получает код, отписка работает»: новый тест (`test/i18n-runtime.test.mjs:173-191`) вызывает `notifyLanguageLoadFailures('de')` и проверяет, что оба живых подписчика получают код (`['de', '2:de']`), что отписанный слушатель замолкает, а второй продолжает получать (`['de', '2:de', '2:de']`), и что после отписки обоих доставок больше нет. Проверено автотестом — сам прогнал `node --test --test-name-pattern="#354" test/i18n-runtime.test.mjs` внутри `mutation-gate` (гейт печатает «чистый прогон» перед мутацией) — зелёный. Adversarial-часть: мутант `locale-failure-delivery-cut` заменяет ровно ту строку `for (const listener of languageLoadFailureListeners) listener(code);`, которой в r1 я вручную ломал доставку и юниты этого не замечали — теперь красит юнит (1/1, воспроизведено мной запуском `node scripts/mutation-gate.mjs --id=locale-failure-delivery-cut`). Дисциплина «тест умеет падать» соблюдена не на словах автора, а моим собственным прогоном. - **Контракт-юнит теперь пиннит и саму разводку**: новая ассерция в `test/i18n-runtime.test.mjs:146-150` матчит источник на `new LanguageRuntime(LANGUAGE_REGISTRY, BUILD_FINGERPRINT, console.warn, notifyLanguageLoadFailures,)` — если кто-то вернёт инлайн-стрелку без фан-аута наружу, юнит покраснеет раньше мутационного гейта. Проверено чтением и совпадением с текущим `src/i18n/registry.ts:73-79`. - **L1-фикс не переизобретает формулировку**: оба абзаца (`docs/USER-GUIDE.md:149-153`, `docs/USER-GUIDE.ru.md:155-158`) описывают тот же сценарий («обе попытки не удались») и добавляют одно предложение про тост, не меняя остального текста — проверено построчным диффом. - **User-Visible: no обоснован по существу**, не только по трейлеру: дельта не меняет ни один пользовательский артефакт (текст тоста, ключи локалей, условия его появления) — только внутреннее имя функции и тестовую/ документационную обвязку. Changelog оставлен нетронутым правомерно. - **Генерируемые копии бандла**: после `npm run build && npm run bundle:sync` рабочее дерево чистое — три копии синхронны с закоммиченным состоянием на `HEAD`. - **Регенерация docs-фингерпринта корректна**: `sourceFingerprint` в `docs/images/screenshots.json` пересчитан автором в том же коммите (потому что `src/i18n/registry.ts` входит в покрытие отпечатка) и `check-docs.mjs` подтверждает его актуальность — не унаследованный зелёный статус из r1, а новый, пересчитанный под `c1356fae`. - **«Одно число — один источник»**: дельта не добавляет и не меняет ни одну пользователю видимую величину (тот же текст тоста, что и в r1) — правило не применимо. ## Чего не проверял - `npm run golden:verify` — дельта не меняет геометрию/рендер/стили/слои (внутренняя разводка i18n + два предложения в докс); решение, не пропуск. - `python -m pytest tests_backend` — ни один `custom_components/**/*.py` не тронут ни в r1, ни в этой дельте. - `npm run invariants` — геометрия, `layout`, `marker.space`, `open_spans` не затронуты. - Performance-профили — не названы в AC, чувствительные к перфу пути не задеты. - Новый браузерный смок под `languageLoadFailureListeners`/ `notifyLanguageLoadFailures` — не создавался; см. обоснование в разделе «Как проверялось» (smoke-select → НЕОПРЕДЕЛЁННОСТЬ, поведение уже доказано существующим `demo/smoke_german_locale.mjs`). - 5 из 6 «прямых совпадений» `smoke-select` по символу `_showToast` из r1 — вне дельты этого раунда, не перепрогонялись повторно (тот же метод и там не менялся). - Ручной просмотр карточки в живом браузере HA — только автоматические прогоны (unit, mutation-gate, Playwright-смок).