docs: review document for #62

Issue: #62
User-Visible: no
This commit is contained in:
claude[bot]
2026-08-27 23:53:02 +00:00
parent 6540474ff5
commit d0d6cf57a7
+270
View File
@@ -0,0 +1,270 @@
# 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`:
```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()` поверх этой же функции:
```ts
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`, а не только прочитано):
```js
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`).