18 KiB
CODE-REVIEW-417-r1
Issue: #417 — «Подтверждение опасного действия всё ещё зависает в двух ветках render(): потерянное пространство и языковой гейт».
Трек: trivial. Заход: r1. Бюджет циклов лёгкого/короткого трека: 2, израсходовано 0.
Ветка: issue/417-danger-confirm-render-guards. Проверяемый SHA: ad4cce0ddaa372f1c82612919a5541238b1d822f (сверен git rev-parse HEAD непосредственно перед выводом — совпадает).
Скоуп
Регрессия покрытия после #402: _confirmDanger() регистрировал запрос в контроллере в двух ветках корневого render(), которые не рисуют <hp-confirm> — потерянное активное пространство (!space → nothing) и языковой гейт warm → noChange. Контракт (тело issue): такой запрос обязан немедленно резолвиться false, не оставляя контроллер с висящим состоянием; уже открытый запрос при переходе в ветку потерянного пространства также обязан резолвиться false.
AC1/AC2/AC3 — доказательство smoke. Файлы по контракту: src/houseplan-card.ts, demo/smoke_danger_confirm_branches.mjs. Диапазон материала — git diff origin/dev...HEAD (3 некоммита D-класса промоушена в счёт не идут по существу, см. ниже).
Что изменено (по коду, не по описанию автора)
src/houseplan-card.ts:
- добавлено поле
_dangerConfirmLocaleGate: LanguageRenderGate = 'ready', записывается в_renderBody()сразу после вычисленияlocaleGate, до всех раннихreturn(:11306-11309) — значение актуально для любой ветки, включаяcold/фиксированный этаж/обычную; - добавлен
_dangerConfirmMissingSpace()(:2160-2168):falseдля онбординга (!model.length) и дляfixed.kind === 'pending'/'invalid'(эти ветки реально рисуют карточку и хостятhp-confirmчерез обёрткуrender()), иначе!this._spaceModel()— что точно совпадает с реальной веткойif (!space) return nothing;(:11386 в текущем файле); _confirmDanger(:2166-2172) добавляет два ранних отказа перед регистрацией в контроллере:_dangerConfirmLocaleGate === 'warm'и_dangerConfirmMissingSpace();- в
willUpdate()(:4168-4171), после_syncEmptySpaceState()и до обработкиhass, добавлен синхронныйif (this._dangerConfirm && this._dangerConfirmMissingSpace()) this._cancelDangerConfirm();— снимает уже открытый диалог до того, какrender()в этом же цикле обновления заменит тело наnothing.
_cancelDangerConfirm() вызывает HpConfirmController.cancel() → resolve(token, false), что резолвит промис false и обнуляет _active/уведомляет _changed(null) (src/danger-confirm.ts:68-72) — то есть «резолвится false» и «контроллер не хранит запрос» из AC1 обеспечены одним и тем же вызовом, а не двумя независимыми путями.
demo/smoke_danger_confirm_branches.mjs: расширен тремя блоками, воспроизводящими буквально то, что не покрывал прежний файл (issue прямо называл этот пробел) — реальный вход в warm через удержание сетевого запроса немецкого чанка, вход в ветку потерянного пространства через непустую модель с _spaceModel → undefined, и already-open-запрос, отменяемый синхронно при потере пространства. Регрессионные сценарии из #402 (онбординг, fixed-floor pending/invalid, основная ветка, выживание диалога при смене ветки) сохранены и продолжают проходить.
scripts/mutation-gate.mjs: три новых мутанта, каждый снимает один из трёх guard-путей и указывает demo/smoke_danger_confirm_branches.mjs как обязанный покраснеть.
AC — доказательство
| AC | Доказательство | Проверено как |
|---|---|---|
AC1 (потерянное пространство: новый запрос → false, контроллер пуст, hp-confirm отсутствует; уже открытый запрос → false) |
demo/smoke_danger_confirm_branches.mjs: lostSpaceBranchIsActuallyEntered, openConfirmCancelsWhenSpaceIsLost, lostSpaceRequestRefusesImmediately (все true) + мутанты danger-confirm-lost-space-request-guard-removed, danger-confirm-lost-space-transition-cancel-removed |
Прогнал смок и оба мутанта лично — оба красятся при снятии соответствующего guard, зелены при исходном коде |
AC2 (языковой гейт warm: новый запрос → false, контроллер пуст, render() сохраняет noChange) |
demo/smoke_danger_confirm_branches.mjs: warmLanguageGateActuallyEntered, warmLanguageGateRefusesImmediately, warmLanguageGateKeepsControllerEmpty, warmLanguageGateKeepsNoChangeDom (все true) + мутант danger-confirm-warm-language-guard-removed |
Прогнал смок и мутант лично — мутант красится |
AC3 (онбординг/fixed-floor pending и invalid/основная ветка по-прежнему показывают ровно один hp-confirm; отмена/согласие завершают промис; мутант, убирающий любой из новых guard-сценариев, красит смок) |
Тот же файл: onboardingBranchShowsConfirm, onboardingBranchResolves, fixedFloorPendingStillShowsConfirm, fixedFloorInvalidStillShowsConfirm, mainBranchRendersExactlyOneConfirm, mainBranchCancelResolvesFalse, openConfirmSurvivesBranchChange — все true; регрессия для #402 не сломана |
Прогнал npm test (1735/1735, не считая 1 skip) и сам смок — 34/34 |
Дисциплина «тест умеет падать» соблюдена не декларативно: я лично прогнал каждый из трёх новых мутантов через node scripts/mutation-gate.mjs --id=<mutant> (не через заявление автора) — все три красят smoke_danger_confirm_branches.mjs при снятии своего guard и проходят при родном коде.
Как проверялось — гейты
| Гейт | Кем прогнан | Результат |
|---|---|---|
npx tsc --noEmit |
ревьюер | зелёный |
npm test |
ревьюер | 1735 passed, 0 failed, 1 skipped, 1736 total — совпадает с отчётом автора |
npm run build + git status |
ревьюер | сборка воспроизводит закоммиченный dist/** побайтово (рабочее дерево чистое после сборки) |
npm run bundle:sync |
ревьюер | зелёный, три дерева бандла синхронизированы |
node scripts/no-new-any.mjs --base origin/dev --head HEAD |
ревьюер | зелёный, 29 добавленных строк, новых any нет |
node scripts/check-docs.mjs |
ревьюер | зелёный (diff трогает src/**, поэтому обязателен) |
node demo/smoke_danger_confirm_branches.mjs |
ревьюер | зелёный, 34/34 — совпадает с отчётом автора |
node demo/smoke_fixed_floor.mjs |
ревьюер | зелёный, 14/14 (прямое совпадение по smoke-select.mjs) |
node demo/smoke_lock_invariant.mjs |
ревьюер | зелёный, 7/7 (прямое совпадение) |
node demo/smoke_french_locale.mjs |
ревьюер | зелёный, 5/5 (зарегистрированная связь languageRenderGate, #348) |
node scripts/mutation-gate.mjs --id=<3 новых мутанта> |
ревьюер, поштучно | все три: «покраснел, как обязан» → «поймано 1 из 1» |
node scripts/smoke-select.mjs --base origin/dev --head HEAD |
ревьюер | вывод см. ниже; решения по каждой строке приняты |
npm run bundle:budget |
ревьюер | initial View 290566 B gzip, запас 9434 B — совпадает с отчётом автора; см. «Риск, не относящийся к находкам» |
Вывод smoke-select.mjs и решения
- Прямое совпадение (3):
smoke_danger_confirm_branches,smoke_fixed_floor,smoke_lock_invariant— все три прогнаны (см. таблицу выше), все зелёные. - Зарегистрированная связь (1):
smoke_french_locale(languageRenderGate, #348) — прогнан, зелёный. Автор дополнительно прогналsmoke_german_locale(не входит в вывод инструмента как отдельная строка, но обоснованно: German — единственный смок, реально доводящий карточку до состоянияwarm); повторно не гонял, доверяю отчёту автора здесь, так как German напрямую совпадает по символу_dangerConfirmLocaleGateи уже покрыт содержимым основного смока (warmGate-блок использует ту же технику удержания chunk-запроса). - Слабая связь (15, все через
_model): не прогонял. Модель и её render-семантика диффом не задеты (единственные тронутые строки — новый приватный guard и присвоение локального поля); точные ветви/переходы, которые могли бы пострадать, покрытыsmoke_danger_confirm_branchesнапрямую. Полная матрица — предрелизный гейт, не гейт этого ревью.
Гейты, которые не прогонял, и почему
npm run golden:verify— diff не меняет видимый рендер:docs/images/screenshots.jsonпоказывает 10/10imageSha256без изменений (толькоsourceFingerprint/sourceSha256— хэш исходников, не пикселей), т.е. канонический прогон уже доказал нулевой pixel diff. Собственный прогон golden поверх этого был бы дублированием.npm run invariants -- --config <...>и model-invariants в целом — diff не трогает геометрию,layout,marker.space,open_spans, толщину стен; затронутые файлы и AC это подтверждают.python -m pytest tests_backend -q—custom_components/**/*.pyне тронут.- performance-профили — не названы в AC, чувствительные к перфу пути не тронуты (guard — синхронная проверка двух булевых веток).
- полный
node scripts/mutation-gate.mjs(все ~90 мутантов) — дорогой прогон с пересборкой бандла на мутанта; документирован как предрелизный гейт, не гейт ревью. Три релевантных новых мутанта прогнаны поштучно и осознанно.
Единственный источник числа
Diff не добавляет и не меняет ни одной пользовательски видимой величины (текста, числа, подписи). Единственное новое состояние — булев guard, видимый только как «диалог показан / диалог не показан», без дублирующего представления. test/single-source-numbers.test.mjs прошёл в составе npm test. Раздел неприменим по существу.
Находки
Нет. High: 0, Medium: 0, Low: 0.
Риск, не относящийся к находкам (не блокирует)
npm run bundle:budget показывает запас initial-бандла 9434 Б при пороге предупреждения 15000 Б — это существующий тренд (issue #367), не следствие именно этого диффа: правка добавляет один приватный метод и одно поле, оценочно десятки байт до gzip. Не блокирует #417 и не относится к его AC; упоминаю для полноты гейтов, отдельный issue не завожу — тренд уже отслеживается в #367.
Проверено чтением, не исполнением
- Порядок вызовов в
willUpdate():_dangerConfirmMissingSpace()читаетthis._model(геттер, мемоизированный на_serverCfg) иthis._fixedFloorState()/this._spaceModel()— оба чистые, без побочных эффектов, поэтому повторный вызов из_confirmDangerв другой момент времени безопасен и не дублирует работу с сайд-эффектами. _dangerConfirmLocaleGateприсваивается в_renderBody()до всех раннихreturn, поэтому при следующем вызове_confirmDanger()(например, из обработчика клика вне цикла рендера) отражает состояние по итогам последнего фактического рендера, а не устаревшее значение по умолчанию'ready'— за исключением состояния до первого рендера, когда_confirmDangerв любом случае отказывает раньше по!this._config || !this.hass.HpConfirmController.cancel()→resolve(token, false)резолвит промис и обнуляет состояние одним вызовом (src/danger-confirm.ts:59-72) — значит «промис резолвитсяfalse» и «контроллер не хранит запрос» из AC1 гарантированы одной и той же операцией, а не двумя независимыми, которые могли бы разойтись.
Класс изменений и трейлеры
Три коммита в диапазоне, все с Issue: #417. User-Visible: yes — коммит e46220f3 («Fix confirmation guards for unrenderable branches») правит оба changelog в этом же коммите (проверено чтением диффа коммита). Два последующих коммита — User-Visible: no (уточнение теста, промоушен fingerprint скриншотов) корректно помечены как непользовательские.
dist/** и custom_components/houseplan/frontend/** (класс D) изменены — легитимно, это промоушен пересобранного бандла в том же PR, обязательный по правилу «сборка отражает исходники»; воспроизвёл его независимой сборкой и она побайтово совпала с закоммиченным.
Вердикт
Все три AC доказаны автотестом, и я лично убедился, что каждый новый guard способен провалить тест (три мутанта поймано 1 из 1 каждый). Регрессия #402 не сломана. Находок нет.
Зелёный.
Материал раунда
- Ветка:
issue/417-danger-confirm-render-guards - SHA материала (сверен
git rev-parse HEADперед выводом):ad4cce0ddaa372f1c82612919a5541238b1d822f - Диапазон:
git diff origin/dev...HEAD(origin/devна момент ревью = состояние после релиза v1.70.0) - Первый раунд — раздела «Унаследовано из r0» и «Закрытие предыдущего раунда» не требуется.
Материал раунда
- Ветка:
issue/417-danger-confirm-render-guards, коммитad4cce0ddaa3— ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет. - Дерево материала:
bd84e7215b27fd327da21b1db018c370667417c0git log --all --format='%H %T' | grep bd84e7215b27