# Ревью ТЗ — 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-тестом.