mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Волна 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
254 lines
13 KiB
Markdown
254 lines
13 KiB
Markdown
# 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 по каждому
|
||
полю.
|