mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 11:49:16 +00:00
685 lines
43 KiB
Markdown
685 lines
43 KiB
Markdown
# #157 — тип проёма «Открытый проём»
|
||
|
||
- Issue: [#157](https://github.com/Matysh/houseplan-card/issues/157)
|
||
- Приоритет: P2
|
||
- Ветка: `issue/157-open-passage`
|
||
- Статус документа: замечания первого раунда review учтены; issue остаётся в
|
||
`S3-spec` по прямому указанию владельца
|
||
- Основание: issue владельца от 2026-08-14, аналитика от 2026-08-15 и review от
|
||
2026-08-17
|
||
|
||
## 1. Пользовательская проблема и результат
|
||
|
||
На реальном плане часто есть арка или обычный открытый дверной проём: стена
|
||
сохраняется по сторонам, но полотна двери и связанных с ним устройств нет.
|
||
Сейчас такой объект приходится изображать дверью либо виртуально открытым
|
||
участком стены. Дверь добавляет ложную створку и предлагает датчик/замок, а
|
||
виртуальный участок означает отсутствие кладки и отмечается пунктиром в
|
||
редакторе.
|
||
|
||
После #157 пользователь получает четвёртый тип обычного проёма —
|
||
`passage` / «Открытый проём». Он:
|
||
|
||
- вырезает реальный участок кладки;
|
||
- не рисует створку, дугу, пунктир или самостоятельную рамку;
|
||
- продолжает пол через существующую геометрию opening tunnel;
|
||
- всегда пропускает свет между двумя распознанными комнатами;
|
||
- не имеет датчика, замка и параметров створки;
|
||
- доступен в существующем потоке размещения и редактирования проёмов.
|
||
|
||
## 2. Подтверждённая техническая база
|
||
|
||
На момент подготовки ТЗ:
|
||
|
||
- `OpeningCfg.type` и backend schema знают только `door`, `window`, `gate`;
|
||
- Full card уже вырезает из wall body все сохранённые opening geometry и рисует
|
||
room-coloured tunnel fill;
|
||
- `renderOpeningVisibleGeometry()` имеет fallback-ветку двери, поэтому простого
|
||
расширения union недостаточно: `passage` ошибочно получил бы дверную створку;
|
||
- Plan сохраняет невидимый hitbox и hover outline отдельно от видимого символа;
|
||
этот механизм можно переиспользовать для выбора `passage`;
|
||
- свет уже считает `door`/`gate` внутренним проходом только при наличии пола с
|
||
обеих сторон и оставляет внешний проём непрозрачным;
|
||
- скрытая изометрия вырезает opening geometry из стены отдельно от построения
|
||
полотен, но fallback в `buildIsoOpeningBasis()` также превращает неизвестный
|
||
тип в дверь;
|
||
- Static card сейчас не рисует opening symbols и не подаёт opening cuts в свою
|
||
wall geometry;
|
||
- `CONFIG_SCHEMA` сохраняет неизвестные sibling-поля opening благодаря
|
||
`extra=vol.ALLOW_EXTRA`, а marker validators уже задают принятый образец
|
||
change-aware проверки «старое битое можно прочитать и пронести без изменения,
|
||
новое битое записать нельзя»;
|
||
- full-card и static-card snapshots сейчас подписываются на `contact`/`lock`
|
||
каждого проёма без проверки типа.
|
||
|
||
## 3. Нормативные продуктовые решения
|
||
|
||
1. Сохраняемый literal нового типа — только `passage`.
|
||
2. Пользовательское название:
|
||
- RU: `Открытый проём`;
|
||
- EN: `Open passage`.
|
||
3. Новый проём размещается тем же инструментом, на тех же физических стенах и
|
||
с теми же правилами snap/центрирования/измерений, что дверь.
|
||
4. Default ширины нового проёма — **90 см**, как у двери.
|
||
5. В покое `passage` не имеет собственного архитектурного символа. Видим только
|
||
физический разрыв кладки и пол внутри него.
|
||
6. В Plan допускается временный editor chrome: hover/selection outline, hitbox,
|
||
центральная точка preview и размерные подсказки. Это не часть итогового
|
||
условного обозначения и не показывается в View/Static.
|
||
7. `contact`, `lock`, `invert`, `flip_h`, `flip_v` для `passage` неприменимы.
|
||
8. При переходе существующего проёма в `passage` сохранение удаляет эти пять
|
||
известных полей. Если были непустые `contact` или `lock`, до сохранения
|
||
показывается явное предупреждение.
|
||
9. Обратный переход из сохранённого `passage` не восстанавливает удалённые
|
||
привязки или флаги.
|
||
10. Для света `passage` прозрачен только когда проба по обе стороны стены
|
||
попадает на пол комнаты. Внешний либо неопределённый край остаётся
|
||
fail-dark.
|
||
Любой неизвестный будущий тип проёма также считается непрозрачным: переход
|
||
от прежнего неявного `type !== 'window'` к явному allowlist — осознанное
|
||
ужесточение forward-совместимости ради защиты от утечки света.
|
||
11. `passage` отличается от open span наличием кладки по сторонам и физических
|
||
откосов. Open span по-прежнему означает отсутствие кладки на всём участке и
|
||
показывается пунктиром только в Plan.
|
||
12. Публичность скрытого изометрического режима не меняется.
|
||
|
||
## 4. Scope
|
||
|
||
### 4.1 Модель и backend
|
||
|
||
- расширить TypeScript union и backend enum литералом `passage`;
|
||
- добавить change-aware semantic validator неприменимых полей;
|
||
- подключить его ко всем путям записи и импорта;
|
||
- сохранить lossless round-trip незатронутых legacy/future данных.
|
||
|
||
### 4.2 Plan editor
|
||
|
||
- добавить `passage` в submenu инструмента проёмов;
|
||
- добавить четвёртый radio-вариант в диалог;
|
||
- применить default 90 см;
|
||
- скрывать binding/flip/invert controls;
|
||
- показывать предупреждение о снятии существующих привязок;
|
||
- корректно создавать, выбирать, перетаскивать, менять тип и удалять объект;
|
||
- сохранять обычный geometry history/undo contract.
|
||
|
||
### 4.3 Рендер и свет
|
||
|
||
- Full 2D Plan/View;
|
||
- Static card;
|
||
- скрытая изометрия;
|
||
- общий wall cut и opening tunnel fill;
|
||
- Glow/light barrier geometry;
|
||
- исключение stale binding-полей `passage` из runtime подписок и UI.
|
||
|
||
### 4.4 Совместимость и документация
|
||
|
||
- full/space export-import;
|
||
- downgrade note;
|
||
- RU/EN i18n;
|
||
- пользовательское руководство, LIGHT, ISOMETRIC и CONFIG-COMPATIBILITY;
|
||
- unit/backend tests, smoke и golden evidence.
|
||
|
||
## 5. Non-scope
|
||
|
||
- моделирование криволинейной арки, верхней перемычки или высоты проёма;
|
||
- выбор формы проёма, материала откосов или декоративной обналички;
|
||
- датчик присутствия либо иной entity binding для `passage`;
|
||
- изменение прозрачности по состоянию сущности;
|
||
- публичное включение изометрии;
|
||
- общая parity-задача по отображению `door`/`window`/`gate` в Static card;
|
||
- автоматическое преобразование существующих дверей или open spans;
|
||
- изменение формата open spans;
|
||
- изменение существующей семантики дверей, окон и ворот.
|
||
|
||
В этой задаче «арка» — пользовательское название открытого проёма. Геометрически
|
||
это прямоугольный полноразмерный разрыв стены в текущей 2.5D-модели.
|
||
|
||
## 6. Контракт данных
|
||
|
||
### 6.1 TypeScript
|
||
|
||
```ts
|
||
export interface OpeningCfg {
|
||
id: string;
|
||
type: 'door' | 'window' | 'gate' | 'passage';
|
||
x: number;
|
||
y: number;
|
||
angle: number;
|
||
length: number;
|
||
contact?: string | null;
|
||
lock?: string | null;
|
||
invert?: boolean;
|
||
flip_h?: boolean;
|
||
flip_v?: boolean;
|
||
}
|
||
```
|
||
|
||
Общие optional-поля остаются в интерфейсе ради совместимости чтения. Для
|
||
канонической записи `type: 'passage'` допустимы только `id`, `type`, `x`, `y`,
|
||
`angle`, `length` и неизвестные текущей версии extension-поля.
|
||
|
||
Пример канонической записи:
|
||
|
||
```json
|
||
{
|
||
"id": "o-passage-hall",
|
||
"type": "passage",
|
||
"x": 0.417,
|
||
"y": 0.286,
|
||
"angle": 90,
|
||
"length": 0.09
|
||
}
|
||
```
|
||
|
||
### 6.2 Defaults и ограничения
|
||
|
||
- `lengthCm = 90` при новом размещении;
|
||
- существующие ограничения диалога `20..600 см`, step 5 см сохраняются;
|
||
- `x`, `y`, `angle`, `length`, id generation и нормализация не меняются;
|
||
- версия модели и migration step не добавляются;
|
||
- существующие три литерала и их сериализация не меняются.
|
||
|
||
### 6.3 Канонизация из UI
|
||
|
||
При сохранении `passage` frontend:
|
||
|
||
1. записывает актуальные id/type/geometry;
|
||
2. удаляет `contact`, `lock`, `invert`, `flip_h`, `flip_v`, а не записывает
|
||
`null`/`false`;
|
||
3. при редактировании существующего opening сохраняет неизвестные sibling-поля;
|
||
4. не меняет другие openings и их порядок.
|
||
|
||
Явное редактирование `passage` может канонизировать перечисленные известные
|
||
неприменимые поля. Несвязанное сохранение не должно переписывать объект.
|
||
|
||
## 7. Backend validation и broken-read contract
|
||
|
||
### 7.1 Schema
|
||
|
||
Inline opening schema в `validation.py` принимает четвёртый enum literal. Поля
|
||
`contact`, `lock`, `invert`, `flip_h`, `flip_v` остаются syntactically readable,
|
||
чтобы загрузка и round-trip старых/будущих данных не ломались до semantic stage.
|
||
|
||
### 7.2 Semantic validator
|
||
|
||
Добавляется чистая функция, например:
|
||
|
||
```py
|
||
validate_opening_passages(
|
||
config: dict,
|
||
previous: dict | None = None,
|
||
*,
|
||
validate_all: bool = False,
|
||
) -> None
|
||
```
|
||
|
||
Она сопоставляет пространства по `space.id`, openings внутри пространства по
|
||
`opening.id` и применяет правила:
|
||
|
||
| Ситуация | Результат |
|
||
|---|---|
|
||
| Новый `passage` без запрещённых ключей | принять |
|
||
| Смена другого типа на `passage`, запрещённый ключ остался | отклонить |
|
||
| В существующем `passage` добавлен или изменён любой запрещённый ключ | отклонить |
|
||
| Неизменённый некорректный `passage` проходит с несвязанной правкой | принять без переписывания |
|
||
| Из некорректного `passage` удалены запрещённые ключи | принять |
|
||
| Full/space import содержит любой запрещённый ключ у `passage` | отклонить |
|
||
|
||
Запрещённым считается само наличие ключа, включая `contact: null` и
|
||
`flip_h: false`: каноническая новая запись не содержит неприменимых полей.
|
||
|
||
Если старый объект имел другой `type`, смена контекста на `passage` считается
|
||
новой семантикой и проверяется полностью, даже когда значение binding-поля
|
||
текстово не изменилось.
|
||
|
||
Стабильный public error code: `invalid_passage_fields`. Технический payload
|
||
содержит id пространства, id проёма и отсортированный список запрещённых
|
||
полей, но не значения entity ids. Frontend локализует ошибку человекочитаемо:
|
||
`У открытого проёма в комнате «<название>» есть недопустимые параметры: …` /
|
||
`The open passage in room “<name>” has unsupported settings: …`. Если название
|
||
комнаты отсутствует, используется id пространства; id проёма остаётся в
|
||
технических деталях, а не является единственным ориентиром в пользовательском
|
||
сообщении.
|
||
|
||
### 7.3 Точки подключения
|
||
|
||
Одна и та же проверка обязательна для:
|
||
|
||
- `houseplan/config/set` с предыдущей конфигурацией;
|
||
- `houseplan/plan/optimize` с предыдущей конфигурацией;
|
||
- merge-import с предыдущей конфигурацией;
|
||
- replace/full import с `validate_all=True`;
|
||
- space import с `validate_all=True` для входящего/перенумерованного content.
|
||
|
||
Ошибка преобразуется в существующий websocket/import error contract; partial
|
||
write запрещён.
|
||
|
||
## 8. UX редактора
|
||
|
||
### 8.1 Палитра и размещение
|
||
|
||
В submenu «Проём» добавляется пункт:
|
||
|
||
- label: `Открытый проём` / `Open passage`;
|
||
- icon: `mdi:arch`;
|
||
- id/type: `passage`;
|
||
- default: 90 см, `flipH=false`, `flipV=false`.
|
||
|
||
Порядок пунктов: окно, дверь, открытый проём, ворота. Hover/click resolver,
|
||
physical-wall eligibility, запрет на open span, center magnet, shoulder rulers и
|
||
commit flow полностью общие с остальными openings.
|
||
|
||
Preview нового типа не рисует дверной symbol. Он показывает только существующие
|
||
временные элементы размещения: центральную точку, размерные подписи и нейтральный
|
||
selection footprint. Пунктир open span и створка не используются.
|
||
|
||
### 8.2 Диалог
|
||
|
||
Диалог содержит четвёртый radio option. При `type === 'passage'`:
|
||
|
||
- поле ширины остаётся;
|
||
- contact selector скрыт;
|
||
- invert скрыт;
|
||
- lock selector скрыт;
|
||
- flip horizontal и flip vertical скрыты;
|
||
- icon диалога — `mdi:arch`;
|
||
- Save и Delete работают как для остальных openings.
|
||
|
||
Если draft содержит непустой `contact` или `lock`, после выбора `passage`
|
||
показывается видимый inline warning:
|
||
|
||
- RU: `При сохранении датчик открытия и замок будут удалены.`;
|
||
- EN: `Saving will remove the open/close sensor and lock.`
|
||
|
||
Текст может грамматически перечислять только реально заполненные поля, но смысл
|
||
и наличие предупреждения обязательны. Warning имеет `role="status"` и не
|
||
полагается только на цвет или icon.
|
||
|
||
Переключение radio само по себе не изменяет config. Пока диалог не сохранён,
|
||
скрытые значения остаются в draft: если пользователь вернулся к исходному типу,
|
||
поля снова видны. Save с `passage` удаляет их. Отдельный confirm modal не нужен:
|
||
явное предупреждение в открытом commit-диалоге удовлетворяет продуктовой
|
||
формулировке и не добавляет второй уровень подтверждения.
|
||
|
||
Если прочитан уже сохранённый некорректный `passage` со stale bindings, диалог
|
||
показывает то же предупреждение, а Save канонизирует запись. Cancel ничего не
|
||
меняет.
|
||
|
||
### 8.3 История и взаимодействие
|
||
|
||
- create/edit/type change/delete используют существующие history labels;
|
||
- drag меняет только geometry и остаётся одним history step;
|
||
- undo/redo восстанавливает целиком прежний opening, включая type и bindings;
|
||
- в Plan невидимый opening выбирается существующим hitbox и показывает hover
|
||
outline;
|
||
- в View и Static `passage` inert и не открывает info card;
|
||
- stale `contact`/`lock` у прочитанного `passage` не создают lock badge,
|
||
opening info или live entity subscription.
|
||
|
||
## 9. Нормативная матрица рендера
|
||
|
||
| Surface/state | Кладка | Пол/tunnel | Собственный symbol | Editor chrome |
|
||
|---|---|---|---|---|
|
||
| Full View | разрыв по `length` | есть | нет | нет |
|
||
| Full Plan, покой | разрыв по `length` | есть | нет | только при hover/selection |
|
||
| Full Plan, placement | будущая позиция читается по preview/размерам | preview не коммитит geometry | нет створки/пунктира | да |
|
||
| Static card | разрыв по `length` | есть | нет | нет |
|
||
| Скрытая изометрия | полноразмерный вертикальный разрыв, видимые откосы | существующий floor | нет полотна и его тени | нет |
|
||
|
||
### 9.1 Full 2D
|
||
|
||
- `renderOpeningVisibleGeometry()` обязан иметь явную ветку `passage`,
|
||
возвращающую пустую visible geometry; fallback неизвестного типа не должен
|
||
превращать его в дверь.
|
||
- `openingVisibleMetrics()` продолжает выдавать размеры hitbox/outline по длине
|
||
и толщине стены.
|
||
- wall cut и opening tunnel используют существующий общий pipeline без отдельной
|
||
декоративной рамки.
|
||
- `openingAmount('passage', ...)` всегда возвращает `1` и не читает contact.
|
||
- lock badge разрешён только для `door` и `gate`, а не по условию
|
||
`type !== 'window'`.
|
||
- `hide_openings` скрывает symbols старых типов, но не заделывает физический
|
||
разрыв `passage`.
|
||
|
||
### 9.2 Static card
|
||
|
||
Static должен показать новый тип, но #157 не меняет исторический визуал трёх
|
||
старых типов. Поэтому static wall fingerprint и wall builder получают только
|
||
нормализованные cuts с `type === 'passage'`; door/window/gate по-прежнему не
|
||
добавляют Static symbols/cuts в рамках этой задачи.
|
||
|
||
Для плана без `passage` output Static и cache fingerprint остаются прежними. При
|
||
наличии `passage` canonical wall geometry и tunnel/floor layer должны дать
|
||
чистый разрыв и пол, в том числе при default wall thickness. Никаких HTML
|
||
hit-targets или entity subscriptions для passage Static не создаёт.
|
||
|
||
### 9.3 Изометрия
|
||
|
||
- `IsoOpeningType` расширяется `passage`;
|
||
- structural basis для `passage` содержит пустой список leaves;
|
||
- opening остаётся во входных cuts wall boolean geometry;
|
||
- projection и bounds безопасно обрабатывают пустой basis;
|
||
- не рисуются panel, leaf shadow и live-state animation;
|
||
- откосы являются частью общей extrusion geometry стены;
|
||
- высота/форма арки не моделируются: cut идёт на полную текущую высоту стены.
|
||
|
||
## 10. Свет и Glow
|
||
|
||
Классификация проходов должна стать явной, а не зависеть от условия
|
||
`type !== 'window'`:
|
||
|
||
- `door`, `gate`, `passage` — кандидаты interior passage;
|
||
- `window` — не passage;
|
||
- любой неизвестный тип — opaque/fail-dark и не попадает в cuts, пока новый
|
||
literal не будет явно классифицирован и покрыт тестом;
|
||
- каждый кандидат проходит одинаковую проверку пола по обе стороны;
|
||
- только прошедшие кандидаты вычитаются из light masonry и добавляются в cuts;
|
||
- `passage` не зависит от HA state, `contact` и `invert`, даже если эти поля
|
||
присутствуют в старой битой записи;
|
||
- наружный `passage`, passage у пустой стороны или на нераспознанной геометрии
|
||
остаётся непрозрачным для light;
|
||
- sun/window rays по-прежнему используют только windows;
|
||
- ширина прозрачного участка и Glow footprint совпадают с открытой дверью той
|
||
же geometry при одинаковом floor test.
|
||
|
||
Fingerprint/cache key света должен зависеть от geometry passage так же, как от
|
||
других interior passages. Изменение type door↔passage при неизменной geometry не
|
||
обязано перестраивать barriers, если итоговая классификация та же; correctness
|
||
важнее этой оптимизации.
|
||
|
||
## 11. Import, export и совместимость
|
||
|
||
### 11.1 Текущая версия
|
||
|
||
- full export и space export сохраняют literal `passage` без remap;
|
||
- import remap меняет id пространства/opening по существующим правилам, но не
|
||
type и geometry;
|
||
- valid passage проходит full/space import;
|
||
- passage с запрещённым ключом отклоняется до commit;
|
||
- неизвестные sibling-поля сохраняются backend schema и при несвязанном
|
||
round-trip;
|
||
- оптимизация не удаляет и не превращает passage.
|
||
|
||
### 11.2 Старые конфигурации
|
||
|
||
Конфигурация без `passage` не мигрируется, не переписывается и не меняет
|
||
визуальное или runtime-поведение. Нового top-level capability flag не требуется.
|
||
|
||
### 11.3 Downgrade
|
||
|
||
Перед реализацией текущий pre-feature frontend фактически попадает для
|
||
неизвестного opening type в дверную fallback-ветку. Поэтому ожидаемый
|
||
best-effort downgrade для frontend v1.64.0:
|
||
|
||
- загрузка и рендер не падают;
|
||
- passage может выглядеть как дверь, то есть визуальная точность не гарантируется;
|
||
- старый диалог не знает radio option и не должен считаться поддерживаемым
|
||
редактором этого типа;
|
||
- старый backend со строгим enum может отклонить последующую запись всей
|
||
конфигурации, содержащей `passage`.
|
||
|
||
Эти ограничения явно записываются в `docs/CONFIG-COMPATIBILITY.md`. Реализация
|
||
обязана исполнением загрузить fixture с `passage` в собранный из git tag
|
||
`v1.64.0` bundle через demo harness и подтвердить отсутствие необработанного
|
||
исключения в консоли. Source-аудит не считается достаточным доказательством;
|
||
поддержка редактирования на старой версии не требуется.
|
||
|
||
## 12. I18n, accessibility и touch
|
||
|
||
Минимальный набор новых RU/EN ключей:
|
||
|
||
- `opening.passage`;
|
||
- `opening.passage_binding_warning`;
|
||
- при необходимости отдельный help/description key для отличия от open span.
|
||
|
||
Требования:
|
||
|
||
- RU/EN key parity test проходит;
|
||
- radio имеет обычную label association и доступен с клавиатуры;
|
||
- warning читается screen reader и не кодируется одним цветом;
|
||
- toolbar item получает локализованный accessible label через существующий
|
||
menu contract;
|
||
- новый тип не уменьшает существующие touch targets;
|
||
- невидимый passage hitbox в Plan сохраняет существующий минимум; во View его
|
||
pointer events отключены по общему contract openings.
|
||
|
||
## 13. Performance, security и observability
|
||
|
||
- отдельного runtime animation/state resolver для `passage` нет;
|
||
- stale bindings не попадают в full/static snapshot entity sets;
|
||
- wall, light и iso caches используют существующую geometry/fingerprint
|
||
архитектуру; новый unbounded cache не добавляется;
|
||
- change-aware validator работает линейно по spaces/openings с map по id;
|
||
- error не логирует entity ids и не раскрывает больше существующей config API;
|
||
- права websocket/import не меняются;
|
||
- отдельного benchmark profile не требуется; перед бетой проходит канонический
|
||
performance gate всего продукта.
|
||
|
||
## 14. Acceptance criteria
|
||
|
||
1. В toolbar и opening dialog доступен `Открытый проём` / `Open passage` с
|
||
default 90 см.
|
||
2. Для passage показывается только ширина; contact, lock, invert, flip_h,
|
||
flip_v скрыты и не записываются.
|
||
3. При смене объекта с непустым contact/lock на passage до Save виден явный
|
||
warning; Cancel сохраняет исходник, Save удаляет bindings, обратная смена
|
||
после Save их не восстанавливает.
|
||
4. Full View/Plan показывает чистый физический разрыв стены и продолжение пола,
|
||
без створки, дуги, пунктира или рамки.
|
||
5. Passage остаётся выбираемым, перемещаемым и удаляемым в Plan через временный
|
||
editor chrome, но inert в View/Static.
|
||
6. Static card показывает passage как разрыв с полом; план без passage и старые
|
||
opening types сохраняют прежний Static output.
|
||
7. Скрытая изометрия показывает полноразмерный cut и откосы, без leaf/panel
|
||
shadow и без crash.
|
||
8. Внутренний passage пропускает light/Glow независимо от state; наружный,
|
||
неопределённый passage и неизвестный будущий тип остаются fail-dark.
|
||
9. Backend принимает канонический passage и отклоняет новую/изменённую запись с
|
||
любым из пяти запрещённых ключей кодом `invalid_passage_fields`.
|
||
10. Неизменённый legacy-broken passage проходит несвязанное сохранение; его
|
||
исправление принимается; import валидирует весь входящий content.
|
||
11. Full/space export-import сохраняют type/geometry и обычный id remap; partial
|
||
write при ошибке отсутствует.
|
||
12. Конфиги с door/window/gate не мигрируют и проходят regression suite без
|
||
изменений.
|
||
13. Demo harness с реально собранным bundle `v1.64.0` загружает fixture с
|
||
passage без необработанного исключения в консоли; точный fallback и
|
||
ограничение старого backend задокументированы.
|
||
14. В `scripts/mutation-gate.mjs` зарегистрированы и проходят пять обязательных
|
||
executable mutants из §15.6; `--check` подтверждает уникальность их якорей.
|
||
15. RU/EN parity, unit/backend tests, build, smoke и новые golden evidence
|
||
проходят в предусмотренный процессом момент.
|
||
|
||
## 15. Тест-план
|
||
|
||
### 15.1 TypeScript unit
|
||
|
||
- `OpeningPlacementType`/preset: passage, 90 см, false flips;
|
||
- placement на physical wall, запрет на open span, center magnet и measurements
|
||
не расходятся с door;
|
||
- `openingAmount('passage', null/on/off/unavailable, invert)` всегда 1;
|
||
- `renderOpeningVisibleGeometry(passage)` не содержит leaf/arc/glass/gate path;
|
||
- metrics/hitbox остаются конечными и положительными;
|
||
- full renderer не создаёт lock badge/info/binding subscription для stale
|
||
passage fields;
|
||
- light: две комнаты пропускают, внешний край не пропускает, footprint равен
|
||
door при одинаковой geometry; искусственный тип `xyz` не проходит explicit
|
||
allowlist и остаётся opaque;
|
||
- Static fingerprint/output меняется для passage и не меняется для старых типов;
|
||
- iso basis passage имеет zero leaves, projection/bounds безопасны;
|
||
- RU/EN parity.
|
||
|
||
### 15.2 Backend unit
|
||
|
||
- schema принимает canonical passage и прежние три types;
|
||
- новый/сменивший тип passage с каждым запрещённым ключом, включая null/false,
|
||
отклоняется;
|
||
- одновременный список полей даёт стабильный отсортированный error;
|
||
- неизменённый broken passage + unrelated config edit принимается;
|
||
- изменение geometry при неизменных broken fields принимается как несвязанное с
|
||
этими полями изменение;
|
||
- удаление broken fields принимается;
|
||
- изменение/добавление broken field отклоняется;
|
||
- config/set и optimize вызывают validator;
|
||
- merge, full replace и space import покрыты success/failure/atomicity cases;
|
||
- valid export-import round-trip сохраняет type и geometry.
|
||
|
||
### 15.3 Smoke/manual
|
||
|
||
В реализации, а перед бетой — каноническим smoke gate:
|
||
|
||
1. создать passage через toolbar на общей толстой стене;
|
||
2. убедиться в preview без створки и в default 90 см;
|
||
3. сохранить, hover/select, перетащить, undo/redo, удалить;
|
||
4. создать дверь с contact+lock, выбрать passage, увидеть warning, Cancel;
|
||
5. повторить и Save; убедиться, что bindings удалены и не восстановились;
|
||
6. проверить Full View и Static в light/dark themes;
|
||
7. включить Glow: внутренний проход светится насквозь, наружный — нет;
|
||
8. проверить скрытую изометрию;
|
||
9. full и space export/import;
|
||
10. checkout tag `v1.64.0`, собрать его bundle, загрузить fixture с `passage`
|
||
через demo harness и автоматически подтвердить отсутствие `pageerror` и
|
||
необработанных console errors; приложить команду и лог к evidence.
|
||
|
||
### 15.4 Golden
|
||
|
||
Добавить детерминированную сцену с двумя комнатами, толстой общей стеной,
|
||
внутренним passage и источником Glow:
|
||
|
||
- Full 2D, dark theme;
|
||
- Static, dark theme;
|
||
- скрытая изометрия, dark theme;
|
||
- при необходимости отдельный exterior fail-dark кадр.
|
||
|
||
Новые baselines предпочтительнее переписывания несвязанных эталонов. Любое
|
||
изменение существующего `smoke_opening_*` baseline требует объяснения в PR/code
|
||
review evidence.
|
||
|
||
### 15.5 Команды и момент запуска
|
||
|
||
В цикле реализации:
|
||
|
||
```text
|
||
npm run typecheck
|
||
npm test
|
||
npm run build
|
||
python -m pytest tests_backend
|
||
```
|
||
|
||
Golden, browser smoke и performance выполняются перед бетой согласно release
|
||
runbook. Полный HA harness канонически выполняется в Linux CI; невозможность
|
||
локального Windows `fcntl` не считается заменой CI.
|
||
|
||
### 15.6 Исполняемый мутационный гейт
|
||
|
||
Реализация добавляет в `scripts/mutation-gate.mjs` пять реальных entries. Каждый
|
||
entry содержит стабильный id, патч `find`/`replace` на 2–5 строк с ровно одним
|
||
якорем, указанный guard и причину ожидаемого падения. Это не список для ручной
|
||
проверки: runner обязан применить патч в отдельном worktree, собрать bundle,
|
||
получить non-zero от guard и восстановить чистое состояние.
|
||
|
||
| Mutant id | Обязательный патч | Guard |
|
||
|---|---|---|
|
||
| `passage-visible-geometry-door-fallback` | удалить/обойти явную `passage`-ветку в `renderOpeningVisibleGeometry()`, чтобы снова сработал дверной fallback | `node --test test/opening-symbol.test.mjs` — отрицательный passage-case обязан обнаружить leaf/arc/gate path |
|
||
| `passage-light-classifier-removed` | исключить `passage` из явного allowlist interior passages | `node --test test/light-visibility.test.mjs` — внутренний passage перестаёт пропускать свет |
|
||
| `passage-import-validator-bypassed` | отключить вызов `validate_opening_passages` на import-пути | `node scripts/backend-test-guard.mjs invalid_passage_import` — forged full/space import с запрещённым полем обязан быть отклонён |
|
||
| `passage-static-door-cut-leak` | расширить passage-only Static cuts/fingerprint до `door`, вернув изменение старого плана | `node --test test/space-geometry.test.mjs` — fixed план без passage обязан сохранить прежние geometry и fingerprint |
|
||
| `passage-iso-door-fallback` | вернуть `passage` в дверную fallback-ветку `buildIsoOpeningBasis()` | `node --test test/iso-openings.test.mjs` — passage basis обязан иметь zero leaves |
|
||
|
||
Обязательные команды перед передачей реализации в code review:
|
||
|
||
```text
|
||
node scripts/mutation-gate.mjs --check
|
||
node scripts/mutation-gate.mjs --id=passage-visible-geometry-door-fallback
|
||
node scripts/mutation-gate.mjs --id=passage-light-classifier-removed
|
||
node scripts/mutation-gate.mjs --id=passage-import-validator-bypassed
|
||
node scripts/mutation-gate.mjs --id=passage-static-door-cut-leak
|
||
node scripts/mutation-gate.mjs --id=passage-iso-door-fallback
|
||
```
|
||
|
||
Каждый чистый guard сначала должен быть зелёным, а лог мутанта — содержать его
|
||
id, guard и ожидаемый non-zero. Ручное редактирование без runner или только
|
||
`--list`/`--check` acceptance criterion не выполняет.
|
||
|
||
## 16. План реализации
|
||
|
||
1. Расширить types/schema и добавить чистый semantic validator со всеми call
|
||
sites и backend tests.
|
||
2. Расширить placement preset, toolbar, dialog draft/save canonicalization,
|
||
warning и i18n.
|
||
3. Добавить явные passage branches в 2D symbol/amount/locks/subscriptions.
|
||
4. Подключить passage cut/tunnel к Static без изменения output старых типов.
|
||
5. Сделать light classifier явным и покрыть interior/exterior cases.
|
||
6. Добавить zero-leaf passage basis в скрытой изометрии.
|
||
7. Покрыть import/export и исполняемый downgrade fixture на bundle `v1.64.0`.
|
||
8. Зарегистрировать и исполнить пять mutation entries из §15.6.
|
||
9. Обновить документацию и release artifacts, пройти implementation gates.
|
||
|
||
## 17. Release-артефакты
|
||
|
||
При реализации #157 в том же user-visible commit обязательны:
|
||
|
||
- `docs/CHANGELOG.md`;
|
||
- `docs/CHANGELOG.ru.md`;
|
||
- `docs/USER-GUIDE.ru.md` — таблица passage/open span, поток редактора и явное
|
||
пояснение принятой асимметрии Static: passage разрывает стену, а старые
|
||
door/window/gate в рамках #157 сохраняют прежний Static-визуал;
|
||
- `docs/LIGHT.md` — explicit interior-passage rule;
|
||
- `docs/ISOMETRIC.md` — zero-leaf/full-height-cut поведение скрытого режима;
|
||
- `docs/CONFIG-COMPATIBILITY.md` — enum, broken-read validator и downgrade;
|
||
- при необходимости `docs/TESTING.md` — новая smoke/golden сцена;
|
||
- новые reviewed golden baselines и ссылка на evidence в issue/PR.
|
||
|
||
Коммит продуктового кода должен иметь терминальные trailers:
|
||
|
||
```text
|
||
Issue: #157
|
||
User-Visible: yes
|
||
```
|
||
|
||
## 18. Риски и меры
|
||
|
||
| Риск | Мера |
|
||
|---|---|
|
||
| Fallback рисует passage как дверь | явные branches и отрицательные renderer tests |
|
||
| Невидимый объект нельзя выбрать | сохранить отдельные metrics/hitbox/hover tests |
|
||
| Stale binding продолжает влиять на UI/runtime | type allowlist для locks/info/subscriptions, tests с broken fixture |
|
||
| Строгая схема блокирует несвязанные сохранения | change-aware semantic validator вместо cross-field schema reject |
|
||
| Import обходит правило | подключить validator к обоим import flows и optimize |
|
||
| Static меняет старые планы | fingerprint/cuts только для passage, regression snapshots без passage |
|
||
| Свет утекает наружу | сохранить двусторонний floor probe и exterior test |
|
||
| Новый неизвестный opening type неявно пропускает свет | explicit allowlist, fail-dark contract и unit с типом `xyz` |
|
||
| Iso создаёт дверное полотно | zero-leaf basis и golden |
|
||
| Старый frontend портит новый тип | документировать downgrade как read-only best effort |
|
||
|
||
## 19. Откат
|
||
|
||
Код можно откатить обычным revert, но уже сохранённый literal `passage` станет
|
||
неизвестен старому backend. Поэтому безопасный rollback релиза требует либо
|
||
предварительно вернуть такие openings к `door`/`gate`, либо сохранить frontend и
|
||
backend read support для `passage`, отключив только создание в UI. Автоматически
|
||
превращать passage в дверь при rollback запрещено: это создаёт ложный symbol и
|
||
может предложить небезопасные lock bindings.
|
||
|
||
## 20. Принятые технические предположения
|
||
|
||
Следующие мелкие решения приняты без дополнительного продуктового вопроса и
|
||
могут быть изменены при реализации, если не нарушаются acceptance criteria:
|
||
|
||
- `mdi:arch` не поставляется самим card и разрешается runtime-набором MDI в HA;
|
||
до реализации его наличие проверяется в поддерживаемой минимальной версии HA
|
||
через demo/browser fixture. Если glyph отсутствует, нормативный fallback —
|
||
широко поддерживаемый `mdi:door-open`, без изменения persisted contract;
|
||
- порядок passage в submenu нормативно задан единожды в §8.1;
|
||
- inline warning достаточен, отдельный confirm modal не нужен;
|
||
- draft временно хранит скрытые bindings, чтобы смена radio до Save была
|
||
обратимой;
|
||
- unknown sibling-поля сохраняются при явном edit, запрещённые известные поля
|
||
удаляются;
|
||
- наличие запрещённого ключа, даже с null/false, неканонично для новой записи;
|
||
- Static получает только passage cuts, чтобы не расширять #157 до общей opening
|
||
parity;
|
||
- «арка» в этой версии — полноразмерный прямоугольный cut без кривой перемычки;
|
||
- отдельный performance benchmark не нужен, если общий pre-beta gate не
|
||
показывает регрессию.
|