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

18 KiB
Raw Blame History

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 несё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 через смок-ассерт.