18 KiB
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 («подписка получает код, отписка
работает»), и мутант «тост выброшен», красящий смок или юнит. Смок и мутант
на месте и оба проверены (см. таблицу выше). Юнит — нет.
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*) — он
проверяет только «отписанные слушатели молчат», то есть половину заявленного
в названии.
Воспроизведение (тест умеет НЕ падать там, где должен): временно вырезал итерацию по подписчикам в продакшн-инстанции —
- (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несёtIssue: #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 через смок-ассерт.