Files
houseplan-card/docs/reviews/CODE-REVIEW-354-r1.md
T
2026-08-28 15:04:07 +00:00

230 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CODE-REVIEW-354-r1
- **Issue:** https://github.com/Matysh/houseplan-card/issues/354
- **Этап:** код-ревью (PROCESS.md §2.7)
- **Заход:** r1 · блокирующих циклов израсходовано 0 из 2 (лёгкий трек, лимит 2)
- **SHA материала:** `1397afefd5564abf76cb6ed87b9f5eaee074361b` (`git rev-parse HEAD` на момент вердикта)
- **Ветка:** `issue/354-language-runtime`
- **Вердикт: жёлтый**
## Скоуп
ТЗ в теле issue #354, ревизия 2 (принята SPEC-REVIEW-354-r2, зелёный). Три контракта:
- **К1** — `LANGUAGE_RUNTIME` в `src/i18n/registry.ts` становится экземпляром
тестируемого класса `LanguageRuntime`, рукописный `germanDictionary/germanPending/
germanFailed/settleGerman` удаляется.
- **К2 (N7)** — отказ загрузки словаря виден тостом `toast.locale_load_failed`
только в `houseplan-card` (View); остальные три поверхности (`space-card.ts`,
`editor.ts`, `space-editor.ts`) остаются при `console.warn`.
- **К3** — возврат дубля ловится контракт-юнитом (`instanceof` + grep источника)
и мутационным тестом.
Диапазон: `git diff origin/dev...HEAD` — единственный продуктовый коммит
`1397afef`. Файлы: `src/i18n/registry.ts`, `src/i18n/language-runtime.ts`,
`src/houseplan-card.ts`, `src/i18n/{en,ru,de}.json`, `test/i18n-runtime.test.mjs`,
`scripts/mutation-gate.mjs`, `demo/smoke_german_locale.mjs`, оба CHANGELOG,
синхронизированные копии бандла (`dist/**`, `custom_components/houseplan/frontend/**`,
`docs/images/*`, `docs/images/screenshots.json`).
## Как проверялось
| Гейт | Результат | Команда |
|---|---|---|
| typecheck | зелёный | `npx tsc --noEmit` |
| unit-тесты (полный набор) | зелёный, 1487 pass / 0 fail / 1 skip (не связан с #354) | `npm test` |
| build + сверка 3 копий бандла | зелёный, `git status --porcelain` пуст после сборки | `npm run build && npm run bundle:sync` |
| docs-гейт (скриншоты/фингерпринт) | зелёный | `node scripts/check-docs.mjs` |
| bundle:budget | зелёный, 257222 B < 282000 B бюджета (справочно, в AC не заявлен) | `npm run bundle:budget` |
| мутант К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` (полный прогон) | зелёный, включая новую проверку `failureShowsToast` | `node demo/smoke_german_locale.mjs` |
| adversarial-проверка юнита «подписка получает код» | тест НЕ падает при выключенном fan-out — см. находку M1 | ручной мутант в `src/i18n/registry.ts`, откачен |
| `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | см. ниже | — |
**smoke-select:** прямое совпадение по символу `_showToast` дало 6 несвязанных
смоков (`smoke_help_affordance`, `smoke_junction_limits`,
`smoke_optimize_coordinate_canonicalization`, `smoke_partition_openings`,
`smoke_room_resize`, `smoke_zero_wall_migration_unblocked`) — это слабая связь:
`_showToast` не менялся (не тронута ни сигнатура, ни реализация метода в
`src/houseplan-card.ts:6373`), диф лишь добавляет новый вызов с новым ключом.
Ни один не прогонялся — решение, а не пропуск. Зарегистрированная связь —
`demo/smoke_german_locale.mjs` (единственный смок, реально завязанный на
`LanguageRuntime`/`LanguageRuntimeContract`) — прогнан, зелёный.
**Не прогонялось и почему:** `npm run golden:verify` (диф не меняет геометрию,
рендер, стили и слои — только текстовая строка тоста и внутренняя разводка
i18n); `python -m pytest tests_backend` (ни один `*.py` не тронут);
`npm run invariants` (геометрия/`layout`/`marker.space`/`open_spans` не
затронуты); performance-профили (не названы в AC, чувствительные пути не
тронуты). Ручного тестирования в браузере (кроме прогона смоков через
Playwright) не проводилось — доказательства ниже отмечены как «прочитано» либо
«доказано автотестом/смоком».
## Находки
### M1 (Medium, в скоупе задачи) — юнит AC4 не проверяет то, что заявляет
**Файл:** `test/i18n-runtime.test.mjs:166-176`
АС4 текстом ТЗ требует три отдельных доказательства: смок с видимым тостом,
**юнит на `subscribeLanguageLoadFailures` («подписка получает код, отписка
работает»)**, и мутант «тост выброшен», красящий смок или юнит. Смок и мутант
на месте и оба проверены (см. таблицу выше). Юнит — нет.
```js
test('language load failure subscription delivers codes and unsubscribes (#354)', async () => {
const { subscribeLanguageLoadFailures } = await import('../test-build/i18n/registry.js');
const seen = [];
const unsubscribe = subscribeLanguageLoadFailures((code) => seen.push(code));
const second = subscribeLanguageLoadFailures((code) => seen.push(`2:${code}`));
unsubscribe();
second(); // ← это ОТПИСКА второго слушателя, не триггер
assert.deepEqual(seen, [], 'listeners removed before any failure stay silent');
assert.equal(typeof unsubscribe, 'function');
});
```
`second` — это возвращаемая функция отписки второго слушателя (сигнатура
`subscribeLanguageLoadFailures` возвращает `() => void`), а не способ вызвать
доставку кода. Тест ни разу не проводит код через
`languageLoadFailureListeners` (`src/i18n/registry.ts:49-56, 65-72*) — он
проверяет только «отписанные слушатели молчат», то есть половину заявленного
в названии.
**Воспроизведение (тест умеет НЕ падать там, где должен):** временно вырезал
итерацию по подписчикам в продакшн-инстанции —
```diff
- (code) => {
- for (const listener of languageLoadFailureListeners) listener(code);
- },
+ (_code) => {
+ // fan-out отключён
+ },
```
и прогнал `node --test --test-name-pattern="#354" test/i18n-runtime.test.mjs`
— все три `#354`-юнита остались зелёными (3/3 ok), в том числе тест из
находки. Правка отката (не коммитилась, `src/i18n/registry.ts` восстановлен из
бэкапа). Реальную поломку fan-out ловит только браузерный
`demo/smoke_german_locale.mjs` (подтверждено отдельно: мутант
`locale-failure-toast-dropped`, который выключает подписку карточки, а не
сам fan-out, тоже красит только смок) — то есть единственная быстрая, не
браузерная защита от регрессии в строке `for (const listener of
languageLoadFailureListeners) listener(code);` отсутствует, хотя АС явно
просит юнит на этот случай.
Это ровно тот класс дефекта, из-за которого заведён сам #354 (N6, «тест
выглядит защитой, а не ту цель проверяет», #85): название и комментарий теста
обещают проверку доставки кода, а тело её не делает.
**Почему Medium, не High:** реальное поведение карточки доказано
(smoke зелёный, мутант К2 ловится смоком) — пользователь тост увидит.
Дефект в надёжности матрицы гейтов, не в продукте: до релиза поломка
fan-out будет поймана медленным браузерным смоком, а не мгновенным юнитом,
как того хотел АС4.
**Как чинить (в этом же issue, без нового цикла на продукт):** переписать
тело теста так, чтобы `second` вызывался как слушатель, получающий код
(например, дать `subscribeLanguageLoadFailures` слушатель, инициировать
доставку через реальный сбой `LanguageRuntime` с фейковой `de`-записью — как
уже сделано в соседнем тесте «a settled dictionary failure reaches the
load-failed hook once» — либо экспортировать тестовый триггер). Минимально:
переименовать тест и явно ограничить его заявленную гарантию до «отписка
работает», раз доставка проверяется только смоком — но тогда стоит явно
сослаться на смок в комментарии, чтобы не терять доказательство из виду.
### L1 (Low, снята с запиской) — `docs/USER-GUIDE.ru.md` не упоminает новый тост
**Файл:** `docs/USER-GUIDE.ru.md:153-158`
Абзац прямо описывает тот же сценарий, который меняет К2: «если обе
ограниченные попытки загрузки не удались, карточка остаётся работоспособной
на английском» — без упоминания, что теперь при этом показывается тост
`toast.locale_load_failed`. Раздел `USER-GUIDE.ru.md` — канонический источник
формулировок интерфейса (AGENTS.md, «Read this first»), и он расходится с
новым поведением ровно там, где раньше был точным.
Не блокирую: цепочка артефактов (PROCESS.md §7.1) не включает
`USER-GUIDE.ru.md` как обязательное звено — только `changelog`, который
актуален (RU+EN, см. таблицу гейтов). ТЗ #354 не называет обновление гайда
частью контракта. Снимаю с запиской: стоит дополнить абзац одним предложением
о тосте при следующей правке этого раздела (например, вместе с #348, чей
canonical-документ он же расширяет).
## Что проверено и корректно
- **AC1** (=К1, К3): `LANGUAGE_RUNTIME` — экземпляр `new LanguageRuntime(...)`
(`src/i18n/registry.ts:65-72`); `germanPending|germanFailed|settleGerman`
вне тестового регэкспа в репозитории отсутствуют (`grep -rn` — единственное
совпадение это сам паттерн в `test/i18n-runtime.test.mjs:143`). Контракт-юнит
`instanceof` и grep-юнит зелёные; мутант «рукописный дубль» красит юнит
(1/1, воспроизведено локально). Проверено автотестом + мутантом.
- **AC2**: `test/i18n-runtime.test.mjs` — только дополнения, ни одна старая
строка не удалена и не изменена (`git diff` по файлу содержит только `+`);
полный набор зелёный (1487/1487, 0 fail); паритет-тест ключей локалей
(`test/i18n.test.mjs:109-112`, `Object.keys(en) === Object.keys(dictionary)`
для каждого языка) прошёл в общем прогоне — новый ключ
`toast.locale_load_failed` присутствует во всех трёх словарях
(`en.json`, `ru.json`, `de.json`), несовпадений не было бы иначе. `demo/
smoke_german_locale.mjs` зелёный целиком, включая старые проверки
(нейтральный холодный кадр, ровно одна загрузка, атомарный коммит текста,
повторное использование словаря вторым инстансом, ровно два ретрая,
ровно один `console.warn`). Проверено автотестом.
- **AC3**: мутант `language-runtime-handwritten-duplicate` (возврат литерала
вместо `new LanguageRuntime(...)`) красит юнит К3 — поймана 1/1, командой
`node scripts/mutation-gate.mjs --id=language-runtime-handwritten-duplicate`.
Проверено автотестом (мутационным).
- **AC4, часть 1 и 3**: смок проверяет `card._toast === card._t('toast.locale_load_failed')`
в сценарии «оба ретрая упали» (`demo/smoke_german_locale.mjs:83-85`) —
зелёный. Мутант `locale-failure-toast-dropped` (подписка карточки удалена)
красит смок — поймана 1/1. Проверено автотестом/смоком + мутантом.
Часть 2 (юнит на доставку кода) — см. **M1**.
- **К2 периметр**: только `src/houseplan-card.ts` импортирует
`subscribeLanguageLoadFailures` и вызывает `_showToast`; `space-card.ts`,
`editor.ts`, `space-editor.ts` используют `LANGUAGE_RUNTIME` напрямую и не
содержат `toast`/`_showToast` (`grep` пуст). Подписка идёт в
`connectedCallback` (`src/houseplan-card.ts:2480-2484`, с защитой от
повторной подписки через `?.()` перед переприсваиванием, тот же паттерн, что
и у соседнего `_continuityUnsub`), отписка в `disconnectedCallback`
(`:2557-2560`). Проверено чтением + смоком.
- **Паритет поведения кодов реестра** (en/ru eager, de lazy; `state()`/
`dictionary()`/`ensure()` для зарегистрированных кодов): построчное сравнение
старой и новой логики (retry-цикл на 2 попытки, проверка `fingerprint`,
текст warn `[houseplan] unable to load ${code} locale; using English`
байт-в-байт с прежним `de`-текстом) — проверено чтением, не исполнением, плюс
общий прогон существующего `test/i18n-runtime.test.mjs` (доказывающего класс
с #348) не изменил ожиданий.
- **Трейлеры и changelog**: коммит `1397afef` несёt `Issue: #354` и
`User-Visible: yes`; `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` правлены в
том же коммите (см. `git show 1397afef --stat`). Соответствует правилу №10/11.
- **Генерируемые копии бандла**: после `npm run build && npm run bundle:sync`
рабочее дерево чистое (`git status --porcelain` пуст) — три копии
(`dist/**`, `custom_components/houseplan/frontend/**`,
`demo/srv/assets/**`) синхронны с закоммиченным состоянием.
- **check-docs**: `node scripts/check-docs.mjs` зелёный — скриншоты и их
фингерпринт актуальны относительно текущего `src/**`.
- **«Одно число — один источник»**: диф не добавляет и не меняет ни одну
видимую пользователю величину (только строковый текст тоста и внутренняя
разводка подписки) — правило не применимо к этой задаче.
## Чего не проверял
- `npm run golden:verify` — диф не меняет геометрию/рендер/стили/слои, только
текст тоста и внутреннюю разводку i18n; решение, не пропуск.
- `python -m pytest tests_backend` — ни один `custom_components/**/*.py` не
тронут.
- `npm run invariants` — геометрия, `layout`, `marker.space`, `open_spans` не
затронуты этим дифом.
- Performance-профили — не названы в AC, чувствительные к перфу пути не
задеты.
- 5 из 6 «прямых совпадений» `smoke-select` по символу `_showToast`
(`smoke_help_affordance`, `smoke_junction_limits`,
`smoke_optimize_coordinate_canonicalization`, `smoke_partition_openings`,
`smoke_room_resize`, `smoke_zero_wall_migration_unblocked`) — слабая связь,
сам метод не менялся; не прогонялись.
- Ручной просмотр карточки в живом браузере HA (только Playwright-смоки
демо-стенда) — визуальная проверка текста тоста в интерфейсе не
проводилась, доверился рендер-тексту в DOM через смок-ассерт.