Files
houseplan-card/docs/reviews/CODE-REVIEW-476-r1.md
T
2026-09-06 12:56:14 +00:00

205 lines
21 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-476-r1
- **Issue:** #476 — Явное завершение выбора цвета кнопкой «ОК»
- **Этап:** код-ревью (PROCESS.md §2.7), заход r1, блокирующих циклов израсходовано 0 из 4
- **Ветка:** `issue/476-color-picker-ok`, HEAD `b5a1001e` (приведена к `dev` конвейером,
поверх легло 4 коммита `dev`: `34a6b276 → b5a1001e`). Разбор — полный, не по дельте
(ребейз на ушедший вперёд `dev`, §7.2/§2.10).
- **Диапазон:** `git log --oneline origin/dev..HEAD` — 7 коммитов (2 ТЗ, 2 документа
ревью ТЗ, 3 реализации); материал — `git diff origin/dev...HEAD`, 53 файла.
- **ТЗ:** `docs/specs/476-color-picker-ok.md`, лёгкий трек не применялся (полный трек по
собственному решению аналитики — новый UX-контракт), ревью ТЗ зелёное на r2
(`docs/reviews/SPEC-REVIEW-476-r2.md`).
## Скоуп диффа
Общий `hp-color-opacity`: полноширинная кнопка «ОК» (последний DOM-control),
CSS-контракт (100% width, ≥40px, forced-colors), новый признак `_hexNeedsValidInput`
(защита от снятия ошибки повторным «ОК» без нового ввода), `color_picker.confirm` в
4 словарях, per-card labels в `houseplan-card.ts`, обновлённые unit/i18n-тесты,
расширенный `demo/smoke_color_picker.mjs` и правки `demo/smoke_help_affordance.mjs`
(fallback-путь), запись в `scripts/smoke-links.mjs`, оба changelog, `docs/TESTING.md`,
пересобранные `dist/**`/`custom_components/houseplan/frontend/**`, обновлённый
`docs/images/screenshots.json` (только fingerprint, PNG не менялись). Backend, модель
геометрии, config/storage не затронуты.
## Как проверялось
Зелёного Validate на `b5a1001e` не найдено — прогнал дешёвые и часть тяжёлых гейтов сам.
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | чисто, без вывода |
| Unit-тесты | `npm test` | `2077 tests, pass 2076, fail 0, skipped 1` — совпадает с заявленным в хендоффе |
| Build + sync | `npm run build && npm run bundle:sync` | `dist` собран; `cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` — идентичны; `git status --short` после `bundle:sync` пуст (demo/srv/assets, custom_components и dist совпадают с закоммиченным, включая несобственную копию стенда) |
| Docs-гейт | `node scripts/check-docs.mjs` | `Documentation checks passed (7 files, 12 external links)` — обязателен, diff трогает `src/**` |
| Новый `any` | `node scripts/no-new-any.mjs --base origin/dev --head HEAD` | «Новых any нет» (59 добавленных строк в 2 файлах) |
| Выбор смоков | `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | Зарегистрированная связь (2): `smoke_color_picker.mjs`, `smoke_help_affordance.mjs`. Слабая связь (15, общее имя `stopPropagation`) — просмотрел список: `smoke_cover_*`, `smoke_decor`, `smoke_edit_walk`, `smoke_editor_gestures`, `smoke_furniture`, `smoke_hide_layers`, `smoke_inert_openings`, `smoke_modes`, `smoke_room_cards`, `smoke_space_settings`, `smoke_tap_ctx`, `smoke_tap_run`, `smoke_toggle_confirmation`, `smoke_value_face_source` — все про другие компоненты, ни один не адресует `hp-color-opacity`; не гонял |
| Целевой смок 1 | `node demo/smoke_color_picker.mjs` | `OK`, все 21 поле `true` |
| Целевой смок 2 | `node demo/smoke_help_affordance.mjs` | `OK`, все поля `true`, включая новые `fallbackPickerHasConfirm`/`fallbackPickerConfirmCloses` |
| Bundle budget | `npm run bundle:budget` | initial View `299683 B` gzip, потолок `300500±2000` — в бюджете; `::warning::` про низкий запас (1383 Б) — долг #367, не новый и не вызван этим диффом |
| Golden (advisory) | `npm run golden:verify` | 5 названных ТЗ сцен (`decor-color-popover-mobile-ru`, `decor-color-popover-desktop-en`, `general-color-popover-desktop-en`, `device-ripple-color-popover-mobile-ru`, `space-room-color-popover-desktop-ru`) — `different`, ожидаемо (новая кнопка); остальные ~100 сцен — `passed`. Просмотрел `actual/`+`diff/` для обеих цветовых сцен глазами — разница только в кнопке и вертикальном сдвиге содержимого попапа, цвет/геометрия/тема не поехали. Принятие эталонов — предрелизный гейт (`golden:accept -- --reviewed` на Linux CI), не гейт код-ревью; автор в хендоффе прямо назвал это «НЕ сделано» |
| Docs screenshots | ссылка автора: run `34029831929` | `gh run view` → `completed/success`; SHA `ce07db74` — предок HEAD; `check-docs.mjs` на HEAD зелёный, то есть текущий фингерпринт уже совпадает с деревом |
| Process-gate | `node scripts/process-gate.mjs` | «гейт пройден, предупреждений 0» |
| Трейлеры | `git log origin/dev..HEAD` (7 коммитов) | у всех `Issue: #476`; `11cc5386` (реализация) — `User-Visible: yes`, оба changelog в том же коммите; остальные 6 — `User-Visible: no` |
**Мутации, прогнанные лично** (§2.7 «чем краснеет», защитные AC):
| # | Мутация | Файл | Прогон | Результат |
|---|---|---|---|---|
| 1 | Снял ветку `if (this._hexNeedsValidInput) { this._hexInvalid = true; return; }` в `_commitHex` — повторное «ОК» без нового ввода закрывало бы picker | `src/hp-color-opacity.ts` | `npm run bundle:sync && node demo/smoke_color_picker.mjs` | `repeatedConfirmCannotBypassInvalidHex: expected true, got false` — тест красится, как обязан (см. M1) |
| 2 | Убрал `if (normalized !== this._lastValidColor)` — `_commitHex` эмитит всегда | `src/hp-color-opacity.ts` | то же | `confirmClosesWithoutDuplicateOrClickThrough: false`, `validCorrectionAllowsConfirm: false` — AC2 доказан исполнением |
| 3 | Убрал `event.stopPropagation()` из `_confirm` | `src/hp-color-opacity.ts` | то же + отдельный probe-скрипт с листенерами на каждом уровне DOM | `confirmClosesWithoutDuplicateOrClickThrough: true` — **тест НЕ покраснел** (см. M2); probe показал, что событие гасится на `.editor-secondary`, на один уровень выше `hp-color-opacity`, независимо от этой мутации |
После каждой мутации источник восстановлен из `git show HEAD:src/hp-color-opacity.ts`,
`npm run bundle:sync` прогнан повторно, `git status --short` — пусто, `demo/smoke_color_picker.mjs`
снова `OK` (проверено).
**Не гонял и почему:**
- `python -m pytest tests_backend` — diff не трогает `custom_components/**/*.py`.
- `npm run invariants` — diff не меняет рёбра комнат, записи толщины, `layout`,
`marker.space`, `open_spans`; геометрия не затронута.
- Performance-профили — не названы в AC; §15 ТЗ обоснованно утверждает отсутствие
влияния (один статический `button`, один `click`-handler в уже открытом lazy-picker).
- Полная матрица `demo/smoke_*.mjs` и полный `scripts/mutation-gate.mjs` — предрелизные
гейты (PROCESS §8), не гейт код-ревью; по дельте прогнаны только зарегистрированные
смоки плюс личные точечные мутации.
- `npm run golden:accept` — не моя роль на этом этапе; смотри выше.
## Находки
### Medium (в скоупе задачи, обе)
**M1 — AC4: защита от обхода невалидного HEX повторным «ОК» не имеет постоянного
свидетеля в `scripts/mutation-gate.mjs`.**
Признак `_hexNeedsValidInput` — это ровно то, что r1 ревью ТЗ потребовало добавить
(`SPEC-REVIEW-476-r1.md`), и ровно то, что доказывается только браузерным смоком
(`demo/smoke_color_picker.mjs`, дорогой гейт: пересборка бандла + Chromium). По
PROCESS.md §2.7: «Мутант в `scripts/mutation-gate.mjs` обязателен, когда защита живёт
в продуктовом коде и проверяется дорогим гейтом… там ревьюер не воспроизведёт
отрицательный прогон второй раз». Диф не добавляет ни одной записи в
`scripts/mutation-gate.mjs` (только запись-указатель в `scripts/smoke-links.mjs`, это
другой реестр — для выбора смоков, не для мутационного гейта).
Я лично применил ровно эту мутацию (см. таблицу выше, #1) и получил красный
`repeatedConfirmCannotBypassInvalidHex`, так что защита реальна и смок сегодня её ловит
— но без постоянной записи в реестре это разовое доказательство ревьюера, а не
воспроизводимый гейт: следующий рефакторинг того же кода останется незамеченным, если
не начитает эту главу отчёта.
**Чинится:** зарегистрировать мутант (по образцу #366/#384/#330 — `guard: node
demo/smoke_color_picker.mjs`, патч — снятие ветки `if (this._hexNeedsValidInput)` в
`_commitHex`), прогнать `node scripts/mutation-gate.mjs --check` и целевой `--id=`.
**M2 — AC3/§7.2.4: заявленная защита «клик не проваливается в план/toolbar под
поверхностью» не имеет проверки, способной упасть на снятой защите.**
Я убрал `event.stopPropagation()` из `_confirm` (единственный код защиты, добавленный
этой задачей) и прогнал `demo/smoke_color_picker.mjs` — `confirmClosesWithoutDuplicateOrClickThrough`
остался `true`. Добавил зонды-слушатели на каждом уровне DOM (кнопка → `.picker` →
shadowRoot picker → хост `hp-color-opacity` → `.editor-secondary` → shadowRoot `card`
→ `card`) и увидел, что событие в проверяемом сценарии (decor-tool color внутри
`.editor-secondary`) гасится ещё на `.editor-secondary`, на уровень выше самого
пикера, независимо от того, вызывает ли `_confirm` `stopPropagation()`.
Продуктового бага здесь нет — клик в план сегодня не проваливается, даже с двойным
запасом. Но это значит, что заявленная в АС3/§7.2.4 защита самого `hp-color-opacity`
не доказана ни одним тестом, который способен упасть: в единственном
протестированном потребителе есть посторонний внешний гаситель, а другие потребители
общего пикера (general settings, room/space, ripple color) не обязательно обёрнуты
таким же контейнером — для них `_confirm`'s `stopPropagation()` может быть
единственной защитой, и она никем не проверяется.
**Чинится** одним из двух путей: (а) перенести click-through-пробу в потребителя без
такого внешнего гасителя (например, `general-color` или `device-ripple-color`,
благо они уже в golden-матрице) так, чтобы снятие `stopPropagation()` красило смок;
либо (б) зарегистрировать мутант в `scripts/mutation-gate.mjs` с guard'ом на
`demo/smoke_color_picker.mjs`, но тогда сам смок сначала должен научиться падать по
пункту (а) — иначе мутант зарегистрирован, но бесполезен.
High: 0. Medium вне скоупа: 0.
### Low (сняты с записью, не блокируют)
- **L1** `src/houseplan-card.ts:5932` — `invalidHex: …, confirm: …` на одной строке
(`git diff` слепил две записи объекта). Чисто косметика, синтаксис и typecheck
корректны; не мешает чтению настолько, чтобы требовать отдельного цикла. Снято.
- **L2** AC3 «keyboard activation» (Enter/Space по кнопке) проверяется в смоке только
через `.click()`, не через реальный `KeyboardEvent`. Риск исчезающий: кнопка —
нативный `<button type="button">` без собственного `keydown`-перехватчика, поэтому
активация с клавиатуры и мышью в браузере производят один и тот же `click`. Снято.
- **L3** AC5 в ТЗ (§12) декларирует доказательство «smoke» для всех путей закрытия
(`outside pointer`, `trigger`, `Escape`), но ни `demo/smoke_color_picker.mjs`, ни
unit-тесты не проверяют `outside pointer` и повторный клик по `trigger` — ни до
этой задачи, ни после (проверил `git show origin/dev:demo/smoke_color_picker.mjs`
— тоже нет). Код `_outsidePointerDown` и ветка закрытия в `_toggle()` этим диффом
не тронуты (проверено чтением, не исполнением) — риска регрессии нет, `Escape`
же покрыт и прогнан зелёным. Пред-существующий пробел плана тестов, не этой
задачи; попутно не чиню (§ «скоуп не расширяется»), фиксирую, чтобы не читать
как «покрыто», когда это не так.
## AC — таблица доказательств (весь список, r1)
| AC | Чем доказан | Чем краснеет |
|---|---|---|
| AC1 видимая кнопка/геометрия | unit (`color-picker.test.mjs`: `width:100%`, `min-height:40px`, DOM-порядок) + `golden:verify` (5 сцен, диф — только кнопка, см. выше) | не защитный AC (расположение/CSS), правило §2.7 не требует столбца |
| AC2 live parity, без дублирующего события | smoke `confirmClosesWithoutDuplicateOrClickThrough`, `validCorrectionAllowsConfirm` | мутация #2 лично: `_commitHex` эмитит всегда → оба поля `false` |
| AC3 успешное завершение / без click-through | smoke `confirmClosesWithoutDuplicateOrClickThrough` (aria-expanded, focus, `parentClicks`) | click-through часть — **не красится** (M2); закрытие/фокус — красится (проверено логикой `_closePicker(true)`, не мутировал отдельно, риск низкий, код тривиален) |
| AC4 невалидный HEX / защита от обхода | unit (regex на `_hexNeedsValidInput`) + smoke `invalidHex*`, `repeatedConfirmCannotBypassInvalidHex` | мутация #1 лично: красное (M1 — нет постоянной регистрации) |
| AC5 прежние close paths | smoke `escapeClosesFirstAndRefocuses` (Escape); outside/trigger — не проверены автотестом, проверено чтением (L3) | Escape: не мутировал отдельно, код path нетронут диффом |
| AC6 i18n/a11y | unit (`test/i18n.test.mjs`, `color-picker.test.mjs` — EN/RU/DE/FR parity, DOM-порядок = порядок Tab) + smoke `englishLabels`/`cardLanguageOwnsCopy` | не защитный AC (текст/раскладка) |
| AC7 touch/fallback/lifecycle | smoke `confirmIsFullWidthTouchTarget`, `smoke_help_affordance.mjs` (`fallbackPickerHasConfirm`, `fallbackPickerConfirmCloses`) | не мутировал; оба пути (Popover/fallback) реально прогнаны через разные смоки, расхождения нет |
| AC8 совместимость/release/бюджеты | `no-new-any`, `check-docs`, `bundle:budget`, оба changelog в одном коммите (см. таблицу гейтов) | не защитный AC в терминах §2.7 |
## Что проверено и корректно
- Состояние `_hexNeedsValidInput` синхронно с §7.3 ТЗ: снимается только новым
`input`-событием с валидным HEX (`_hexInput`), не снимается нормализацией draft в
`_commitHex`, не снимается повторным «ОК», не снимается `blur` — прочитано построчно
и подтверждено мутацией #1.
- `_hexDraft`/`_lastValidColor` остаются синхронными после любого live-изменения (hue/
sat/val/opacity тоже проходят через `_emit`, который обновляет `_hexDraft`), поэтому
обычное «ОК» после слайдеров не создаёт дублирующий emit — подтверждено мутацией #2
от противного и штатным прогоном смока.
- Кнопка — последний DOM-control независимо от `showOpacity` (проверено и unit-, и
smoke-ассертами на разных инстансах, включая `showOpacity=false`).
- i18n: 4 языка добавлены синхронно; DE/FR allow-list для омографов с EN обновлён
обоснованно (`OK` там действительно совпадает с английским текстом — это ожидаемый
омограф, а не пропущенный перевод).
- Оба changelog в одном `User-Visible: yes` коммите (`11cc5386`), трейлеры на всех 7
коммитах корректны, ветка/issue совпадают.
- Backend, config, миграция, геометрия не затронуты — соответствует ТЗ §10 и §6
(не входит).
- Golden-diff глазами: разница ровно в кнопке и вертикальном сдвиге контента, ничего
постороннего не поехало ни в одной из 5 сцен.
## Чего не проверял
- `pytest tests_backend`, `npm run invariants`, performance-профили, полная матрица
`demo/smoke_*.mjs`, полный `scripts/mutation-gate.mjs`, `golden:accept` — см.
обоснование в таблице гейтов выше.
- 15 «слабых» смоков по `stopPropagation` из вывода `smoke-select.mjs` — просмотрел
список имён, все про другие поверхности, не про `hp-color-opacity`; не гонял.
## Вывод
High: 0. Medium в скоупе: 2 (M1, M2) — обе чинятся в этой же задаче, без отдельного
issue (#202). Verdict: жёлтый.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/476-color-picker-ok`, коммит `34a6b276473c` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `94485fdc0eca504692dd2776c7f3c86518a8dfb9`
```
git log --all --format='%H %T' | grep 94485fdc0eca
```
- ТЗ `docs/specs/476-color-picker-ok.md`, блоб `d21066d56e69c35fe7d0b40d9f965968dac9f803`
```
git log --all --find-object=d21066d56e69c35fe7d0b40d9f965968dac9f803 -- docs/specs/476-color-picker-ok.md
```