Files
houseplan-card/docs/reviews/CODE-REVIEW-62-r1.md
T
2026-08-27 23:53:02 +00:00

22 KiB
Raw Blame History

CODE-REVIEW-62-r1

  • Issue: https://github.com/Matysh/houseplan-card/issues/62
  • Этап: код-ревью (PROCESS.md §2.7)
  • Заход: r1 · блокирующих циклов израсходовано 0/4 (первый код-ревью раунд; спецификация уже прошла свои r1/r2, но код-ревью считает бюджет отдельно)
  • SHA материала: 6540474ff527fdab39c315bbb04a28ab33683c9d (=git rev-parse HEAD, совпадает с последним коммитом origin/dev..HEAD)
  • Диапазон: git log --oneline origin/dev..HEAD (6 коммитов, два ТЗ-ревью + спецификация + реализация), git diff origin/dev...HEAD (15 файлов, +1167/−330)

Скоуп

Задача выводит единый typed registry языков (src/i18n/registry.ts) как источник Lang, словарей, native label и порядка; переводит langOf()/t()/ hasTranslation() и visual-editor selector на него; расширяет test/i18n.test.mjs на registry-driven file-set/key/placeholder/help parity между src/i18n/*.json и custom_components/houseplan/translations/*.json; обновляет CONTRIBUTING.md (раздел Translations) и комментарии src/i18n.ts/src/types.ts. Первая строка docs/SCOPE.md, которую закрывает задача, — J6 «Keep the plan true as the home evolves» опосредованно (это инфраструктура contribution flow, сам продукт для en/ru не меняется; ценность пользователю — 2/10, разработке — 7/10 по аналитике issue).

Не входит и не тронуто: добавление нового языка, lazy loading, RTL, plural rules, связка backend↔frontend registry — всё согласно §5 ТЗ.

Как проверялось

Гейты прогнаны сам (зелёного Validate на этом SHA нет):

Гейт Команда Результат
Typecheck npx tsc --noEmit green, без вывода
Unit-тесты npm test green: # tests 1415 / pass 1414 / skipped 1 (у автора в хендоффе 1413/2 — расхождение в count skipped/passed воспроизводимо окружением: test/process-gate.test.mjs пропускает под-тесты в зависимости от доступности git/gh-стаба; итоговое число тестов и провалов совпадает — 0 fail)
Build + bundle sync npm run build; sha256sum dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js green: оба файла дают идентичный SHA-256, git status --short после пересборки пуст (закоммиченный бандл побайтово совпадает со свежей сборкой)
Docs fingerprint node scripts/check-docs.mjs green: «Documentation checks passed (7 files, 10 external links)»
git diff --check — green, конфликтов пробелов нет

Не прогонял и почему:

  • npm run invariants — diff не трогает геометрию/layout/marker.space/ open_spans; ни одно из трёх соответствий (#253/#254/#258-259) не применимо, гонять не на чем.
  • Browser smokes — node scripts/smoke-select.mjs --base origin/dev --head HEAD: 194 смока, символы на изменённых строках дают только 18 «слабых» совпадений через одно общее имя _config (встречается почти в каждом смоке редактора) и явный НЕОПРЕДЕЛЁННОСТЬ-вердикт по всем реальным новым символам (LANGUAGE_REGISTRY, languageOptions, languageEntry, resolveLanguageCode, normalizeLanguageTag и др. — ни разу не встречаются ни в одном demo/smoke_*.mjs). Просмотрел вручную единственный смок, что мог бы задеть language-selector по смыслу (demo/smoke_general_settings.mjs) — он не трогает поле language вовсе. Само ТЗ (§11) заранее и обоснованно исключает browser smoke: пиксели/сценарии существующих экранов не меняются, доказательство AC5/AC6 — unit + source diff. Решение: не прогонять, единственный экран, который теоретически мог задеть selector визуально (device-editor doc-screenshot), проверил напрямую ниже.
  • npm run golden:verify — ТЗ и diff не меняют рендер/геометрию/стили; единственный изменившийся PNG (см. ниже) проверен вручную побайтово через декодированный RGBA, а не через golden gate.
  • python -m pytest tests_backend -q — diff не трогает custom_components/**/*.py (только translations/*.json, не Python).
  • Performance-профили — не названы в AC, diff не касается чувствительных к перфу путей.

Отдельная ручная проверка единственного изменившегося PNG (docs/images/06-device-editor.png, 297802→297803 байт, imageSha256 поменялся): декодировал оба варианта (origin/dev и HEAD) через Pillow в RGBA и сравнил ImageChops.difference — bbox пуст, т.е. видимое содержимое пиксель-в-пиксель идентично; изменился только байтовый поток PNG (пере-кодирование при пересборке демо-фикстуры), не картинка. Это тот же паттерн, что зафиксирован в docs/reviews/CODE-REVIEW-184-r1.md, CODE-REVIEW-176-r1.md, CODE-REVIEW-198-r2.md, CODE-REVIEW-205-r2.md — известный шум пересъёмки этого конкретного скриншота, не находка.

Находки

High — 1

H1. languageEntry()/LANGUAGE_BY_CODE не самосогласованы с case-insensitive контрактом, который сам registry документирует и тестирует на примере pt-BR; t()/hasTranslation() для канонического кода со смешанным регистром молча возвращают English.

src/i18n/registry.ts:

const LANGUAGE_BY_CODE = new Map<string, LanguageEntry>(
  LANGUAGE_REGISTRY.map((entry) => [entry.code, entry]),   // ключ = entry.code КАК ЕСТЬ
);
export function normalizeLanguageTag(value: unknown): string {
  return typeof value === 'string'
    ? value.trim().replaceAll('_', '-').toLowerCase()        // значение нормализуется
    : '';
}
export function languageEntry(value: unknown): LanguageEntry | undefined {
  return LANGUAGE_BY_CODE.get(normalizeLanguageTag(value));  // ключ ищется по нормализованному виду
}

src/i18n.ts строит t()/hasTranslation() поверх этой же функции:

export function t(lang: Lang, key: Key, vars?): string {
  const dictionary = languageEntry(lang)?.dictionary;
  return subst(dictionary?.[key] ?? FALLBACK_DICTIONARY[key] ?? key, vars);
}

Карта индексируется точным entry.code (canonical spelling, например pt-BR — именно так, с заглавной BR, требует §6.4 ТЗ и то же самое демонстрирует CONTRIBUTING.md, который сам этот PR добавляет: «for example, fr or pt-BR»), а искомое значение всегда нормализуется в lower-case (normalizeLanguageTag). Для en/ru совпадение случайное — оба кода уже в нижнем регистре. Для любого кода с не-lowercase каноническим написанием поиск промахивается.

Воспроизведение (буквальная копия алгоритма registry.ts, выполнено node, а не только прочитано):

const LANGUAGE_REGISTRY = [{ code: 'en' }, { code: 'pt-BR' }];
const normalizeLanguageTag = (v) => typeof v === 'string' ? v.trim().replaceAll('_','-').toLowerCase() : '';
const LANGUAGE_BY_CODE = new Map(LANGUAGE_REGISTRY.map((e) => [e.code, e]));
const languageEntry = (v) => LANGUAGE_BY_CODE.get(normalizeLanguageTag(v));
console.log(languageEntry('pt-BR'));   // → undefined

Результат: undefined.

Почему это блокирует, а не просто мелочь на будущее. Единственная продуктовая причина существования #62 — «contributor добавляет словарь и одну запись реестра, а сборка сама подключает язык» (ТЗ §1, §6.1, CONTRIBUTING.md раздел Translations, добавленный этим же коммитом). resolveLanguageCode() (отдельная, правильно нормализованная map) корректно вернёт Lang = 'pt-BR' — это подтверждает и собственный unit-тест автора (test/i18n.test.mjs, resolver supports exact, primary..., кейс { explicit: 'pt_BR', ha: 'ru-RU', expected: 'pt-BR' }). Но как только этот Lang дойдёт до t()/hasTranslation() — а это единственный путь, которым карточка реально показывает строки — словарь pt-BR не находится, и весь интерфейс для этого языка молча откатывается на English без ошибки, падающего теста или видимого сигнала. Это ровно тот сценарий, который CONTRIBUTING.md (этим же PR) объявляет штатным путём добавления языка, и именно pt-BR — заявленный в ТЗ и тестах канонический пример. Ни один существующий тест это не ловит: unit на резолвер проверяет только resolveLanguageCode, а parity-тесты test/i18n.test.mjs используют LANGUAGE_REGISTRY напрямую (dictionaries.get(code)), а не languageEntry/ t(), поэтому обходят баг стороной.

Сегодняшний рантайм (en/ru) не затронут — оба кода нижнего регистра, поэтому AC7 (unchanged English/Russian) не нарушен, и это не регрессия для текущих пользователей. Но это дефект в самом registry-модуле, который является предметом issue, обнаруживается только чтением (не покрыт ни одним тестом задачи) и превращает документированный «однострочный» contribution flow в скрытую ловушку для первого же контрибьютора, который добавит язык с заглавной буквой в регионе — то есть в большинство реальных кандидатов (pt-BR, zh-Hans, zh-Hant, es-419 и т.п.).

Возможное исправление (не мой мандат — правок кода не делаю, но фиксирую направление для автора): ключевать LANGUAGE_BY_CODE нормализованным кодом — new Map(LANGUAGE_REGISTRY.map((entry) => [normalizeLanguageTag(entry.code), entry])) — тогда languageEntry() снова самосогласован с resolveLanguageCode(), а LanguageOption/selector (которые обходят languageEntry для зарегистрированных кодов и не задеты этим багом) не меняются.

Проверено чтением/исполнением и признано корректным

  • AC1 (registry как единственный источник Lang/словарей/native label/порядка) — тестом + чтением. npm test зелёный; LANGUAGE_REGISTRY — единственное место, откуда выводятся Lang (src/i18n.ts:11-18), словари (t()), native label и порядок (languageOptions, src/i18n/registry.ts:70-88). Грепом по src/*.ts подтвердил отсутствие оставшихся ручных DICTS/прямых импортов i18n/en.json/i18n/ru.json и веток по конкретному 'ru'/'en' вне i18n.ts/registry.ts.
  • AC2 (exact/primary/_/case/explicit/invalid/fallback матрица) — тестом. test/i18n.test.mjs тест «resolver supports exact, primary, explicit and fallback paths» — 8 кейсов, тест умеет падать: локально инвертировал один ожидаемый результат (expected: 'ru' → 'en' в кейсе explicit: 'RU') — тест немедленно упал, вернул как было.
  • AC3/AC4 (frontend/backend file-set и key/placeholder/help parity из registry) — тестом. test/i18n.test.mjs читает оба каталога через readdirSync и сверяет с LANGUAGE_REGISTRY; ls src/i18n/*.json custom_components/houseplan/translations/*.json подтверждает ровно en.json/ru.json в обоих местах, git diff словарей пуст (AC7).
  • AC5 (selector: порядок Auto→registry, без ручного списка) — чтением + тестом. src/editor.ts:98-104 вызывает languageOptions(t(L,'editor.lang_auto'), this._config?.language); grep по src/editor.ts не находит 'en'/'ru' литералов в схеме selector. Тест «editor options follow registry order» проверяет форму результата.
  • AC6 (неизвестный сохранённый код → временная option, не ломает card/editor, не затирается несвязанным изменением) — чтением + тестом. languageOptions() добавляет одну хвостовую option с сырым значением, если оно не среди зарегистрированных (src/i18n/registry.ts:80-87, покрыто тестом editor options ... preserve unknown raw values, включая нормализуемый край-кейс ' RU '). Прочитал _valueChanged (src/editor.ts:151-158) и разбиение <ha-form> на два schema.slice(...) (src/editor.ts:141-147, не менялось этим diff'ом) — language живёт во втором <ha-form>, поэтому value-changed от первой формы (title/ floor/default_floor) не содержит ключ language вовсе, и merge {...this._config, ...ev.detail.value} его не стирает; value-changed от второй формы всегда переносит текущее (в т.ч. неизвестное) значение поля language, потому что .data формы — это _formData, инициализированная тем же сырым значением. Runtime: langOf() для незарегистрированного configLang естественно проваливается в HA-locale→English через тот же resolveLanguageCode, что и AC2. Card/houseplan-card.ts не менялся — вызовы langOf(this.hass, this._config?.language) там уже были универсальными.
  • AC7 (en/ru значения и видимый selector не меняются) — тестом + чтением. git diff origin/dev...HEAD -- src/i18n/en.json src/i18n/ru.json custom_components/houseplan/translations/ пуст. Единственный изменившийся скриншот (06-device-editor.png) проверен побайтово декодированным RGBA — идентичен (см. «Как проверялось»).
  • AC8 (нет locale fetch/dynamic import/Promise-based пути, нет нового первого пустого кадра) — чтением + сборкой. registry.ts использует только статические import ... with { type: 'json' }; grep -n "import(" dist/houseplan-card.js — пусто, бандл остаётся одним файлом (ls dist/ → один houseplan-card.js), пересборка байт-в-байт совпадает с закоммиченной.
  • AC9 (CONTRIBUTING и комментарии описывают фактический flow, plural limitation) — чтением. Новый раздел Translations в CONTRIBUTING.md описывает 3-частный flow, placeholders-контракт и ограничение subst() на plural; устаревшая строка Ground rules «one JSON file + registering it in src/i18n.ts» заменена ссылкой на раздел — проверил, что второй противоречащей формулировки нигде в репозитории не осталось (grep -rn "registering it in\|Adding a language =" — только исторические документы ревью и changelog-запись за старую версию, не текущий процесс). src/i18n.ts докстрока и CardConfig.language комментарий (src/types.ts) приведены в соответствие.
  • Трейлеры и changelog. Все 6 коммитов несут Issue: #62 и User-Visible: no; changelog-файлы не тронуты — согласовано с ТЗ §14 (защитное отображение неизвестного кода признано compatibility guard, не user-facing изменением, решение принято и зафиксировано ещё на этапе зелёного ревью ТЗ r2, technic re-litigation не провожу).
  • Класс изменений и ветка. Диапазон — class A (src/**) + B (test/**, tsconfig.test.json) + C (docs/**, CONTRIBUTING.md) + D (dist/**, custom_components/houseplan/frontend/houseplan-card.js, регенерированный скриншот) — без нарушений границ; issue уже был S5+ до правок кода.

Чего не проверял

  • Golden/browser-смоки в полном составе — не запускал; обоснование выше (это предрелизный гейт, не гейт код-ревью, и smoke-select не нашёл ни прямой, ни зарегистрированной связи).
  • Backend Python HA harness — diff не касается .py.
  • Ручное открытие визуального редактора в браузере (нет ручного тестирования в цикле по контракту процесса) — заменено чтением editor.ts/ registry.ts и юнит-тестами на чистых helper-функциях.
  • Поведение с третьим реальным языком в production — по ТЗ намеренно не вводится в этом issue; H1 обнаружен на синтетическом воспроизведении логики модуля, а не на реальной третьей локали.

Вывод

AC1–AC9 в основном доказаны — либо падающим-способным тестом, либо чтением с явной пометкой. Но H1 — реальный, воспроизведённый исполнением дефект в самом registry-модуле, который является предметом issue: он не портит сегодняшний en/ru рантайм, однако молча ломает ровно тот «добавь JSON + одну запись» flow, который #62 и CONTRIBUTING.md (этим же PR) объявляют результатом задачи, для любого канонического кода со смешанным регистром — и ни один из добавленных тестов его не ловит. High блокирует независимо от того, что видимое поведение en/ru не пострадало.


Вердикт: красный · заход r1 · блокирующих циклов 0/4 · High: 1 · Medium: 0 → в задаче

Документ: docs/reviews/CODE-REVIEW-62-r1.md (публикуется шагом конвейера из /home/runner/work/_temp/review-document.md).