Files
houseplan-card/docs/reviews/SPEC-REVIEW-138-r1.md
T
2026-08-14 12:59:09 +00:00

219 lines
20 KiB
Markdown
Raw 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 #138, цикл r1
- **Issue:** https://github.com/Matysh/houseplan-card/issues/138
- **ТЗ:** `docs/specs/138-adjacent-room-autoclose.md`, коммит `611c5a76f56ce83587eafbd00dbd6ad1a0f8c9d8`
- **Этап:** spec (PROCESS.md §2.4)
- **Вердикт:** красный · цикл r1/4 · High: 1 · Medium: 0
## Скоуп ревью
Прочитаны в указанном порядке: `docs/SCOPE.md`, `AGENTS.md`, `PROCESS.md` (§1,
§2, §3, §4, §7, §12), тело issue #138 и оба комментария владельца (аналитика
Q1–Q5 с defaults, подтверждение ТЗ), `docs/USER-GUIDE.ru.md` (терминология
диалога комнаты, кнопка «Оставить замкнутыми стенами», секции про соседние
комнаты и общую стену), и связанный `docs/specs/137-plan-snap-overlay.md` как
основа, на которой строится #138.
Задача не помечена `small`, ТЗ корректно лежит в `docs/specs/138-adjacent-room-autoclose.md`
и зарегистрировано в `docs/specs/README.md:49`. Формат ревью — полный документ,
не комментарий.
## Как проверялось
Ревью читало не только текст ТЗ, но и код, на который оно опирается, чтобы
отличить обоснованное техническое утверждение от догадки, выданной за факт:
- `src/houseplan-card.ts`: `_markupClick()` (6454–6575), `_closeRoomContour()`
(6426–6452), `_resolvePlanDrawPoint()` (6011–6031), `_draftEndAt()`
(6580–6594) — сверка с §3 «Причина дефекта», §7.1 «Порядок разрешения
клика», §8 «Контракт замыкания».
- `src/plan-snap-overlay.ts` (1–180): структура `PlanSnapSegment`,
`sourceKind: 'room'|'draft'|'partition'`, `cutSegments`/`roomEdges`,
дедупликация по `axisKey` — сверка с §7.2 «Допустимый существующий
интервал» и терминологией «room-owned solid interval».
- `src/wall-thickness.ts:461` `applyWallThicknessToNewRoom()` — сверка с §9
(наследование толщины общей стены) и AC7.
- `src/i18n/ru.json:9,267–269` — существующие `btn.keep_as_walls`,
`toast.room_overlap`, `toast.contour_min_edges`, `toast.contour_cannot_close`
— сверка с заявлением «новых i18n-ключей не требуется» (§4.4, §6).
- `docs/specs/README.md` — регистрация spec-файла.
Каждое утверждение ТЗ о текущем поведении, которое удалось проверить чтением
кода, подтвердилось: терминология (`sourceKind`, `_markupClick`,
`_closeRoomContour`, `_resolvePlanDrawPoint`, `applyWallThicknessToNewRoom`,
«Оставить замкнутыми стенами») — не изобретена, а взята из реального кода.
Отдельно оценивалась полнота контракта: не пропущен ли граничный случай,
который спецификация не проговаривает и не относит явно в non-scope.
## Находки
### High-1 — контракт автозамыкания перехватывает и блокирует обычный второй клик по той же стене
**Где:** `docs/specs/138-adjacent-room-autoclose.md` §7.1 (строки 105–119),
§7.2 (121–139), §8.1 (155–173), §8.2 (175–186).
**Формулировка:** §7.1 п.3 требует проверять на автозамыкание **любой**
обычный клик, отличный от клика в собственную первую точку контура — без
условия «в контуре уже есть хотя бы одно нарисованное ребро». §7.2 требует
только геометрических условий (общий room-owned solid interval, содержит `A`
и финальную точку, положительная длина, точки различны) — тоже без условия на
уже накопленное число вершин. §8.1 требует **минимум три различные вершины**
у prospective polygon `[..., P, B] + B—A`, иначе (§8.2) диалог не открывается,
`B`/`P—B`/`B—A` не записываются, **и клик не превращается в обычное
добавление `B`** — то есть точка не появляется в drafting вообще, только
показывается existing validation toast.
Отсюда прямое следствие: если пользователь ставит **первую** точку `A` на
существующем углу (`this._path = [A]`, `houseplan-card.ts:6544`), а **второй**
клик кладёт на другой конец **той же самой** прямой существующей стены (что
является совершенно естественным способом начать соседнюю комнату — сначала
отметить оба конца общей стены, а затем обвести остальной контур), —
`P` на этот момент равен `A` (в пути одна точка), и prospective polygon равен
`[A, B] + B—A`: **две** различные вершины, не три. Проверка §8.1 обязана
провалиться, и по букве §8.2 клик обязан быть отклонён с toast, а `B` —
**не добавлен в draft вообще**.
Сегодня, до этой задачи, тот же клик работает штатно: `_markupClick()` не
делает такой проверки и просто добавляет `B` как обычную точку контура
(`houseplan-card.ts:6573–6574`, путь `this._path = [...this._path, pt];
this._persistActiveDraftSegment();`). Показательно, что для симметричного
случая — клика в собственную первую точку — система уже сегодня специально
избегает этой ловушки: строка `houseplan-card.ts:6522`
(`const closing = this._path.length >= 3 && this._samePt(pt, this._path[0]);`)
трактует клик как попытку замкнуть контур **только когда уже накоплено
достаточно вершин**; при недостатке вершин точка добавляется как обычная —
ошибка «нужно минимум два ребра» никогда не показывается на пустом контуре.
Новый контракт для автозамыкания (§7.1 п.3) не воспроизводит этот защитный
гейт: он назначает автозамыкание кандидатом для проверки независимо от того,
сколько вершин уже нарисовано, а §8.2 явно запрещает деградацию к обычному
добавлению точки при провале.
**Сценарий воспроизведения (по тексту ТЗ, до реализации — логическая проверка контракта):**
1. Существует завершённая комната с прямой стеной `A—C` (общая длина, `A` и
`C` — её концы).
2. Пользователь выбирает «Контур комнаты», кликает в `A` — `this._path = [A]`.
3. Следующим кликом отмечает `C` — противоположный конец **той же** стены,
намереваясь провести по ней первую грань новой комнаты, а затем обвести
остальной периметр и замкнуть контур явным кликом в `A` (существующий,
неизменяемый способ, приоритет 1 из §7.1).
4. По §7.1 п.3 клик в `C` не равен `_path[0]=A`, не является Ctrl/Cmd — значит
проверяется как кандидат на автозамыкание. По §7.2 `A` и `C` лежат на одном
`sourceKind: room` сплошном интервале, точки различны, длина положительна —
eligibility выполнена.
5. По §8.1 prospective polygon `[A, C] + C—A` содержит 2 различные вершины,
что меньше требуемых трёх → невалиден.
6. По §8.2 диалог не открывается, `C` не добавляется в draft, показывается
toast «Чтобы замкнуть контур, сначала нарисуйте минимум две грани»
(`toast.contour_min_edges`), а **клик не превращается в обычное добавление
точки**.
7. Результат: пользователь не может продолжить рисовать — второй клик по
общей стене, который сегодня работает и является естественным способом
начать примыкающую комнату, теперь ничего не делает, кроме показа
сообщения об ошибке, не имеющего отношения к тому, что человек пытался
сделать.
Это прямая регрессия текущего, работающего поведения — причём именно в той
области (рисование вдоль общей стены соседней комнаты), которую задача #138
должна улучшить, а не ухудшить. Ни один AC (§13) и ни один пункт плана
unit-тестов (§14.1, пп.1–9) не покрывает этот случай: пункт 7
(«одинаковые A/B, zero-length и current anchor не подходят») — про **совпадающие**
точки, а не про две **различные** точки на одном интервале при пустом ранее
контуре. Риск не упомянут и в таблице §17.
**Почему это High, а не Medium:** правило §3.8 и §2.4 требует, чтобы контракт
поведения был однозначным и не содержал воспроизводимой логической ошибки;
здесь ошибка выводится напрямую из текста ТЗ без домысливания реализации, и
затрагивает основной, ежедневный workflow редактора (первые же клики при
разметке соседней комнаты — ровно сценарий из тела issue). Это не пограничный
кейс, а типичный способ начать рисовать общую стену.
**Что нужно поправить в ТЗ (не решение реализации, а контракт):**
Автозамыкание должно проверяться только когда в контуре уже достаточно
вершин, чтобы в принципе замкнуться (аналогично существующему гейту
`this._path.length >= 3` для клика в первую точку — т.е. до клика в `B` уже
должно быть отрисовано **хотя бы одно** дополнительное ребро, не лежащее
целиком на том же интервале что и `A—B`), либо явно указать, что при провале
именно по причине «меньше трёх вершин» (в отличие от self-intersection/overlap/
zero-area) клик **деградирует к обычному добавлению точки**, а не блокируется
toast'ом. Выбор между этими двумя вариантами — продуктовый (что видит
пользователь при клике по второй точке общей стены сразу после первой):
либо клик тихо добавляет обычную точку (как сегодня), либо показывает
ошибку. Это ровно тот вопрос, который стоило задать владельцу вместе с Q1–Q5,
и он остался незамеченным.
## Что проверено и корректно
- **Терминология и техническая база не выдуманы.** Все ссылки на
`_resolvePlanDrawPoint`, `_markupClick`, `_closeRoomContour`, `_draftEndAt`,
`_persistActiveDraftSegment`, `sourceKind: 'room'|'draft'|'partition'`,
`applyWallThicknessToNewRoom`, `plan-snap-overlay.ts`, кнопку «Оставить
замкнутыми стенами» и существующие toast-ключи — соответствуют
действительному коду. §3 «Причина дефекта» описывает код, каким он есть
сегодня, а не предположение.
- **Обязательные разделы §7.1 PROCESS.md на месте:** сценарий/персона/
поверхность (§1), «что человек увидит до/после» одной фразой без терминов
реализации (§2), проблема (§3), scope/non-scope (§5–6), контракт поведения
(§7–9), UX и touch-деградация (§10), модель данных/миграция (§11),
AC1…AC12 с указанием способа доказательства (§13), план автотестов (§14),
риски (§17), откат (§18), release-артефакты (§16).
- **i18n корректно закрыт:** новых ключей нет, переиспользуются существующие
`toast.contour_min_edges`, `toast.contour_cannot_close`, `toast.room_overlap`,
`btn.keep_as_walls` — подтверждено чтением `src/i18n/ru.json`/`en.json`.
- **Наследование толщины общей стены (AC7, §9)** грамотно опирается на уже
существующий `applyWallThicknessToNewRoom()` (`wall-thickness.ts:461–484`),
который и сегодня пропускает интервалы с уже ненулевой толщиной (`cms[i] >
0`) — новый код для этого не нужен, утверждение ТЗ верно.
- **Проёмы и cuts (§7.3, AC4)** корректно опираются на уже существующий
`cutSegments`/canonical cuts из #137 (`plan-snap-overlay.ts`), не вводят
второй resolver — соответствует Non-scope и архитектурному контракту §12.
- **Дедупликация общей стены между двумя комнатами (§9, «не создаёт вторую
физическую стену»)** согласуется с найденной в `plan-snap-overlay.ts`
дедупликацией сегментов по `axisKey` (один `PlanSnapSegment` на общую ось
независимо от числа комнат-источников) — заявление обосновано, не догадка.
- **Открытых продуктовых вопросов действительно не осталось** для того, что
было явно задано: Q1–Q5 из комментария аналитики и все технические defaults
подтверждены владельцем 2026-08-14 (issue-комментарий), ссылка есть в ТЗ
§4 и §19.7. «Ни одного открытого вопроса» здесь не является тревожным
сигналом самим по себе — вопросы были заданы и закрыты пакетом, а не
обойдены.
- **Release-артефакты и трек соответствуют процессу:** `User-Visible: yes`,
оба changelog в одном коммите, `docs/CANVAS.md`/`ARCHITECTURE.md`/
`USER-GUIDE.ru.md` в списке правок (§16), полный трек (не `small`/`trivial`)
обоснован в аналитике — сложность и число задетых инвариантов оправдывают
выбор.
- **Non-scope сформулирован конкретно и исключает реальные соблазны**
расширения (путь по нескольким рёбрам, второй resolver проёмов, новая
визуальная индикация, hit-radius #137) — не оставляет скрытого расширения
скоупа.
## Чего не проверял
- Реализацию — её ещё нет; это ревью ТЗ, а не кода (PROCESS.md §2.4 не
предполагает исполнения на этом этапе).
- Golden/визуальные артефакты — ТЗ корректно утверждает, что новых пикселей
нет (§14.3), баз для сверки нет и не требовалось.
- Производительность вживую — утверждение об O(S)-поиске только на click
(§12, AC10) проверено чтением архитектуры snap-геометрии (кэшированный
snapshot в `_planSnapGeometrySnapshot()`), не профилированием — на этапе ТЗ
профилирование не требуется.
- Backend/schema — ТЗ утверждает отсутствие изменений (§11, AC12); код
`custom_components/houseplan/**/*.py` не затрагивается по scope ТЗ, отдельно
не сверялся построчно, поскольку заявленный scope его не касается.
## Вывод
Один блокирующий (High) дефект контракта: связка §7.1 п.3 + §7.2 + §8.1 + §8.2
без защитного гейта по минимальному числу уже нарисованных вершин превращает
обычный, сегодня работающий второй клик по общей стене в блокирующую ошибку —
регрессия ровно того сценария, который issue #138 должен исправить. Остальной
контракт, включая проёмы, толщину, drafts/partitions, historyCancel/Save,
touch-деградацию и release-артефакты, проверен по коду и корректен.
Возврат в «ТЗ в работе» (`S3-spec`), цикл r1/4. Исправление: явно решить (и
записать в ТЗ), что происходит при клике на другую точку того же интервала,
когда в контуре ещё недостаточно вершин для валидного замыкания — деградация к
обычному добавлению точки (по аналогии с текущим гейтом `path.length >= 3` для
закрытия по первой точке) либо иной явно обоснованный вариант, покрытый
отдельным AC и unit-тестом.