30 KiB
CODE-REVIEW-277-r1
- Issue: #277 — безопасный Resize без изменения топологии
- Заход: r1 (первый код-ревью-заход; спецификация прошла отдельные заходы
SPEC-REVIEW-277-r1 → r2, зелёный, зафиксирован SHA не называется в issue —
восстановлен по коммитам:
9e356504→ правки1bbd53ee→ зелёный r2) - Ветка:
issue/277-safe-resize - SHA ревью:
241821c40e9b2f43b79945af3377fc3b3f4ac084(origin/devна момент ревью: без новых коммитов после ветвления — полный разбор, не по дельте) - Диапазон:
origin/dev...HEAD, 11 коммитов (4 — спецификация/ревью ТЗ, класс C; 7 — реализация)
Скоуп
Полный разбор (первый код-ревью-заход по этой задаче). Диапазон коммитов реализации:
| Коммит | Тема | Класс | User-Visible |
|---|---|---|---|
6335b0e5 |
fix: make room resize topology-safe | A | yes (оба changelog) |
cd0a682a |
test: align legacy resize smokes with safe mode | B | no |
2e22ed79 |
fix: stop resize before lossy metadata rekey | A | yes (оба changelog) |
a5a0f6da |
docs: accept safe resize screenshots | C/D | no |
03b45f2e |
build: synchronize safe resize release bundle | D | no |
b02a3712 |
test: align mixed-thickness smoke with safe resize | B | no |
241821c4 |
test: accept beta.8 safe resize baselines | D | no (Release:/Baseline-Reviewed: присутствуют) |
Затронутые продуктовые файлы: src/resize.ts (+372 строки чистой геометрии),
src/houseplan-card.ts (контроллер, рендер, i18n-проводка), src/styles.ts
(удаление .rszcorner/.rszframe/.rszknob, добавление .disabled),
src/i18n/{en,ru}.json. Плюс тесты, смоки, mutation-gate, benchmark, golden,
9 файлов документации, оба changelog.
Все коммиты несут Issue: #277 и корректный User-Visible; оба
User-Visible: yes коммита правят docs/CHANGELOG.md и .ru.md в том же
коммите. Коммит с demo/golden/baselines/** несёт Release: v1.67.0-beta.8 и
Baseline-Reviewed: <ссылка> — проверено не только по формату (см. «Гейты»).
Как проверялось
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | npx tsc --noEmit |
зелёный |
| Unit | npm test |
1194/1194 passed, 0 fail |
| Build + 3 копии бандла | npm run build && npm run bundle:sync + cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js |
зелёный, байт-в-байт; демо-копия (несомитаемая, #255) синхронизирована тем же скриптом |
| Docs fingerprint | node scripts/check-docs.mjs |
«Documentation checks passed (7 files, 10 external links)» |
| Выбор смоков по дельте | node scripts/smoke-select.mjs --base origin/dev --head HEAD |
37 «прямых совпадений» (0 «зарегистрированных связей», 0 «неопределённостей») — полный вывод приложен ниже |
| Смоки (37 прямых совпадений) | node demo/smoke_<name>.mjs × 37 |
все 37 зелёные (список — ниже) |
| Golden | npm run golden:verify |
80/80 passed, включая новые safe-resize-handles-clamp-{light,dark} |
| Mutation gate (реестр) | node scripts/mutation-gate.mjs --check |
все ~60 мутантов «ok» (патчи применимы), включая 5 новых safe-resize-* |
| Mutation gate (дорогой прогон, только 5 новых мутантов AC14) | node scripts/mutation-gate.mjs --id=<id> × 5 |
все 5 «поймано 1 из 1» |
| Performance | npm run benchmark:safe-resize |
"pass": true; pointer p95 0.0128 ms (бюджет 16 ms, ≤20% над baseline — candidate на два порядка быстрее historical baseline 3.93 ms); commit-preflight p95 0.002 ms (бюджет 75 ms) |
| Model invariants (общий) | часть npm test (test/model-invariants.test.mjs) |
зелёный |
| Model invariants (конкретная конфигурация) | node scripts/model-invariants.mjs --config <after-resize export> — конфигурация собрана вручную через реальный pointer-жест в demo-стенде (комната + стена 20 см + hosted-незав. partition + ordinary opening), после успешного commit |
«Инварианты выполнены: ссылки разрешимы, записи толщины находятся» — ключ стены корректно перекодирован на новую позицию, opening и partition не задеты |
| Backend | — | не запускался: diff не трогает custom_components/**/*.py |
CI-провенанс Baseline-Reviewed |
gh run view 32689229827 / --log для job golden |
run красный целиком ожидаемо: единственная причина — missing-baseline для двух новых сцен safe-resize-handles-clamp-{light,dark}; остальные 104 сцены и job frontend прошли на этом самом SHA. Соответствует описанию коммита 241821c4 и правилу «полный Linux-артефакт», а не «зелёный CI» |
CI-провенанс Reviewed artifact (docs screenshots) |
gh run view 32689026469 |
run зелёный, workflow_dispatch, соответствует коммиту a5a0f6da |
Полный вывод smoke-select.mjs:
Изменено файлов src/**: 3 · символов проекта на изменённых строках: 104
Матрица: 177 смоков · порог «широкого» символа: больше 35 смоков
Прямое совпадение (37): smoke_junction_patch_resilience, smoke_resize_audit_1550,
smoke_room_resize, smoke_glow, smoke_multiwall_junction, smoke_resize_inner_dimensions,
smoke_wall_key_roundtrip, smoke_multiwall_strip_containment, smoke_partition_openings,
smoke_zero_divider_taper, smoke_bg_color, smoke_grid_snap, smoke_merge_split,
smoke_open_passage, smoke_opening_preview, smoke_pan_any_zoom, smoke_registryless_opening,
smoke_resize_virtual_thick, smoke_resize_wall_thickness, smoke_split_corner_wall,
smoke_wall_junctions, smoke_wall_thickness_transition, smoke_card_tool_conflict,
smoke_device_preview_parity, smoke_drag_bounds, smoke_draw_wall_thickness,
smoke_help_affordance, smoke_hide_layers, smoke_infinite_canvas, smoke_opening_binding,
smoke_optimize_coordinate_canonicalization, smoke_optimize_geometry_preflight,
smoke_plan_drawing_repairs, smoke_plan_snap_overlay, smoke_render_perf,
smoke_space_tab_reorder, smoke_sun_soft.
Зарегистрированная связь: 0. НЕОПРЕДЕЛЁННОСТЬ: 0.
Решение по строке: прогнать все 37 — диапазон прямых совпадений умеренный, а
src/houseplan-card.ts правился широко (импорт-лист, _rszApplyPreview,
_wallUnionGeometry reuse), так что «слабые» смоки (smoke_sun_soft ←
axisOf — общее имя функции) тоже стоило проверить не по названию, а по
факту. Полная матрица (177) не прогонялась — она предрелизный гейт (§8);
данный диапазон полностью покрывает найденные прямые совпадения.
Находки
Medium (в скоупе задачи — правится в этой же issue)
M1. Кэш допустимости (eligibility) на самом деле не memoизирует дорогую часть — рендер ручек Resize пересчитывает полный geometry fingerprint для КАЖДОЙ ручки на КАЖДЫЙ рендер, а не один раз на изменение геометрии.
src/houseplan-card.ts:8330-8351 (_rszResolution):
const snap = this._rszDrag?.snap || this._rszSnapshot();
const key = `${this._space}|${this._cellCm}|${this._gridPitch}|${snap}`;
if (!this._rszEligibilityCache || this._rszEligibilityCache.key !== key) {
this._rszEligibilityCache = { key, values: new Map() };
}
_rszSnapshot() (:8353-8356) вызывает JSON.stringify(this._geometrySnapshot()),
а _geometrySnapshot() (:7078-7104) делает JSON.parse(JSON.stringify(...))
над rooms/openings/walls/open_spans/room_drafts/partitions/wall_columns/decor
всего пространства. _renderResizeLayer() (:8691 и далее) вызывает
_rszResolution(r.id, i) для КАЖДОЙ стены КАЖДОЙ комнаты на каждый вызов
render(), то есть указанный fingerprint пересчитывается N раз за один
рендер (N = число ручек), а не один раз. Ключ кэша обязан быть вычислен
ПЕРЕД тем, как узнать, есть ли попадание в кэш — поэтому «дорогая часть»
(fingerprint) выполняется независимо от того, попадает запрос в кэш или нет.
Это напрямую противоречит §13 ТЗ («Eligibility всех handles мемоизируется по
exact geometry fingerprint и не пересчитывает boolean geometry на каждый
render») и docs/ARCHITECTURE.md, который повторяет то же утверждение.
Само ТЗ (§13) отдельно называет естественный сигнал инвалидации — «Cache
ограничен активным пространством и очищается при cfg epoch/tool exit» — то
есть уже существующий дешёвый счётчик this._cfgEpoch (44 места инкремента,
только на реальных изменениях геометрии) годился бы как ключ кэша практически
бесплатно. Вместо этого ключ строится из полного JSON.stringify сцены.
Воспроизведение (команды выполнены мной, не автором):
Однопользовательский этаж из штатной фикстуры demo/fixtures/large-house.mjs
(makeLargeHouseFixture().config.spaces[0]: 20 комнат, 80 ручек, 20
partitions, 14 columns, 167 decor, 49 walls) — это та же «large-house» шкала,
которой пользуется собственный benchmark:large-house проекта, то есть не
надуманный крайний случай, а верхняя граница целевого масштаба SCOPE.md
(«house/large flat, 20–200 devices, several floors»).
инструмент resize, requestUpdate() без изменения геометрии (эмулирует
посторонний реактивный ререндер — тик устройства, sun-ray и т.п. — при
открытом инструменте Resize), кэш ТЁПЛЫЙ (ничего не инвалидировано):
6 замеров: 148, 137, 134, 130, 131, 129 ms
тот же рендер инструментом 'boundary' (те же 20 комнат/20 partitions/14
columns/167 decor, БЕЗ слоя ручек Resize):
5 замеров: 112, 103, 103, 102, 102 ms
инструмент resize, кэш ХОЛОДНЫЙ на каждом замере (эмулирует изменение
cfgEpoch на каждый тик):
8 замеров: ~200-224 ms
Разница «resize минус boundary» (≈ +30-100 мс на рендер) целиком относится к
циклу _rszResolution/_rszSnapshot, и «тёплый» кэш почти не дешевле
«холодного» (130 мс против 205 мс) — то есть кэш ловит повторный вызов
resolveSafeResize, но не ловит повторный fingerprint, который и есть
доминирующая стоимость. Это ключевое отличие от benchmark_safe_resize.mjs
(AC13, зелёный): бенчмарк меряет только чистые функции clampSafeResize/
applySafeResize, минуя именно этот путь — поэтому AC13 numerically проходит,
а реальный путь рендера ручек остаётся непроверенным этим бенчмарком.
Почему это Medium, не High. Не искажает геометрию, не создаёт запись, не блокирует основной safety-контракт задачи (AC1–AC12, AC14–AC17 подтверждены отдельно). Влияет на отзывчивость: при открытом инструменте Resize на доме такого масштаба каждый посторонний реактивный тик (живое состояние устройства, sun-ray, час/минута) стоит лишние ~30-100 мс сверх базовой стоимости рендера — заметный, повторяющийся джанк, а не разовая проблема. В скоупе задачи: сам кэш и его контракт производительности — предмет #277, не соседняя подсистема.
Как чинить (не мой мандат — только для контекста автору): вычислять
key/snap один раз на вызов _renderResizeLayer() (или на _cfgEpoch,
как и предполагает сам текст ТЗ), а не один раз на каждую пару
(roomId, edge).
Low (снимаю с записью, не блокирует)
L1. Мёртвый код и устаревший комментарий: _rszSel теперь ничего не
рендерит, хотя всё ещё выставляется кликом с комментарием про удалённую
угловую рамку.
src/houseplan-card.ts:7386-7389:
if (this._tool === 'resize') {
// a click picks the room for the scale frame; handle drags never get here
...
this._rszSel = room?.id || null;
Угловая scale-рамка (AC11) удалена из _renderResizeLayer() полностью — блок
const sel = this._rszSel ? ... ; if (sel) { ... } вычищен вместе с
.rszcorner/.rszframe/.rszknob. _rszSel продолжает выставляться на клик по
комнате в инструменте Resize и путешествует в view-state (rszSel при
сохранении/восстановлении вида), но не имеет ни одного потребителя в рендере.
Комментарий буквально описывает удалённую фичу. Не влияет на поведение —
клик просто ничего не делает визуально, как и раньше при клике по
неинтерактивной точке. Снимаю с записью: чисто косметическая уборка,
безопасно оставить автору на будущее мелкое polish-изменение, блокировать
задачу из-за одной мёртвой строки состояния и комментария нецелесообразно.
Проверка AC — таблица
| AC | Как доказано | Вердикт |
|---|---|---|
| AC1 | test/resize.test.mjs («diagonal and non-perpendicular…»); i18n-тест resize-production-path.test.mjs; смок safe_resize.* |
доказано (тест умеет падать — проверено мутантом safe-resize-axis-eligibility-bypassed, 1/1) |
| AC2 | test/resize.test.mjs («exactly two existing vertices…», топология вручную сломана в тесте и корректно отклонена); model-invariants на реальном post-commit конфиге (см. выше) |
доказано |
| AC3 | test/resize.test.mjs («exact shared: two rooms move…»); смок safe_resize.commit_left/right/third_static |
доказано |
| AC4 | test/resize.test.mjs («partial shared and third-owner…», включая фикстуру AC17); мутант safe-resize-third-room-cascade-enabled 1/1 |
доказано |
| AC5 | test/resize.test.mjs («irregular exact pair clamps at the first corner…»); смок safe_resize.corner_clamped/corner_topology; мутант safe-resize-topology-signature-bypassed 1/1 |
доказано |
| AC6 | test/resize.test.mjs («side opening stops at the physical jamb…»); мутант safe-resize-opening-jamb-bypassed 1/1; golden safe-resize-handles-clamp-{light,dark} (просмотрено — обе сцены «passed» в golden:verify) |
доказано |
| AC7 | test/resize.test.mjs («moving opening travels once; hosted moving opening is disabled»); смок safe_resize.opening_once |
доказано |
| AC8 | смок safe_resize.preview_moved/preview_not_persisted/commit_left/one_undo; test/resize-production-path.test.mjs (lossy-rekey guard); мутант safe-resize-commit-preflight-bypassed 1/1 |
доказано |
| AC9 | _rszCandidateRenderable/validateSafeResize разобраны чтением (проверено чтением, не исполнением — сама структура try/catch + fail-closed на исключении); смок safe_resize.preflight_no_commit/preflight_zero_write инжектирует отказ _checkOptimizeGeometry и проверяет zero-write |
доказано |
| AC10 | смок safe_resize.undo_exact; test/resize.test.mjs round-trip проверки |
доказано |
| AC11 | test/resize-production-path.test.mjs (grep-проверка отсутствия старых символов в бандле-исходнике и в стилях); ручной grep applyRoomScale/clampRoomScale/shiftSharedSpans/simplifyPoly( в houseplan-card.ts — отсутствуют; golden safe-resize-handles-clamp-* (визуально нет рамки) |
доказано |
| AC12 | test/resize-production-path.test.mjs (aria-disabled/tabindex/_rszDisabledKey/cursor); смок safe_resize.disabled_visible/disabled_reason/disabled_focusable/disabled_no_drag/disabled_zero_write |
доказано |
| AC13 | npm run benchmark:safe-resize — "pass": true, числа приведены выше |
доказано для чистого pointer/preflight-пути geометрии; НЕ покрывает путь рендера ручек — см. M1 |
| AC14 | node scripts/mutation-gate.mjs --id=<5 новых id> — все 5 «поймано 1 из 1» (прогнано мной, не только --check) |
доказано |
| AC15 | typecheck/unit/build — зелёные (таблица «Как проверялось»); байт-идентичность бандлов подтверждена cmp |
доказано |
| AC16 | docs/RESIZE.md, ARCHITECTURE.md, CANVAS.md, WALL-THICKNESS.md, TESTING.md, USER-GUIDE.{md,ru.md}, оба changelog — прочитаны построчно, согласованы с реализацией и друг с другом; check-docs зелёный |
доказано |
| AC17 | test/fixtures/resize-safe-regression.json + unit-тест ссылается на неё (resize.test.mjs); demo/smoke_room_resize.mjs воспроизводит ту же топологию (совпадающие координаты) реальными pointer-событиями через production-бандл и проверяет disabled/zero-write; model-invariants пройден на аналогичном (не буквально том же файле) post-resize экспорте |
доказано с оговоркой (Low, не заводится отдельно): смок дублирует геометрию фикстуры инлайн вместо чтения resize-safe-regression.json напрямую — при рассинхронизации фикстуры и смока рассинхронизация не будет замечена автоматически. Не блокирует: обе стороны сейчас идентичны и это видно построчным сравнением координат, а объём — одна строка ТЗ, не критерий приёмки. |
Что проверено и корректно
- Полный geometry pipeline (
resolveSafeResize/applySafeResize/validateSafeResize/clampSafeResize) прочитан целиком; порядок причин disabled соответствует §4 ТЗ ровно (диагональ → side-angle → duplicate-physical-wall → partial-shared → unequal-shared → multiple-rooms → thickness-conflict → opening-conflict → invalid-geometry). - Старые механизмы (
planEdgeDrag/applyEdgeDrag/clampEdgeDrag/applyRoomScale/clampRoomScale/shiftSharedSpans/simplifyPoly) остаются вsrc/resize.tsтолько для регрессионной истории тестов (test/resize.test.mjsих использует), но не импортируются вhouseplan-card.ts— подтверждено grep’ом и отдельным тестом. Угловая scale-рамка удалена из рендера и стилей (см. AC11). _rszUpоткатывает превью и не создаёт запись/Undo при провалеsnapshotStillCurrent/topologyValid/candidateValid— включает защиту от гонки с внешним изменением конфига междуpointerdownиpointerup(_rszSnapshot() === g.snap), что закрывает многоклиентский сценарий J6._rszApplyPreviewдополнительно останавливает превью до потери толщины/ virtual-span при перекодировании (коммит2e22ed79, добавлен автором до хендоффа на ревью — не отдельная находка ревью). Live-исполнение подтверждено вручную: собран реальный_serverCfg(комната + стена 20 см + independent-partition + ordinary opening), выполнен настоящий pointer-жест через demo-стенд, коммит создал ровно одну запись Undo (_geometryHistory.size === 1), стена корректно перекодирована на новую позицию с сохранённымcm, partition не тронута — и это же провереноmodel-invariants.mjs(см. таблицу гейтов).- «Одно число — один источник»:
_rszEdgeLabels()берёт длины/площадь из того же кандидатаres.polys, что коммитится, и через канонические форматтеры (formatLength/formatArea/innerContourForRoom/areaM2, та же конвенция внутренних граней, что и у карточки комнаты, #233) — не через отдельный приближённый расчёт для превью.test/single-source-numbers.test.mjsвходит в зелёныйnpm test. - i18n: все 9 disabled-причин плюс
resize.commit_failedприсутствуют и непусты в en/ru (проверено тестом и вручную); тексты причин не содержат room id/координат/исключений (§4 ТЗ) — прочитаны все 9×2 строки. Формулировкаtitle.markup_resize/markup.hint_resizeиспользует «ручку» по-русски (не непереведённый «handle»), согласованно сUSER-GUIDE.ru.md— учтено замечание L1 из SPEC-REVIEW-277-r2 (это замечание было по тексту спеки, не по реализации, но реализация ему не противоречит). - Риск-таблица §12.1 спеки подтверждена по факту: #276 действительно
S8-merged(влился раньше #277, как и предписано порядком merge), #264 действительно остаётсяS1-new(не начат параллельно, как и предписывал риск-митигейт), #278 —S5-ready, не начат. Порядок #276→#277→#278 соблюдён. Baseline-Reviewed/Reviewed artifactв коммитах241821c4/a5a0f6daпроверены по факту черезgh run view --log, не только по формату строки: оба указывают на реальные прогоны на точном SHA/ветке этой задачи; «красный» golden-run32689229827красен исключительно из-за ожидаемогоmissing-baselineдвух новых сцен — не сокрытая находка.- Производительность (кроме M1): бенчмарк численно на два порядка лучше
бюджета, budgets/cache-bound соблюдены, кэш
clampSafeResizeограничен (WeakMap, 4096 записей на план, проверено тестомsafeResizeCachedDeltaCount). - Документация (
RESIZE.md,ARCHITECTURE.md,CANVAS.md,WALL-THICKNESS.md,TESTING.md,USER-GUIDE.{md,ru},DEVELOPMENT.md,STATUS.md) прочитана целиком; описывает именно то поведение, которое реализовано — включая честные технические формулировки (rekey/preflight/ clamp), а не только пользовательский пересказ.
Чего не проверял
- Полный набор
demo/smoke_*.mjs(177 файлов) — предрелизный гейт (§8), запущены только 37 «прямых совпадений» дельты. python -m pytest tests_backend— не запускался: diff не касаетсяcustom_components/**/*.py.- Полный
npm run golden:capture/визуальный человеческий пересмотр всех 80 сцен построчно — доверился «passed/failed» отчётуgolden:verifyи предметно посмотрел только на факт прохождения двух новыхsafe-resize-handles-clamp-*; попиксельное сравнение не делал (это работаgolden:verify, а не ревьюера). - WSL/полный HA-харнесс — не касается этой задачи (нет правок Python).
- Реальный приватный экспорт пользователя, вызвавший #277 — не публикуется
по условиям issue; проверена только анонимизированная
resize-safe- regression.jsonи её топология. - Мутанты вне пятёрки
safe-resize-*(остальные ~55 в реестре) — не перепрогонялись полностью (дорогой прогон), только--check(применимость патчей); они не относятся к дельте этой задачи.
Унаследовано из ревью ТЗ
Код-ревью не наследует находки предыдущего ревью ТЗ автоматически (разные
стадии, разные бюджеты циклов, §10.4 PROCESS.md: «цикл считается по этапу»).
Тем не менее использовано как контекст: SPEC-REVIEW-277-r2.md (зелёный,
SHA 4ed94a52) — все три M1-M3 находки r1 спеки закрыты (риски, «что человек
увидит», AC17-фикстура), обе Low-находки r2 (нейминг «handle»/«ручка»,
непоказанный SHA r1) сняты автором ревью ТЗ с записью, без обязательных
правок. Проверено, что реализация не противоречит финальному тексту ТЗ
(docs/specs/277-safe-resize.md, HEAD) — сверено построчно по всем 18
разделам.
Вердикт
Жёлтый: одна Medium-находка в скоупе задачи (M1), 0 High. AC1–AC12 и AC14–AC17 доказаны автотестами, которые умеют падать (мутанты 1/1) или живым воспроизведением через production-бандл; AC13 доказан для чистой геометрии, но не для пути рендера ручек, где M1 и обнаружена. L1 снимается с записью (мёртвый код, не блокирует).