21 KiB
Код-ревью issue #198 — очистка изолированного микро-интервала толщины через Optimize (цикл r2)
- Цикл: r2/4
- Вердикт: жёлтый · High: 0 · Medium: 1 (в скоупе задачи)
- Диапазон:
git log --oneline origin/dev..HEAD=9d6cd8b(ТЗ),8c9241b(ревью ТЗ r1),0df8051(реализация),f4ab0b3(ревью-документ r1),1c9575a(follow-up тесты на замечания r1),0625b6f(обновление provenance скриншотов после измененияsrc/) - Диапазон диффа:
git diff origin/dev...HEAD - ТЗ:
docs/specs/198-optimize-micro-interval.md(ревью ТЗ зелёное, r1) - Предыдущий цикл:
docs/reviews/CODE-REVIEW-198-r1.md(жёлтый · Medium-1 AC5, Medium-2 AC8 — обе «доказательство обещанным способом отсутствует», не дефект поведения)
Скоуп
Тот же, что в r1: явное действие «Общие настройки → Оптимизировать планы» получает строго ограниченный lossy-шаг — схлопывание одиночного изолированного интервала толщины стены короче половины шага сетки между двумя одинаковыми коллинеарными соседями, при отсутствии room-vertex/ opening-узла на его концах. Runtime и редактор остаются lossless.
Этот цикл — не новая функциональность, а закрытие двух Medium-находок r1:
- AC5: добавлен прямой unit
collapseIsolatedWallThicknessIslands()на реверс endpoints, перестановкуwalls, перестановку/реверс windingrooms, неизменность входов (test/plan-optimizer.test.mjs). - AC8: добавлен прямой regression unit для
normalizeWallIntervals()иdegradeWalls()на изолированном22→15→22-профиле вне Optimize (test/wall-thickness.test.mjs, ранее не тронут диапазоном).
Дополнительно найден и учтён коммит 0625b6f — он появился на ветке уже
после старта этого цикла ревью (см. «Как проверялось»): обновляет
docs/images/screenshots.json + один PNG (06-device-editor.png,
отличие на 1 байт) вслед за изменением sourceFingerprint src/, класс C,
User-Visible: no, не относится к продуктовому поведению #198.
Продуктовый код (src/plan-optimizer.ts) в этом цикле не менялся —
git diff 0df8051..HEAD -- src/ пуст. Проверка была нужна только для
двух добавленных тестовых файлов и одного docs-коммита.
Как проверялось
Материал — актуальный tip ветки. Важно: сессия ревью изначально была
развёрнута на коммите 1c9575a, но git fetch показал, что ветка
issue/198-optimize-micro-interval к моменту ревью уже продвинулась на
0625b6f (owner исправил red docs-job). Переключился на
origin/issue/198-optimize-micro-interval перед тем, как выносить
вердикт, — иначе ревью судило бы устаревший срез.
| Гейт | Результат | Команда |
|---|---|---|
| typecheck | green | npx tsc --noEmit |
| unit | green, 917/917 (было 915/915 в r1, +2 новых теста) | npm test |
| build + 3 копии бандла | green, побайтово идентичны | npm run build && cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js && cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js |
| targeted smoke (AC7) | green, все 10 подпроверок | node demo/smoke_optimize_micro_interval.mjs |
| mutation-gate, полный self-check | green, 36/36 | node scripts/mutation-gate.mjs --check |
| mutation-gate, целевой мутант (AC9) | мутант пойман 1/1 | node scripts/mutation-gate.mjs --id=optimizer-micro-interval-cleanup-disabled |
docs-gate (затронут 0625b6f) |
green | node scripts/check-docs.mjs --external |
| whitespace | чисто | git diff --check origin/dev...HEAD |
| адверсариальная проверка новых AC5/AC8-тестов мутацией кода (см. находку ниже) | AC8-тест ловит регрессию; AC5-тест — нет для одного из сценариев | см. «Находки» |
Не прогонялось и почему (без изменений относительно r1 — диапазон с r1 по продуктовому коду и UI-поверхности не менялся):
npm run golden:verifyи смежныеsmoke_grid_snap/smoke_align_guides— этот цикл не трогаетsrc/plan-optimizer.ts, рендер, геометрию, стили или UI-пайплайн Optimize; изменились толькоtest/*.mjsи provenance двух docs-артефактов. Повторный прогон той же UI-поверхности без изменений в ней добавил бы время без нового сигнала — уже перепрогнан и подтверждён зелёным в r1 против того же кода.python -m pytest tests_backend -q— ни один файлcustom_components/**/*.pyне входит в диапазон ни в r1, ни в r2.- performance-профили — не названы в AC; изменения этого цикла — только тесты и docs-provenance, нет тронутых perf-путей.
- Полный набор из 127 browser-смоков — диапазон r2 не касается ни одной
новой UI-поверхности; единственный релевантный (
smoke_optimize_micro_interval) прогнан.
Проверка дисциплины «тест должен уметь падать» для двух новых юнитов
Не ограничился чтением — реально мутировал код и убедился в результате,
затем вернул рабочее дерево в чистое состояние (git diff пуст, npm test 917/917 после отката).
AC8-тест (test/wall-thickness.test.mjs, «lossless wall helpers preserve
an isolated sub-half-step thickness island outside Optimize») —
подтверждённо способен падать. Внёс в degradeWalls() (src/wall-thickness.ts)
однострочную мутацию входа (walls[0].cm = 999) сразу после guard if (!walls?.length) return [];. Прогон npm test дал 5 красных тестов,
включая именно новый (not ok 843 - lossless wall helpers preserve an isolated sub-half-step thickness island outside Optimize), плюс два
существующих соседних degradeWalls-теста и оба уже покраснели бы сами
по себе — то есть новый тест не единственная защита, но он сам по себе
дискриминативен для входной мутации. Откатил правку (git checkout -- src/wall-thickness.ts), npm test снова 917/917.
AC5-тест (test/plan-optimizer.test.mjs, «micro-interval cleanup is
endpoint, input-order and room-order invariant without mutation») — не
ловит именно тот сценарий регрессии, который Medium-1 из r1 назвал
целевым. Подробности — находка ниже.
Находки
Medium-1 (в скоупе) — новый AC5-тест не хермитичен к сценарию входной мутации, который он должен доказывать
Файл: test/plan-optimizer.test.mjs, тест «micro-interval cleanup is
endpoint, input-order and room-order invariant without mutation» (строки
127–159 в HEAD)
Суть: тест сперва вызывает baseline = collapseIsolatedWallThicknessIslands( [fixture.rooms[0], detached], fixture.walls, ...) на непроклонированном
fixture.walls/fixture.rooms, а затем строит reversedMiddle и итоговый
walls, читая fixture.walls[1] уже после этого первого вызова
({...fixture.walls[1], a: [...fixture.walls[1].b], b: [...fixture.walls[1].a]}).
Если бы collapseIsolatedWallThicknessIslands регрессировала ровно так,
как предупреждал Medium-1 в r1 — начала мутировать входной массив walls
на месте (wall.cm = target вместо иммутабельного {...wall, cm: target} внутри out.map(...) на строках 178–182 src/plan-optimizer.ts) —
то baseline-вызов сам испортил бы fixture.walls[1].cm (15 → 22) ещё
до того, как из него строится reversedMiddle/walls для проверки
инвариантности. К моменту, когда тест снимает beforeWalls = structuredClone(walls), входной центральный интервал уже был бы виден
как cm: 22, кандидат схлопывания отфильтровался бы условием centreCm === leftCm (строка 138 src/plan-optimizer.ts — уже «канонично», сливать
нечего), второй вызов не тронул бы объект вообще, и
assert.deepEqual(walls, beforeWalls) прошла бы тривиально, потому что
мутировать было уже нечего.
Проверено исполнением, не только чтением. Ввёл в
src/plan-optimizer.ts минимальную адверсариальную мутацию — заменил
out = out.map((wall) => {... return wall.cm === target ? wall : {...wall, cm: target}; }) на цикл for (const wall of out) { ...; wall.cm = target; } (мутация того же самого объекта, на который ссылаются и
out, и исходный walls, поскольку out = walls.slice() — мелкое
копирование массива, не элементов). Прогнал npm test: все 917 тестов
остались зелёными, включая AC5-тест. Отдельным изолированным
node-скриптом против пересобранного test-build/plan-optimizer.js
подтвердил механизм: fixture.walls[1].cm меняется с 15 на 22 сразу
после baseline-вызова (для немутированного, реального кода —
остаётся 15, для мутированного — становится 22). Значит именно
описанный сценарий воспроизводится, а не является гипотетическим.
Сценарий отказа, который прошёл бы незамеченным: будущий рефакторинг
collapseIsolatedWallThicknessIslands, меняющий иммутабельный .map на
мутацию на месте (естественная логическая ошибка при, например,
попытке «оптимизировать» цикл), не будет пойман этим тестом — именно
тем автотестом, который ТЗ и r1-ревью называют доказательством AC5.
test/wall-thickness.test.mjs-тест того же follow-up-коммита для AC8
устроен иначе (сравнивает итоговую переменную walls с её собственным
before-снимком, снятым до любых вызовов) и корректно ловит тот же
класс регрессии — см. проверку выше. Это подтверждает, что дефект
специфичен для одного теста, а не общая недостижимая планка.
Почему Medium, а не Low: это не стилистика — тест был написан
специально в ответ на Medium-1 из r1 как «Reversal/permutation/
immutability unit» для строгого продуктового инварианта неизменности
персистентных данных (docs/WALL-THICKNESS.md), и для сценария, прямо
названного в тексте самого Medium-1 r1 («если бы будущая правка начала
мутировать переданный walls-массив на месте»). Регрессия именно этого
класса останется незамеченной автотестами задачи.
Что нужно: в начале теста один раз снять structuredClone со всего,
что дальше используется как источник и для baseline, и для проверяемого
вызова (например, клонировать fixture целиком перед первым вызовом,
либо строить reversedMiddle/walls/rooms из независимой копии
fixture, а не из объекта, уже прошедшего через baseline-вызов). После
правки повторить именно ту адверсариальную мутацию, которой я проверял
эту находку (иммутабельный .map → мутирующий for), и убедиться, что
тест в таком варианте краснеет.
Medium-2 — нет (закрыта)
Assertion из r1 подтверждена исполнением как реально дискриминативная (см. «Проверка дисциплины „тест должен уметь падать“» выше) — новых находок по AC8 нет.
Low — нет
Находок уровня Low не выявлено.
Что проверено и корректно
- AC8 закрыт полностью и доказан заявленным способом. Новый unit в
test/wall-thickness.test.mjsвызываетnormalizeWallIntervals()иdegradeWalls()напрямую (безcollapseIsolatedWallThicknessIslands, то есть вне Optimize) на изолированном22→15→22-профиле короче половины шага и подтверждает: обе lossless-функции не трогают его, вход не мутируется. Дискриминативность подтверждена адверсариальной мутацией (см. выше) — тест реально краснеет на регрессии. - Продуктовый код не менялся с r1.
git diff 0df8051..HEAD -- src/пуст — контракт §6 ТЗ, non-cascading snapshot, разрешение неоднозначности черезtargets.size === 1и защиту topology-узлов из r1 не требовалось перепроверять заново по существу; перепроверены только заново пересобранные гейты (typecheck/test/build/mutation-gate/ targeted smoke), все зелёные на актуальном коде. - Docs-provenance коммит
0625b6fкорректен и не расширяет скоуп.sourceFingerprintвdocs/images/screenshots.jsonактуализирован под изменившийсяsrc/(тестовые правки этого же issue меняют fingerprint, т.к. он покрывает весьsrc/+ build inputs),node scripts/check-docs.mjs --externalзелёный, класс C,User-Visible: no, никакой продуктовой логики не касается. 1-байтовое отличие PNG06-device-editor.pngпроверено на невоспроизводимость по байтам: два последовательных локальных прогонаnpm run build && node demo/docs/capture.mjsдают разные SHA-256 даже для одного и того же коммита — это известная нестрогая детерминированность самого capture-пайплайна (не связана с #198), аcheck-docs.mjsпроверяет только внутреннюю согласованность манифеста и файла, а не побитовую воспроизводимость между запусками, так что это не дефект. - Idempotenность и mutation guard.
node scripts/mutation-gate.mjs --check(36/36) и целевой--id=optimizer-micro-interval-cleanup-disabled(1/1) зелёные на актуальном HEAD. - Трейлеры. Все пять новых/изменённых коммитов диапазона несут
Issue: #198;User-Visible: noна всех — корректно, ни один из них (тесты, ревью-документы, docs-provenance) не меняет видимое поведение; changelog-и не тронуты этим циклом и не должны были быть (правки r1 уже внесли оба changelog в0df8051,User-Visible: yes, подтверждено в r1 и повторно проверено — диапазон не менялся). - Три копии бандла синхронны.
cmpдал совпадение бит-в-бит дляdist/,custom_components/houseplan/frontend/иdemo/srv/assets/послеnpm run buildна актуальном HEAD.
Чего не проверял
- Полный HA backend harness (
tests_backendпод настоящим Home Assistant) — диапазон не содержит правок вcustom_components/**/*.py. npm run golden:verifyи полный browser-smoke набор (127 файлов) — диапазон r2 не трогает рендер/геометрию/UI-пайплайн; обоснование то же, что в r1, и продуктовый код не менялся.- Производительность/бенчмарки — не названы в AC, изменения цикла — только тесты и docs-provenance.
- Повторный ручной прогон adversarial-мутаций на самом
exactMatches(реверс endpoints без общей мутации входа) — этот сценарий был исполнен в r1 (см.CODE-REVIEW-198-r1.md, Medium-1) и один раз дополнительно в этом цикле при диагностике находки выше; отдельно третий раз не повторял, так как вывод не менялся между попытками.
Вывод
High-находок нет. Одна Medium-находка — строго в скоупе этой же задачи
(это тест, добавленный именно как ответ на Medium-1 из r1 для доказательства
AC5, и находка о том, что он не доказывает заявленное для конкретного
класса регрессии), поэтому решение не заводит отдельный issue, а
возвращает задачу автору жёлтым вердиктом. Продуктовый код и AC8
подтверждены; исправить нужно только конструкцию AC5-теста (устранить
разделяемую немутированную ссылку на fixture между baseline-вызовом
и построением проверяемых walls/rooms) и повторно прогнать npm test + адверсариальную мутацию .map→for, которой было
продемонстрировано отсутствие защиты.