Files
houseplan-card/docs/reviews/CODE-REVIEW-397-r2.md
T
2026-08-31 00:15:10 +00:00

21 KiB
Raw Blame History

CODE-REVIEW-397-r2

  • Issue: #397 — «Undo позиций #74: своё же эхо сбрасывает историю, а доказывающий это смок подстроен»
  • ТЗ: docs/specs/397-device-position-echo.md (ревизия 2, принята SPEC-REVIEW-397-r1/r2 → зелёным)
  • Этап: code, заход r2, блокирующих циклов израсходовано 1 из 4
  • r1: docs/reviews/CODE-REVIEW-397-r1.md, вердикт жёлтый, SHA 08d56122 (Medium-1: AC3 не имел теста; Low-1, Low-2 — не блокировали)
  • Диапазон этого раунда: git diff 08d56122..HEAD (HEAD f4c66bed). Единственный продуктовый коммит раунда — f4c66bed («test: prove AC3 on the reconnect path, with a probe that can fail»); b17f4167 — сам документ r1, артефактов не меняет.

Почему разбор сужен до дельты, а не полный

Дельта раунда — 26 добавленных / 3 удалённые строки в одном файле (demo/smoke_device_position_history.mjs), только фикстура. Продуктовый код (src/houseplan-card.ts) в этом раунде не менялся — фикс d87cc298 тот же, что проверялся в r1. Ребейза на dev не было (git merge-base HEAD origin/dev не менялся между раундами — диапазон origin/dev..HEAD идентичен по базе), контракт поведения не менялся, новая подсистема не задета. Условия §2.9 для полного разбора не выполняются → разбор по AC ограничен AC3 (предмет Medium-1) и тем, до чего дотягивается сама дельта — общими для нескольких AC частями фикстуры, которые дельта тоже трогает (см. ниже).

Скоуп проверки дельты

demo/smoke_device_position_history.mjs:

  1. Новый обработчик houseplan/config/get в фейковом callWS — эхо c._serverCfg/c._cfgRev, чтобы _adoptStructuralResponses не отличил configChanged по посторонней причине и проверка отвечала только за layout-вопрос AC3.
  2. echoProbe-позиция сменена с канонической {x:0.42, y:0.42} на заведомо неканоническую {x:0.024999999999999942, y:0.7/7} — та же пара, что уже использовалась в ТЗ как пример расхождения канонизации (39 из 115 вида px/800*0.997+0.0013 расходятся; 0.024999999999999942 → 0.025).
  3. Новая проверка out.reconnectKeepsHistory: после перемещения маркера — await c._loadFromServer() (полный reconnect-путь, читает ОБА ответа) — canUndo должен остаться true.

Это ровно то, что просило Medium-1 из r1: доказательство для _adoptStructuralResponses через _loadFromServer, а не только через _reloadLayoutOnly.

Поскольку echoProbe-позиция теперь общая для трёх проверок в одном блоке (AC3 reconnectKeepsHistory, AC5б deleteEchoKeepsHistory, AC7 inFlightPositionWinsTheMerge), дельта фактически затрагивает доказательства всех троих — разбор по коду и прогон распространён на них тоже, а не только на новую строку.

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

Зелёного Validate на f4c66bed нет — все гейты прогнаны локально.

  1. Чтение диффа. git diff 08d56122..HEAD -- demo/smoke_device_position_history.mjs построчно сверен с текстом Medium-1 и с планом автотестов ТЗ (пункт 5).
  2. Чтение вызываемого пути. _loadFromServer (src/houseplan-card.ts:4283-4369) действительно шлёт Promise.all([houseplan/config/get, houseplan/layout/get]) и передаёт оба ответа в _adoptStructuralResponses(cfgResp, layResp) (:4320) — то есть новая проверка идёт через второй путь, который в r1 был не покрыт, а не через _reloadLayoutOnly ещё раз. Прочитан также _adoptStructuralResponses (:4203-4269): configChanged считается по contentFingerprint(nextConfig) vs _cfgContentFingerprint — при эхе c._serverCfg без изменений это false, значит очистка истории по configChanged (реальная, но не относящаяся к AC3 причина) исключена, и единственная переменная в проверке — расхождение _layout из B3.
  3. Дешёвые гейты, все на HEAD (f4c66bed), после npm run bundle:sync:
    • npx tsc --noEmit — чисто.
    • npm test — 1657 pass / 0 fail / 1 skip (совпадает с r1; продуктовый код не менялся, расхождения не ожидалось).
    • npm run build — зелёный; git status --short после сборки пуст → три копии бандла (dist/, custom_components/houseplan/frontend/, demo/srv/assets) совпадают с закоммиченными.
    • node scripts/check-docs.mjs — не прогонял: дельта не трогает src/** (только demo/**), условие обязательности гейта не выполняется.
    • npm run invariants / python -m pytest tests_backend — не применимы: геометрия, рёбра, layout-ключи решётки и custom_components/**/*.py дельтой не затронуты.
    • npm run golden:verify — не применим: диф не меняет рендер/геометрию/ стили/слои, только служебную фикстуру теста.
  4. node scripts/smoke-select.mjs --base 08d56122 --head HEAD — вывод: «Исполняемого frontend-диффа нет (src/**/*.ts не тронут). Browser-smoke этим диффом не выбираются — это не "пропустить проверки", а "выбирать нечего": смоки проверяют собранную карточку. Тронуто файлов: 2.» Полную матрицу (209 файлов) не гонял: инструмент прямо говорит, что выбирать нечего, диф — только фикстура одного уже названного АС3 смока, продуктовый код не менялся.
  5. node demo/smoke_device_position_history.mjs — прогнан явно на HEAD: все 31 проверка зелёные, включая новую reconnectKeepsHistory: true.
  6. Дисциплина «тест умеет падать» — проверена вручную, не принята на слово автора. В _persistDevicePlacement (src/houseplan-card.ts:5246-5250) временно заменил
    const pos = canonicalizePosition(this._layout[deviceId]);
    if (contentFingerprint(pos) !== contentFingerprint(this._layout[deviceId])) {
      this._layout = { ...this._layout, [deviceId]: pos };
    }
    pending = pos;
    
    на
    const pos = canonicalizePosition(this._layout[deviceId]); void pos;
    pending = pos;
    
    (тот же мутант, что описан в device-echo-keeps-local-noncanonical), пересобрал (npm run bundle:sync) и перезапустил смок. Результат:
    FAILED (5):
      - localCopyEqualsTheWire: expected true, got false
      - sameContentReloadKeepsHistory: expected true, got false
      - ownEchoMatchesWhatWentOverTheWire: expected true, got false
      - reconnectKeepsHistory: expected true, got false
      - deleteEchoKeepsHistory: expected true, got false
    
    Совпадает буквально с заявлением автора в тексте коммита («five checks red… including reconnectKeepsHistory and deleteEchoKeepsHistory»). deleteEchoKeepsHistory краснеет не потому, что ветка удаления сама сломана — а потому, что предшествующий _loadFromServer() в этом же прогоне уже обнулил _devicePositionHistory (расхождение реальное, canUndo остаётся false до конца блока); при исправленном коде тот же вызов ничего не чистит, и deleteEchoKeepsHistory проверяет свою собственную ветку независимо — это подтверждено зелёным прогоном на восстановленном коде (см. п.5). Каскад ожидаем и не обесценивает проверку. После теста откатил файл (git checkout -- src/houseplan-card.ts, git status --short снова пуст) и пересобрал (npm run bundle:sync), вернув дерево в состояние HEAD; финальный прогон смока — снова все 31 зелёные (см. «Что проверено и корректно»).
  7. Трейлеры. git show f4c66bed — Issue: #397, User-Visible: no. Корректно: изменение затрагивает только тестовую фикстуру, видимого поведения не меняет (сам видимый эффект уже описан в CHANGELOG коммитом d87cc298 из r1). Отдельной правки CHANGELOG не требуется и не добавлено.

Закрытие раунда r1

Находка r1 Чем закрыта Где это видно
Medium-1: AC3 не имел теста — _adoptStructuralResponses/_loadFromServer не упоминались ни в одном изменённом файле demo/smoke_device_position_history.mjs теперь вызывает await c._loadFromServer() сразу после записи неканонической позиции и пишет out.reconnectKeepsHistory; серверный конфиг в фейковом WS зеркалит c._serverCfg, чтобы исключить configChanged как постороннюю причину очистки demo/smoke_device_position_history.mjs:296-304 (проверка), :37-42 (мок config/get); падение подтверждено вручную (раздел «Как проверялось», п.6) — 5 красных, включая reconnectKeepsHistory
Low-1: AC1 доказан не тем способом, что назвало ТЗ (браузерный смок + текстовый юнит вместо юнита с перехватом callWS) Не тронуто в этом раунде Оставлено на усмотрение автора, как и предлагал r1; не блокирует
Low-2: план ТЗ (риски, п.3) просил отдельный юнит на «каноническая позиция не создаёт лишней записи» — теста нет, только код-гвард Не тронуто в этом раунде То же; гвард в коде не менялся и остаётся корректным (см. «Унаследовано»)

Low-1 и Low-2 не были предметом Medium и не требовали правки в этом раунде; формально они остаются открытыми, но не блокируют — решение об их закрытии (комментарием или правкой) остаётся за автором, как и было сказано в r1.

Разбор по AC (только то, чего касается дельта)

  • AC3 (тот же инвариант для _adoptStructuralResponses, reconnect-путь) — выполнено. Доказано смоком reconnectKeepsHistory через реальный _loadFromServer(); падает без фикса, зеленеет с фиксом (проверено вручную, не на слово). Пункт 5 плана автотестов ТЗ закрыт.
  • AC5б (эхо удаления не чистит историю) — уже был «Выполнено» в r1; затронут дельтой (та же echoProbe-позиция, тот же блок). Перепроверено: зелёный на HEAD, независимо подтверждает свою ветку (без искусственной зависимости от AC3 при исправленном коде — см. п.6 «Как проверялось»). Остаётся выполненным.
  • AC7 (запись в полёте побеждает ответ сервера) — уже был «Выполнено» в r1; использует тот же echoProbe, но собственные значения позиции (inFlightPos = {x:0.63,y:0.21}), не изменённые этой дельтой. Перепроверено прогоном — зелёный, inFlightPositionWinsTheMerge: true. Остаётся выполненным.

Унаследовано из r1

Документ: docs/reviews/CODE-REVIEW-397-r1.md, SHA 08d56122. Принято без повторной проверки в этом раунде (продуктовый код не менялся):

  • AC1 — порядок операций в _persistDevicePlacement, локальная запись канонического значения раньше _sentPos.set и раньше callWS. Юнит test/device-position-echo.test.mjs и смок localCopyEqualsTheWire не тронуты дельтой, продуктовый код (src/houseplan-card.ts:5226-5261) тоже — оснований перепроверять нет.
  • AC2 (sameContentReloadKeepsHistory + ownEchoMatchesWhatWentOverTheWire) — файл-источник (_reloadLayoutOnly, :4904-4948) не менялся, фикстура в этой части (использует deviceId, не echoProbe) дельтой не затронута.
  • AC4 (remoteContentClearsHistory) — то же самое, вне блока echoProbe, код и фикстура в этой части не менялись.
  • AC5а (applyDevicePlacement(layout, id, null) удаляет ключ до фиксации отпечатка) — юнит-доказательство, продуктовый код не менялся.
  • AC6 (смок краснеет на коде до фикса) — доказательство r1 относилось к прежней версии смока (3 красных проверки); в этом раунде переисполнено заново на новой версии смока (см. «Как проверялось», п.6, 5 красных проверок) — это не наследование, а повторное независимое доказательство, что фиксирую отдельно, чтобы не создавать видимость слепого доверия там, где дельта как раз и меняет предмет AC6.
  • Мутант device-echo-keeps-local-noncanonical, регистрация в scripts/mutation-gate.mjs, согласованность с test/mutation-gate.test.mjs — файлы не в дельте, не перепроверялись.
  • Трейлеры и CHANGELOG коммита d87cc298 — не в дельте, r1 подтвердил.
  • Бандл-артефакты, актуальность скриншотов на момент 08d56122 — вне дельты по скриншотам; актуальность на HEAD подтверждена заново в этом раунде (см. «Как проверялось», п.3 — git status --short пуст после пересборки), а не унаследована слепо, так как сборка в любом случае перезапускалась для falsification-теста.

Что проверено и корректно

  • houseplan/config/get-мок зеркалит c._serverCfg/c._cfgRev без изменений → configChanged в _adoptStructuralResponses остаётся false на протяжении всего сценария AC3, значит reconnectKeepsHistory отвечает ровно на layout-вопрос, а не на посторонний config-вопрос.
  • Проба {x:0.024999999999999942, y:0.7/7} — действительно неканоническая (снапается на 0.025/0.1), то есть чек не мог бы проходить «по случаю», как это было с прежним {0.42,0.42} (M1-класс дефекта, но для AC3, а не только для AC2, — и он же теперь закрыт заодно).
  • Финальный прогон смока на восстановленном HEAD — 31/31 зелёных, включая reconnectKeepsHistory, deleteEchoKeepsHistory, inFlightPositionWinsTheMerge.
  • Дерево репозитория после ручного falsification-теста восстановлено: git status --short пуст, git diff пуст.
  • Трейлеры f4c66bed: Issue: #397, User-Visible: no — корректно для теста-only коммита.

Чего не проверял и почему

  • GitHub-комментарии к issue #397 — MCP-инструмент get_issue_comments отклонён средой (permission not granted) на трёх попытках подряд; вердикт и контекст r1 восстановлены по коммитам и по самому документу CODE-REVIEW-397-r1.md, чего достаточно для этого раунда — SHA и находки там названы явно. Если в комментариях есть более свежее решение по Low-1/ Low-2 (например, автор их уже закрыл словом), эта информация не попала в документ; на вердикт это не влияет, так как Low не блокирует независимо от того, закрыт он явно или нет.
  • node scripts/check-docs.mjs — дельта не трогает src/**, условие обязательности гейта не выполняется.
  • npm run invariants, python -m pytest tests_backend — геометрия, layout-ключи решётки и custom_components/**/*.py не затронуты ни этой дельтой, ни диапазоном в целом (r1 это тоже отметил).
  • npm run golden:verify — диф не меняет рендер/геометрию/стили/слои.
  • Полная матрица demo/smoke_*.mjs (209 файлов) — smoke-select.mjs прямо ответил «выбирать нечего» (диф вне src/**); диф — фикстура одного уже названного АС смока, поверхность не расширена.
  • scripts/mutation-gate.mjs (полный/точечный) — не в дельте, не перезапускал; r1 уже подтвердил регистрацию и согласованность (test/mutation-gate.test.mjs, 10/10) на неизменном с тех пор файле.
  • HA-харнесс — в песочнице нет Home Assistant; предрелизный гейт, не гейт ревью.

Вывод

Medium-1 закрыт доказательно (не на слово автора — falsification воспроизведена и совпадает буквально: 5 красных проверок, включая reconnectKeepsHistory). AC3 выполнен. Побочно исправлена скрытая слабость доказательства AC5б (прежняя каноническая проба 0.42 не могла бы поймать регрессию своей же ветки — теперь может, за счёт общей неканонической echoProbe-позиции). Low-1 и Low-2 остаются открытыми, но не блокирующими, как и в r1. Продуктовый код не менялся, три копии бандла синхронны, трейлеры корректны, User-Visible: no — CHANGELOG не требуется. Находок в скоупе задачи, требующих новой правки, нет.

Вердикт: зелёный.