diff --git a/docs/specs/113-optional-space-model.md b/docs/specs/113-optional-space-model.md new file mode 100644 index 00000000..472b19ba --- /dev/null +++ b/docs/specs/113-optional-space-model.md @@ -0,0 +1,253 @@ +# Issue #113 — честный optional-контракт `_spaceModel()` + +- **Issue:** https://github.com/Matysh/houseplan-card/issues/113 +- **Статус документа:** готово к будущей реализации; issue остаётся на `S3-spec` +- **Приоритет:** P2 +- **Тип:** tech-debt, обычный продуктовый трек +- **Пользовательское изменение:** нет; предотвращается повтор класса crash #111 + +## 1. Проблема + +`_spaceModel(id?): SpaceModel` возвращает найденное пространство либо первый +элемент `_model`. При пустом массиве фактический результат — `undefined`, но тип +обещает `SpaceModel`. `strict` не ловит это без `noUncheckedIndexedAccess`. + +#111 закрыл один вызов из render snapshot. Остальные consumers остаются +безопасными только благодаря текущему порядку render/lifecycle. Новый вызов до +empty-state gate может снова получить `Cannot read properties of undefined`. + +## 2. Цели + +1. Типом зафиксировать отсутствие SpaceModel при `spaces: []`. +2. Разобрать каждый production call site по допустимому empty behavior. +3. Не заменять проблему non-null assertions или фиктивной моделью. +4. Сохранить нормальное поведение всех editor/render paths при существующем + пространстве. +5. Расширить regression contract пустого плана за пределы одного snapshot. + +## 3. Не входит в задачу + +- изменение empty-state UX; +- автоматическое создание пространства; +- migration config/layout; +- включение `noUncheckedIndexedAccess` для всего репозитория; +- рефакторинг всех методов `houseplan-card.ts`; +- изменение backend API; +- восстановление повреждённой модели с duplicate ids. + +Глобальный `noUncheckedIndexedAccess` может быть отдельным tech-debt issue после +измерения diff. Он не должен незаметно расширить #113. + +## 4. Нормативный API + +`_spaceModel()` становится честно optional: + +```ts +private _spaceModel(id?: string): SpaceModel | undefined { + const requested = id ?? this._space; + return this._model.find((space) => space.id === requested) ?? this._model[0]; +} +``` + +Допустимо вынести pure `selectSpaceModel(models, activeId, requestedId)` для +unit. Возврат `null` вместо `undefined` допустим только единообразно; публичное +требование — тип не обещает объект. + +Запрещено: + +- возвращать synthetic empty `SpaceModel`; +- ставить `!` у каждого consumer; +- бросать исключение в обычном empty plan lifecycle; +- использовать `this._serverCfg.spaces[0]` как второй скрытый fallback. + +## 5. Классы call sites + +### 5.1 Lifecycle до render gate + +`willUpdate`, `updated`, snapshot capture, theme/paper resolver, frame/viewport, +mode transition, timers, ResizeObserver и WS handlers обязаны принимать +отсутствие модели. + +Нормативное поведение: + +- не строить geometry/devices/openings; +- очистить transient tooltip/hover/drag/dialog state, относящееся к удалённому + пространству; +- не писать config/layout; +- не запускать service call; +- сохранить рабочий empty-state и кнопку создания пространства. + +### 5.2 Event handlers редакторов + +Draw/split/resize/opening/decor/device handlers начинают с локального guard: + +```ts +const space = this._spaceModel(); +if (!space) return; +``` + +Guard расположен до pointer capture, history mutation, optimistic mutation и +persist. Если активный drag потерял пространство, вызывается соответствующий +abort path, а не частичный commit. + +### 5.3 Pure render helpers + +Методы, формирующие TemplateResult/geometry, при отсутствии space возвращают +пустой layer/empty array/fallback frame согласно типу. Они не создают dummy room +или wall. Default parameters вида `space = this._spaceModel()` удаляются, если +их тип больше не гарантирует объект. + +### 5.4 Paths с уже доказанным space + +После единственного локального guard объект передаётся параметром вниз по +вызовам. Не нужно повторять lookup и optional chaining десятки раз. Такой +локальный narrowing предпочтительнее нового throwing `_requireSpaceModel()`. + +Strict helper допускается только в pure internal function, если caller уже +передал `SpaceModel`; метод карточки не должен падать на пользовательском empty +state. + +## 6. Active id и fallback + +Если `_model` непуст, но текущий `_space` отсутствует после adoption новой +конфигурации, сохраняется нынешний fallback на первый space. Lifecycle, который +принимает новую model, затем нормализует active id обычным путём. + +Явный `id`: + +- возвращает exact space при наличии; +- если id отсутствует, fallback на текущий первый элемент сохраняет legacy + semantics только там, где caller намеренно просит display fallback; +- callers, для которых missing explicit id означает stale object, должны + проверять identity отдельно и abort-ить, а не редактировать первый этаж. + +Чтобы исключить опасную двусмысленность, допустимо разделить API: + +- `_spaceModel()` — active-or-first optional; +- `_spaceModelById(id)` — exact optional без fallback. + +Это техническое разделение рекомендуется для drag/history/dialog commands, +содержащих stable `spaceId`. + +## 7. Empty-state cleanup + +При переходе non-empty → empty очищаются или отменяются: + +- `_tip`, `_hoverRoom`, opening/info transient cards; +- active pointer/pan/pinch/drag и pointer capture; +- editor drafts/selections, которые ссылаются на удалённый space; +- projection/room-fit transition текущего space; +- pending persist debounce, если у него нет валидного target. + +Глобальные config dialogs, которые создают новое пространство, остаются +доступны. Cleanup не удаляет server files, layout другого пространства или +пользовательский backup. + +## 8. Type-system guard + +После смены return type `npm run typecheck` обязан заставить обработать все +production consumers. Исправление не считается полным, если ошибки погашены +массовым `?.` без определения поведения. + +Добавляется source-contract test: + +- `_spaceModel` объявлен optional; +- в production source нет `_spaceModel()!` и `this._spaceModel()!`; +- опасные default parameters не возвращают optional как required; +- empty-sensitive lifecycle helpers покрыты executable tests. + +Source test — дополнительный guard, а не замена typecheck/code review. + +## 9. Совместимость, touch и security + +- visible UI с одним/несколькими spaces pixel-identical; +- config/layout schema и revisions не меняются; +- empty View одинаково безопасен на desktop/touch/kiosk/read-only; +- editor touch safety floor не меняется; +- нет новых service calls или permissions; +- удаление последнего space не удаляет файл подложки автоматически. + +## 10. Acceptance criteria + +1. `_spaceModel`/exact variant возвращает optional тип. +2. Все production call sites компилируются без non-null assertions на lookup. +3. Empty render/update/resize/theme/WS cycles не бросают исключений. +4. Удаление последнего space оставляет рабочий empty-state. +5. Pending pointer/drag/editor action при исчезновении space abort-ится без + history/persist/service call. +6. Read-only cold start с `spaces: []` остаётся полным и стабильным. +7. Non-empty View/editors сохраняют текущие pixels/actions. +8. Missing explicit stale space id не мутирует первый space. +9. Active-id fallback при непустой model сохраняет согласованное legacy behavior. +10. Type/source gates не позволяют вернуть прежнюю ложную сигнатуру. + +## 11. План тестирования + +### Unit + +- selector: empty, active match, requested match, missing active, missing exact; +- exact lookup не падает в first-space fallback; +- cleanup state transition non-empty → empty; +- optional geometry helpers возвращают safe empty result. + +### Browser smoke + +- cold load с zero spaces; +- удалить единственное пространство и дождаться нескольких Lit/RAF cycles; +- theme/resize/visibility/registry/WS update после удаления; +- empty → create first space → View/editor usable; +- удалить space во время pointer drag/mode transition; +- read-only empty config; +- no console errors/unhandled promise rejection. + +### Регрессия + +- targeted smoke #111 и #131; +- View/Plan/Devices/Backdrop open-close cycle с одним и несколькими spaces; +- typecheck, full unit и build; +- mutation: вернуть required signature/unguarded lifecycle read — тест красный. + +Golden не требуется при нулевом visual diff. Существующий empty-state screenshot +может использоваться как regression evidence без новой переакцептации. + +## 12. План реализации + +1. Ввести optional active и exact lookup helpers с unit tests. +2. Поменять type и классифицировать compile errors по §5. +3. Исправить lifecycle/render paths. +4. Исправить editor handlers и передавать narrowed space вниз. +5. Добавить empty-state cleanup и smoke. +6. Добавить source/mutation contract и прогнать гейты. + +## 13. Документация и release-артефакты + +- changelog не нужен: ожидаемый user-visible behavior не меняется + (`User-Visible: no`); +- `docs/ARCHITECTURE.md` фиксирует optional active-space invariant; +- `docs/TESTING.md` расширяет empty-plan lifecycle matrix; +- user guide и i18n не меняются; +- golden/screenshots не требуются при pixel parity; +- performance gate нужен только если diff затронет hot render helpers; +- backend HA harness не требуется. + +## 14. Риски и откат + +| Риск | Мера | +| --- | --- | +| Optional chaining скрывает частичный mutation | guard до side effects | +| Stale id редактирует первый space | exact lookup для commands | +| Cleanup закрывает Create flow | отдельная global/space-bound state matrix | +| Массовый diff меняет pixels | targeted smoke и existing golden parity | +| Ложный тип возвращается позже | source + mutation guard | + +Откат возможен без data migration, но возвращать required signature допустимо +только вместе с доказанным total/sentinel API, которого #113 не вводит. + +## 15. Принятые технические предположения + +- глобальный `noUncheckedIndexedAccess` остаётся отдельной задачей; +- exact lookup вводится для stable-id commands, active lookup сохраняет legacy + first-space fallback при непустой model; +- empty-state visual design не меняется; +- guards группируются на границах, а не размазываются optional chain по каждому + полю. diff --git a/docs/specs/README.md b/docs/specs/README.md index 254116bb..14c09456 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -86,6 +86,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным | [#94](https://github.com/Matysh/houseplan-card/issues/94) Универсальное действие «Переключить состояние» | [094-universal-state-toggle.md](094-universal-state-toggle.md) | | [#101](https://github.com/Matysh/houseplan-card/issues/101) Плавный переход View ↔ редакторы | [101-view-editor-transition.md](101-view-editor-transition.md) | | [#107](https://github.com/Matysh/houseplan-card/issues/107) Переключение виртуального источника света «Всегда» | [107-virtual-light-toggle.md](107-virtual-light-toggle.md) | +| [#113](https://github.com/Matysh/houseplan-card/issues/113) Optional-контракт SpaceModel | [113-optional-space-model.md](113-optional-space-model.md) | | [#117](https://github.com/Matysh/houseplan-card/issues/117) Registry-less entity у проёма | [117-registryless-opening-entity.md](117-registryless-opening-entity.md) | | [#122](https://github.com/Matysh/houseplan-card/issues/122) Изометрический режим Stage 2: скрытый режим и визуальная полировка | [122-isometric-stage2.md](122-isometric-stage2.md) | | [#123](https://github.com/Matysh/houseplan-card/issues/123) Split из вершины не меняет наружную геометрию стен | [123-corner-split-wall.md](123-corner-split-wall.md) |