27 KiB
CODE-REVIEW-306-r1
- Issue: #306 — нулевые стены вместо виртуальных границ
- Этап: code (PROCESS.md §2.7)
- Заход: r1 · блокирующих циклов израсходовано 0/4 (это первый заход код-ревью; предыдущие r1–r4 в истории issue относятся к этапу ТЗ, §2.4)
- Ветка / SHA материала:
issue/306-zero-thickness-walls@a66fb7ef53906bed33c6f92369b14e8e398be3bd(совпадает с SHA, названным автором в хендоффе;git statusна этом SHA чист) - ТЗ:
docs/specs/306-zero-thickness-walls.md(принят, зелёное spec-review r4, High:0/Medium:0)
1. Скоуп
Полная замена инструмента «Граница» и сущностей space.open_spans/rooms[].open_to
на обычные атомы стены с cm:0; единая настройка пространства zero_wall_style
(dashed/solid), управляющая одновременно визуальным стилем и светопроницаемостью
(Glow + солнце) всех нулевых стен; атомарная миграция legacy-полей в model v9 поверх
уже принятой #282 (wall_segments[] authoritative). J4/J6 из docs/SCOPE.md.
Диапазон материала: git diff origin/dev...HEAD — 91 файл, +4210/-2989 (frontend,
backend, i18n, докиs, тесты). Разбор проведён полностью (не по дельте): это первый
заход код-ревью для этой задачи.
2. Как проверялось
2.1 Гейты — что прогнано
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | npx tsc --noEmit |
green, exit 0 |
| Unit | npm test |
1345 tests: 1344 passed, 1 skipped, 0 failed |
| Build + сверка бандлов | npm run build && cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js |
green, byte-identical; после npm run bundle:sync все три копии (dist/, custom_components/houseplan/frontend/, demo/srv/assets/) идентичны, git status чист |
| Docs fingerprint | node scripts/check-docs.mjs |
green: «Documentation checks passed (7 files, 10 external links)» — обязателен, т.к. diff трогает src/** |
| Model invariants | node scripts/model-invariants.mjs --config test/fixtures/optimize-storage-roundtrip.json --json |
green: {"violations": [], "notes": []} — обязателен, diff трогает геометрию/ссылки на неё (wall_segments, open_spans, open_to, layout) |
| Смок-выборка | node scripts/smoke-select.mjs --base origin/dev --head HEAD |
91 «прямое совпадение», 29 «слабая связь»; выборка приложена ниже |
| Целевые браузерные смоки (лично, Chromium в песочнице) | node demo/smoke_zero_walls.mjs, smoke_glow.mjs, smoke_sun.mjs, smoke_wall_thickness.mjs, smoke_merge_split.mjs, smoke_opening_preview.mjs (после npm run bundle:sync, первая попытка без него упала с «stale bundle», это ожидаемо и не находка) |
все шесть green, все проверяемые ключи true (см. §3) |
2.2 Что НЕ прогонялось и почему
- Backend pytest (
tests_backend/test_validation.py,test_wall_segment_model.py,test_ha_import_export.py) — не прогонялись мной: в этой песочнице нет ни системногоpytest/voluptuous, ниhomeassistant, ни.venv-backend(последний по AGENTS.md провиженится только стартап-скриптом облачного агента, здесь его не было). Установка гологоpytestв/tmp/venv-backendне помогла: импортcustom_components.houseplanтриггеритcustom_components/houseplan/__init__.py, который безусловно импортируетhomeassistant.components.frontend— без него не собирается даже «чистый» тест. Backend-логика (validation.py,wall_segment_model.py) проверена чтением, не исполнением (см. §3); дополнительно автор в хендоффе (2026-08-26T10:40) назвал команду и результат: «pure backend Python 3.13 — 206 passed, 1 skipped». Итоговым гейтом остаётся CI jobbackendна точном SHA. npm run golden:verify/ golden-эталоны — не перезапускал; автор явно отчитался о запуске с ожидаемыми visual diff (следствие удаления Boundary и единой семантики прежнихcm:0) и не принимал baseline локально — принятие эталонов не входит в полномочия код-ревью (npm run golden:accept -- --reviewedтолько по Linux CI артефакту).- Полный smoke-набор (все ~190 файлов) — не запускался, задача не задевает всё; автор в хендоффе назвал 26 целевых смоков, зелёные. Я независимо повторил 6 из «прямого совпадения» smoke-select (см. таблицу выше) как перекрёстную проверку, а не повторение работы автора вслепую.
- Performance-бенчмарк (
benchmark:wall-model) — не перезапускал; автор назвал команду и число (p95 173.214 ms при бюджете 500 ms), что при близких числах в двух последовательных прогонах (160→173 ms) выглядит стабильным и далёким от бюджета. AC17 в явном виде проверен по коду (кэш-fingerprint включаетzero_wall_style, см. §3). - Touch/Playwright specifically-touch smoke — не запускал отдельно;
smoke_zero_walls.mjsвключаетhiddenInView/visibleInEditorпроверки, но не отдельный pinch/cancel touch сценарий. Автор заявил его в 26 целевых смоках; не перезапускал вручную.
3. Находки
Medium (в скоупе задачи — чинится в этом issue)
M1. Отчёт «Оптимизировать планы» показывает退ый термин «виртуальный»/«virtual», который это же issue обязано убрать из продукта.
src/i18n/en.json:818 / ru.json:818, ключ gs.optimize_changes (не изменён этим
диффом — сверено по git show origin/dev:src/i18n/en.json — идентичен):
en: "...merged real-wall fragments: {w}; virtual fragments: {s}; independent walls: {i}."
ru: "...объединено отрезков реальных стен: {w}; виртуальных: {s}; независимых: {i}."
Показывается пользователю в диалоге Optimize (src/houseplan-card.ts:16920-16924,
modelMaintenance ? ... this._t('gs.optimize_changes', {..., s: String(r.spansMerged), ...})).
r.spansMerged — тот самый счётчик, который этот диифф в src/plan-optimizer.ts:544
(spansMerged += Math.max(0, zeroParts - zeroWalls.length)) теперь наполняет данными
о нулевых стенах (sourceZeroWalls/zeroWalls, включая legacy open_spans/open_to
projection через legacyZeroContourLines). Спецификация прямо требует обратного:
«Отдельного инструмента и термина «виртуальная стена» больше нет» (§3, последняя
строка), и это условие входит в тот же AC18 («документация и релизные артефакты
описывают одну систему стен»). Пользователь, открывший Optimize после этой правки,
увидит слово «виртуальных»/«virtual» — ровно тот термин, который #306 обязано
устранить, — в отчёте, который сам этот PR модифицировал по смыслу.
Дополнительно: спецификация §8.4 требует, чтобы Optimize preview отдельно считал
«legacy virtual spans migrated» и «zero-wall atoms merged» — два разных числа.
Фактически есть один смешанный счётчик spansMerged (легаси-spans и обычные
соседние нулевые атомы, слитые при переносе комнаты, суммируются в одну цифру) и
несвязанный с зеро-стенами общий счётчик gs.wall_segments_migrated
(r.wallSegmentsMigrated, из commitWallSegmentModelInPlace().migratedSegments —
существовал до #306, считает вообще любые впервые атомизированные сегменты, не
специфично про legacy virtual spans). Отдельного «legacy virtual spans migrated»
счётчика с таким текстом нет.
Воспроизведение: открыть Optimize на плане со старым open_spans/виртуальными
границами → отчёт покажет «виртуальных: N» / «virtual fragments: N».
Почему в скоупе: оба затронутых файла (plan-optimizer.ts, houseplan-card.ts)
и сам счётчик spansMerged модифицированы этим диффом для новой семантики; правка —
переименование строки i18n и/или разделение счётчика, не архитектурная работа.
M2. docs/CHANGELOG.md/docs/CHANGELOG.ru.md не содержат обязательного
предупреждения о backup перед миграцией (спецификация §19).
Спецификация, раздел «Release-артефакты» (§19), прямо требует в changelog:
«удаление «Границы», стены 0, настройка пунктир/сплошная и предупреждение о
migration backup». Фактический добавленный текст (git diff origin/dev...HEAD -- docs/CHANGELOG.md docs/CHANGELOG.ru.md) описывает первые три пункта корректно,
но не содержит ни слова «backup»/«резерв», ни любой рекомендации сохранить копию
перед первой структурной записью. Это не декоративная деталь: §12.2 самой
спецификации прямо говорит, что downgrade после записи model v9 не
поддерживается, а надёжный путь назад — «импорт backup», т.е. пользователь,
не предупреждённый до первого сохранения, теряет путь назад молча.
Воспроизведение: прочитать добавленные абзацы в обоих changelog — слова «backup»/«резерв(ная копия)» нет.
Почему в скоупе: тот же коммит уже редактирует оба файла по этому же поводу; это одна-две фразы, не отдельная задача.
Low (замечено, не блокирует — на суждение автора: править или снять с запиской)
L1. Четыре i18n-ключа из таблицы §15 ТЗ реализованы не под тем именем/точным текстом.
Спецификация называет ключи toast.zero_wall_opening_conflict,
toast.zero_wall_ambiguous, toast.zero_wall_migration_blocked,
gs.zero_walls_migrated с конкретным RU/EN текстом. Реализация вместо них
переиспользует существующие обобщённые ключи:
toast.zero_wall_opening (RU текст совпадает почти буквально, EN — «wall stretch»
вместо «wall segment»), toast.wall_model_migration_blocked (другой текст, тот же
смысл и {reason}), gs.wall_segments_migrated (другой смысл — см. M1). Отдельного
ключа/сообщения под «неоднозначный узел» с текстом из спецификации нет вовсе —
переиспользован уже существующий toast.wall_repair_ambiguous для схожего случая
на общих основаниях (не проверял отдельно, вызывается ли он именно на пути рисования
cm:0 через неоднозначный T/X-узел, а не только на wall-repair; функционально
пользователь получает понятное сообщение в обоих языках).
Расхождение с буквальным текстом §15 таблицы — за принятым 4 раунда ТЗ-ревью контрактом; при этом ни один наблюдаемый пользователем сценарий не остаётся без сообщения. Оставляю на суждение автора/владельца: заменить точными ключами по таблице §15 либо снять с запиской, что переиспользование обобщённых ключей эквивалентно по смыслу.
4. Проверка AC — что подтверждено и как
Материал: чтение продуктового и тестового кода + шесть лично перезапущенных браузерных смоков (§2.1) + отчёт автора с именованными командами там, где самостоятельный прогон был недоступен (backend, golden, perf — §2.2).
- AC1 (один инструмент). Подтверждено чтением:
grep -i boundaryпоsrc/houseplan-card.tsне находит ни кнопки, ни режима, ни подсказок — только не связанные слова (pointOnBoundary, «transaction boundary»);boundary.*/toast.boundary_*удалены из обоих i18n-файлов и не реimportируются. Подтверждено исполнением:demo/smoke_zero_walls.mjs→noBoundaryTool: true,zeroLineInEditor: true. Тест умеет падать:test/wall-segment-model.test.mjsпроверяетresolveRoomOpeningHostчерезNumber(segment.cm) > 0, а не||(инвертировать guard — тест упадёт). - AC2 (толщина 0↔positive без побочных эффектов). Подтверждено чтением:
custom_components/houseplan/validation.py:1400/1425/1448— диапазон0..100дляwall_segments/room_drafts/partitions;wall_columns(:1471) остаётся1..150.src/physical-geometry.ts—Number.isFinite(rawCm) ? rawCm : 15вместоcm || 15(старый0 → 15-клэмп устранён), плюс явныйif (!(halfDepth > 0)) continue. Подтверждено исполнением:demo/smoke_wall_thickness.mjs→zeroApplied: true,emptyRejected: true;demo/smoke_zero_walls.mjs→positiveRestored: true. - AC3 (единая семантика
cm:0, безzero_kind).git diffпо всему диффу не содержитzero_kind/legacy_originвне текстов документации, обсуждающих сам отказ от них (провереноgrep -in "zero_kind\|legacy_origin"по полному diff).src/zero-walls.tsи backend_thickness/resolvedThicknessCmне хранят и не читают происхождение атома — только текущую геометрию/cm. - AC4 (нет физического тела).
physical-geometry.tsизменения (см. AC2) плюсspace-render.ts:zeroWalls.contourиспользуется как cut, а не body. Не прогонял golden лично (см. §2.2); текстовые проверки вtest/physical-geometry.test.mjsне читал построчно — риск отношу на «унаследовано от отчёта автора» (npm test включает этот файл и прошёл зелёным). - AC5/AC6 (свет: dashed пропускает, solid блокирует, без тела). Прочитан целиком
src/zero-walls.ts::resolveZeroWalls— единый resolver,barriers/transmissiveкак зеркальные множества по стилю. Прочитан вызывающий код: Glow-occluders (houseplan-card.ts:16487-16520) режетoutlineWithout(poly, cuts, eps)поzeroWalls.transmissive(только dashed contour) и отдельно добавляетzeroWalls.barriers(solid, все — contour+independent) как двухточечный барьер без полигона; sun (:15301-15315) использует тот жеzeroWalls.barriersкакsunOccludersвместе сphysical. Оба потребителя дергают один и тот жеthis._zeroWalls(). Подтверждено исполнением:demo/smoke_glow.mjs,demo/smoke_sun.mjsзелёные (набор существующих проверок не содержит специфичных для #306 ключей с именем zero/dashed/solid — эти два смока проверяют общий контракт освещения, которому #306 не должно противоречить; специфичную dashed/solid проверку несётsmoke_zero_walls.mjs→dashedTransmits: true,solidBlocks: true,solidPaint/dashedPaint: true). - AC7 (переключение без reload). Fingerprint-функция Glow (
houseplan-card.ts:16472,mix(zeroWalls.style === 'solid' ? 1 : 0)) и sun-кэш-ключ (:15264-15266,zeroKey/zeroWalls.styleв составеkey) включают стиль — переключение меняет ключ кэша, старый кэш не используется. Не проверял отдельным смоком переключение в одной сессии без reload сверх того, что делаетsmoke_zero_walls.mjs(последовательно рисует, проверяет dashed, потом solid, в одном запуске браузера — это де-факто и есть проверка «без reload»). - AC8/AC9 (миграция
open_spans/open_to, идемпотентность, lineage). Прочитанcustom_components/houseplan/wall_segment_model.py::_atomize/_migrate_spaceи зеркальныйsrc/wall-segment-model.ts::buildAtoms/migrateSpace:open_spansимеет приоритет,open_to— fallback только при их отсутствии (if not legacy_segments: ... linked = ...), оба поля удаляютсяspace.pop("open_spans")/delete space.open_spansтолько в конце сборки итогового candidate. Тест умеет падать: агент-проверка (см. §5) подтвердилаtest_v8_open_span_migrates_to_zero_atoms_and_removes_legacy_fields— конкретные числовые ассерты на[0.0, 15.0, 15.0], не тавтология. - AC10 (проёмы защищены). Backend:
_host_openings.eligible()теперь отклоняетcm<=0(validation.pyаналогично:float(segment["cm"]) <= 0→ invalid). Frontend:resolveRoomOpeningHost— тот же guardNumber(segment.cm) > 0. UI-путь:_wallThickApply(houseplan-card.ts:11699-11704, 11734-11744) проверяет занятость интервала проёмом и показывает toast до записи, не удаляя проём. Исполнено:demo/smoke_zero_walls.mjs→openingBlocksZero: true;demo/smoke_wall_thickness.mjs→openingBlocksZero: true;demo/smoke_opening_preview.mjsзелёный целиком (общий контракт размещения проёмов не нарушен). Backend-тестtest_v8_open_span_over_an_opening_blocks_atomicallyподтверждён внешней проверкой как реальный, не как заглушка (raises +assert base == before, т.е. no partial write). - AC11 (геометрические операции сохраняют 0/роль).
demo/smoke_merge_split.mjsзелёный (splitKeepsLegacyWallThickness: trueи др.). Не проверял лично T/X-узел и «совпадающую partition» отдельным смоком — доверяюnpm test(test/wall-segment-model.test.mjs«fixed-topology move carries zero walls without leaving phantom breakpoints», подтверждено внешней проверкой как реальный тест) иtest/wall-face-graph.test.mjs(chainSegmentCms,>= 0вместо> 0). - AC12 (read-only не мигрирует). Прочитан вызов миграции — она происходит только
внутри структурных writer-путей (
migrateSpace/commitWallSegmentModelInPlace), не в рендере/подписке наhass.npm testвключает mutation-gate тесты (зелёный). Не проверял отдельным websocket-call-log смоком лично — унаследовано из зелёногоnpm testи структуры кода. - AC13 (Optimize/import атомарны).
vol.Length(max=MAX_WALL_SEGMENTS)в схеме — превышение лимита проваливает всю схему целиком (не частичная запись). Один найденный отход от буквы AC13/§8.4 — M1 (нет отдельного «legacy virtual spans migrated» счётчика в preview). - AC14 (parity). Общий resolver (
zeroWalls.style) используется и вhouseplan-card.ts(Flat), и вspace-render.ts(тот же вызовresolveZeroWalls). Изометрию (iso-walls.ts) не нашёл в diff — не изменена этим диффом; не проверял отдельно, действительно ли iso уже консистентна с новым стилем без изменений (полагаю, что да, поскольку iso рендерится из того же geometry model, но не прогонял iso-специфичный смок лично). - AC15 (backend/registry). Подтверждено чтением (см. §3 backend diff):
_config_wall_segment_invariantsотклоняетopen_spans/open_toв v9, отклоняет opening host сcm<=0.docs/CONFIG-COMPATIBILITY.mdиscripts/config-field-registry.mjs— статусыdeprecated-read/migrate-on-writeподтверждены внешней проверкой (см. §5). - AC16 (touch floor). Не проверял отдельным touch-смоком лично; полагаюсь на заявленный автором прогон 26 смоков включая touch safety.
- AC17 (perf/кэши). Fingerprint включает
zero_wall_style(см. AC7). Числовой бенчмарк не перезапускал (§2.2). - AC18 (документация). PASS по большинству пунктов (внешняя проверка, см. §5), кроме M1/M2/L1 выше.
5. Дополнительная проверка (делегирована, независимо перепроверена мной точечно)
Для покрытия большого диффа без потери точности были запущены два фактологических подзапроса (без права вывода вердикта — только факты, которые я лично оценил):
- Полнота удаления Boundary/UX-контракта настройки пространства/i18n-паритета/ консистентности документации — привело к находкам M1 (частично, через мой собственный grep), M2, L1.
- Реальность (не тавтологичность) новых/изменённых тестов
(
test/zero-walls.test.mjs,test/wall-segment-model.test.mjs,tests_backend/test_validation.py,tests_backend/test_wall_segment_model.py,tests_backend/test_ha_import_export.py,demo/smoke_zero_walls.mjs) — все признаны реальными (вызывают продуктовый код, а не пересчитывают ожидаемое значение локально); ключевые примеры процитированы в §4. Единственная замеченная просадка покрытия: изtest_ha_import_export.pyубран фикстур-кейс сfuture_spanв legacyopen_spansпри импорте — вероятно избыточен на фоне более строгой backend-проверки вtest_validation.py, не считаю это отдельной находкой (не в AC, не поведенческий регресс).
Обе проверки я оценил и учёл сам; выводы выше — мои, не пересказ подпроцессов.
6. Итог
- High: 0
- Medium (в скоупе): 2 (M1, M2)
- Low: 1 (L1, на суждение автора)
- Вне скоупа: 0 — новый issue не заводится
Оба Medium — точечные правки в файлах, которые этот же коммит уже трогает (i18n-строка/счётчик в Optimize-отчёте; два предложения в changelog), не затрагивают архитектуру, geometry-модель или тесты. AC выполнены за вычетом M1/§8.4 (единственная содержательная AC-просадка). Вердикт — жёлтый: без High это возврат автору на правку в текущем issue, не блокер архитектуры.