docs: specify optional space model contract

Issue: #113
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-08-19 02:47:50 +03:00
parent c1676cf26a
commit 0c2a5dedea
2 changed files with 254 additions and 0 deletions
+253
View File
@@ -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 по каждому
полю.
+1
View File
@@ -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) |