21 KiB
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, мутационный guardempty-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