Files
houseplan-card/legacy/specs/113-optional-space-model.md
Claudeandclaude[bot] df46fd1c3e docs(hygiene): ТЗ выпущенных задач без живых ссылок — в legacy/specs (#682)
Волна 5 эпика #674, перенос ТЗ (класс C). Из 240 файлов `docs/specs/` в
`legacy/specs/` уехали 219: на них не ссылается ни один живой файл (код,
тесты, скрипты, workflow, документы вне архива и ревью). Остались 21 ТЗ —
на которые ссылаются код, ADR, ISOMETRIC, SUN, RADAR, LIGHT (`docs/specs/067`),
DECOR-EDITOR, support-relay, и те, на которые ссылаются они сами; README
каталога объясняет, где искать остальное. Открытых issue с файлом ТЗ среди
перенесённых нет. Относительные ссылки перенесённых файлов переписаны
(`../X` → `../../docs/X`, соседние оставшиеся ТЗ → `../../docs/specs/…`) —
все 26 резолвятся. Попутно: битая ссылка в
`089-isometric-view-stage1.md:8` на удалённый `089-isometric-view.md` —
теперь команда `git show` по истории. Строка в `legacy/README.md`.

Issue: #682
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-27 22:10:46 +00:00

254 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 по каждому
полю.