Files
houseplan-card/docs/reviews/CODE-REVIEW-113-r1.md
T
2026-08-19 02:49:39 +03:00

21 KiB
Raw Blame History

CODE-REVIEW-113-r1 — честный optional-контракт _spaceModel()

  • Issue: https://github.com/Matysh/houseplan-card/issues/113
  • ТЗ: docs/specs/113-optional-space-model.md, ревью ТЗ — docs/reviews/SPEC-REVIEW-113-r1.md (зелёный, Medium-1 → #184, не блокирует)
  • Диапазон: origin/dev..HEAD = 5f02dd3 (docs: specify optional space model contract), 7660868 (docs: review document), 09b5a74 (Make empty space model explicit)
  • Коммит реализации: 09b5a74e866a595138bd93703fd09a4860bf7757
  • Цикл: r1/4
  • Роль: ревьюер кода (Claude), свежая сессия, без контекста реализации Codex

1. Скоуп

Единственный коммит класса A/B по существу — 09b5a74. Он:

  • меняет _spaceModel(): SpaceModel → _spaceModel(): SpaceModel | undefined и вводит _spaceModelById(id): SpaceModel | undefined (exact lookup, без first-space fallback);
  • выносит чистые селекторы в новый файл src/space-model-selection.ts (selectActiveSpaceModel, selectSpaceModelById) с unit-тестами;
  • классифицирует и правит все ~45+ производственных обращений к _spaceModel() по классам §5 ТЗ (lifecycle/render-гейт, event handlers редакторов, pure render helpers, paths с уже доказанным space);
  • вводит _syncEmptySpaceState() — вызываемый из willUpdate() guard, который один раз на переход non-empty→empty снимает pointer capture, обрывает жесты/drag/pan/pinch/resize/vac-fit, закрывает draft/history/space-scoped диалоги, отменяет debounced config write и возвращает _mode в 'view'; повторно вооружается при следующем appearance/disappearance цикле; добавляет Debounced.cancel();
  • добавляет demo/smoke_optional_space_model.mjs, test/space-model-selection.test.mjs, test/optional-space-model-contract.test.mjs, мутационный guard empty-space-cleanup-disabled в scripts/mutation-gate.mjs;
  • документирует инвариант в docs/ARCHITECTURE.md и добавляет чек-лист в docs/TESTING.md.

User-Visible: no — согласовано с владельцем на этапе ТЗ (§13 спецификации): видимое поведение при существующих пространствах не меняется (гейт render() на model.length === 0 уже отделял пустой план от общего пути, старый код уже безопасно работал по факту порядка вызовов). Изменение устраняет класс латентных крашей (#111 повторно), не новую пользовательскую возможность. Соответствует, обе changelog-правки в этом коммите отсутствуют обоснованно.

Medium-1 из ревью ТЗ (#184, fallback-семантика §6 для явного id) вынесен отдельным issue, не расширяет #113; ниже проверено, что реализация фактически перевела все стабильные-id call sites на exact lookup (сильнее, чем требовало формально необязательное AC8) — см. §4.

2. Как проверялось — таблица гейтов

Гейт Команда Результат
Typecheck npx tsc --noEmit OK, без ошибок
Unit npm test 811/811 — совпадает с заявленным в хендоффе и с npm run inventory
Build + 3 копии бандла 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 OK, обе сверки — байт-в-байт совпадение
Именованный smoke (AC3–AC6) node demo/smoke_optional_space_model.mjs OK, все 9 подпроверок true
Именованный smoke (AC6, read-only cold start) node demo/smoke_readonly_cold_start.mjs OK, все 13 подпроверок true
Мутационный guard AC10/AC4/AC5 node scripts/mutation-gate.mjs --id=empty-space-cleanup-disabled поймано 1 из 1 — чистый прогон зелёный, мутант (if (empty) return; вместо if (this._emptySpaceStateActive) return;) красит смок, т.е. тест умеет падать
Регрессия непустого пути (поверхности: markers/positions, wall-thickness, free walls/columns, opening preview, open/close wall, isometric contract, merge/split, vacuum fit, corner split, draw-wall thickness) node demo/smoke_marker_stay.mjs, smoke_wall_thickness.mjs, smoke_free_walls.mjs, smoke_opening_preview.mjs, smoke_openwall.mjs, smoke_isometric_contract.mjs, smoke_merge_split.mjs, smoke_vacuum.mjs, smoke_split_corner_wall.mjs, smoke_draw_wall_thickness.mjs OK, все 10 зелёные
Process gate node scripts/process-gate.mjs --issues гейт пройден, предупреждений 0

Не прогонялось, и почему:

  • Полный набор из 135 браузерных смоков — diff трогает десятки геометрических хелперов (_frameOf, _isoScene, wall/opening/decor/room helpers), но во всех них новый код — это guard, добавленный до существующей логики, срабатывающий только при model.length === 0. При непустой модели _spaceModel() = models.find(...) ?? models[0], что при непустом массиве никогда не даёт undefined — путь if (!space) return … в каждом таком месте логически недостижим для непустого плана (проверено чтением, не исполнением, во всех местах диффа). Прогнан репрезентативный набор из 10 смоков по каждой тронутой геометрической поверхности (таблица выше) — все зелёные, что подтверждает отсутствие регрессии на практике, а не только по рассуждению. Прогон всех 135 не добавил бы информации, пропорционально задаче не прогонялся (PROCESS §8).
  • npm run golden:verify — не прогонялся. По той же причине (недостижимость нового кода при непустой модели, подтверждённая чтением render(): if (!model.length) return html\…`;идёт раньшеconst space = this._spaceModel(); if (!space) return nothing;, и второй returnматематически недостижим, когдаmodel.length > 0`) видимый результат при непустом плане не меняется; для пустого плана экран тот же самый JSX-блок, что и раньше диффа (не тронут). Пиксельного риска нет, golden не запускался осознанно, а не пропущен по умолчанию.
  • python -m pytest tests_backend — не тронут ни один файл custom_components/**/*.py, бэкенд не запускался.
  • Performance-профили — ни в AC, ни в диффе не затронут ни один hot render path кроме добавления O(1)-проверок в начале функций и одного one-shot querySelectorAll('*'), срабатывающего не на каждый рендер, а один раз на реальный переход в пустой план (проверено чтением _syncEmptySpaceState/willUpdate). Не прогонялись.
  • Оставшиеся ~120 смоков вне таблицы (isometric-live-touch и т.п. поверхности, которые дифф не трогает напрямую) — не прогонялись; сознательное решение, а не молчаливый пропуск.

3. Проверка AC (docs/specs/113-optional-space-model.md §10)

AC Доказательство Статус
AC1 optional тип у _spaceModel/exact варианта npx tsc --noEmit зелёный при сигнатуре SpaceModel | undefined; test/optional-space-model-contract.test.mjs проверяет regex-ом наличие точной сигнатуры обоих методов доказано unit-тестом, тест умеет падать (см. §4)
AC2 все production call sites без non-null assertions тот же contract-test: assert.doesNotMatch(source, /this\._spaceModel\(\)\s*!/ …) плюс запрет наготовой . без ?./guard доказано unit-тестом, экспериментально подтверждена ловля регрессии (см. §4)
AC3 empty render/update/resize/theme/WS не бросают исключений smoke_optional_space_model.mjs: deleteLastRendersEmpty, wsEmptyAbortsLiveGesture, emptySurvivesThemeResizeReadonly — все true, без падения страницы доказано браузерным smoke (исполнение)
AC4 удаление последнего space оставляет рабочий empty-state deleteLastRendersEmpty, createFlowSurvivesEmpty доказано smoke
AC5 pending pointer/drag/editor action abort без history/persist/service call deleteLastAbortsEditorState, deleteLastCancelsPendingWrite; проверено чтением, что _drag/_pointers очищаются до какого-либо pointerup-коммита (коммит живёт в up-хендлерах, которые физически не вызываются после очистки состояния) смок + чтение кода up-хендлеров
AC6 read-only cold start с spaces: [] стабилен emptySurvivesThemeResizeReadonly (в smoke_optional_space_model.mjs, _serverCanWrite=false) + smoke_readonly_cold_start.mjs (непустой read-only путь, не регрессировал) доказано smoke
AC7 non-empty View/editors — pixels/actions без изменений логически недостижимый guard (см. §2) + 10 регрессионных smoke по тронутым поверхностям, все зелёные проверено чтением + smoke, golden сознательно не прогонялся (см. §2)
AC8 missing explicit stale id не мутирует первый space test/space-model-selection.test.mjs: selectSpaceModelById(spaces, 'stale') === undefined; optional-space-model-contract.test.mjs проверяет, что _livePos, _vacPlanRoomAnchors, _vacStartFit, _labelMove, _rlResizeMove используют _spaceModelById; лично сверено чтением, что все явные call sites (_livePos, _vacPlanRoomAnchors, _vacStartFit, _labelMove, _rlResizeMove, _saveMarker's targetSpaceModel) действительно вызывают _spaceModelById, а не _spaceModel(id) (метод с параметром id больше не существует) доказано unit-тестом + подтверждено чтением исходника
AC9 active-id fallback при непустой модели сохраняет legacy behavior test/space-model-selection.test.mjs: selectActiveSpaceModel(spaces, 'stale') === spaces[0], selectActiveSpaceModel(spaces, null) === spaces[0] доказано unit-тестом
AC10 type/source gates не пускают прежнюю ложную сигнатуру node scripts/mutation-gate.mjs --id=empty-space-cleanup-disabled → поймано 1 из 1; отдельно вручную подтверждено, что regex контракт-теста ловит намеренно испорченный naked-deref (см. §4) доказано мутационным тестом (исполнение)

4. Дисциплина «тест умеет падать» — что лично проверено исполнением

  • Мутационный гейт: node scripts/mutation-gate.mjs --id=empty-space-cleanup-disabled фактически применяет патч (if (this._emptySpaceStateActive) return; → if (empty) return;), пересобирает и гоняет smoke_optional_space_model.mjs — результат тест покраснел, как обязан. Не «предположительно ловит», а подтверждённая красная реакция.
  • Source-contract regex: собран мутированный в памяти текст (без изменения репозитория) с заменой this._spaceModel()?.bg → this._spaceModel().bg и прогнан ровно тем же regex, что использует optional-space-model-contract.test.mjs (/this\._spaceModel\(\)\s*\./) — совпадение true, то есть тест обязательно упадёт при возврате naked-deref. Проверка выполнена вживую через node -e, не только прочитана.

5. Находки

Нет находок High. Нет находок Medium.

Low-1 (не блокирует, не требует правки — оставлено с записью). _syncEmptySpaceState() не сбрасывает _splitSel, _mergeDialog, _wallDialog при переходе в пустой план (ТЗ §7 перечисляет конкретный список, и эти три в него не входят напрямую). Разобрано чтением: все три рендерятся только внутри ветки render() после if (!model.length) return … и if (!space) return nothing;, то есть при пустом плане не видны; _splitClick/аналоги уже содержат защитный код на случай "roomId не найден в моделе" (строки ~11239–11244), который сработает безопасно, если пользователь пересоздаст план с другими id комнат и продолжит с залипшим _splitSel. Наблюдение косметическое (не входит ни в один пользовательский сценарий из ТЗ, не даёт сбоя) — снимаю без issue.

Low-2 (не блокирует). render()'s if (!space) return nothing; (после const space = this._spaceModel(); в непустой ветке) недостижим при model.length > 0 — то же верно и для нескольких других мест диффа (см. §2/AC7). Это защитный код на случай будущего разъединения инварианта «модель непуста ⇒ активный space существует», не дефект; TS не может доказать эту связь сам, так что явная проверка оправдана как type-safety, а не как признак недоделки. Отмечено, правки не требуется.

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

  • _model (геттер) строится через spaceModels(cfg).map(...) 1:1 по cfg.spaces, без фильтрации записей; _renderCfg сохраняет длину spaces (заменяет только контент одного элемента при активном resize-preview). Значит проверка _syncEmptySpaceState на this._serverCfg.spaces.length === 0 и проверка render() на this._model.length === 0 наблюдают одно и то же состояние — не расходятся (прочитано _buildModel, spaceModels, _renderCfg).
  • _syncEmptySpaceState() вызывается из willUpdate() до _captureRenderDeviceSnapshot() на каждом обновлении (не только при смене _serverCfg), но благодаря флагу _emptySpaceStateActive весь путь очистки — не более двух проверок свойств при непустой модели; повторный вход в пустое состояние (после пересоздания и нового WS-опустошения) снова срабатывает — подтверждено вторым циклом внутри самого smoke.
  • Все guard'ы в event-хендлерах редакторов (_physicalDown, _openingClick, _opPointerDown, _physicalRotateDown, _savePhysicalDialog, _commitMerge, _commitRoom, _saveMarker и далее по диффу) расположены до stopPropagation/capturePointer/мутации диалога — соответствует ТЗ §5.2 «guard до side effects». Проверено построчным чтением каждого изменённого метода в диффе, не выборочно.
  • _saveMarker — самая тяжёлая перестройка диффа: targetSpaceModel (через _spaceModelById(explicitSpaceId) либо активный _spaceModel()) вычисляется и проверяется (if (!targetSpaceModel) return;) до this._markerDialog = { ...dlg, busy: true } — то есть до первого наблюдаемого побочного эффекта транзакции. Старый код вместо этого читал this._spaceModel(space || undefined).vb внутри try, что при пустой модели тип обещал безопасным, а исполнение — падало; теперь путь физически не достижим без валидной модели.
  • _isoSource/_isoScene/_frameOf — все места, где сигнатура стала | null, имеют парный if (!x) return … у каждого вызывающего (7 точек использования _isoScene() в файле, проверены все, не выборочно).
  • npx tsc --noEmit зелёный — это, в частности, доказывает, что каждый параметр, куда «доказанный» SpaceModel передаётся вниз по вызовам (§5.4 ТЗ), действительно набран нешироким (не | undefined) типом на входе — компилятор не пропустил бы несовпадение, обходов через any в диффе не найдено при чтении.
  • Коммит 09b5a74 несёт Issue: #113 и User-Visible: no; изменений в docs/CHANGELOG*.md нет и не требуется. docs/ARCHITECTURE.md/docs/TESTING.md обновлены в том же коммите.
  • node scripts/process-gate.mjs --issues зелёный на этом диапазоне.

7. Чего не проверял

  • Полный браузерный набор (135 смоков) и golden:verify — обоснование сужения в §2.
  • pytest tests_backend — класс A/B бэкенда не тронут.
  • Performance-профили — не названы в AC, hot-path не тронут (обоснование в §2).
  • Не проверялось поведение при повреждённой модели с дублирующимися id (явно вне скоупа, ТЗ §3).
  • Не перепроверял Medium-1/#184 по существу — это отдельный issue, не в диапазоне этого код-ревью.

8. Вердикт

Все AC1–AC10 доказаны — либо исполняемым тестом с подтверждённой способностью упасть (unit, browser smoke, mutation gate), либо явной записью «проверено чтением, не исполнением» с указанием конкретных строк/инвариантов. High: 0. Medium: 0. Two Low observations waived with a note, no new issue required.

Вердикт: зелёный · цикл r1/4 · High: 0 · Medium: 0 → нет · Документ: docs/reviews/CODE-REVIEW-113-r1.md