mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 20:29:00 +00:00
218 lines
15 KiB
Markdown
218 lines
15 KiB
Markdown
# Issue #201 — наследование толщины для атомарного участка стены
|
||
|
||
- Дата: 2026-08-19
|
||
- Тип: bug · приоритет P2 · ценность 6/10 · сложность 4/10
|
||
- Issue: [#201](https://github.com/Matysh/houseplan-card/issues/201)
|
||
- Ветка: `issue/201-atomic-thickness-lookup`
|
||
|
||
Канонические документы: `docs/SCOPE.md`, `docs/UX-MODES.md`,
|
||
`docs/WALL-THICKNESS.md`, `docs/TESTING.md`.
|
||
|
||
## 1. Сценарий и персона
|
||
|
||
Администратор редактирует план с частично совпадающими границами трёх комнат.
|
||
На одном физическом пролёте уже задана толщина стены. Администратор закрывает
|
||
виртуальный участок этой границы и ожидает, что восстановленная кладка получит
|
||
толщину ближайшего сплошного продолжения, как и обещает текущий инструмент.
|
||
|
||
Это часть J4/J6: редактирование не должно незаметно заменять сохранённую
|
||
физическую характеристику значением по умолчанию.
|
||
|
||
## 2. Что человек увидит до и после
|
||
|
||
**До:** если соседний сплошной участок является атомарной частью более длинной
|
||
wall-записи, Close не узнаёт его толщину и создаёт восстановленный участок с
|
||
default 15 см. На стене 20/22 см появляется непреднамеренная ступень.
|
||
|
||
**После:** Close наследует фактическую толщину покрывающего точного wall-run.
|
||
Default 15 см используется только тогда, когда ни один подходящий сосед
|
||
действительно не имеет толщины.
|
||
|
||
## 3. Подтверждённая проблема и причина
|
||
|
||
На production-scale fixture из #197 сохранённая запись 20 см точно покрывает
|
||
пролёт `[620.83,550]–[887.5,550]`. Третья комната делит его на два атомарных
|
||
ребёнка:
|
||
|
||
- `[620.83,550]–[691.67,550]`;
|
||
- `[691.67,550]–[887.5,550]`.
|
||
|
||
На текущем `dev` `wallIntervals()` правильно разрешает обоих детей в 20 см:
|
||
`cmsForPoly()` уже умеет искать покрывающий exact span. Но публичный pure-helper
|
||
`thicknessCmAt()` вызывает только `lookupWall()`, рассчитанный на собственный
|
||
key/midpoint одного stretch, и возвращает 0 для обоих детей. Полный parent-run
|
||
тем же вызовом разрешается в 20 см.
|
||
|
||
Единственный продуктовый consumer `thicknessCmAt()` — `thicknessOnClose()` в
|
||
`src/open-spans.ts`. Он перебирает соседние `solidEdges`; нулевой ответ исключает
|
||
реального соседа и приводит к `DRAW_WALL_DEFAULT_CM`.
|
||
|
||
Исходное предположение issue о нулевой толщине в общем render-path устарело:
|
||
render использует `wallIntervals()` и на текущем коде сохраняет 20 см. Баг
|
||
ограничен чтением exact parent-run при закрытии виртуальной границы.
|
||
|
||
## 4. Scope
|
||
|
||
- научить `thicknessCmAt()` читать наиболее узкий exact stored span, который
|
||
коллинеарен и полностью покрывает запрошенный дочерний сегмент;
|
||
- сохранить прямой key/tolerant lookup как первый и совместимый путь;
|
||
- обеспечить правильное наследование в `thicknessOnClose()` и
|
||
`applyThicknessOnClose()`;
|
||
- покрыть normalized и production coordinate scales;
|
||
- добавить regression unit, browser smoke, mutation gate, тестовый контракт и
|
||
пользовательские changelog.
|
||
|
||
## 5. Non-scope
|
||
|
||
- изменение `wallIntervals()`, `cmsForPoly()`, body geometry или рендера;
|
||
- очистка уже сохранённых коротких интервалов (#198);
|
||
- изменение выбора ближайшего коллинеарного соседа в `thicknessOnClose()`;
|
||
- изменение default 15 см, UI Close, snapping, open-span ownership или Undo;
|
||
- расширение legacy key-only записи на произвольный покрывающий parent;
|
||
- schema/backend/API, миграция, Optimize и переписывание config при чтении;
|
||
- общая замена midpoint/key модели wall entries.
|
||
|
||
## 6. Контракт разрешения толщины
|
||
|
||
### 6.1. Порядок поиска
|
||
|
||
Для query `[a,b]` функция `thicknessCmAt(walls,a,b,pitch,coordScale)`:
|
||
|
||
1. сохраняет текущий результат `lookupWall()` при direct key или его
|
||
совместимом midpoint fallback;
|
||
2. если direct hit отсутствует, рассматривает только записи с валидными
|
||
конечными точками `a/b`, положительной толщиной и совпадающим направлением;
|
||
3. принимает запись только тогда, когда обе query endpoints лежат на её
|
||
конечном отрезке в действующем scale-relative tolerance, то есть stored span
|
||
покрывает query целиком;
|
||
4. при нескольких кандидатах выбирает наиболее узкий покрывающий span; tie
|
||
разрешается детерминированно и независимо от порядка входного массива;
|
||
5. если кандидата нет, возвращает 0 как сейчас.
|
||
|
||
### 6.2. Запрет утечки толщины
|
||
|
||
Exact запись, покрывающая только часть query, не подходит, даже если направления
|
||
и midpoint близки. `[0..4]` не может дать толщину запросу `[0..10]`; тем самым
|
||
сохраняется защита AUD-159B6-01. Параллельный сосед на другой линии,
|
||
перпендикулярный участок, zero-length/malformed endpoints и `cm <= 0` также не
|
||
подходят.
|
||
|
||
Legacy `{key,cm}` без exact endpoints продолжает работать только по нынешнему
|
||
direct/midpoint контракту: новая логика не может доказать его покрытие.
|
||
|
||
### 6.3. Close
|
||
|
||
`thicknessOnClose()` не меняет сортировку кандидатов: среди коллинеарных
|
||
`solidEdges` побеждает ближайший к закрываемому span участок с разрешённым
|
||
`cm > 0`. Исправление меняет только ложный `0` на фактическую толщину exact
|
||
parent-run. Default применяется лишь при отсутствии любого такого соседа.
|
||
|
||
`applyThicknessOnClose()` записывает выбранную толщину существующим
|
||
`setWallThickness()`; последующая normalisation, preview/apply и Undo не
|
||
получают отдельной ветки поведения.
|
||
|
||
## 7. Данные, миграция и совместимость
|
||
|
||
Формат `walls`, ключи и exact endpoints не меняются. Миграции нет. Существующие
|
||
планы не переписываются до явного Close. Уже созданные ошибочные 15-см участки
|
||
остаются данными пользователя и относятся к #198.
|
||
|
||
Downgrade возвращает неверное наследование только при следующем Close, не
|
||
повреждая уже сохранённый план. Backend validation и старые key-only планы
|
||
сохраняют текущую совместимость.
|
||
|
||
## 8. UX, i18n, accessibility и touch
|
||
|
||
Новых элементов, строк, уведомлений и жестов нет. Исправляется результат уже
|
||
существующего действия. Keyboard/touch policy, focus и editor affordance не
|
||
меняются.
|
||
|
||
## 9. Acceptance criteria и доказательства
|
||
|
||
| AC | Критерий | Обязательное доказательство |
|
||
|---|---|---|
|
||
| AC1 | Exact parent `[0..10]` толщиной 20 см даёт 20 для атомарных queries `[0..4]` и `[4..10]`. | Table-driven unit `wall-thickness.test.mjs`. |
|
||
| AC2 | AC1 одинаков при `coordScale = 1` и `coordScale = NORM_W`; направление endpoints можно развернуть. | Unit scale/direction matrix. |
|
||
| AC3 | Exact partial `[0..4]` не даёт толщину query `[0..10]`; параллельный offset, perpendicular и malformed rows не совпадают. | Negative unit matrix. |
|
||
| AC4 | Из нескольких покрывающих exact spans выбирается наиболее узкий; перестановка rows не меняет ответ. | Unit permutation test. |
|
||
| AC5 | Legacy key-only direct/midpoint lookup сохраняет текущий результат. | Existing + focused regression unit. |
|
||
| AC6 | `thicknessOnClose()` на частично расщеплённом parent-run наследует 20/22 см, а без толстого соседа сохраняет default 15 см. | `open-spans.test.mjs`. |
|
||
| AC7 | Реальное Close в Plan на трёхкомнатном partial-overlap fixture сохраняет восстановленный span с соседним cm, без console/page errors; Undo восстанавливает virtual span. | Расширенный targeted browser smoke. |
|
||
| AC8 | Render-path полного fixture до/после не меняется вне закрываемого span. | Existing wall/open-span units и targeted smoke. |
|
||
| AC9 | Пользовательский и тестовый контракты обновлены. | Оба changelog + `docs/TESTING.md`. |
|
||
| AC10 | Regression доказана исполняемым мутантом. | `mutation-gate --check`, clean green / mutant red. |
|
||
| AC11 | Рабочие gates зелёные. | typecheck, unit, build, targeted smoke. |
|
||
|
||
## 10. План автотестов
|
||
|
||
### 10.1. Unit
|
||
|
||
В `test/wall-thickness.test.mjs` добавить матрицу exact containment: child,
|
||
reversed, scale 1/1000, nested candidates, row permutation и negative cases.
|
||
Тест обязан быть красным на текущем `dev` именно потому, что child возвращает 0.
|
||
|
||
В `test/open-spans.test.mjs` построить parent-run с атомарными `solidEdges` и
|
||
проверить, что Close выбирает parent cm. Отдельно сохранить default-case.
|
||
|
||
### 10.2. Browser smoke
|
||
|
||
Расширить ближайший по fixture smoke (`smoke_resize_virtual_thick.mjs` либо
|
||
выделенный targeted smoke): три комнаты создают частичный breakpoint на одной
|
||
линии, более длинный exact wall-run имеет не-default толщину, средний virtual
|
||
span закрывается UI-действием. Проверить persisted cm, визуальное отсутствие
|
||
15-см ступени, Undo и отсутствие ошибок. Выбор конкретного файла — техническое
|
||
решение реализации.
|
||
|
||
Golden не обязателен: видимая форма уже покрывается числовой browser-проверкой,
|
||
а общий wall renderer не меняется. Если реализация добавит новую визуальную
|
||
сцену, её baseline принимается только перед бетой.
|
||
|
||
### 10.3. Mutation gate
|
||
|
||
Добавить entry `atomic-child-thickness-parent-fallback`. Мутант отключает новый
|
||
exact-parent fallback в `thicknessCmAt()` и возвращает прежний direct-only
|
||
результат. Guard — focused unit `wall-thickness` + `open-spans`; на чистом коде
|
||
green, на мутанте non-zero.
|
||
|
||
## 11. Риски и меры
|
||
|
||
| Риск | Мера |
|
||
|---|---|
|
||
| Частичная запись снова протекает на весь parent. | Полное покрытие обеих query endpoints и AC3. |
|
||
| Overlapping exact rows дают order-dependent cm. | Наиболее узкий span + stable tie, permutation AC4. |
|
||
| Scale tolerance работает только в normalized units. | AC2 на `coordScale = 1/NORM_W`. |
|
||
| Исправление меняет общий renderer. | Render не использует новый fallback; AC8 и non-scope. |
|
||
| Поиск по walls дорог. | Он выполняется только при editor Close; линейный проход сопоставим с текущим lookup и не находится в render tick. |
|
||
|
||
Security/privacy boundary не меняется.
|
||
|
||
## 12. Rollback
|
||
|
||
Frontend pure-правка откатывается вместе с тестами и документацией одним
|
||
коммитом. Данные и schema не требуют обратной миграции. Уже выполненный Close
|
||
остаётся валидной wall-записью с выбранной толщиной.
|
||
|
||
## 13. Release-артефакты
|
||
|
||
- записи в `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` в implementation-коммите
|
||
с `User-Visible: yes`;
|
||
- обновление `docs/TESTING.md`;
|
||
- unit, targeted browser smoke и mutation entry;
|
||
- синхронные bundle snapshots;
|
||
- отдельные backend, i18n, user-guide, golden, performance и migration
|
||
artifacts не нужны;
|
||
- полные smoke/golden/performance остаются предрелизным гейтом.
|
||
|
||
## 14. Принятые предположения
|
||
|
||
Принято предположительно, поменять свободно при ревью:
|
||
|
||
1. Fallback реализуется внутри `thicknessCmAt()`, а `lookupWall()` сохраняет
|
||
узкий контракт «один key — один stretch».
|
||
2. Для containment используется уже действующий scale-relative wall lookup
|
||
tolerance, без нового пользовательского порога.
|
||
3. Stable tie не меняет persisted rows; он только делает чтение независимым от
|
||
порядка.
|
||
4. Targeted browser-сценарий расширяет ближайший существующий smoke, если это
|
||
не ухудшит его читаемость; иначе создаётся отдельный файл.
|