mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 11:49:16 +00:00
The spec review found the i18n section missing outright — the analysis comment claimed it was untouched, the document itself said nothing, and a DoR section cannot be inferred from a comment. It now says so explicitly, and adds that the absence of i18n files from the diff is part of the contract rather than an accident. The waived Low is closed too: the old wallChainSegments treated a recorded zero as valid while the new contract requires strictly positive values, so zero is now named in the AC1 examples instead of being derivable from the prose. Issue: #234 User-Visible: no
261 lines
20 KiB
Markdown
261 lines
20 KiB
Markdown
# Issue #234 — Толщина отрезка цепочки не расходится между превью и записью
|
||
|
||
- Дата: 2026-08-21
|
||
- Тип: bug · приоритет P2 · ценность 8/10 · сложность/риск 4/10 и 5/10
|
||
- Issue: [#234](https://github.com/Matysh/houseplan-card/issues/234)
|
||
- Ветка: `issue/234-chain-segment-thickness`
|
||
- Статус ТЗ: на ревью
|
||
|
||
Канонические документы: `docs/SCOPE.md`, `docs/WALL-THICKNESS.md`,
|
||
`docs/CANVAS.md`, `docs/CONFIG-COMPATIBILITY.md`, `docs/TOUCH-SUPPORT.md`,
|
||
`docs/USER-GUIDE.ru.md`.
|
||
|
||
## 1. Сценарий и персона
|
||
|
||
Администратор дома рисует на десктопе цепочку стен инструментом «Стены», меняя
|
||
поле толщины между отрезками. Цепочка завершается как независимые перегородки
|
||
либо замыкается в комнату.
|
||
|
||
Иногда сохранённая толщина отрезка оказывается равной 15 см, хотя на экране во
|
||
время рисования была нарисована другая. Обнаруживается это позже и случайно: при
|
||
наведении на комнату контур идёт по 15 см, а инструмент «Толщина» подсвечивает
|
||
15 вместо видимых 30. Ручная правка толщины исправляет запись насовсем.
|
||
|
||
## 2. Что человек увидит до и после
|
||
|
||
**До:** нарисовано 30 см — сохранено 15 см, и об этом ничто не сообщает.
|
||
Расхождение живёт в конфиге, пока пользователь не заметит его глазами.
|
||
|
||
**После:** сохраняется ровно то, что было показано в превью. Если запись
|
||
толщины для отрезка отсутствует (черновик, сохранённый до этого исправления),
|
||
отрезок получает толщину предыдущего отрезка своей цепочки — то есть то, что
|
||
человек видел на экране, — а не глобальные 15 см.
|
||
|
||
Пользователь не видит новых элементов интерфейса: задача про достоверность
|
||
чисел, а не про новый контракт.
|
||
|
||
## 3. Подтверждённый диагноз
|
||
|
||
Расхождение доказано исполнением: `wallChainSegments(path, [30, 30], 15)` на
|
||
цепочке из трёх отрезков даёт `30 | 30 | 15`, а формула превью
|
||
(`houseplan-card.ts:18507`) на тех же данных — `30 | 30 | 30`.
|
||
|
||
Причина: «толщина отрезка i» решается в пяти местах **тремя разными способами**,
|
||
и они расходятся ровно там, где записи нет.
|
||
|
||
| Место | Fallback при отсутствии `cms[i]` |
|
||
|---|---|
|
||
| превью цепочки, `houseplan-card.ts:18507` | текущее поле `drawCm`, иначе 15 |
|
||
| незамкнутая цепочка → перегородки, `:6550` → `wall-face-graph.ts:69` | 15 |
|
||
| замкнутая цепочка → перегородки, `:13002` | 15 |
|
||
| замкнутый контур → комната, `:12618`, `:12645` | `edgeCms[0]` либо поле |
|
||
| `_wallSourceCmAt`, `:12329-12351` | 15 |
|
||
|
||
Пятое место питает подсветку инструмента «Толщина» — тот самый способ, которым
|
||
дефект и обнаруживается.
|
||
|
||
Пропуск в `_draftSegmentCms` возможен потому, что инвариант
|
||
«`_draftSegmentCms.length === _path.length - 1`» нигде не выражен: точка
|
||
добавляется в `_path` вызывающим (`:7259-7260`), а толщина пишется отдельным
|
||
методом `_persistActiveDraftSegment` (`:7376`), который **молча выходит**, если
|
||
`_drawWallCm == null` — состояние «поле толщины в этот момент пусто или вне
|
||
1…100», ровно возникающее при правке толщины на ходу. Дублирующая проверка в
|
||
`_canAppendRoomDraftPoint` (`:7130-7131`) сегодня закрывает нормальный путь, но
|
||
согласованность двух проверок ничем не закреплена, а восстановление черновика из
|
||
persisted `segments` (`:2618`, `:7237`, `:12721`, `:7364`) и из батча граней
|
||
(`:3083`, `:12738`) переносит уже существующий пропуск дальше.
|
||
|
||
## 4. Зафиксированные продуктовые решения
|
||
|
||
1. **Отсутствующая запись наследует толщину предыдущего отрезка** той же
|
||
цепочки; если предыдущего нет — текущее значение поля; если и его нет —
|
||
`DRAW_WALL_DEFAULT_CM`. Решение владельца 2026-08-21: это ближе к тому, что
|
||
человек видел на экране, чем глобальные 15 см, и совпадает с уже
|
||
существующим поведением пути «контур → комната».
|
||
2. **Превью и запись обязаны давать одинаковый вектор толщин** на любых входных
|
||
данных. Это не оптимизация, а определение корректности задачи.
|
||
3. Формат конфигурации не меняется: `partitions[].cm` и
|
||
`room_drafts[].segments[].cm` остаются как есть.
|
||
|
||
## 5. Границы задачи
|
||
|
||
### Входит
|
||
|
||
- единая функция разрешения толщины отрезка цепочки и её применение во всех
|
||
пяти местах из §3;
|
||
- явный инвариант длины `_draftSegmentCms` относительно `_path`;
|
||
- поведение при загрузке черновика с недостающими `segments`;
|
||
- тесты, мутанты, changelog.
|
||
|
||
### Не входит
|
||
|
||
- изменение самого поля толщины и его валидации (диапазон 1…100 остаётся);
|
||
- изменение того, как толщина применяется к общим участкам между комнатами
|
||
(`applyWallThicknessToNewRoom`, `setWallThickness`) — задача про выбор
|
||
значения, а не про его раскладку по интервалам;
|
||
- миграция уже испорченных конфигов: исправление действует на новые записи и на
|
||
чтение черновиков; уже сохранённые перегородки с 15 см правятся инструментом
|
||
«Толщина», как сейчас. Автоматически «дочинить» их нельзя — исходная
|
||
задуманная толщина не сохранена нигде.
|
||
|
||
## 6. Контракт поведения
|
||
|
||
Вводится чистая функция (рабочее имя `chainSegmentCms`) в
|
||
`src/wall-face-graph.ts`:
|
||
|
||
```
|
||
chainSegmentCms(segmentCount, recorded, activeCm, defaultCm) -> number[]
|
||
```
|
||
|
||
- `segmentCount` — число отрезков (`path.length - 1`, для замкнутого контура —
|
||
число вершин);
|
||
- `recorded` — `_draftSegmentCms` как есть, любой длины, с любым мусором;
|
||
- `activeCm` — текущее значение поля толщины либо `null`;
|
||
- `defaultCm` — `DRAW_WALL_DEFAULT_CM`.
|
||
|
||
Правило для каждого индекса `i`:
|
||
|
||
1. `recorded[i]` — число и `> 0` → берётся оно;
|
||
2. иначе — последнее валидное значение среди `recorded[0…i-1]` (то есть
|
||
«предыдущий отрезок»);
|
||
3. иначе — `activeCm`, если он валиден;
|
||
4. иначе — `defaultCm`.
|
||
|
||
Функция **всегда** возвращает массив ровно длины `segmentCount`, каждый элемент
|
||
`> 0`. Она не знает про замыкание контура: значение закрывающего отрезка
|
||
подаётся вызывающим как `recorded[segmentCount - 1]`, если оно известно
|
||
(`_closingWallCm`).
|
||
|
||
`wallChainSegments` перестаёт иметь собственный fallback: она принимает уже
|
||
разрешённый массив толщин.
|
||
|
||
## 7. Инвариант и его нарушение
|
||
|
||
`_draftSegmentCms.length` обязана равняться числу отрезков `_path`.
|
||
|
||
- **Запись становится атомарной:** добавление точки в `_path` и запись её
|
||
толщины выполняются одним методом; отдельный вызов, способный «промолчать»,
|
||
устраняется. Если толщина невалидна, точка не добавляется вовсе — это уже
|
||
проверяет `_canAppendRoomDraftPoint`, и теперь проверка не дублируется, а
|
||
является единственной.
|
||
- **Чтение чинит молча, но не тихо:** при загрузке черновика или батча массив
|
||
приводится к нужной длине через `chainSegmentCms`; факт дозаполнения пишется в
|
||
`console.debug` с id черновика. Пользователю ничего не показывается: он не
|
||
причина расхождения и починить его не может.
|
||
|
||
## 8. Поверхности
|
||
|
||
Plan-редактор (десктоп, мышь): рисование цепочки, замыкание в комнату,
|
||
превращение в перегородки, подсветка инструмента «Толщина». View и киоск не
|
||
затронуты: там ничего не рисуется.
|
||
|
||
`Touch editor: not exposed` — рисование цепочки стен на тач-устройствах не
|
||
поддерживается сегодня и этой задачей не вводится; safety floor
|
||
`docs/TOUCH-SUPPORT.md` соблюдён по построению, поведение на тач не меняется.
|
||
|
||
## 9. Изменяемые файлы и i18n
|
||
|
||
- `src/wall-face-graph.ts` — новая `chainSegmentCms`, `wallChainSegments` без
|
||
собственного fallback;
|
||
- `src/houseplan-card.ts` — пять мест из §3 плюс атомарная запись точки;
|
||
- `test/wall-face-graph.test.mjs` — юниты правила;
|
||
- `test/` — юнит на согласие превью и записи (общая функция, один вход — один
|
||
результат);
|
||
- `demo/smoke_wall_chain_thickness.mjs` — новый смок;
|
||
- `scripts/mutation-gate.mjs` — три записи (§11);
|
||
- `docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md`.
|
||
|
||
**i18n: не затронут.** Ни одной новой пользовательской строки не добавляется и
|
||
ни одна существующая не меняется: задача исправляет значение, которое пишется в
|
||
конфиг, а не текст интерфейса. Файлы `src/i18n/en.json` и `src/i18n/ru.json` в
|
||
диффе отсутствуют, и их отсутствие — часть контракта: появление там правок
|
||
означало бы, что скоуп разъехался.
|
||
|
||
**Миграции и compatibility-полей нет:** формат не меняется (§4.3), поля
|
||
`partitions[].cm` и `room_drafts[].segments[].cm` сохраняют тип и смысл,
|
||
`docs/CONFIG-COMPATIBILITY.md` править не требуется.
|
||
|
||
## 10. Acceptance criteria
|
||
|
||
| AC | Требование | Доказательство |
|
||
|---|---|---|
|
||
| AC1 | `chainSegmentCms` возвращает массив длины `segmentCount`, все элементы `> 0`, для любых входов, включая пустой `recorded`, дырки в середине, мусор (`NaN`, `-5`, `null`) и **явный `0`** — прежняя `wallChainSegments` считала ноль валидным, контракт §6 сдвигает границу на `> 0`, и это должно быть закреплено примером, а не выводиться из текста | unit |
|
||
| AC2 | Отсутствующая запись наследует предыдущий валидный отрезок; при отсутствии предыдущего — `activeCm`; при отсутствии и его — `defaultCm` | unit |
|
||
| AC3 | Превью и запись дают **идентичный** вектор толщин на одних входных данных — проверяется через общую функцию, а не сравнением двух формул | unit |
|
||
| AC4 | Незамкнутая цепочка из трёх отрезков, нарисованных 30 см при пропуске записи последнего, сохраняется как `30, 30, 30`, а не `30, 30, 15` | unit + smoke |
|
||
| AC5 | Замыкание в комнату сохраняет то же распределение толщин, что показывало превью; общие участки с соседней комнатой сохраняют свою толщину | unit |
|
||
| AC6 | Подсветка инструмента «Толщина» (`_wallSourceCmAt`) показывает то же значение, что записано | smoke |
|
||
| AC7 | Черновик с `segments` короче `points - 1` после загрузки даёт полный вектор по правилу §6 и не роняет рендер | unit |
|
||
| AC8 | Инвариант: точка не добавляется в `_path`, если толщина невалидна; после любой последовательности добавлений длины согласованы | unit |
|
||
| AC9 | release-артефакты: обе записи changelog в том же коммите | ревью кода |
|
||
|
||
## 11. Mutation guards
|
||
|
||
| id | Что ломает | Что обязано покраснеть |
|
||
|---|---|---|
|
||
| `chain-thickness-falls-back-to-default` | правило §6 п.2 заменяется на `defaultCm` | AC2, AC4 |
|
||
| `chain-thickness-preview-diverges` | превью снова считает толщину своей формулой | AC3 |
|
||
| `chain-thickness-ignores-invariant` | снимается приведение длины при загрузке черновика | AC7 |
|
||
|
||
Каждый мутант обязан краснеть на **своём** тесте: две формулы разошлись именно
|
||
потому, что ни один тест не сравнивал их между собой.
|
||
|
||
## 12. План автотестов
|
||
|
||
1. Юниты `chainSegmentCms` — таблица входов из AC1/AC2, включая `recorded`
|
||
длиннее `segmentCount` (лишнее отбрасывается).
|
||
2. Юнит согласия: собрать вектор для превью и для записи через общий путь,
|
||
сравнить.
|
||
3. Смок `demo/smoke_wall_chain_thickness.mjs`: нарисовать цепочку из трёх
|
||
отрезков, меняя поле толщины между кликами, завершить как перегородки,
|
||
прочитать `partitions[].cm` из конфига и сверить с нарисованным; отдельная
|
||
проверка — навести инструмент «Толщина» и сверить подсвеченное значение.
|
||
4. Регресс: `demo/smoke_draw_wall_thickness.mjs` и
|
||
`demo/smoke_wall_thickness_transition.mjs` остаются зелёными.
|
||
|
||
## 13. Производительность, безопасность, touch
|
||
|
||
Функция чистая, вызывается на кадр рисования на массиве длиной не больше
|
||
`MAX_DRAFT_POINTS`; влияния на перф нет. Безопасность не затронута. Touch — см.
|
||
§8.
|
||
|
||
## 14. Откат
|
||
|
||
Одна ревизия: вернуть прежние формулы. Данные при откате не страдают —
|
||
сохранённые `cm` остаются валидными числами в существующем формате.
|
||
|
||
## 15. Риски
|
||
|
||
1. **Путь «контур → комната» уже имеет свою логику** (`edgeCms[source] || cm`) и
|
||
раскладывает толщину по атомарным интервалам. Подмена его на общую функцию
|
||
может изменить поведение на общих участках между комнатами. Митигация: AC5 и
|
||
регрессионные смоки; при сомнении — оставить раскладку как есть, заменив
|
||
только выбор значения.
|
||
2. **Замыкающий отрезок** (`_closingWallCm`) участвует в двух путях по-разному.
|
||
Митигация: он подаётся в `recorded` вызывающим, функция про него не знает.
|
||
3. **Старые черновики** могут содержать `segments` с нулями и отрицательными
|
||
значениями. Митигация: правило §6 считает валидным только `> 0`.
|
||
|
||
## 16. Release-артефакты
|
||
|
||
- `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` — запись про сохранение
|
||
нарисованной толщины;
|
||
- `docs/USER-GUIDE.ru.md` — правки не требуются: поведение приводится к тому,
|
||
что документация и так описывает;
|
||
- golden не затронут: визуальное представление не меняется, менялась только
|
||
запись.
|
||
|
||
## 17. Принятые предположения (техническое, менять свободно)
|
||
|
||
1. Имя `chainSegmentCms` и место в `wall-face-graph.ts` — рабочее решение;
|
||
допустимо перенести в `logic.ts`, если так короче цепочка импортов.
|
||
2. Дозаполнение при чтении логируется в `console.debug`; если это сочтут шумом,
|
||
можно убрать — на контракт не влияет.
|
||
3. Приведение длины делается в момент чтения черновика, а не при сохранении:
|
||
правка чужих записей на диске без действия пользователя мне кажется хуже, чем
|
||
починка на входе.
|
||
|
||
**Не является предположением:** согласие превью и записи (§4.2, AC3) и правило
|
||
наследования (§4.1, AC2) — это продуктовые решения, зафиксированные владельцем;
|
||
менять их можно только через issue.
|