22 KiB
CODE-REVIEW-397-r1
- Issue: #397 — «Undo позиций #74: своё же эхо сбрасывает историю, а доказывающий это смок подстроен»
- ТЗ:
docs/specs/397-device-position-echo.md(ревизия 2, принята жёлтым → зелёным в SPEC-REVIEW-397-r1/r2) - Этап: code, заход r1, блокирующих циклов израсходовано 0 из 4
- Диапазон:
git diff origin/dev...HEAD(HEAD08d56122), реализация в коммитеd87cc298(«fix: the card keeps the position it sent, so its echo is not foreign (#397)»), артефакты — в08d56122
Скоуп проверки
Диф вне docs/reviews и бандлов:
src/houseplan-card.ts— 5 строк,_persistDevicePlacementdemo/smoke_device_position_history.mjs— правка фикстуры (M1) + новые проверки AC2/AC5б/AC6/AC7test/device-position-echo.test.mjs— новый юнит (3 теста)scripts/mutation-gate.mjs— новый мутантdevice-echo-keeps-local-noncanonicaldocs/CHANGELOG.md/docs/CHANGELOG.ru.md— пункт User-Visibledist/**,custom_components/houseplan/frontend/**,docs/images/**— артефакты сборки/скриншотов, сверены с исходником (см. «Как проверялось»)
Продуктовый код в узком скоупе ТЗ: только _persistDevicePlacement. Геометрия,
рёбра комнат, layout/marker.space/open_spans не затронуты — гейт
инвариантов (npm run invariants) не применим.
Как проверялось
Зелёного Validate на 08d56122 не было — все гейты прогнаны локально.
- Чтение диффа.
git diff origin/dev...HEAD -- src/houseplan-card.tsпострочно сверен с описанием B3 в ТЗ и с текстом коммита. Прочитаны контекст_persistDevicePlacement(:5224–5261),applyDevicePlacement(src/device-position-history.ts:45-67),_reloadLayoutOnly(:4904–4948),_adoptStructuralResponses(:4203–4269),_loadFromServer(:4280–…) — чтобы понять, какой путь реально вызывается при reconnect. - Дешёвые гейты (все на
08d56122, послеnpm run bundle:sync, чтобы поднятьdemo/srv/assets, — без него браузерные смоки не грузят карточку вообще, что не связано с задачей, а с локальным окружением):npx tsc --noEmit— чисто.npm test— 1657 pass / 0 fail / 1 skip (совпадает с заявлением автора).npm run build— зелёный;git statusпосле сборки чист → три копии бандла (dist/,custom_components/houseplan/frontend/, плюс git-игнорируемыйdemo/srv/assets, пересобранный отдельно) совпадают с закоммиченными.node scripts/check-docs.mjs— «Documentation checks passed (7 files, 10 external links)», включая отпечаток скриншотов (diff трогаетsrc/**, значит гейт обязателен) — прошёл, скриншоты (docs/images/01-view-desktop.png,09-device-info.png) иdocs/images/screenshots.jsonв коммите08d56122актуальны.node scripts/mutation-gate.mjs --check— все патчи реестра, включая новыйdevice-echo-keeps-local-noncanonical, ложатся на текущий код (ok device-echo-keeps-local-noncanonical).node --test test/mutation-gate.test.mjs— 10/10, реестр не разошёлся с кодом.
node scripts/smoke-select.mjs --base origin/dev --head HEAD— результат «НЕОПРЕДЕЛЁННОСТЬ»: единственный изменённый символ_layoutслишком широк (встречается почти везде),contentFingerprintне связан ни с одним смоком явно. АС задачи прямо называютdemo/smoke_device_position_history.mjsкак способ доказательства — этого достаточно, полную матрицу (209 файлов) не гонял: диф не расширяет поверхность за пределы одного метода и уже названного смока.demo/smoke_device_position_history.mjs— прогнан явно (node demo/smoke_device_position_history.mjs, послеnpm run bundle:sync): все 31 проверка зелёные, включая новыеlocalCopyEqualsTheWire,ownEchoMatchesWhatWentOverTheWire,deleteEchoKeepsHistory,inFlightPositionWinsTheMerge.- Дисциплина «тест умеет падать» — проверена вручную, а не принята на
слово. В
_persistDevicePlacementвременно заменилнаconst pos = canonicalizePosition(this._layout[deviceId]); if (contentFingerprint(pos) !== contentFingerprint(this._layout[deviceId])) { this._layout = { ...this._layout, [deviceId]: pos }; }const pos = canonicalizePosition(...); void pos;(то же преобразование, что делает мутантdevice-echo-keeps-local-noncanonical), пересобрал (npm run bundle:sync) и перезапустил смок. Результат:Это ровно то расхождение, которое ТЗ требует для AC6, и ровно три проверки, которые называет коммит-сообщение автора («three checks red without the fix»). После проверки откатил файл (FAILED (3): - localCopyEqualsTheWire: expected true, got false - sameContentReloadKeepsHistory: expected true, got false - ownEchoMatchesWhatWentOverTheWire: expected true, got falsegit diffснова пуст) и пересобрал, чтобы вернуть дерево в исходное состояние. node --test test/device-position-echo.test.mjs— 3/3 pass отдельно от общего прогона, чтобы увидеть их независимо.- Трейлеры и changelog.
git show d87cc298содержитIssue: #397иUser-Visible: yes; правки обоихdocs/CHANGELOG*.mdлежат в этом же коммите (не в отдельном).git show 08d56122—User-Visible: no, корректно для чисто артефактного коммита.
Разбор по AC
- AC1 (после
_persistDevicePlacement_layout[deviceId]побайтово раве отправленному, отпечаток по нему). Доказано двояко: поведенчески — смокlocalCopyEqualsTheWire(зелёный, покраснел при отмене фикса); структурно —test/device-position-echo.test.mjs(AC1: the update branch stores what it sends, before sending it) проверяет порядок операторов в исходнике. ТЗ просило «юнит с перехватом callWS» — по факту это не Node-юнит с мокнутымhass.callWS, а (а) браузерный смок с фейковым WS и (б) текстовый юнит по исходнику. Проверил, что это не слабее: смок реально перехватываетmessage.posна проводе и сравнивает с_layout, то есть доказывает AC1 не хуже мокнутого юнита. Отклонение от буквы метода доказательства — Low, не блокирует. - AC2 (честный reload не чистит историю) —
sameContentReloadKeepsHistory- новый
ownEchoMatchesWhatWentOverTheWire(пин того, что стороны реально совпали — без этого проверка AC2 могла бы быть тавтологией). Зелёные, красные при отмене фикса. Выполнено.
- новый
- AC3 (тот же инвариант для
_adoptStructuralResponses, reconnect-путь, пункт 5 плана автотестов) — не выполнено. См. находку Medium-1 ниже. - AC4 (чужое изменение чистит историю) —
remoteContentClearsHistory, код и фикстура не менялись в этой части, поведение унаследовано и подтверждено прогоном (зелёное). - AC5а (удаление: ключ удалён локально до отпечатка) — юнит
AC5a: the delete branch removes the key before the fingerprint, плюс чтением подтверждено:applyDevicePlacement(layout, id, null)удаляет ключ (src/device-position-history.ts:50-54), вызывается безусловно в начале_persistDevicePlacement(:5230) до отправки и до фиксации отпечатка. Выполнено. - AC5б (эхо удаления не чистит историю) —
deleteEchoKeepsHistory, зелёный. Отдельно проверено, чтоechoProbeв текущей демо-фикстуре разрешается (неnull) — прогон подтвердилtrue, ветка реально исполняется, а не проваливается вelse-заглушку. Выполнено. - AC6 (смок краснеет на коде до фикса) — доказано вручную (раздел «Как проверялось», п.5): 3 проверки красные без фикса, все остальные 28 — зелёные (значит, откат не сломал ничего постороннего, поломка точечная). Выполнено.
- AC7 (запись в полёте побеждает ответ сервера) —
inFlightPositionWinsTheMerge, зелёный; прочитан код_reloadLayoutOnly(:4912-4926) —_sentPosнакладывается наremoteпослеmine, порядок не менялся этим диффом. Выполнено.
Находки
Medium-1 (в скоупе, чинится в этом же раунде). AC3 не имеет ни одного
теста — _adoptStructuralResponses не упомянут ни в одном изменённом файле
ТЗ (docs/specs/397-device-position-echo.md:114-117) требует отдельного
доказательства для _adoptStructuralResponses («полная перезагрузка конфига
и layout, reconnect-путь») именно потому, что сам документ называет его
вторым независимым путём, читающим то же расхождение (раздел «(1) B3»,
строки 41-49): "Дальше расхождение читают два независимых пути:
_reloadLayoutOnly… _adoptStructuralResponses…". План автотестов, пункт 5,
прямо предписывает: «Перемещение → полный _adoptStructuralResponses
(reconnect) → canUndo === true (AC3)».
Ни demo/smoke_device_position_history.mjs, ни новый
test/device-position-echo.test.mjs, ни любой другой файл в диффе не
упоминают _adoptStructuralResponses или _loadFromServer (проверено:
git diff origin/dev...HEAD | grep -i adoptStructural — пусто). Все новые
проверки (sameContentReloadKeepsHistory, ownEchoMatchesWhatWentOverTheWire,
deleteEchoKeepsHistory, inFlightPositionWinsTheMerge) идут через
_reloadLayoutOnly — это первый путь, не второй.
Воспроизведение: grep -n "_adoptStructuralResponses" demo/*.mjs test/*.mjs внутри изменённых файлов не находит совпадений; в
docs/specs/397-device-position-echo.md:154 пункт 5 плана заявлен, но не
реализован.
Разобрано по коду (проверено чтением, не исполнением) — риска регрессии в
проде я не вижу: _adoptStructuralResponses (:4245-4258) сравнивает
contentFingerprint(nextLayout) с (this._layoutContentFingerprint || contentFingerprint(this._layout)) — тем же _layoutContentFingerprint и тем
же _layout, которые фикс в _persistDevicePlacement теперь синхронизирует
с отправленным на сервер значением. Других мест, где этот путь читал бы
неканонический слепок, нет. То есть B3 закрыт для обоих потребителей одним и
тем же присваиванием — функционального дефекта, скорее всего, не осталось.
Но это не заменяет обязательство: ТЗ приняло AC3 с явным способом
доказательства, автор его не выполнил и не заявил как исключение. «Верно по
рассуждению ревьюера» — это ровно то временное доверие, из-за которого
заводился прецедент #102 (правка по одному замечанию тихо ломает
непроверяемый соседний путь) — только с обратным знаком: здесь никто и не
проверял. Нужно: добавить в demo/smoke_device_position_history.mjs
сценарий пункта 5 (переместить маркер → вызвать полный путь конфиг+layout,
которым реально идёт _loadFromServer/reconnect, с честным серверным
слепком → canUndo === true) либо явно проговорить в ТЗ/PR, почему этот
путь достаточно покрыт первым, и получить это как решение ревью, а не как
факт по умолчанию.
Low-1. AC1 доказан не тем способом, что назвало ТЗ
ТЗ: «Доказательство: юнит с перехватом callWS». Реализация: браузерный смок
(перехват фейкового WS в demo/) + текстовый юнит по исходнику в test/. По
существу доказательство не слабее (см. разбор AC1 выше), но название файла в
плане (test/device-position-persist.test.mjs) и метод («юнит с перехватом
callWS») разошлись с тем, что реализовано (test/device-position-echo.test.mjs,
текстовый разбор + смок). Не блокирует, автор может закрыть комментарием
одной строкой, если согласен, что смок сильнее.
Low-2. Юнит-план, пункт 3 («каноническая позиция не создаёт лишней
записи»), не имеет отдельного теста
Раздел «Риски» ТЗ (docs/specs/397-device-position-echo.md:169-172) просил
юнит на то, что уже каноническая позиция не вызывает лишнего присваивания
_layout (мера против лишнего ре-рендера). Гвард в коде
(if (contentFingerprint(pos) !== contentFingerprint(this._layout[deviceId])) {…})
присутствует и корректен — проверено чтением: при равенстве отпечатков блок
не выполняется, второе присваивание _layout не происходит. Отдельного теста
на это нет ни в test/, ни в смоке. Riск-митигация без теста хуже, чем без
митигации, но здесь код всё же есть и логика прямолинейна (!== перед
присваиванием) — не блокирует, оставляю на усмотрение автора.
Что проверено и корректно
- Порядок операций в
_persistDevicePlacementсоответствует контракту ТЗ: локальная запись канонического значения →_sentPos.set→hass.callWS(...)→_layoutContentFingerprintпо актуальному_layout. Порядок «локальная запись раньше_sentPos» (риск-митигация из ТЗ) сохранён — проверено чтением :5241-5253. - Ветка удаления использует тот же безусловный
applyDevicePlacementв начале функции (:5230), отдельного расхождения между «обновить» и «удалить» в части фиксации отпечатка нет. - M1 закрыт:
serverLayout = structuredClone(c._layout)перед_reloadLayoutOnly()убран из смока; сервер теперь только то, что реально ушло по фейковому WS. Подтверждено падением 3 проверок при отмене фикса. _sentPosпродолжает побеждать в_reloadLayoutOnly(:4918-4926) — код этого пути не тронут диффом, AC7 подтверждает поведение, а не чинит регрессию.- CHANGELOG (
docs/CHANGELOG.md,docs/CHANGELOG.ru.md) — пункт добавлен в том же коммите, что и фикс; трейлерыIssue: #397/User-Visible: yesна месте. - Бандл-артефакты (
dist/,custom_components/houseplan/frontend/, скриншоты) актуальны — подтверждено пересборкой и чистымgit status. - Мутант
device-echo-keeps-local-noncanonicalзарегистрирован корректно, guard (node demo/smoke_device_position_history.mjs) существует и реально ловит поломку (проверено вручную, см. «Как проверялось», п.5), реестр не разошёлся с кодом (test/mutation-gate.test.mjsзелёный).
Чего не проверял и почему
node scripts/mutation-gate.mjs(полный/по--id=device-echo-keeps-local-noncanonical) — не гонял через штатный раннер: он в этой песочнице падает на не связанном с задачей препятствии (python3 -m pytest tests_backend/...— «No module named pytest», модуль не установлен) ещё на этапе чистого прогона, до применения самого мутанта. Это гейт предрелизного окна (.github/workflows/mutation-gate.yml), не гейт ревью, и файл сам себя так описывает («прогон дорогой… его место — перед стабильным релизом»). Заменил его точечной ручной проверкой (см. п.5 «Как проверялось»), которая доказывает то же самое для конкретного мутанта.python -m pytest tests_backend -q— диф не трогаетcustom_components/**/*.py, гейт не применим.npm run invariants— диф не трогает геометрию/рёбра/layout-ключи решётки, гейт не применим.npm run golden:verify— диф не меняет рендер/геометрию/стили/слои, только служебное поле_layoutи порядок присваиваний; визуального следа у AC нет.- HA-харнесс — в этой песочнице нет Home Assistant; это предрелизный гейт на точном SHA, автор пометил то же самое в своём комментарии.
- Полная матрица
demo/smoke_*.mjs(209 файлов) — не запускал:scripts/smoke-select.mjsне нашёл сильной связи за пределами уже названного в АС смока, а сам диф не расширяет поверхность (один метод, один смок, названный ТЗ явно).
Унаследовано из этапа spec
SPEC-REVIEW-397-r1 (жёлтый, Medium: AC5б/AC7 без способа доказательства) и
SPEC-REVIEW-397-r2 (зелёный) заранее проверили точность самого диагноза (B3,
M1) построчно против кода на SHA 83692e79/6edcde01 — до реализации.
Диагноз принят без повторной проверки номеров строк из ТЗ (они успели
сместиться в ходе реализации на единицы строк, что нормально и не меняет
сути); сам код B3/M1 в этом раунде я перечитал заново на HEAD, а не
унаследовал.