From 0ac63b62965c167fc14efebf7e552731d79ae400 Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Sat, 22 Aug 2026 15:58:53 +0300 Subject: [PATCH] docs: specify Optimize geometry preflight Issue: #199 User-Visible: no --- docs/specs/199-optimize-geometry-preflight.md | 419 ++++++++++++++++++ docs/specs/README.md | 1 + 2 files changed, 420 insertions(+) create mode 100644 docs/specs/199-optimize-geometry-preflight.md diff --git a/docs/specs/199-optimize-geometry-preflight.md b/docs/specs/199-optimize-geometry-preflight.md new file mode 100644 index 00000000..c9114f94 --- /dev/null +++ b/docs/specs/199-optimize-geometry-preflight.md @@ -0,0 +1,419 @@ +# Issue #199 — geometry preflight перед записью Optimize + +- **Issue:** https://github.com/Matysh/houseplan-card/issues/199 +- **Тип / приоритет:** tech-debt / P1 +- **Оценка:** пользовательская ценность 8/10; ценность для разработки 9/10; + сложность 7/10; риск 8/10 +- **Трек:** обычный +- **Область:** explicit whole-plan Optimize, canonical physical geometry, + preview/Apply, RU/EN i18n, unit/smoke/performance +- **Модель данных:** без изменений и миграции +- **Связано:** #197, #198, #223, #224, #229, `docs/CANVAS.md` §9.5, + `docs/WALL-THICKNESS.md`, `docs/TOUCH-SUPPORT.md` + +## 1. Сценарий и персона + +**Персона:** домашний администратор, который обслуживает старый или импортированный +план через «Общие настройки → Оптимизировать планы». + +**Поверхность и момент:** администратор открывает preview Optimize и собирается +подтвердить единое изменение всех пространств. Один из maintenance-pass создаёт +candidate, для которого production geometry builder не может построить кладку +либо бумагу хотя бы одного пространства. + +Задача поддерживает J6 из `docs/SCOPE.md`: долговечный редактируемый план не +должен становиться непригодным после штатной операции обслуживания. Она также +закрывает safety floor `docs/TOUCH-SUPPORT.md`: editor-действие не имеет права +молча записать повреждённую геометрию. View/киоск не получают нового control, +но защищаются от результата опасной записи. + +## 2. Что человек увидит до и после + +**До:** preview показывает обычный структурный отчёт и разрешает Apply, даже +если candidate уже не строится тем же geometry engine, которым затем рисуется +план. Ошибка становится видна только после записи; one-deep Undo исчезает после +следующего изменения. + +**После:** если хотя бы одно пространство candidate не проходит production- +эквивалентную проверку, preview называет проблемные пространства, сообщает, +что планы не изменены, и не показывает кнопку «Оптимизировать». Ни config, ни +layout не записываются. + +## 3. Подтверждённая проблема + +Текущий путь на `origin/dev`: + +1. `HouseplanCard._openAlignDialog()` вызывает чистый + `optimizePlans(this._serverCfg, this._layout)`; +2. `OptimizeResult` содержит exact `config`/`layout`, отчёт и `changed`, но не + состояние канонической geometry; +3. `_renderAlignDialog()` показывает отчёт только по структурным счётчикам; +4. `_runAlignToGrid()` отправляет сохранённый candidate в + `houseplan/plan/optimize`; +5. backend проверяет permission, size, expected revisions, schema, marker/light/ + opening semantics и наличие файлов, после чего crash-resumably записывает + два Store и создаёт one-deep snapshot; +6. ни frontend preview/Apply, ни Python endpoint не вызывают + `wallBodiesGeometry()`/`floorFootprintGeometry()` для candidate. + +#197 доказал цену этого разрыва: один junction patch делал +`wallBodiesGeometry() === null` после Optimize, а пользователь обнаруживал +исчезнувшую кладку уже после записи. #197 исправил конкретный patch-loop, но не +общий барьер. #198/#229 меняют отдельные optimizer-pass, #223 очищает координаты +внутри Optimize, #224 канонизирует координаты на общих write barriers. Ни одна +из этих задач не проверяет итоговую renderability candidate, поэтому #199 не +является дубликатом. + +## 4. Решения владельца + +Владелец принял defaults 2026-08-22: + +1. отказ любого пространства блокирует **весь** whole-plan Optimize; +2. уже деградированное пространство также блокирует запись, даже когда candidate + не выглядит хуже исходника; +3. preview показывает понятное сообщение и названия пространств, не раскрывает + exception/polyclip details и не показывает Apply. + +Эти решения являются продуктовым контрактом, а не техническими предположениями. + +## 5. Scope + +В задачу входят: + +1. чистый production-equivalent preflight exact config candidate по всем + пространствам; +2. единая подготовка geometry inputs для preflight и production renderer либо + общий чистый helper, исключающий два расходящихся алгоритма; +3. проверка room masonry/paper вместе с независимыми partitions, незаконченными + drafts, columns, обычными и hosted-partition openings; +4. различение structural failure (`null`/exception) и корректного пустого + результата; +5. fail-closed preview и Apply: один failure блокирует всю операцию и ноль WS + writes; +6. понятное RU/EN сообщение с детерминированным списком пространств; +7. привязка preflight result к exact candidate fingerprint и отсутствие + повторной проверки при неизменном candidate; +8. сохранение нынешней одной backend-транзакции и one-deep Undo для успешного + candidate; +9. unit, production-bundle smoke, visual state, mutation и large-house + performance evidence; +10. пользовательская, архитектурная, тестовая и release-документация. + +## 6. Non-scope + +- исправление geometry, из-за которой preflight стал красным; +- частичная оптимизация только исправных пространств; +- сравнение «до/после» и разрешение уже существующего structural failure; +- raw-ring, centreline либо renderer-specific fallback; +- замена `polyclip-ts`, общая смена tolerance/precision или новый geometry engine; +- port polygon boolean engine в Python; +- новый persisted field, `PLAN_MODEL_VERSION`, schema migration или Store; +- фоновая проверка при обычном Save/read/render/import; +- сохранение stack trace или исходного config в диагностике/telemetry; +- новый ручной repair control; +- изменение срока жизни Optimize Undo; +- публичное включение скрытой изометрии. + +Найденная preflight проблема получает отдельный bug issue с reproducer; #199 не +используется как umbrella для её попутного исправления. + +## 7. Контракт geometry preflight + +### 7.1 Exact candidate и область проверки + +`checkOptimizeGeometry(config)` (рабочее имя) является чистой функцией: не +читает DOM/Lit/HA state, не пишет Store, не мутирует config и не зависит от +активного этажа. Вход — exact `OptimizeResult.config`; выход содержит: + +- content fingerprint входа; +- ordered list пространств со статусом `ok | failed | not-applicable`; +- публичный failure list только из `spaceId` и безопасного display name; +- внутренний bounded reason code для unit/diagnostic assertions без exception + text. + +Порядок совпадает с `config.spaces`; перестановка пространств меняет только +порядок отчёта, не результат каждого пространства. + +Проверяются **все** пространства candidate, не только активное и не только +изменённые по счётчикам Optimize. Проверка запускается лишь когда +`OptimizeResult.changed === true`: no-op preview ничего не записывает и +сохраняет действующее сообщение «Все планы уже используют актуальную…». + +### 7.2 Production-equivalent inputs + +Для каждого пространства используются те же чистые источники и параметры, что +production render pipeline: + +- `spaceModels()` и `NORM_W` для render coordinates; +- persisted `walls` в config coordinates; +- `resolveOpenCuts()` для explicit/legacy room spans; +- ordinary room openings и валидные hosted-partition openings с тем же compat + resolver; +- `wallIntervals()` + `partitionOpeningHasCompositeRoomWall()` для решения, + режет ли hosted opening совпавшую room masonry; +- `physicalBodyParts()` с `PartitionOpeningCut[]` для partitions/drafts/columns; +- `GRID_STEP_N`, `cell_cm`, `GRID_PITCH`, `NORM_W`; +- `wallBodiesGeometry()` для общей masonry/paper geometry; +- `floorFootprintGeometry()` только там, где есть хотя бы одна комната и нет + уже построенного `paperGeom`. + +Подготовка opening/physical inputs выносится в общий pure helper либо напрямую +переиспользует существующие pure resolvers. Копия private card-логики с +отличающимися условиями запрещена. + +### 7.3 Успех, failure и not-applicable + +Пространство успешно, когда каждый обязательный production pass, который должен +быть вызван для его данных, завершился без exception и вернул не-`null` geometry. + +- При наличии wall records либо independent physical bodies + `wallBodiesGeometry()` обязан вернуть object. Его документированный successful + empty result не является failure сам по себе. +- При наличии комнат должен существовать paper/floor result: `paperGeom` от + wall pass либо `floorFootprintGeometry()` без него. +- Пространство без комнат, wall records и independent bodies имеет + `not-applicable`, а не failure: image-only/пустой новый этаж разрешён. +- Пространство без комнат, но с валидными partitions/drafts/columns проверяет + physical/masonry pass; отсутствие room floor не является ошибкой. +- Невалидный hosted opening, который действующий compat renderer намеренно не + материализует, остаётся обязанностью schema/semantic validation и не получает + новую альтернативную трактовку в preflight. +- Любой неожиданный exception внутри подготовки или boolean pass превращается + в bounded failure reason; наружу и в UI exception text не выходит. + +Preflight ничего не чинит, не округляет и не канонизирует сверх уже полученного +Optimize candidate. + +### 7.4 Fingerprint и повторная проверка + +Preflight result хранит `contentFingerprint(candidate.config)`. Dialog хранит +тот же exact candidate и result. + +- Обычный Apply использует готовый зелёный result и не выполняет второй дорогой + boolean pass. +- Перед WS call код снова вычисляет дешёвый fingerprint. Совпадает — result + применим. Не совпадает — preflight выполняется заново для изменившегося + candidate. +- Красный повтор переводит dialog в тот же failure state и не вызывает WS. +- Изменение server config другим клиентом по-прежнему ловится backend revision + conflict; preflight не заменяет optimistic locking. +- Result не кладётся в глобальный render cache и не переживает закрытие dialog. + +## 8. UX-контракт + +### 8.1 Failure preview + +При failure обычный отчёт о сдвигах/миграциях не показывается как предложение к +записи. Вместо него dialog показывает: + +- RU: «Не удалось безопасно проверить геометрию следующих пространств: + {spaces}{more}.» +- RU hint: «Планы не изменены. Обновите House Plan и повторите. Если ошибка + останется, приложите экспорт пространства к отчёту об ошибке.» +- EN: equivalent plain-language text without implementation terms. + +Display name: непустой `space.title`, иначе `space.id`, иначе локализованное +«Пространство N» / `Space N`. Первые три имени перечисляются в config order; +при большем числе добавляется локализованный suffix «и ещё N» / `and N more`. +Lit text binding экранирует имена; HTML интерполяция запрещена. + +Footer содержит только «Отмена»/закрытие. Кнопка «Оптимизировать» **не +рендерится**, а не только получает `disabled`: пользователь не должен принимать +failure как предупреждение, которое можно обойти. + +Title, Escape, focus trap, scrim close и restore focus остаются общими для +`hp-dialog`. Новых жестов нет. На узком/touch экране сообщение переносится и +остаётся целиком доступным; editor touch parity не обещается, но safety floor +блокирует запись так же, как на desktop. + +### 8.2 No-op и success + +- `changed: false`: действующий `gs.align_none`, без preflight failure UI и без + Apply. +- `changed: true`, preflight green: нынешний точный отчёт, warning и Apply без + текстовых/поведенческих изменений. +- Cancel/close в любом состоянии ничего не пишет. +- Успешный Apply по-прежнему показывает `gs.align_done`; Undo по-прежнему + доступен до следующего edit. + +## 9. Backend, atomicity, compatibility и security + +`houseplan/plan/optimize` не получает новый параметр. Python не имеет и не +должен получать вторую реализацию TypeScript/polyclip geometry. Preflight — +защитный барьер штатной карточки, не security attestation от недоверенного +клиента. + +Backend сохраняет независимые гарантии: + +- admin permission и size limit; +- schema/semantic validation; +- expected config/layout revisions; +- missing-plan validation; +- intent-first config+layout commit; +- one-deep snapshot и crash recovery. + +При красном preflight endpoint не вызывается вообще, поэтому не создаются +pending/backup, revisions и update events. При зелёном вызывается ровно один +существующий endpoint с exact candidate; частичной записи по пространствам нет. + +Старый сохранённый config читается без миграции. Новые i18n keys — единственное +добавление к пользовательскому контракту. Неизвестные поля candidate, files, +layout metadata и backend Undo сохраняются существующими механизмами. + +Имена пространств считаются недоверенным пользовательским текстом и выводятся +только через Lit escaping. Exception, geometry coordinates и config payload не +логируются в browser console как часть штатного failure и не отправляются +третьим сторонам. + +## 10. Производительность + +Проверка user-triggered и выполняется не чаще одного раза для одного открытого +dialog/candidate. Она запрещена в: + +- render/update и HA state tick; +- pointermove, pan, pinch и hover; +- обычном config/layout Save; +- переключении этажа, темы или View/Plan режима. + +Базовое измерение автора ТЗ 2026-08-22 на текущем Windows checkout после +`npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs`: прямой +production wall/physical pass существующей `large-house` fixture (3 пространства, +60 комнат, 100 проёмов, 60 partitions, 40 columns) — median 155.9 ms, p95 +162.56 ms, max 163.67 ms по 20 warm samples. + +Acceptance budget для готового helper на той же fixture: + +- все 3 пространства green; +- p95 не больше **250 ms** после не менее 3 warmups и 20 samples; +- wrapper/input-preparation overhead относительно прямого production builder + того же процесса не больше **20% + 15 ms**; +- повторный Apply неизменного candidate не вызывает второй geometry pass; +- heap/result cache не растёт после закрытия dialog. + +Отдельный benchmark script печатает fixture counts, median/p95/max, baseline и +candidate timings. Если абсолютный budget нестабилен в CI, ревьюер не повышает +его молча: измеряет same-run baseline и заводит отдельный performance issue; +для #199 остаётся обязательным относительный budget. + +## 11. Acceptance criteria + +| AC | Критерий | Доказательство | +|---|---|---| +| AC1 | Exact Optimize candidate проверяется по всем пространствам теми же room/opening/wall/physical builders и параметрами, что production renderer; вход не мутируется. | Focused unit + code review call-site parity table. | +| AC2 | Валидная матрица room masonry, ordinary/hosted openings, partitions, drafts, columns, image-only и empty space даёт `ok/not-applicable` без false positive. | Parameterized unit fixtures. | +| AC3 | Forced `wallBodiesGeometry() === null`, floor failure и thrown exception дают bounded failure соответствующего пространства; successful empty geometry не считается failure. | Injectable seam/mutant unit. | +| AC4 | Failure одного из нескольких пространств возвращает deterministic ordered list и блокирует whole-plan operation; исправные пространства не записываются отдельно. | Multi-space unit + browser smoke. | +| AC5 | Уже красное исходное пространство не получает исключения «не стало хуже»: changed candidate блокируется и оставляет exact config/layout/revisions без изменений. | Before/after regression unit + browser smoke. | +| AC6 | Failure dialog выводит первые три безопасных display names, RU/EN suffix остальных и plain-language hint без exception; Apply отсутствует, Cancel/Escape/focus работают. | i18n/UI unit + production-bundle smoke + dark/light golden candidate. | +| AC7 | `changed:false` сохраняет прежний no-op dialog без preflight; green candidate сохраняет прежний report/Apply/toast. | UI unit + existing Optimize smokes. | +| AC8 | Green fingerprint используется ровно для exact config candidate; unchanged Apply не повторяет geometry pass, changed fingerprint rechecks и fail-closes до WS. | Controlled-call-count unit. | +| AC9 | Красный preflight делает 0 `houseplan/plan/optimize` calls, не меняет `_serverCfg`, layout, revisions, history/Undo и не создаёт events; green делает ровно один atomic call. | Production-bundle smoke with WS recorder. | +| AC10 | Успешный Apply/Undo, revision conflict и backend semantic validation остаются без изменений. | Existing frontend/backend suites + targeted smoke. | +| AC11 | Large-house helper выполняет budget §10, не попадает в render/state/pointer пути и не оставляет растущий cache. | Targeted benchmark + call-count regression. | +| AC12 | Mutation gates ловят bypass preflight, active-space-only check, acceptance `null` и отображение Apply при failure. | `scripts/mutation-gate.mjs` entries, каждый caught 1/1. | +| AC13 | RU/EN changelog, user/canvas/testing/status docs и три bundle-копии актуальны; docs fingerprint green. | `check-docs`, bundle byte comparison, doc review. | +| AC14 | Рабочие gates задачи зелёные. | typecheck, unit, build, targeted smoke, targeted golden candidate/benchmark as required by process. | + +## 12. План реализации и тестов + +### 12.1 Код + +1. Добавить pure module `src/plan-geometry-preflight.ts` (имя может быть + изменено ревьюером) с per-space preparation/check и result/fingerprint types. +2. Вынести из `houseplan-card.ts` только необходимую production input projection + в pure helpers либо переиспользовать существующие resolvers напрямую; renderer + и preflight должны сходиться на одной функции, а не копиях условий. +3. Расширить private `_alignDialog` preflight result и integration в + `_openAlignDialog`, `_runAlignToGrid`, `_renderAlignDialog`. +4. Добавить RU/EN i18n keys для failure, hint, fallback name и `more` suffix. +5. Backend endpoint и persisted schema не менять. + +Предполагаемые продуктовые файлы: `src/plan-geometry-preflight.ts`, +`src/houseplan-card.ts`, при необходимости общий geometry/opening helper, +`src/i18n/ru.json`, `src/i18n/en.json`. + +### 12.2 Автотесты + +- `test/plan-geometry-preflight.test.mjs`: AC1–AC5, AC8, call parity, + immutability, empty/failure matrix; +- существующие `plan-optimizer`, `wall-thickness`, `partition-openings` tests + остаются зелёными; +- `demo/smoke_optimize_geometry_preflight.mjs`: собранный bundle, multi-space + red/green/no-op, RU/EN, zero/one WS calls, state/Undo preservation; +- `demo/benchmark_optimize_geometry_preflight.mjs`: §10; +- golden matrix: failure dialog dark/light; baselines принимаются только из + полного Linux CI artifact через `golden:accept -- --reviewed`; +- mutation ids: `optimize-preflight-bypassed`, + `optimize-preflight-active-space-only`, `optimize-preflight-accepts-null`, + `optimize-preflight-renders-apply-on-failure` либо эквивалентные точные anchors. + +До `S7-code-review` выполняются fast gates и targeted smoke/benchmark из AC. +Полный smoke set, golden verify и общий performance остаются pre-beta gates. + +## 13. Риски и меры + +| Риск | Мера | +|---|---| +| Preflight расходится с renderer | Общий pure input helper + AC1 parity table; никакого упрощённого polygon check. | +| Проверяется только активный этаж | Pure function обходит ordered `config.spaces`; mutant active-space-only. | +| False positive на пустом/virtual-only плане | Явный `not-applicable` и successful-empty contract, parameterized AC2/AC3. | +| Старый failure пропускается как «не хуже» | Решение владельца Q2 и AC5: любой candidate failure блокирует. | +| Whole-plan Apply становится частичным | Ноль WS calls при любом failure; backend endpoint вызывается один раз только на green. | +| UI показывает внутреннюю ошибку или ломается от title | Bounded reason codes, Lit escaping, список ≤3 + count. | +| Дорогой boolean pass тормозит обычный View | Только explicit dialog, fingerprint reuse, budget и call-count tests. | +| Cache удерживает config/geometry | Result живёт только в dialog; geometry не хранится, только statuses/fingerprint. | +| Старый browser bundle может вызвать endpoint без preflight | Это не security boundary; пакет поставляет card+integration вместе. Backend продолжает schema/semantic guards, но не дублирует polyclip. | + +## 14. Touch и accessibility + +Touch editor: **best effort**, но safety behavior полностью поддерживается. +Проверка запускается той же кнопкой General settings; failure не допускает +запись ни мышью, ни tap. Message/footer не требуют hover, помещаются в общий +responsive `hp-dialog`, Escape/focus restore остаются desktop guarantees. +Новых pointer handlers и рисков pinch/pointercancel нет. View/киоск rendering +не меняется; предотвращение повреждённой записи улучшает их надёжность. + +## 15. Release-артефакты и rollback + +Изменение пользовательское. Implementation commit имеет `User-Visible: yes` и +включает: + +- `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` со ссылкой на #199; +- `docs/USER-GUIDE.ru.md` — failure behavior Optimize и действие пользователя; +- `docs/CANVAS.md` — preflight/fingerprint/whole-plan contract; +- `docs/ARCHITECTURE.md` — граница frontend geometry barrier/backend atomicity; +- `docs/TESTING.md` — unit/smoke/mutation/performance evidence; +- `docs/STATUS.md` — фактическая release-line запись; +- RU/EN i18n, tests, smoke, benchmark, mutation entries; +- recapture `demo/docs/capture.mjs`/`docs/images/screenshots.json` для актуального + source fingerprint при любом `src/**` diff; +- три byte-identical bundle-копии; +- dark/light golden candidate; acceptance baseline только по reviewed full Linux + artifact перед beta. + +Security report, schema migration, backend release note и persisted recovery +artifact не требуются. + +Rollback — revert implementation commit. Persisted данные не меняются новым +preflight, поэтому обратной миграции нет. После rollback Optimize снова разрешит +запись unchecked candidate; уже успешно оптимизированные планы и Undo snapshots +остаются в прежнем формате. + +## 16. Принятые технические предположения + +Эти решения не наблюдаемы пользователем и могут быть изменены ревьюером без +нового решения владельца, если AC сохраняются: + +1. Pure module/result/type names не являются публичным API. +2. Fingerprint использует существующий `contentFingerprint`, а не новый hash. +3. No-op candidate не проверяется: Apply отсутствует, значит опасной записи нет. +4. Preflight result хранит statuses/reasons, но не polygon geometry, чтобы dialog + не становился вторым render cache. +5. Backend endpoint не требует декларативного `preflight_passed` поля: без + возможности исполнить polyclip оно не добавляет доказательства и ломает + старые clients. +6. Performance budget относится к geometry helper отдельно от уже существующего + `optimizePlans()`; same-run baseline отделяет новый overhead от polygon engine. +7. `space.title → id → Space N` — единственный display-name fallback. +8. Targeted file/fixture names могут меняться без изменения доказательной + матрицы AC. diff --git a/docs/specs/README.md b/docs/specs/README.md index 71a6c477..6b301023 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -54,6 +54,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным | [#167](https://github.com/Matysh/houseplan-card/issues/167) Экспорт «только планировка» | [167-plan-only-export.md](167-plan-only-export.md) | | [#170](https://github.com/Matysh/houseplan-card/issues/170) HA-устройство не привязывается к комнате без HA-зоны | [170-room-without-area.md](170-room-without-area.md) | | [#179](https://github.com/Matysh/houseplan-card/issues/179) Новый визуальный язык маркеров устройств | [179-device-icons-redesign.md](179-device-icons-redesign.md) | +| [#199](https://github.com/Matysh/houseplan-card/issues/199) Geometry preflight перед записью Optimize | [199-optimize-geometry-preflight.md](199-optimize-geometry-preflight.md) | | [#210](https://github.com/Matysh/houseplan-card/issues/210) Фиксированное пространство экземпляра карточки | [210-fixed-floor-card.md](210-fixed-floor-card.md) | | [#211](https://github.com/Matysh/houseplan-card/issues/211) Визуальное соответствие маркеров дизайн-пакету #179 | [211-device-icons-visual-parity.md](211-device-icons-visual-parity.md) | | [#217](https://github.com/Matysh/houseplan-card/issues/217) Внешняя рамка Text-маркера должна быть капсулой | [217-text-shell-outline.md](217-text-shell-outline.md) |