Compare commits

...
Author SHA1 Message Date
claude[bot] 5d295e7356 docs: review document for #223
Issue: #223
User-Visible: no
2026-08-20 19:32:13 +00:00
Sergey Matyunin 38f74e0209 fix: canonicalize near-grid coordinates exactly
Issue: #223
User-Visible: yes
2026-08-20 22:23:25 +03:00
claude[bot] 73944bf58a docs: review document for #223
Issue: #223
User-Visible: no
2026-08-20 19:17:16 +00:00
Sergey Matyunin 8c9e5feae3 docs: clarify optimize report terminology
Issue: #223
User-Visible: no
2026-08-20 22:13:14 +03:00
claude[bot] 981d3d6ccd docs: review document for #223
Issue: #223
User-Visible: no
2026-08-20 19:12:26 +00:00
Sergey Matyunin 965711ee20 docs: address coordinate spec review
Issue: #223
User-Visible: no
2026-08-20 22:06:54 +03:00
claude[bot] c0fff33322 docs: review document for #223
Issue: #223
User-Visible: no
2026-08-20 19:05:37 +00:00
Sergey Matyunin 3a81dd6223 docs: specify exact coordinate canonicalization
Issue: #223
User-Visible: no
2026-08-20 21:57:07 +03:00
24 changed files with 1282 additions and 47 deletions
File diff suppressed because one or more lines are too long
@@ -0,0 +1,103 @@
// #223: explicit Optimize removes stored ULP noise without claiming a visible
// move. Exercise the production bundle's Preview → Cancel → Apply → Undo flow.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 920, height: 840 });
const out = await page.evaluate(async () => {
const result = {};
const card = window.__card;
const S = 1 / 240;
const noisyFloor = [
[[0.46666666666666673, 0.7083333333333334], [0.6125, 0.9],
[0.4666666666666667, 1], [0.46666666666666673, 0.9]],
[[0.1625, 0.3], [0.3458333333333333, 0],
[0.46666666666666673, 1], [0.3458333333333333, 1]],
[[0.7, 0], [0.8, 0], [0.8, 0.7083333333333334], [0.7, 0.7083333333333334]],
[[0.7, 0.7083333333333335], [0.8, 0.7083333333333335], [0.8, 1], [0.7, 1]],
[[0.85, 0], [0.9, 0], [0.9, 0.4], [0.85, 0.4]],
[[0.85, 0.5], [0.9, 0.5], [0.9, 1], [0.85, 1]],
];
const original = {
model_version: 6,
spaces: [{
id: 'noisy', title: 'Noisy floor', view_box: [0, 0, 1, 1], cell_cm: 5,
rooms: noisyFloor.map((poly, index) => ({ id: `room-${index}`, poly })),
future: { kept: true },
}],
markers: [], settings: {},
};
const clone = (value) => JSON.parse(JSON.stringify(value));
const isCanonical = (value) => value === Math.round(value / S) * S;
let serverConfig = clone(original), serverLayout = {}, backup = null;
let lastToast = '';
const sent = [];
const baseCall = card.hass.callWS.bind(card.hass);
card.hass = {
...card.hass,
callWS: async (message) => {
if (message.type === 'houseplan/plan/optimize') {
sent.push(message.type);
backup = { config: clone(serverConfig), layout: clone(serverLayout) };
serverConfig = clone(message.config); serverLayout = clone(message.layout);
return { ok: true, config_rev: 2, layout_rev: 2, can_undo: true };
}
if (message.type === 'houseplan/plan/optimize_undo') {
sent.push(message.type);
serverConfig = clone(backup.config); serverLayout = clone(backup.layout);
return { ok: true, config_rev: 3, layout_rev: 3, can_undo: false };
}
if (message.type === 'houseplan/config/get')
return { config: clone(serverConfig), rev: 3, can_write: true };
if (message.type === 'houseplan/layout/get')
return { layout: clone(serverLayout), rev: 3 };
return baseCall(message);
},
};
const baseToast = card._showToast.bind(card);
card._showToast = (message) => { lastToast = message; baseToast(message); };
card._config = { ...card._config, language: 'en' };
card._serverCfg = clone(original); card._layout = {};
card._modelCache = null; card._frame = null; card._space = 'noisy';
card.requestUpdate(); await card.updateComplete;
card._openAlignDialog(); await card.updateComplete;
const preview = card._alignDialog;
const previewText = card.renderRoot.querySelector('hp-dialog .body')?.textContent || '';
result.previewOffersInvisibleCleanup = !!preview?.changed
&& preview.report.moved === 0
&& preview.report.coordsCanonicalized > 0
&& preview.report.maxShift === 0
&& preview.report.maxShiftCm === 0;
result.previewNamesBothReportUnits = previewText.includes('spaces updated:')
&& previewText.includes('noisy coordinate values removed:');
result.previewOffersApply = !!card.renderRoot.querySelector('hp-dialog .btn.on');
result.previewDoesNotWrite = sent.length === 0
&& JSON.stringify(card._serverCfg) === JSON.stringify(original);
card._alignDialog = null; await card.updateComplete;
result.cancelDoesNotWrite = sent.length === 0
&& JSON.stringify(card._serverCfg) === JSON.stringify(original);
card._openAlignDialog(); await card.updateComplete;
await card._runAlignToGrid(); await card.updateComplete;
result.applyUsesOneAtomicWrite = sent.filter((type) => type === 'houseplan/plan/optimize').length === 1;
result.applyStoresOnlyCanonicalRoomCoordinates = card._serverCfg.spaces[0].rooms
.every((room) => room.poly.every(([x, y]) => isCanonical(x) && isCanonical(y)));
result.applyPreservesUnknownFields = card._serverCfg.spaces[0].future?.kept === true;
result.toastExplainsZeroMoveCleanup = lastToast.includes('0 elements moved')
&& !lastToast.includes('0 records maintained');
card._openAlignDialog(); await card.updateComplete;
result.secondRunIsExactNoOp = card._alignDialog?.changed === false
&& card._alignDialog.report.coordsCanonicalized === 0
&& !card.renderRoot.querySelector('hp-dialog .btn.on');
card._alignDialog = null; await card.updateComplete;
await card._undoPlanOptimization(); await card.updateComplete;
result.undoUsesServerSnapshot = sent.filter((type) => type === 'houseplan/plan/optimize_undo').length === 1;
result.undoRestoresExactNoisyValues = JSON.stringify(card._serverCfg) === JSON.stringify(original);
result.undoIsOneDeep = card._canOptimizeUndo === false;
return result;
});
await finish(browser, checkAll(out));
File diff suppressed because one or more lines are too long
+5 -5
View File
File diff suppressed because one or more lines are too long
+11 -1
View File
@@ -419,6 +419,10 @@ nodes. New editor operations cannot create more. General settings contain
a **Plan maintenance** group whose action previews and then repairs old
data through all current passes: model upgrades, mandatory grid
alignment, exact open-span canonicalisation and wall-interval compaction.
Unlike live snapping, the explicit maintenance pass also replaces a stored
coordinate which is only one or several ULPs away from its node with the exact
computed node. That has no visible displacement but removes topology noise at
its persisted source.
Why an action rather than a silent migration:
@@ -468,7 +472,7 @@ idempotence case in `test/plan-optimizer.test.mjs`:
* a stray opening with no wall within 6 steps is left exactly where it
is rather than teleported;
* **idempotent**: a second run reports `moved: 0`, `changed: false`, and
returns objects deep-equal to the first run's;
`coordsCanonicalized: 0`, and returns objects deep-equal to the first run's;
* the report is an **upper bound**, not a sample (AUD-158B1-01).
### The report is a promise
@@ -492,6 +496,12 @@ The confirmation is the decision gate in front of a geometry rewrite, so
says which space the maximum is in; openings corrected in angle alone
are counted on a line of their own.
`coordsCanonicalized` counts individual near-node coordinate values actually
written to the candidate. It excludes ordinary shifts above `EPS`, rejected
partition snaps and wall/open-span maintenance. Those values do not increase
`moved` or `maxShift*`; the dialog instead labels them as removed coordinate
noise and keeps the existing updated-space counter separate.
One undo is available until the next config or layout edit. It restores
the stored snapshot; re-running optimization itself is never treated as
undo because a grid projection is not invertible.
+4
View File
@@ -2,6 +2,10 @@
## Unreleased
- “Optimize plans” now removes microscopic floating-point noise from stored
grid coordinates even when nothing visibly moves. Its preview separately
reports updated spaces and cleaned coordinate values, and a second run is an
exact no-op ([#223](https://github.com/Matysh/houseplan-card/issues/223)).
- Placing an individual Home Assistant entity no longer leaves a duplicate
automatic marker of its complete parent device. The automatic marker now
contains only the remaining active visible entities and disappears when
+5
View File
@@ -8,6 +8,11 @@
## Не выпущено
- «Оптимизировать планы» теперь устраняет микроскопический floating-point шум
сохранённых координат сетки, даже если визуально ничего не сдвигается. В
предпросмотре отдельно показаны обновлённые пространства и очищенные
координаты, а повторный запуск становится точным no-op
([#223](https://github.com/Matysh/houseplan-card/issues/223)).
- Размещение отдельной сущности Home Assistant больше не оставляет рядом
дублирующий автоматический маркер всего родительского устройства. В
auto-marker теперь входят только оставшиеся активные видимые сущности, а при
+1 -1
View File
@@ -22,7 +22,7 @@ metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
| Item | State |
|---|---|
| Version | **v1.66.0** everywhere (manifest, const.py, package.json, CARD_VERSION) — stable promotion of the published v1.66.0-beta.1 product line |
| Current local cycle | v1.66.0 promotes the exact product behaviour already published in beta.1: capsule-shaped Text marker outlines, resilient Glow floor clipping and the unified red/green lock plus theme-aware orange-core palette. The stable commit contains only version fields, generated snapshots and release metadata; stable publication is gated by the full local suite and green exact-SHA Validate and Full Performance workflows. |
| Current local cycle | Post-v1.66.0 Unreleased development includes entity/parent marker deduplication (#226) and exact coordinate maintenance (#223): explicit Optimize rewrites persisted near-grid ULP noise without claiming a visible move, reports cleaned coordinates separately and remains exactly idempotent. |
| Hidden Labs Stage | #89 Stage 1 ships in v1.63.0-beta.1. #122 Stage 2 ships in v1.64.0 and evolves the same hidden, expiring `iso` experiment with matte walls, a low exterior floor edge, restrained shared shadows and live vertical door/window/gate panels. Flat remains default; editors and `houseplan-space-card` remain flat; live floor effects and HA actions remain unchanged. Public activation remains a separate task. |
| Workflow | Superseded 2026-08-12: the pre-1.62 rule of "local edits without tests or commits" is **dead** — since release 1.62 every product change follows `PROCESS.md` (issue in `S5-ready`+, branch `issue/<NN>-slug`, trailers on every commit, review pipeline; `AGENTS.md` is the summary). Release mechanics below remain current. A requested pre-release gets a production build plus the smallest targeted unit/smoke set covering the changed surfaces, one tested `dev` commit/tag and a GitHub Release with `prerelease=true`; `main` stays untouched. The complete local frontend/backend/smoke gate runs only before a stable release, after which `main` is fast-forwarded to the exact tested `dev` SHA and the GitHub Release uses `prerelease=false`. Release bodies are short and bilingual (Russian first): only significant user changes get individual bullets, while minor/code-only work is grouped as `Мелкие исправления и улучшения` / `Small fixes and improvements`; every body ends with separate links to the Russian and English changelogs. Detailed RU/EN changelog bullets may link the corresponding closed GitHub Issues; open or partially delivered issues are never presented as shipped. Telegram announcements are sent only for stable releases; beta and RC publication is silent. `docs/RELEASE-NOTES.md` is the current canonical body instance; `npm run release:prerelease -- <tag> --issues=… --yes` is the primary local publication path and the manual `Publish prerelease` workflow is its GitHub-only equivalent once present on `main`. Nothing is copied to the home instance by hand |
| GitHub | https://github.com/Matysh/houseplan-card — [Issues](https://github.com/Matysh/houseplan-card/issues) are the canonical task records; their labels carry priority and workflow status (`PROCESS.md` §9). GitHub Projects is no longer used. `main` carries stable releases; pre-release tags may point directly at `dev`. Work lands on `dev` and is merged into `main` for a stable release, so `dev` is normally equal to or ahead of `main`, never behind. Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
+10
View File
@@ -2042,6 +2042,16 @@ require hands on real hardware — they remain for the human pass.
snap to nodes in ONE write, openings stay on their walls, and pressing the
button a second time reports nothing to do. Cancel does nothing at all
[auto: smoke_grid_snap + unit test/align-grid.test.mjs]
- [ ] **Optimize removes stored ULP coordinate noise (#223)**: a six-room
fixture whose shared grid vertices differ by `5.5e-17` offers Apply with
`moved: 0` and a positive cleaned-coordinate count. Preview distinguishes
updated spaces from removed coordinate noise; Cancel writes nothing;
Apply stores exact grid nodes in one transaction; the next run is a no-op
and server Undo restores the original noisy bits. A rejected hosted
partition contributes nothing to the counter
[unit: align-grid + plan-optimizer + i18n; auto:
smoke_optimize_coordinate_canonicalization; mutation:
`snapn-returns-input-near-node`].
- [ ] Optimizer migration safety: legacy decor width/text size is clamped to
the backend schema, `fill: true` receives explicit fill style, invalid
legacy `plan_scale` is preserved for repair, an already canonical plan is
+7 -2
View File
@@ -1270,14 +1270,19 @@ show_signal: true
|---|---|
| Миграции модели | Старые, однозначно преобразуемые поля переводятся в текущий формат; для `entity:switch.*` собственный switch в старом `controls` становится ролью «Всегда» (для `device:*` это делает сохранение настроек устройства, где доступен реестр HA) |
| Масштаб сетки | Некорректный старый `cell_cm` приводится к допустимому диапазону 0,1–1000 см |
| Комнаты | Вершины округляются к сетке |
| Комнаты | Вершины записываются точными узлами сетки; микроскопический floating-point шум также устраняется без видимого сдвига |
| Декор и мебель | Положение и размеры округляются к сетке |
| Устройства и подписи комнат | Позиции округляются к сетке |
| Проёмы | Возвращаются на ближайшую стену, смещение вдоль стены округляется, угол исправляется |
| Стены | Одинаковые соседние участки толщины объединяются. Изолированный участок другой толщины короче половины шага сетки также схлопывается, только если с обеих сторон находятся участки одной толщины и на его концах нет вершины комнаты или границы проёма |
| Виртуальные стены | Соседние/перекрывающиеся участки объединяются и приводятся к общей границе |
Перед записью диалог показывает количество затрагиваемых элементов, максимальный сдвиг в сантиметрах и пространство с этим сдвигом.
Перед записью диалог показывает количество затрагиваемых элементов,
максимальный сдвиг в сантиметрах и пространство с этим сдвигом. Отдельная
строка различает пространства с обновлённым представлением стен/связей и
координаты, в которых устранён только вычислительный шум. Поэтому возможен
честный предпросмотр «сдвинуто элементов — 0» с ненулевым числом очищенных
координат. Повторный Optimize над результатом ничего не предлагает.
### Что оптимизация сохраняет
+166
View File
@@ -0,0 +1,166 @@
# Код-ревью #223 — Optimize канонизирует координаты без floating-point шума
- Цикл: r1/4 (код-ревью; ТЗ прошло отдельный лимит и закрыто зелёным на r3)
- Диапазон: `git diff origin/dev...HEAD` (7 коммитов, `origin/dev..HEAD`)
- Реализация: коммит `38f74e0` (`fix: canonicalize near-grid coordinates exactly`,
`Issue: #223`, `User-Visible: yes`) — единственный коммит с продуктовым
изменением; предыдущие 6 коммитов — ТЗ и его ревью (r1–r3), уже закрыты
отдельным циклом.
- ТЗ: `docs/specs/223-optimize-coordinate-canonicalization.md`, зелёное ревью
`docs/reviews/SPEC-REVIEW-223-r3.md`.
## Скоуп проверки
Полный разбор — это первый цикл код-ревью, §2.10 (дельта по раундам) здесь не
применяется. Список изменённых файлов (`git diff origin/dev...HEAD --name-only`)
сверен построчно с §4/§12 ТЗ — расхождений нет, попутных правок нет:
`src/align-grid.ts`, `src/houseplan-card.ts`, `src/i18n/{en,ru}.json`,
`test/align-grid.test.mjs`, `test/plan-optimizer.test.mjs`, `test/i18n.test.mjs`,
`scripts/mutation-gate.mjs`, `demo/smoke_optimize_coordinate_canonicalization.mjs`,
`docs/{CANVAS,USER-GUIDE.ru,TESTING,STATUS,CHANGELOG,CHANGELOG.ru}.md`,
`docs/specs/README.md`, три копии бандла. `custom_components/**/*.py` не
тронут — контракт ТЗ («persisted schema и backend не меняются») подтверждён
структурой diff, не только текстом.
## Как проверялось (гейты)
Прогнано в этом цикле:
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | pass, без вывода |
| Unit | `npm test` | `tests 975, pass 975, fail 0` |
| Build + сверка бандлов | `npm run build` затем `sha256sum dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js demo/srv/assets/houseplan-card.js` | все три файла — один и тот же хэш `3f7eaf824d79b33f190b9323e2d873aff7eb19d6eef7749488e96b39c76ad33a` (совпадает с хэшем из хендоффа автора) |
| Targeted browser smoke (AC6/AC7) | `node demo/smoke_optimize_coordinate_canonicalization.mjs` | `13/13 true`, `OK` |
| Смежные browser smoke (диф трогает общий `alignAllToGrid`/`snapN`, используемый этими сценариями) | `node demo/smoke_grid_snap.mjs` и `node demo/smoke_optimize_micro_interval.mjs` | оба `OK`, все проверки `true` |
| Mutation guard (AC1) | `node scripts/mutation-gate.mjs --id=snapn-returns-input-near-node` | `поймано 1 из 1` — мутант пойман, чистый прогон зелёный |
| Mutation registry hygiene | `node scripts/mutation-gate.mjs --check` | `ok snapn-returns-input-near-node` среди прочих, без ошибок уникальности `find` |
| Process gate (офлайн) | `node scripts/process-gate.mjs` | `гейт пройден, предупреждений 0` |
Не прогонялось и почему:
- **Полный набор из 127 браузерных смоков** — diff меняет ровно один
batch-путь (`alignAllToGrid` внутри explicit Optimize) и явно поименованный
смок плюс два смежных по тому же пути прогнаны выше; остальные 124 смока не
затрагивают ни `align-grid.ts`, ни отчёт Optimize. Полный набор — предрелизный
гейт (PROCESS.md §8), не гейт ревью.
- **`npm run golden:verify`** — diff не меняет разметку диалога (тот же
`.alignmsg`, добавлена подстрока в уже существующий текст) и не двигает
геометрию видимо: величина правки — доли `EPS` (≈4.2e-9 нормализованной
единицы), она гарантированно ниже порога любого golden-снапшота. Само ТЗ
§10 фиксирует это решение («Golden baseline не требуется... перед бетой
выполняется общий golden verify по процессу»), и я согласен с этой оценкой
после чтения diff.
- **`python -m pytest tests_backend`** — ни один файл
`custom_components/houseplan/**/*.py` не тронут (подтверждено списком файлов
выше).
- **performance-профили** — в AC не названы; изменение линейно по уже
обходимым координатам, добавляет одну точную проверку на компонент; ТЗ §11
явно снимает необходимость отдельного performance-гейта, и чтение
`alignAllToGrid` (без новых циклов/аллокаций сверх O(1) на компоненту)
подтверждает это.
## Находки
Находок, блокирующих или требующих правки в задаче, нет.
### Low — AC2 (неконечные числа) не имеет отдельного unit-доказательства
- **Файл:** `src/align-grid.ts:69-72` (`snapN`), контракт — AC2 в
`docs/specs/223-optimize-coordinate-canonicalization.md:147`.
- **Что не так:** AC2 требует доказательства «boundary unit matrix» для двух
утверждений: (1) значение дальше `EPS` продолжает выравниваться как раньше —
это покрыто (`test/align-grid.test.mjs:48-50`, случай `off = node + S/3`);
(2) «не конечные числа сохраняют прежний результат» — для этого в текущем
наборе тестов нет ни одного `assert` на `NaN`/`Infinity`/`-Infinity`, ни в
новых тестах, ни в уже существовавших (`git show origin/dev:test/align-grid.test.mjs`
такого теста тоже не содержит).
- **Почему не блокирует:** строка `if (!Number.isFinite(v)) return v;`
(`src/align-grid.ts:70`) в этом diff не менялась — это тот же guard, что был
до задачи, и он был непокрыт тестом и раньше. Риск регрессии от этого diff
нулевой: изменённая строка (`return Math.round(...)`) находится строго после
guard и не может исполниться для нефинитного входа.
- **Решение ревьюера:** снимается без правки в этой задаче. AC2 по факту
доказан читкой (guard не тронут), а не только тестом; отдельная задача на
добавление такого unit-теста не заводится (тривиальная гигиена покрытия,
не дефект поведения) — по желанию автора может быть добавлена заодно со
следующей правкой этого файла.
## Проверено по AC (код-ревью отвечает за «оно вообще работает»)
| AC | Доказательство | Как проверено |
|---|---|---|
| AC1 | `test/align-grid.test.mjs:41-51` (юнит), мутант `snapn-returns-input-near-node` | Юнит прогнан зелёным в составе `npm test`; мутант отдельно прогнан и **пойман** (`scripts/mutation-gate.mjs --id=...` → `1 из 1`) — тест умеет падать. |
| AC2 | Юнит на `off`-случай зелёный; неконечные числа — см. находку Low выше | Частично тестом, частично чтением (guard не тронут). |
| AC3 | `test/plan-optimizer.test.mjs:75-107` (six-room ULP fixture), `unionBodies` | Юнит зелёный в `npm test`; проверено, что тест реально утверждает `moved:0, maxShift:0, coordsCanonicalized>0, changed:true` и что `unionBodies` не падает на результате — тест умеет падать (до фикса `snapN` возвращал `v`, координаты остались бы носящими шум, `coordsCanonicalized` был бы `0`, `assert.ok(... > 0)` покраснел бы). |
| AC4 | `test/align-grid.test.mjs:53-94` (poly/rect/partition accepted+rejected/wall_column/decor/marker) | Юнит зелёный; читкой кода (`src/align-grid.ts:264-291`) подтверждено, что вклад партиции считается **после** ветвления `hostedFit`, а не на входе в `snapN` — ровно то, что требовал r1 ревью ТЗ. Тест явно проверяет, что `partitions[1].a[0]` (`hostedFit=false`) остаётся `noisy`, а счётчик не растёт от неё (`coordsCanonicalized === 8`, вручную пересчитано по фикстуре — сходится). |
| AC5 | `test/align-grid.test.mjs:158-162`, `test/plan-optimizer.test.mjs:101-106` | Юниты зелёные, оба проверяют `coordsCanonicalized: 0` и `changed: false` на повторном проходе, включая глубокое равенство. |
| AC6 | `test/i18n.test.mjs:30-40`, targeted smoke | i18n-юнит проверяет ровно обе строки RU/EN и обе точки использования в `houseplan-card.ts` через regex по исходнику; smoke проверяет реальный рендер (`previewNamesBothReportUnits`, `toastExplainsZeroMoveCleanup`) — оба зелёные при прогоне выше. |
| AC7 | `test/plan-optimizer.test.mjs:83` (`assert.deepEqual(config, before, ...)`), smoke | Юнит на немутацию preview зелёный; smoke подтверждает Cancel/Apply/Undo на реальном диалоге (`previewDoesNotWrite`, `cancelDoesNotWrite`, `applyUsesOneAtomicWrite`, `undoRestoresExactNoisyValues`, `undoIsOneDeep`) — все `true` в прогоне выше. |
| AC8 | Чтение: `grep -rn "alignAllToGrid\|snapN(" src/*.ts` | `alignAllToGrid` вызывается ровно из одного места — `plan-optimizer.ts:398`, внутри `optimizePlans()`, который сам вызывается только явным `_runAlignToGrid`/`_openAlignDialog` (нет вызовов из пути save/render). Проверено чтением, не исполнением. Полный `npm test` (975/975) зелёный, включая существующие тесты #218. |
| AC9 | `git diff` по docs, `sha256sum` трёх бандлов | Документация обновлена по списку §12 ТЗ; хэши бандлов идентичны (см. таблицу гейтов). |
| AC10 | Таблица гейтов выше | typecheck/unit/build/targeted-smoke/mutation — все зелёные. |
## Что проверено и корректно (сверх таблицы AC)
- **Архитектура счётчика.** `noteCanonicalCoordinate`/`noteCanonicalPoint`
(`src/align-grid.ts:157-166`) считают вклад строго по значению, реально
записанному в candidate: для `room_drafts` вклад учитывается только для точек,
попавших в итоговый `draft.points` (`written` строится параллельно с `points`,
включая тот же dedup по `EPS`), и только когда сам draft не был отфильтрован
фильтром `points.length >= 2` (иначе весь draft уходит в `removedDrafts`, а не
в candidate). Для partition — вклад учитывается только внутри
`if (snappedLength > EPS && hostedFit)`, то есть ровно в ветке, где `p.a`/`p.b`
реально присваиваются.
- **Скрещённая проверка арифметики.** Вручную пересчитан фикстурный тест
`near-node report counts only coordinate values actually written to the
candidate` (`test/align-grid.test.mjs:53-88`): из восьми `noisy`-вхождений в
фикстуре ожидаемый вклад — poly (1: только x первой вершины), rect (1: x
дальнего угла через `x0+w0`), partition `accepted` (1), `wall_column` (1),
decor `line`/`box`/`text` (по 1), marker (1) = 8, ровно то, что проверяет
`assert.equal(result.report.coordsCanonicalized, 8)`. Партиция `rejected`
корректно исключена.
- **`OptimizeReport` наследует поле честно.** `plan-optimizer.ts:38` —
`OptimizeReport extends AlignReport`, а итоговый report собирается через
`{ ...alignReport, ... }` (`plan-optimizer.ts:522`) без явного поля —
`coordsCanonicalized` переносится автоматически, не задваивается и не
теряется; `changed` в `optimizePlans()` вычисляется отдельно, через полное
сравнение JSON конфигурации (`plan-optimizer.ts:513`), поэтому корректно
становится `true` даже если единственная правка — ULP-чистка.
- **`moved`/`maxShift*` не растут от ULP-правок.** Это гарантирует не новый, а
существующий `note()` (`src/align-grid.ts:181-187`, не изменён этим diff):
`moved` увеличивается только при `d > EPS`. Новый код добавляет отдельный
счётчик параллельно, не переиспользуя и не искажая этот путь.
- **UX-терминология.** Финальная строка `gs.optimize_changes` (RU: «обновлено
пространств: {c}; устранён шум координат: {p}»; EN аналогично) разводит два
счётчика без пересечения с занятым во всём проекте термином «нормализовано»/
«канонизировано» из `docs/CANVAS.md` — ровно то решение, которое было
согласовано на r2/r3 ревью ТЗ; я перечитал `docs/CANVAS.md:12,20,333,489,539`
и не нашёл нового конфликта термина с этой формулировкой.
- **Итоговый toast** (`gs.align_done`, `src/houseplan-card.ts:14288-14291`)
включает `coordsCanonicalized` в общую сумму обслуженных записей — предположение
§13.4 ТЗ реализовано буквально.
- **Отсутствие побочных путей записи.** Подтверждено чтением (см. AC8), что
`snapN`/`alignAllToGrid` недостижимы вне явного Optimize — обычные
read/render/Save не подвергаются риску скрытой мутации персистентных данных.
- **Три копии бандла синхронны**, коммит несёт верные трейлеры, оба changelog
правлены в том же коммите, что и поведение (`38f74e0`).
## Чего не проверял
- Полный browser-smoke набор (127 сценариев), `golden:verify`,
`pytest tests_backend`, performance-профили — не прогонялись; причины и
обоснование сужения — в таблице гейтов выше. Это решение ревьюера, а не
молчаливый пропуск.
- Ручного визуального прогона карточки в браузере (открыть демо, покликать
диалог глазами) не делал — заменён точечным browser-smoke и чтением
рендер-кода; в процессе фазы ручного тестирования нет (PROCESS.md §2.7), и
именно smoke здесь стоит на её месте.
- WSL/полный HA harness и Windows-специфичные smoke не запускались — сессия
ревью работает в Linux CI-подобном окружении, что и предусмотрено процессом
для код-ревью.
## Вердикт
Зелёный. High: 0. Medium: 0. Одна находка Low снята решением ревьюера с
записью (см. раздел «Находки») — правка не требуется.
+178
View File
@@ -0,0 +1,178 @@
# SPEC-REVIEW-223-r1
- Issue: [#223](https://github.com/Matysh/houseplan-card/issues/223) «Оптимизировать планы» должна
канонизировать координаты, а не консервировать floating-point шум
- ТЗ: [`docs/specs/223-optimize-coordinate-canonicalization.md`](../specs/223-optimize-coordinate-canonicalization.md)
на коммите `3a81dd6223095465f5563cf9cd76cd3ca5355bdb`
- Трек: обычный (не `small`) — оценка владельца в аналитике: сложность/риск 4/10, одна общая
функция координат + изменения в `AlignReport`/`OptimizeReport` + пользовательский текст + несколько
доказательных поверхностей
- Раунд: r1/4
- Вердикт: **жёлтый**
## Скоуп ревью
Первый цикл — разбор полный, по всему ТЗ и по коду затронутой поверхности
(`src/align-grid.ts`, `src/plan-optimizer.ts`, i18n-строка `gs.optimize_changes`,
`docs/CANVAS.md` §9.3/§9.5, `docs/USER-GUIDE.ru.md` §19). Задача не помечена `small`, ТЗ
существует файлом, что соответствует критерию — трек выбран верно.
## Как проверялось
1. `docs/SCOPE.md` — задача лежит в J6 («Keep the plan true as the home evolves»,
явное обслуживание плана уже входит в этот пункт), не расширяет продуктовый скоуп.
2. `AGENTS.md`/`PROCESS.md` §7.1 — обязательные разделы ТЗ проверены построчно (сценарий,
что видит человек, причина, scope/non-scope, контракт, UX, данные/миграция, AC,
план тестов, риски, откат, release-артефакты — все присутствуют).
3. Тело issue #223 и все три комментария (аналитика владельца, занятие, хендофф автора).
4. `docs/USER-GUIDE.ru.md` §19 «Обслуживание планов» — текущая формулировка таблицы
(«Комнаты — вершины округляются к сетке») и текст диалога.
5. `docs/CANVAS.md` §9.2–§9.5 целиком — контракт grid-bound/wall-bound, идемпотентность,
`optimizePlans`/`alignAllToGrid` как чистые функции.
6. Код: `src/align-grid.ts` (`snapN`, `note()`, все вызовы `snapN` по типам элементов —
room poly/rect, room_drafts, partitions, wall_columns, decor, layout), `src/plan-optimizer.ts`
(`canonicalized`/`meaningfulChanged`/стамп `model_version`), `src/logic.ts` (`snapToWall`,
подтверждение, что проёмы не используют `snapN`), `src/space-geometry.ts` (`GRID_N`,
`GRID_STEP_N` — подтверждает арифметику из тела issue), `src/i18n/{en,ru}.json`
(существующая строка `gs.optimize_changes` и её текущие параметры `m,c,w,s`),
`src/houseplan-card.ts` (рендер диалога, откуда видно, что `c` — это уже существующий
`r.canonicalized`, а не новый показатель), `test/align-grid.test.mjs`,
`test/plan-optimizer.test.mjs` (существующие покрытия и структура фикстур).
7. Мутация арифметики из тела issue (241 узел решётки, `GRID_N=240`) — подтверждена
константами в `src/space-geometry.ts`.
Ревью кода/AC не проводилось — этап `spec`, ручного запуска гейтов не требовалось.
## Находки
### Medium (в скоупе задачи) — AC4 не покрывает партиции и колонны, где `snapN()` вызывается безусловно, а результат применяется только условно
`src/align-grid.ts:239-253` — единственное место, где вычисленный `snapN()` может быть
**отброшен**: для `partitions` координаты `a`/`b` считаются всегда, но записываются в
объект (`p.a = a; p.b = b;`) только если `snappedLength > EPS && hostedFit`. Контракт
§6 ТЗ прямо требует третьего условия для счётчика — «результат действительно записан в
candidate» — именно ради таких случаев. Но §10 описывает реализацию как «локальную
tracked-обёртку над чистым `snapN()`», а обёртка, считающая на **входе** в вызов
(естественная первая реализация), увеличит `coordsCanonicalized` для отброшенной
партиции, нарушив собственный контракт ТЗ незаметно для проверяющего.
AC4 называет доказательную матрицу: «Units для room poly, rect/decor/layout и off-grid
negative case» — `partitions` и `wall_columns` в этот список не входят, хотя оба
являются grid-bound элементами по `docs/CANVAS.md` (раздел «Independent wall geometry»,
`partitions[].a/b`, `wall_columns[].center`) и оба — реальные вызовы `snapN()` в этом же
файле (`align-grid.ts:239-240` и `:262`). Код-ревью, сверяющее AC ровно по названному в
ТЗ списку тестов, примет реализацию, у которой партиции считаются неверно, потому что
эта ветка кода не названа ни в одном AC.
**Воспроизведение (почему это не гипотетический краевой случай):** партиция с проёмом,
чей `t`/`length` не помещается в границы после снапа стены (`hostedFit === false`) —
обычная ситуация при явном Optimize старого плана, где стена сама сдвигается. `a`/`b`
для такой партиции остаются шумными (что корректно — правка отклонена), но наивная
tracked-обёртка всё равно засчитает эти два компонента в `coordsCanonicalized`, хотя
candidate их не получил. Пользователь увидит положительный счётчик «канонизировано
координат» для координат, которые физически не изменились в записи.
**Фикс:** добавить в AC4 явное требование теста на партицию/колонну, для которой снап
вычислен, но не применён (та же ветка `else d = 0`, что уже используется для `moved`) —
счётчик должен остаться на прежнем значении для этого компонента. Технически это
решает автор (порядок вызова обёртки vs. присвоение), но без явного AC эта ветка кода
не гарантированно будет проверена ни тестом, ни рецензентом.
### Medium (в скоупе задачи) — новый показатель делит слово «канонизировано» с уже существующим показателем другого смысла в той же строке диалога
`src/i18n/ru.json:785` / `src/i18n/en.json:785` — действующая строка `gs.optimize_changes`
уже содержит «канонизировано планов: {c}» / «plans canonicalized: {c}», где `c` —
это `OptimizeReport.canonicalized` из `src/plan-optimizer.ts:404-500`: число
**пространств**, у которых изменилось JSON-представление `open_spans`/`open_to`-связей/
`walls` после rekey. Это уже существующий, отдельный от геометрии показатель.
ТЗ §7 предлагает добавить в **ту же общую строку** («в общей строке всегда, включая
ноль, так же как действующие счётчики миграций/планов/стен/виртуальных фрагментов»)
третий показатель с тем же корнем — RU «канонизировано координат: {p}», EN
«coordinates canonicalized: {p}». В итоге один и тот же диалог, в одном и том же
предложении, дважды использует «канонизировано» для двух несвязанных единиц счёта
(пространства vs. отдельные координатные компоненты). Это ровно тот отчёт, для
которого `docs/CANVAS.md` §9.5 и `AUD-158B1-01` в шапке `align-grid.ts` требуют
точности как условия доверия («A number that is merely typical... is worse than no
number at all»): диалог — единственный гейт перед действием без undo per-объектно,
и админ, сверяющий два числа с одинаковым словом, разумно может решить, что это
одна и та же категория или что числа должны совпадать/суммироваться.
ТЗ не упоминает этот уже существующий показатель `c` вовсе (ни в контракте §6, ни в
рисках §11), хотя он находится в той же строке, которую §7 правит. Риск в §11
("Счётчик вводит пользователя в заблуждение как число объектов") адресует другую
путаницу (координаты vs объекты), но не эту.
**Фикс (в скоупе, решает автор):** развести термины — например «канонизировано
пространств: {c}» для существующего показателя и оставить «канонизировано координат:
{p}» для нового, либо иначе разграничить формулировки так, чтобы два числа в одном
предложении не делили корень для разных единиц. Строка `gs.optimize_changes` в любом
случае правится этой задачей, так что это не выход за её границы.
## Что проверено и корректно
- **Причина и цифры воспроизведения (issue, раздел «Причина»)** — подтверждены
чтением `src/align-grid.ts:62-72`: `EPS = GRID_STEP_N * 1e-6 ≈ 4.17e-9`,
`snapN` действительно возвращает `v` при `|s-v| <= EPS`. Заявленный порядок
расхождения (`5.5e-17` против порога `~4.2e-9`, разница ~10⁸×) арифметически
верен.
- **Контракт снапа (§6 ТЗ)** — `Math.round(v / GRID_STEP_N) * GRID_STEP_N`, `NaN`/
`Infinity` не трогаются — совпадает с текущим кодом `snapN()` построчно (меняется
только ветвление возврата, не формула узла).
- **Идемпотентность после фикса рассуждением** — если первый прогон записал ровно
вычисленный узел `s = round(v/step)*step`, то повторный вызов той же формулы над
тем же `s` детерминированно вернёт `s` с разницей `0`, что по контракту §6 п.1
(«результат... численно отличается от входа») не считается канонизацией —
`coordsCanonicalized: 0` на втором прогоне (AC5) логически следует из формулировки
контракта, а не является отдельным недоказанным утверждением.
- **Проёмы верно исключены из нового счётчика** (§6: «Изменение угла проёма...
не входят в новый счётчик») — подтверждено чтением `snapToWall()` в `src/logic.ts:166-206`:
проекция на стену использует собственную квантизацию смещения вдоль стены, не
вызывает `snapN()` вовсе, что согласуется с WALL-BOUND правилом `docs/CANVAS.md` §9.3.
- **`snapN()` не вызывается за пределами `align-grid.ts`** (кроме тестов) — grep
подтверждает, что расширение поведения не задевает live-снаппинг редактора
(`_snap`/`snapToGrid`/`snapR` в других файлах — отдельные функции), что согласуется
с Non-scope п.1 («без автоматической канонизации на Save/импорте/рендере»).
AC8 корректно выделяет это в отдельный негативный тест.
- **Расчёт `moved`/`maxShift*` не растёт от канонизации** — `note()` (`align-grid.ts:169-175`)
фильтрует по `d > EPS`; после фикса разница `d` для «уже почти на узле» координаты
останется `<= EPS`, то есть `moved` не увеличится — контракт §6 п. «`maxShift`,
`maxShiftCm` и `maxSpace` не растут от замен в пределах EPS» реализуем без
дополнительных изменений `note()`.
- **Формат AC** — все десять критериев проверяемы и у каждого указан способ
доказательства (unit/regression/idempotence/i18n+smoke/existing suite/docs+bundle/gates),
что удовлетворяет DoR §2.5.
- **Отсутствие догадок, выданных за факт** — контракт §6 и границы §4/§5 совпадают
с уже задокументированным поведением `docs/CANVAS.md` §9.3/§9.5, а не изобретают
новое; единственные пять пунктов, отмеченных как решения автора, помечены явно
в §13 и являются техническими (не продуктовыми), что соответствует правилу
PROCESS.md §7.1.
- **Продуктовых вопросов владельцу нет** — сценарий, видимый результат и границы
зафиксированы однозначно в аналитике владельца; согласуется с J6.
- **Ссылка issue ↔ ТЗ** — `docs/specs/README.md` обновлён в том же коммите,
таблица содержит строку на #223.
- **Release-артефакты (§12 ТЗ)** — список документов для правки (CHANGELOG ru/en,
USER-GUIDE.ru.md, CANVAS.md, TESTING.md, STATUS.md) покрывает все места, где
сейчас зафиксировано текущее (неверное) поведение; ни один канонический документ
не остаётся расходящимся с новым контрактом.
## Чего не проверял
- Не проверялся сам код реализации — он не написан на этапе `spec`; находки выше
относятся к пробелам в AC/контракте ТЗ, а не к дефектам существующего кода.
- Не запускались тесты/гейты — не требуется на этапе ревью ТЗ.
- Не проверялась точная фикстура «6 комнат владельца» из #218 — координаты не
воспроизводились из треда #218 построчно; AC3 сформулирован достаточно строго
(`changed:true`, `moved:0`, `coordsCanonicalized>0`, `union` проходит), чтобы быть
проверяемым независимо от того, откуда именно автор возьмёт числа.
- Не оценивалось golden-влияние построением скриншота — принято на веру рассуждение
ТЗ «компоновка не меняется, добавляется текст в существующую строку»; риск
минимальный и явно закрыт пререлизным `golden:verify` по процессу.
## Вердикт
Вердикт: жёлтый · цикл r1/4 · High: 0 · Medium: 2 → в задаче
Обе находки Medium и в скоупе текущего issue (правят ровно тот AC4 и ту же
i18n-строку `gs.optimize_changes`, которые уже переписывает эта задача) — чинятся
в ТЗ без отдельного issue (владелец, 2026-08-19, #202).
+183
View File
@@ -0,0 +1,183 @@
# SPEC-REVIEW-223-r2
- Issue: [#223](https://github.com/Matysh/houseplan-card/issues/223) «Оптимизировать планы» должна
канонизировать координаты, а не консервировать floating-point шум
- ТЗ: [`docs/specs/223-optimize-coordinate-canonicalization.md`](../specs/223-optimize-coordinate-canonicalization.md)
на коммите `965711ee2005b8ec2587b9c70fb9b14735c7756e` (HEAD)
- Раунд: r2/4
- Вердикт: **жёлтый**
## Скоуп ревью (по дельте, PROCESS.md §2.10)
Round r1 получен на `c0fff33` (документ `docs/reviews/SPEC-REVIEW-223-r1.md`,
вердикт жёлтый, High: 0, Medium: 2). Предмет этого раунда —
`git diff c0fff33..965711e`: правки только в
`docs/specs/223-optimize-coordinate-canonicalization.md`, 27 добавлений / 15
удалений, разделы §7 (UX/запись), AC4, AC6, §10 (план реализации), §11 (риски),
§13 (принятые предположения). Ни код, ни другие документы, ни тело issue в
дельте не участвуют — дельта локальна текстом ТЗ, разбор полным не открывается
(критерии §2.10 «дельта не локальна» не выполнены: нет ребейза, нет смены
контракта, не задета новая подсистема, объём дельты — 42 строки против ~230
строк исходного ТЗ).
Отдельно проверено: не расширился ли скоуп задачи и не сломан ли этой правкой
какой-либо AC, который r1 уже принял (§2.10 п.5, регрессия по образцу #102) —
см. «Унаследовано из r1» ниже.
## Как проверялось
1. Восстановлен вердикт r1 и его SHA командой `gh issue view 223 --comments` —
`c0fff33`, назван в самом документе r1 (в этом раунде SHA назван, замечание
предыдущего цикла о неназванном SHA сюда не относится).
2. `git diff c0fff33..965711e` — построчный разбор всех правок (полный текст
выше в тред-контексте инструмента).
3. По каждой из двух находок r1 — проверено текстовое место закрытия (см.
таблицу ниже), не только заявление автора в хендофф-комментарии.
4. Для находки №2 (терминологическая коллизия) — проверено, не создаёт ли
выбранная замена термина новую коллизию: `grep` по `нормализ|normali` в
`docs/*.md` и `src/i18n/*.json` показал, что «normalised/нормализовано»
уже плотно занято во всём проекте другим, устоявшимся значением —
координата в диапазоне 0..1 холста (`docs/CANVAS.md:12,20,333,489,539`,
`docs/ARCHITECTURE.md:345,356` и др.), и что канонический документ самой
этой фичи (`docs/CANVAS.md` §9.5, строка 421) уже называет ровно ту
операцию, которую считает счётчик `c` (rekey `open_spans`/`open_to`),
термином **«canonicalisation»**, а не «normalisation». Разбор ниже.
5. AC4/AC6/§10/§11/§13 в новой редакции сверены на внутреннюю
непротиворечивость (совпадение формулировки контракта, доказательной
матрицы и риска для каждого изменения).
6. Комментарии issue #223 целиком, включая хендофф r1→r2, прочитаны заново на
предмет незаявленных изменений скоупа — не найдено.
Код не проверялся: реализация не написана, этап `spec`.
## Закрытие раунда r1
| Находка r1 | Чем закрыта | Где видно |
|---|---|---|
| Medium 1 — AC4 не покрывает `partitions`/`wall_columns`, наивная tracked-обёртка на входе засчитает отклонённый snap партиции | AC4 расширен явным требованием: «Принятая партиция и `wall_column` учитываются; у партиции с `hostedFit = false` вычисленный, но не записанный snap не учитывается и noisy endpoints сохраняются», доказательная матрица AC4 включает «применённой и отклонённой partition, wall column»; §10 переписан — вклад в `coordsCanonicalized` подтверждается «только в месте фактической записи значения в candidate», явно расписана ветка `snappedLength > EPS && hostedFit` и её `else`-ветвь; §11 получил строку риска «Отклонённый snap партиции попадает в отчёт» с мерой | `docs/specs/223-optimize-coordinate-canonicalization.md` §9 (AC4), §10, §11, коммит `965711e` |
| Medium 2 — новый показатель делит корень «канонизировано» с существующим `c` в одной строке диалога | §7 переписан: `c` получил метку «нормализовано пространств» / «spaces normalized», `p` — «устранён шум координат» / «noisy coordinate values removed»; текст явно называет оба значения раздельно и утверждает «два разных показателя не используют один термин "канонизировано" в одном предложении»; AC6 и §11 обновлены синхронно; §13 добавил пункт 3 о том, что переименование `c` затрагивает только текст, не структуру отчёта | `docs/specs/223-optimize-coordinate-canonicalization.md` §7, §9 (AC6), §11, §13, коммит `965711e` |
Буквальная коллизия слова «канонизировано» действительно устранена. Но выбор
замены для находки 2 создаёт новую проблему того же класса — см. находку ниже:
закрытие частично, регресс того же типа воспроизвёлся другим словом.
## Находки
### Medium (в скоупе задачи) — замена термина для находки r1 №2 меняет одну коллизию на другую: «нормализовано»/«normalized» уже устойчиво означает координатную систему 0..1, а не эту операцию
`docs/specs/223-optimize-coordinate-canonicalization.md` §7 (правка этого
раунда): существующий показатель `c` (`OptimizeReport.canonicalized` — число
пространств, где переписано JSON-представление `open_spans`/`open_to`/`walls`
после rekey) получает пользовательскую метку RU «нормализовано пространств»,
EN «spaces normalized».
Ровно это слово уже занято в проекте другим, никак не связанным значением —
координата в диапазоне 0..1 холста, а не операция над JSON-представлением
пространства:
- `docs/CANVAS.md:12` «the whole plan… as if the normalised unit square»;
- `docs/CANVAS.md:20` «device positions are still stored normalised»;
- `docs/CANVAS.md:333,489,539` — то же значение ещё трижды в том же файле;
- `docs/ARCHITECTURE.md:345` «All coordinates are **normalized (0..1 of the
canvas)**»; `:356` то же для layout v2.
Хуже: канонический документ именно этой фичи (`docs/CANVAS.md` §9.5, строка
421 — «Оптимизировать планы» explicit whole-plan maintenance, ровно тот пассаж,
который описывает проход, считаемый показателем `c`) уже называет эту
операцию своим устоявшимся именем — **«exact open-span canonicalisation»**, не
«normalisation». ТЗ заменяет пользовательскую метку операции с одного слова,
конфликтовавшего с `p` в том же предложении («канонизировано» дважды), на
слово, конфликтующее с устоявшимся термином координатной системы в том же
каноническом документе и во всём проекте — и одновременно расходящееся с тем,
как канонический документ называет саму эту операцию.
**Почему это не гипотетика, а конкретный сценарий чтения.** Строка диалога
после правки — «нормализовано пространств: {c}; устранён шум координат: {p}» —
это два счётчика **в одном предложении о геометрии/координатах**, один из
которых называется словом, которое во всей остальной документации означает
«координата приведена к 0..1». Администратор, читающий предпросмотр Optimize
рядом с формулировкой issue («канонизировать координаты») или с `docs/CANVAS.md`
(где то же слово стоит для другого понятия дважды на расстоянии нескольких
строк от «canonicalisation»), может прочитать «нормализовано пространств» как
третий, ещё один вид работы с координатами — то есть тот же класс путаницы,
который находка r1 №2 уже описывала для слова «канонизировано», просто с новым
словом. `docs/CANVAS.md` §9.5 и комментарий `AUD-158B1-01` в шапке
`align-grid.ts` (процитирован в ревью r1) требуют точности этого отчёта как
условия доверия — новый термин это условие не выполняет лучше старого.
**Фикс (в скоупе, решает автор):** не использовать «нормализовано»/«normalized»
для `c` — слово занято координатной системой во всём проекте, включая
канонический документ самой этой фичи. Канонический термин для операции,
которую считает `c`, уже есть в `docs/CANVAS.md` §9.5 — «canonicalisation»
(open-span); задача может либо оставить `c` на этом корне и развести его с `p`
через уточняющее существительное (что именно канонизировано — представление
связей пространства, а не координаты: например RU «пространств приведено к
каноническому представлению: {c}» / EN «spaces re-canonicalised: {c}»), либо
выбрать нейтральное слово без занятого во всём проекте значения (например
«обновлено»/«updated», «перелинковано»/«relinked»). Любой вариант закрывает
находку без создания новой при условии, что слово не совпадает ни с
«канонизировано» (уже занято `p`), ни с «нормализовано»/«normalized» (уже
занято координатной системой).
## Что проверено и корректно
- **Находка r1 №1 (AC4/`partitions`/`wall_columns`) закрыта по существу** —
контракт §6 п.3 («результат действительно записан в candidate») теперь
дословно отражён в AC4 и в плане реализации §10 с указанием точной ветки
(`snappedLength > EPS && hostedFit`); риск-таблица получила отдельную строку.
Ни один тест из новой доказательной матрицы не назван расплывчато.
- **Идемпотентность, контракт снапа §6, исключение проёмов, границы
scope/non-scope, release-артефакты, отсутствие продуктовых вопросов** — не
затронуты дельтой этого раунда, проверка не повторялась (см. «Унаследовано
из r1»).
- **Нумерация принятых предположений в §13** — после вставки нового пункта 3
весь список перенумерован без дублей и разрывов (1…6), проверено построчно.
- **Формулировка §7 больше не содержит самоссылки** — прежний текст перечислял
«счётчики миграций/**планов**/стен/виртуальных фрагментов» рядом с полем,
которое само и есть «планов»; новая редакция убрала слово из перечисления
(осталось «миграций/стен/виртуальных фрагментов») — мелкая, но корректная
правка, не нёсшая отдельного вреда, если бы её не сделали.
- **AC6 согласован с §7 построчно** — оба места одинаково называют оба
показателя и одинаково описывают случай `moved=0`.
## Чего не проверял
- Код реализации — не написан, этап `spec`.
- Гейты (`typecheck`/`test`/`build`) — не запускались; для этапа `spec` не
требуются (правка не затрагивает класс A/B, только `docs/specs/**`).
- Собственно текст `docs/USER-GUIDE.ru.md`/`docs/CANVAS.md` после будущей
реализации — они пока не переписаны (AC9 это будущая работа реализации), r2
проверял только формулировку самого ТЗ и её согласованность с уже
существующим текстом канонических документов.
- Все AC, не задетые дельтой (AC1, AC2, AC3, AC5, AC7, AC8, AC9, AC10) —
унаследованы из r1 без повторной проверки, ниже.
## Унаследовано из r1
Принято без повторной проверки в этом раунде, по документу
`docs/reviews/SPEC-REVIEW-223-r1.md` на SHA `c0fff33`:
- скоуп по `docs/SCOPE.md` (J6) и выбор обычного трека — не изменился, дельта
трек-критерии не затрагивает;
- обязательные разделы ТЗ по PROCESS.md §7.1 присутствуют и по форме верны;
- подтверждённая причина и порядок величин шума (issue, §3 ТЗ) — арифметика
`EPS`/`GRID_STEP_N` из `src/align-grid.ts:62-72`, не менялась;
- контракт снапа §6, идемпотентность (AC5), исключение проёмов из счётчика,
отсутствие вызовов `snapN()` вне `align-grid.ts`, отсутствие роста
`moved`/`maxShift*` от near-node замен — код и раздел §6 не входят в дельту
этого раунда;
- AC1, AC2, AC3, AC5, AC7, AC8, AC9, AC10 — доказательство ни одного из них
дельта не задевает (правки только в AC4/AC6/§7/§10/§11/§13);
- ссылка issue ↔ ТЗ в `docs/specs/README.md`, release-артефакты §12 — не
входят в дельту;
- отсутствие продуктовых вопросов владельцу — подтверждено r1, дельта
чисто техническая (формулировка отчёта и план реализации), новых
продуктовых вопросов не порождает.
## Вердикт
Вердикт: жёлтый · цикл r2/4 · High: 0 · Medium: 1 → в задаче
Находка Medium в скоупе текущего issue (правит ту же i18n-строку
`gs.optimize_changes` и тот же §7 ТЗ, что и вся задача) — чинится в этом ТЗ без
отдельного issue (владелец, 2026-08-19, #202).
+168
View File
@@ -0,0 +1,168 @@
# SPEC-REVIEW-223-r3
- Issue: [#223](https://github.com/Matysh/houseplan-card/issues/223) «Оптимизировать планы» должна
канонизировать координаты, а не консервировать floating-point шум
- ТЗ: [`docs/specs/223-optimize-coordinate-canonicalization.md`](../specs/223-optimize-coordinate-canonicalization.md)
на коммите `8c9e5feae3503c086cc9f987d57f676e5435e537` (HEAD)
- Раунд: r3/4
- Вердикт: **зелёный**
## Скоуп ревью (по дельте, PROCESS.md §2.10)
Round r2 получен на `965711ee2005b8ec2587b9c70fb9b14735c7756e` (SHA назван в
самом документе `docs/reviews/SPEC-REVIEW-223-r2.md`, вердикт жёлтый, High: 0,
Medium: 1). Предмет этого раунда — `git diff 965711e..HEAD`. Диапазон состоит
из двух коммитов:
- `981d3d6` — публикация документа `docs/reviews/SPEC-REVIEW-223-r2.md`
(артефакт самого ревью, не правка ТЗ);
- `8c9e5fe` («docs: clarify optimize report terminology») — единственная
содержательная правка: 4 вставки / 4 удаления в
`docs/specs/223-optimize-coordinate-canonicalization.md`, три места —
§7 (текст диалога), строка AC6 и строка риска в §11. Меняется только слово
«нормализовано»/«normalized» → «обновлено»/«updated» для существующего
счётчика `c`; структура, семантика, доказательная матрица AC не затронуты.
Дельта локальна текстом (§2.10): нет ребейза, нет смены контракта поведения,
не задета новая подсистема, объём (8 строк) на порядок меньше и исходного ТЗ
(~230 строк), и даже правки r2 (42 строки). Полный разбор не открывается.
Отдельно проверено, не расширяет ли эта правка скоуп и не ломает ли AC,
которые r1/r2 уже приняли (§2.10 п.5, регрессия по образцу #102) — нет: правка
меняет исключительно пользовательскую метку одного и того же существующего
показателя `c`, не его состав, не структуру `OptimizeReport`, не AC4/AC5/§6/§8.
## Как проверялось
1. Восстановлен вердикт r2 и его SHA командой `gh issue view 223 --json
comments` — `965711e`, назван в документе r2 (в этом раунде замечание
«SHA не назван» не применимо — второй раунд подряд его исправно называет).
2. `git show 8c9e5fe` — построчный разбор правки, единственная содержательная
правка в диапазоне.
3. Проверено буквальное закрытие находки r2: `grep -in "нормализ|normali"
docs/specs/223-optimize-coordinate-canonicalization.md` — осталось ровно
два вхождения: (а) `§6`, «нормализованного числа» — легитимное употребление
термина в его устоявшемся во всём проекте значении (координата 0..1
холста, никак не связано со счётчиком `c`); (б) `§11`, строка риска, где
слово упомянуто явно как «занятый термин, не используемый для `c`» — то
есть подтверждение отказа от него, а не остаточное использование. Нового
употребления «нормализовано»/«normalized» как метки счётчика `c` не
осталось нигде.
4. Проверено, не создаёт ли выбранная замена («обновлено»/«updated») новую
коллизию того же класса, которую уже дважды находил этот цикл ревью
(находка r1 №2 — «канонизировано» дважды; находка r2 — «нормализовано»
занято координатной системой):
- `grep -rn "\bupdated\b" docs/CANVAS.md docs/ARCHITECTURE.md` — ноль
совпадений: слово не занято описанием координатной системы ни в одном
каноническом документе;
- `grep -rn "обновлен" docs/*.md` — единственное релевантное совпадение
вне `CHANGELOG.ru.md` (журнал, не термин) — `toast.room_updated` /
«Комната обновлена» (`src/i18n/ru.json:562`, `en.json:562`): это
обобщённое подтверждение сохранения формы редактора комнаты, не термин
геометрии и не соседствует с координатным текстом Optimize — коллизии
смысла нет;
- `docs/CANVAS.md` §9.5 (строка 421) — канонический документ фичи называет
операцию, которую считает `c`, термином «exact open-span
canonicalisation»; «обновлено»/«updated» нейтрально по отношению к этому
термину и не конкурирует с ним за то же слово, в отличие от отвергнутых
«канонизировано» и «нормализовано»;
- выбранное слово — буквально один из двух вариантов по умолчанию,
предложенных самим ревью r2 в качестве примера («например
"обновлено"/"updated"»); автор использовал предложенный дефолт, а не
самостоятельно ввёл новый термин, что снижает риск третьей итерации той
же коллизии.
5. Сверено, что правка синхронна во всех трёх местах (§7, AC6, риск §11) и в
обоих языках (RU/EN) — да, во всех трёх местах пара
«обновлено»/«обновлённые»/«spaces updated» согласована, расхождений между
местами нет.
6. Перечитаны комментарии issue #223 целиком на предмет незаявленного
расширения скоупа — не найдено; хендофф r2→r3 (комментарий 2026-08-20
19:13:25Z) точно описывает сделанную правку.
7. Проверено происхождение семантики `c` в коде: `src/plan-optimizer.ts:404-500`
— `canonicalized++` инкрементируется по каждому элементу цикла
`for (let i = 0; i < config.spaces.length; i++)`, то есть ровно по
пространствам (`space`), чьё сериализованное представление
`open_spans`/`open_to`/`walls` изменилось. Формулировка «обновлено
пространств: {c}» / «spaces updated: {c}» точно соответствует коду,
который считает счётчик.
Код не проверялся: реализация не написана, этап `spec`, ни один файл класса
A/B в диапазоне `965711e..HEAD` не менялся (`git diff --stat` — только
`docs/reviews/**` и `docs/specs/**`).
## Закрытие раунда r2
| Находка r2 | Чем закрыта | Где видно |
|---|---|---|
| Medium — замена «нормализовано»/«normalized» для `c` заново создаёт коллизию того же класса: слово устойчиво занято координатной системой 0..1 во всём проекте, включая канонический документ фичи (`CANVAS.md`), который к тому же называет саму операцию `c` термином «canonicalisation», а не «normalisation» | Термин «нормализовано»/«normalized» заменён на «обновлено»/«updated» во всех трёх местах — §7 (диалоговая строка), AC6 (доказательная формулировка), §11 (строка риска); слово выбрано ровно из набора дефолтов, предложенных самим ревью r2, и не пересекается ни с «канонизировано» (занято `p`), ни с «нормализовано» (занято координатной системой) | `docs/specs/223-optimize-coordinate-canonicalization.md` §7, AC6 (таблица §9), §11, коммит `8c9e5fe` |
Закрытие точное и полное: `grep` не находит остаточного использования слова
в роли пользовательской метки счётчика, а выбранная замена проверена на тот
же класс коллизии, который дважды всплывал в предыдущих раундах (r1 №2, r2),
и не воспроизводит его в третий раз.
## Находки
Новых находок нет. High: 0, Medium: 0.
## Что проверено и корректно
- Находка r2 закрыта буквально и без побочной коллизии (см. выше).
- Три места правки (§7/AC6/§11) синхронны между собой и с новой пользовательской
формулировкой; ни AC4/AC5/§6/§8/§9(остальные AC)/§10/§12/§13, ни структура
`OptimizeReport`/`AlignReport` дельтой не задеты.
- `c` семантически описан точно тому, что делает код (`plan-optimizer.ts:500`).
- Скоуп задачи не расширился и не сузился; продуктовых вопросов по-прежнему
нет, дельта чисто терминологическая.
- Обязательные разделы ТЗ по PROCESS.md §7.1 присутствуют полностью (не
повторная проверка формы — унаследовано, см. ниже, — но сам факт, что
правка не удалила и не сломала ни один раздел, проверен диффом).
## Чего не проверял
- Код реализации — не написан, этап `spec`.
- Гейты (`typecheck`/`test`/`build`) — не запускались; дельта не затрагивает
класс A/B (только `docs/specs/**` и `docs/reviews/**`), гейты для этапа
`spec` не требуются.
- Текст `docs/USER-GUIDE.ru.md`/`docs/CANVAS.md`/`docs/CHANGELOG*` после
будущей реализации — они по плану переписываются в реализации (AC9,
§12), не в ТЗ; r3 проверял только формулировку самого ТЗ.
- Все AC и разделы, которых дельта `965711e..HEAD` не касается (AC1, AC2,
AC3, AC4, AC5, AC7, AC8, AC9, AC10, §1–§6, §8, §10, §12, §13) —
унаследованы из r2 без повторной проверки, ниже.
## Унаследовано из r2
Принято без повторной проверки в этом раунде, по документу
`docs/reviews/SPEC-REVIEW-223-r2.md` на SHA `965711ee2005b8ec2587b9c70fb9b14735c7756e`:
- скоуп по `docs/SCOPE.md` (J6) и выбор обычного трека — не изменился, дельта
трек-критерии не затрагивает;
- обязательные разделы ТЗ по PROCESS.md §7.1 присутствуют и по форме верны
(подтверждено ещё в r1, дельта r2 и r3 разделов не удаляла);
- подтверждённая причина и порядок величин шума (issue, §3 ТЗ) — арифметика
`EPS`/`GRID_STEP_N` из `src/align-grid.ts:62-72`, не менялась ни в r2, ни
здесь;
- контракт снапа §6, идемпотентность (AC5), исключение проёмов из счётчика,
отсутствие вызовов `snapN()` вне `align-grid.ts`, отсутствие роста
`moved`/`maxShift*` от near-node замен — код и раздел §6 не входят в дельту
ни r2, ни r3;
- AC4/`partitions`/`wall_columns` (находка r1 №1) — закрыта в r2, дельта r3
этих разделов (§9 AC4, §10, §11 соответствующей строки риска) не касается;
- AC1, AC2, AC3, AC4, AC5, AC7, AC8, AC9, AC10 — доказательство ни одного из
них дельта r3 не задевает (правка только в §7-фрагменте, строке AC6 и
строке риска §11, причём даже внутри AC6 меняется только слово, а не сама
проверяемая формулировка контракта);
- ссылка issue ↔ ТЗ в `docs/specs/README.md`, release-артефакты §12 — не
входят в дельту;
- отсутствие продуктовых вопросов владельцу — подтверждено r1 и r2, дельта r3
чисто терминологическая правка одной пользовательской строки, новых
продуктовых вопросов не порождает.
## Вердикт
Вердикт: зелёный · цикл r3/4 · High: 0 · Medium: 0
Обе находки предыдущих раундов (r1 №1, r1 №2/r2) закрыты по существу, третья
попытка того же класса коллизии не воспроизвелась. ТЗ готово к переходу в
«Готово к разработке».
@@ -0,0 +1,229 @@
# Issue #223 — Optimize канонизирует координаты без floating-point шума
- Дата: 2026-08-20
- Тип: bug / maintenance canonicalisation · приоритет P1
- Оценка: пользовательская ценность 8/10 · ценность для разработки 7/10 · сложность 4/10 · риск 4/10
- Issue: [#223](https://github.com/Matysh/houseplan-card/issues/223)
- Ветка: `issue/223-optimize-coordinate-canonicalization`
Канонические документы: `docs/SCOPE.md`, `docs/CANVAS.md`,
`docs/USER-GUIDE.ru.md`, `docs/CONFIG-COMPATIBILITY.md`, `docs/TESTING.md`.
## 1. Сценарий, персона и момент
Администратор обслуживает старый либо импортированный план через явное действие
«Общие настройки → Оптимизировать планы». Координаты комнат визуально уже лежат
на сетке, но содержат накопленный IEEE-754 шум величиной в один или несколько
ULP. Пользователь ожидает, что Optimize сохранит геометрию без видимого сдвига,
запишет канонические узлы сетки и тем самым восстановит надёжную работу
объединения комнат, Glow и другой downstream boolean-геометрии.
## 2. Что человек увидит до и после
**До:** Optimize считает значения в пределах `EPS` уже выровненными и возвращает
исходные числа бит-в-бит. В воспроизводимом плане из шести комнат preview
сообщает, что изменений нет, хотя шум около `5.5e-17` сохраняется и объединение
комнат может не распознать общую границу.
**После:** тот же явный Optimize предлагает изменение без видимого перемещения:
`сдвинуто элементов — 0`, но отдельно сообщает количество канонизированных
координат. После подтверждения общие вершины имеют ровно одинаковые числовые
значения, объединение комнат и Glow не зависят от прежнего ULP-шума. Повторный
Optimize сообщает, что изменений нет.
## 3. Подтверждённая причина
`snapN()` в `src/align-grid.ts` вычисляет ближайший узел `s`, но при
`abs(s - v) <= EPS` возвращает `v`. Защитное правило было введено ради
идемпотентности, однако фактически консервирует почти канонический ввод. Это
противоречит обещанию явного Optimize из `docs/CANVAS.md`: maintenance-действие
должно переписывать persisted-геометрию в каноническую форму.
Потребительская устойчивость из #218 остаётся необходимой страховкой на чтении,
но не очищает источник данных. #199 — будущая общая проверка кандидата перед
записью, а не исправление данной канонизации. Задачи не являются дубликатами.
## 4. Scope
- `snapN()` всегда возвращает вычисленный ближайший узел сетки для конечного
числа, в том числе когда отличие не превышает `EPS`;
- `alignAllToGrid()` отдельно считает канонические замены без заметного
перемещения и учитывает их в `changed`;
- `AlignReport` и `OptimizeReport` получают поле `coordsCanonicalized`;
- preview и итоговый toast Optimize на русском и английском объясняют случай
`moved = 0`, `changed = true`;
- сохраняются preview/Cancel/Apply/server Undo, идемпотентность, неизвестные
поля конфигурации и действующая model/schema compatibility;
- добавляются unit, targeted production-bundle smoke и mutation guard;
- актуализируются документы о канонизации координат.
## 5. Non-scope
- автоматическая канонизация при каждом Save, импорте, чтении или рендере;
- изменение шага сетки, `EPS`, координатной системы или допустимого
пользовательского размещения между узлами;
- замена устойчивых geometry-сравнений из #218 строгим равенством;
- общая pre-apply валидация/барьер из #199;
- исправление иных optimizer-pass, стен, проёмов либо миграций модели;
- новая persisted-схема, model-version, backend API или HA permission;
- новый control, жест, route либо предупреждение помимо существующего preview.
## 6. Контракт канонизации и отчёта
Для каждого конечного нормализованного числа `v`, переданного в `snapN()`,
канонический результат равен:
`Math.round(v / GRID_STEP_N) * GRID_STEP_N`.
Результат должен быть точно равен этому вычисленному узлу (`===`), а повторное
применение не должно менять значение. `NaN` и бесконечности сохраняют прежнее
поведение и возвращаются без преобразования.
`coordsCanonicalized` — число отдельных координатных компонент/узлов, для
которых в ходе `alignAllToGrid()` одновременно выполнено:
1. конечный результат `snapN(v)` численно отличается от входа;
2. абсолютная разница не превышает `EPS`;
3. результат действительно записан в candidate.
Считается каждое место использования координаты, а не уникальное числовое
значение и не объект целиком. Для прямоугольника его ближняя и дальняя стороны
считаются как координатные компоненты, даже когда дальняя сторона вычислена как
`x + w`. Заметное выравнивание с разницей `> EPS` остаётся только в `moved` и не
дублируется в `coordsCanonicalized`. Изменение угла проёма, удаление draft и
канонизация стен/спанов не входят в новый счётчик.
`AlignResult.changed` истинно при `moved > 0` **или**
`coordsCanonicalized > 0`; остальные существующие причины изменения сохраняют
свои текущие контракты. В `optimizePlans()` новый счётчик переносится без
потери в `OptimizeReport`. `maxShift`, `maxShiftCm` и `maxSpace` не растут от
замен в пределах `EPS`: пользовательское обещание о физическом сдвиге остаётся
честным.
## 7. UX, запись и Undo
Preview остаётся существующим диалогом. В строку обслуживания добавляется
отдельный показатель, а существующий счётчик `canonicalized` получает
однозначное название по своей единице измерения:
- RU: «обновлено пространств: {c}; устранён шум координат: {p}»;
- EN: «spaces updated: {c}; noisy coordinate values removed: {p}».
`c` по-прежнему означает число пространств, где переписано представление
`open_spans`/`open_to`/`walls`; `p` означает число отдельных coordinate values
по §6. Два разных показателя не используют один термин «канонизировано» в одном
предложении. Оба выводятся в общей строке всегда, включая ноль, так же как
действующие счётчики миграций/стен/виртуальных фрагментов. При единственном
изменении из-за ULP-шума диалог не показывает «изменений нет»: он показывает
нулевой видимый сдвиг и положительный `coordsCanonicalized`.
Итоговый toast использует новый показатель в сумме обслуженных записей, поэтому
после Apply не сообщает `0` обслуженных записей. Cancel не пишет candidate.
Apply отправляет тот же exact candidate через существующую backend-транзакцию;
Undo возвращает исходные noisy-значения в рамках действующего срока жизни
резервной копии.
Новых focus, keyboard, touch и screen-reader взаимодействий нет. Действующий
admin-only safety floor и подтверждение сохраняются.
## 8. Данные, compatibility и миграция
Persisted schema и `PLAN_MODEL_VERSION` не меняются. Числа остаются обычными
JSON number; меняется только их exact representation после добровольного
Optimize. Неизвестные поля, backdrop calibration, view boxes, unattached layout
entries и файлы сохраняются. Входные `config` и `layout` не мутируются.
Старые версии карточки читают получившиеся координаты как валидные значения на
той же сетке. Фоновая миграция не запускается. Повторный `optimizePlans()` над
собственным результатом возвращает глубокий эквивалент, `changed: false` и
`coordsCanonicalized: 0`.
## 9. Acceptance criteria
| AC | Критерий | Доказательство |
|---|---|---|
| AC1 | Для канонического узла с добавленным/вычтенным ULP `snapN()` возвращает ближайший вычисленный узел точно, а не исходный шум. | Focused `align-grid` unit; mutant `snapn-returns-input-near-node`. |
| AC2 | Значение дальше `EPS` продолжает выравниваться как раньше; не конечные числа сохраняют прежний результат. | Boundary unit matrix. |
| AC3 | Воспроизводимый fixture из шести комнат после Optimize имеет точно совпадающие общие вершины, `changed: true`, `moved: 0`, `coordsCanonicalized > 0`; union/downstream geometry успешно строится. | `plan-optimizer` regression unit на fixture из #223/#218. |
| AC4 | Счётчик считает отдельные компоненты только при разнице `<= EPS`, не дублирует заметно перемещённые элементы и не увеличивает `maxShift*`. Принятая партиция и `wall_column` учитываются; у партиции с `hostedFit = false` вычисленный, но не записанный snap не учитывается и noisy endpoints сохраняются. | Units для room poly, rect/decor/layout, применённой и отклонённой partition, wall column и off-grid negative case. |
| AC5 | Второй запуск над candidate возвращает `changed: false`, `coordsCanonicalized: 0` и побитово/глубоко тот же JSON. | Idempotence unit. |
| AC6 | RU/EN preview разными терминами показывает «обновлённые пространства» (`canonicalized`) и «устранённый шум координат» (`coordsCanonicalized`); случай `moved=0` не превращается в «нет изменений»; итоговый toast учитывает очищенные координаты. | i18n/UI unit + targeted production-bundle browser smoke. |
| AC7 | Preview не мутирует входы; Cancel ничего не пишет; Apply сохраняет exact candidate; Undo восстанавливает исходные noisy-координаты и неизвестные поля. | Optimizer immutability unit + targeted browser/backend smoke. |
| AC8 | Обычные read/render/Save без явного Optimize не переписывают persisted config; устойчивость #218 остаётся зелёной. | Existing regression suite + focused negative unit. |
| AC9 | Документация описывает явную exact-канонизацию и значение нового счётчика; три bundle-копии синхронны. | Docs check, bundle parity check. |
| AC10 | Рабочие gates зелёные. | typecheck, unit, build, targeted smoke, mutation gate. |
## 10. План реализации и тестов
В `alignAllToGrid()` вводится локальный tracked-механизм над чистым `snapN()`.
Он возвращает snapped value и признак near-node replacement отдельно, а вклад в
`coordsCanonicalized` подтверждается только в месте фактической записи значения
в candidate. В частности, `partitions` добавляет вклад четырёх endpoints лишь в
ветке `snappedLength > EPS && hostedFit`; отклонённая ветка оставляет и координаты,
и счётчик без изменения. `wall_columns` и остальные безусловно записываемые
grid-bound call sites подтверждают вклад сразу после присваивания. Счётчик
остаётся контекстом одного maintenance-pass и не добавляет глобального состояния
в базовый helper.
Unit matrix расширяется в `test/align-grid.test.mjs` и
`test/plan-optimizer.test.mjs`. Реальный noisy fixture должен сохранять точные
исходные числа, чтобы тест падал при возврате старой ветки `return v`.
Targeted smoke запускает Optimize через собранный production bundle и проверяет
Preview/Cancel/Apply/Undo вместе с RU/EN строкой. Golden baseline не требуется:
компоновка диалога не меняется, добавляется текстовый счётчик; перед бетой
выполняется общий golden verify по процессу.
Mutation entry возвращает старое поведение near-node `snapN()` либо отключает
tracked increment. Чистая ветка зелёная, мутант обязан падать на focused unit.
Полные golden/smoke/performance выполняются перед бетой, а в implementation-
цикле — typecheck, unit, build и targeted smoke из этого ТЗ.
## 11. Риски, производительность и security
| Риск | Мера |
|---|---|
| Счётчик вводит пользователя в заблуждение как число объектов | Точное название «координат» и семантика отдельных компонент в §6. |
| Два разных счётчика выглядят одной категорией канонизации | Нейтральное «обновлено пространств» и точное «устранён шум координат» явно называют разные единицы и действия; занятый термин нормализованных координат не используется для `c`. |
| Отклонённый snap партиции попадает в отчёт | Вклад подтверждается только после фактической записи; отдельный `hostedFit=false` unit. |
| Near-node rewrite ошибочно считается физическим сдвигом | Раздельные counters; `moved` и `maxShift*` не растут при `<= EPS`. |
| Идемпотентность нарушается из-за недвоичного шага сетки | Результат определяется тем же выражением узла; exact second-run unit для всех call sites. |
| Часть координат остаётся noisy | Все grid-bound вызовы `snapN()` в batch переводятся на tracked wrapper; реальный fixture. |
| Изменится обычный Save/render | Helper вызывается только в существующем explicit Optimize pipeline; AC8. |
Проход остаётся линейным по уже обходившимся координатам и добавляет одну exact-
проверку и сравнение на компоненту. Работы в render/state tick, новых сетевых
запросов, данных третьим сторонам, HTML-инъекций или изменений HA permissions
нет. Отдельный performance/security gate не требуется.
## 12. Release-артефакты и rollback
Изменение пользовательское. Implementation-коммит имеет `User-Visible: yes` и
включает:
- `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` со ссылкой на #223;
- `docs/USER-GUIDE.ru.md` — exact-канонизацию и новый показатель Optimize;
- `docs/CANVAS.md` — разграничение live snapping и явного maintenance-pass;
- `docs/TESTING.md` — unit/smoke/mutation coverage;
- `docs/STATUS.md` — фактическую release-линию;
- RU/EN i18n, unit, targeted smoke, mutation entry и три синхронные bundle-
копии.
Новая schema/migration, backend, screenshot baseline, performance baseline и
security artifact не нужны. Rollback — revert implementation-коммита. Уже
канонизированные координаты остаются валидными и визуально эквивалентными;
вернуть прежние noisy-биты можно только существующим Undo Optimize либо backup.
## 13. Принятые предположения
1. `coordsCanonicalized` считает координатные компоненты/узлы, а не объекты:
именно это даёт детерминированный отчёт для polygon и box call sites.
2. Near-node означает `abs(s - v) <= EPS`; граница включительна и не меняет
действующую константу tolerance.
3. Существующий `canonicalized` переименовывается только в пользовательском
тексте: структура и семантика report field не меняются.
4. Новый счётчик включается в существующее число «обслужено записей» итогового
toast, хотя единица там исторически агрегирует разные виды обслуживания.
5. Точное имя targeted smoke и размещение tracked-механизма являются техническим
решением, если AC и explicit-Optimize boundary сохраняются.
6. Продуктовых вопросов нет; эти предположения технические и могут быть свободно
скорректированы на ревью без дополнительного решения владельца.
+1
View File
@@ -61,6 +61,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
| [#219](https://github.com/Matysh/houseplan-card/issues/219) Единая палитра замков и glyph на оранжевых подложках | [219-lock-orange-palette.md](219-lock-orange-palette.md) |
| [#205](https://github.com/Matysh/houseplan-card/issues/205) Продолжение следа после короткой остановки пылесоса | [205-vacuum-trail-resume-grace.md](205-vacuum-trail-resume-grace.md) |
| [#226](https://github.com/Matysh/houseplan-card/issues/226) Entity-marker не дублируется родительским HA-устройством | [226-entity-parent-dedup.md](226-entity-parent-dedup.md) |
| [#223](https://github.com/Matysh/houseplan-card/issues/223) Optimize канонизирует координаты без floating-point шума | [223-optimize-coordinate-canonicalization.md](223-optimize-coordinate-canonicalization.md) |
## P2
+14
View File
@@ -40,6 +40,20 @@ import { fileURLToPath } from 'node:url';
// `find` обязан встречаться в файле ровно один раз: патч, который ложится «куда
// попало», проверяет не то, что объявлен проверять. Это контролирует --check.
export const MUTANTS = [
{
id: 'snapn-returns-input-near-node',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="snapN returns the exact nearest node" '
+ 'test/align-grid.test.mjs',
because: 'explicit Optimize must replace a stored ULP tail with the exact nearest grid node '
+ 'instead of preserving the noisy input merely because its displacement is visually tiny',
patches: [{
file: 'src/align-grid.ts',
find: ' return Math.round(v / GRID_STEP_N) * GRID_STEP_N;',
replace: ' const s = Math.round(v / GRID_STEP_N) * GRID_STEP_N;\n'
+ ' return Math.abs(s - v) <= EPS ? v : s;',
}],
},
{
id: 'union-quantization-removed',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+68 -22
View File
@@ -65,18 +65,17 @@ const WALL_TOL = GRID_STEP_N * 6;
/** What one cell is worth when a space does not say — the card's own default. */
const DEFAULT_CELL_CM = 5;
/** Round a NORMALISED coordinate to the nearest grid node, idempotently.
* A value already on a node is returned UNCHANGED (bit for bit), so a second
* run of the alignment writes nothing at all. */
/** Round a NORMALISED coordinate to the exact nearest grid node. */
export function snapN(v: number): number {
if (!Number.isFinite(v)) return v;
const s = Math.round(v / GRID_STEP_N) * GRID_STEP_N;
return Math.abs(s - v) <= EPS ? v : s;
return Math.round(v / GRID_STEP_N) * GRID_STEP_N;
}
export interface AlignReport {
/** Elements whose coordinates the run would change. */
moved: number;
/** Near-node coordinate components rewritten without a visible displacement. */
coordsCanonicalized: number;
/** Elements examined (rooms, decor shapes, openings, markers, labels). */
total: number;
/** Largest displacement in NORMALISED units (1 = the plan's width). */
@@ -151,8 +150,21 @@ export function alignAllToGrid(
const spaces = JSON.parse(JSON.stringify(spacesIn || []));
const layout: Record<string, any> = JSON.parse(JSON.stringify(layoutIn || {}));
let moved = 0, total = 0, maxShift = 0, maxShiftCm = 0, maxSpace = '', rotated = 0;
let coordsCanonicalized = 0;
let removedDrafts = 0;
/** Count only a near-node value which was actually written to the candidate. */
const noteCanonicalCoordinate = (before: number, after: number): void => {
if (Number.isFinite(before) && Number.isFinite(after)
&& before !== after && Math.abs(after - before) <= EPS) {
coordsCanonicalized++;
}
};
const noteCanonicalPoint = (before: number[], after: number[]): void => {
noteCanonicalCoordinate(before[0], after[0]);
noteCanonicalCoordinate(before[1], after[1]);
};
// a marker or a room label names its space; the scale of THAT space is the
// one its centimetres are in
const cellById: Record<string, number> = {};
@@ -186,6 +198,7 @@ export function alignAllToGrid(
r.poly = r.poly.map((p: number[]) => {
const q = [snapN(p[0]), snapN(p[1])];
d = Math.max(d, dist(p[0], p[1], q[0], q[1]));
noteCanonicalPoint(p, q);
return q;
});
} else if (r.x != null && r.y != null) {
@@ -198,6 +211,10 @@ export function alignAllToGrid(
const nw = Math.max(GRID_STEP_N, x2 - nx), nh = Math.max(GRID_STEP_N, y2 - ny);
d = boxShift(x0, y0, w0, h0, nx, ny, nw, nh);
r.x = nx; r.y = ny; r.w = nw; r.h = nh;
noteCanonicalCoordinate(x0, nx);
noteCanonicalCoordinate(y0, ny);
noteCanonicalCoordinate(x0 + w0, nx + nw);
noteCanonicalCoordinate(y0 + h0, ny + nh);
}
note(d, cell, sid);
}
@@ -206,17 +223,22 @@ export function alignAllToGrid(
for (const draft of sp.room_drafts || []) {
total++;
let d = 0;
const snapped = (draft.points || []).map((p: number[]) => {
const sourcePoints: number[][] = draft.points || [];
const snapped = sourcePoints.map((p: number[]) => {
const q = [snapN(p[0]), snapN(p[1])];
d = Math.max(d, dist(p[0], p[1], q[0], q[1]));
return q;
});
const points: number[][] = snapped.length ? [snapped[0]] : [];
const written: Array<[number[], number[]]> = snapped.length
? [[sourcePoints[0], snapped[0]]]
: [];
const segments: any[] = [];
for (let i = 0; i + 1 < snapped.length; i++) {
const next = snapped[i + 1], last = points[points.length - 1];
if (last && dist(last[0], last[1], next[0], next[1]) <= EPS) continue;
points.push(next);
written.push([sourcePoints[i + 1], next]);
segments.push({
...(draft.segments?.[i] || {}),
cm: Number(draft.segments?.[i]?.cm) || 15,
@@ -224,6 +246,9 @@ export function alignAllToGrid(
}
draft.points = points;
draft.segments = segments;
if (points.length >= 2) {
for (const [before, after] of written) noteCanonicalPoint(before, after);
}
note(d, cell, sid);
}
if (Array.isArray(sp.room_drafts)) {
@@ -236,10 +261,11 @@ export function alignAllToGrid(
// ---- independent partitions and columns --------------------------
for (const p of sp.partitions || []) {
total++;
const a = [snapN(p.a[0]), snapN(p.a[1])];
const b = [snapN(p.b[0]), snapN(p.b[1])];
let d = Math.max(dist(p.a[0], p.a[1], a[0], a[1]),
dist(p.b[0], p.b[1], b[0], b[1]));
const beforeA = [p.a[0], p.a[1]], beforeB = [p.b[0], p.b[1]];
const a = [snapN(beforeA[0]), snapN(beforeA[1])];
const b = [snapN(beforeB[0]), snapN(beforeB[1])];
let d = Math.max(dist(beforeA[0], beforeA[1], a[0], a[1]),
dist(beforeB[0], beforeB[1], b[0], b[1]));
const snappedLength = dist(a[0], a[1], b[0], b[1]);
const hostedFit = (sp.openings || [])
.filter((opening: any) => opening.host?.kind === 'partition'
@@ -253,15 +279,20 @@ export function alignAllToGrid(
&& along - length / 2 >= -EPS
&& along + length / 2 <= snappedLength + EPS;
});
if (snappedLength > EPS && hostedFit) { p.a = a; p.b = b; }
else d = 0;
if (snappedLength > EPS && hostedFit) {
p.a = a; p.b = b;
noteCanonicalPoint(beforeA, a);
noteCanonicalPoint(beforeB, b);
} else d = 0;
note(d, cell, sid);
}
for (const c of sp.wall_columns || []) {
total++;
const center = [snapN(c.center[0]), snapN(c.center[1])];
const d = dist(c.center[0], c.center[1], center[0], center[1]);
const before = [c.center[0], c.center[1]];
const center = [snapN(before[0]), snapN(before[1])];
const d = dist(before[0], before[1], center[0], center[1]);
c.center = center;
noteCanonicalPoint(before, center);
note(d, cell, sid);
}
@@ -270,20 +301,30 @@ export function alignAllToGrid(
total++;
let d = 0;
if (sh.kind === 'line') {
const a = [snapN(sh.x1), snapN(sh.y1)], b = [snapN(sh.x2), snapN(sh.y2)];
d = Math.max(dist(sh.x1, sh.y1, a[0], a[1]), dist(sh.x2, sh.y2, b[0], b[1]));
const beforeA = [sh.x1, sh.y1], beforeB = [sh.x2, sh.y2];
const a = [snapN(beforeA[0]), snapN(beforeA[1])];
const b = [snapN(beforeB[0]), snapN(beforeB[1])];
d = Math.max(dist(beforeA[0], beforeA[1], a[0], a[1]),
dist(beforeB[0], beforeB[1], b[0], b[1]));
sh.x1 = a[0]; sh.y1 = a[1]; sh.x2 = b[0]; sh.y2 = b[1];
noteCanonicalPoint(beforeA, a);
noteCanonicalPoint(beforeB, b);
} else {
const nx = snapN(sh.x), ny = snapN(sh.y);
const x0 = sh.x, y0 = sh.y, w0 = sh.w, h0 = sh.h;
const nx = snapN(x0), ny = snapN(y0);
if (sh.w != null && sh.h != null) {
const x2 = snapN(sh.x + sh.w), y2 = snapN(sh.y + sh.h);
const x2 = snapN(x0 + w0), y2 = snapN(y0 + h0);
const nw = Math.max(GRID_STEP_N, x2 - nx), nh = Math.max(GRID_STEP_N, y2 - ny);
d = boxShift(sh.x, sh.y, sh.w, sh.h, nx, ny, nw, nh);
d = boxShift(x0, y0, w0, h0, nx, ny, nw, nh);
sh.w = nw; sh.h = nh;
noteCanonicalCoordinate(x0 + w0, nx + nw);
noteCanonicalCoordinate(y0 + h0, ny + nh);
} else {
d = dist(sh.x, sh.y, nx, ny);
d = dist(x0, y0, nx, ny);
}
sh.x = nx; sh.y = ny;
noteCanonicalCoordinate(x0, nx);
noteCanonicalCoordinate(y0, ny);
}
note(d, cell, sid);
}
@@ -340,6 +381,8 @@ export function alignAllToGrid(
const nx = snapN(p.x), ny = snapN(p.y);
const d = dist(p.x, p.y, nx, ny);
layout[k] = { ...p, x: nx, y: ny };
noteCanonicalCoordinate(p.x, nx);
noteCanonicalCoordinate(p.y, ny);
// an entry whose space is gone still moves, and the promise must not
// shrink because of it: the largest scale on the plan is the safe one
const sid = typeof p.s === 'string' ? p.s : '';
@@ -348,7 +391,10 @@ export function alignAllToGrid(
return {
spaces, layout,
report: { moved, total, maxShift, maxShiftCm, maxSpace, rotated, removedDrafts },
changed: moved > 0,
report: {
moved, coordsCanonicalized, total, maxShift, maxShiftCm, maxSpace,
rotated, removedDrafts,
},
changed: moved > 0 || coordsCanonicalized > 0,
};
}
+3 -2
View File
@@ -14288,7 +14288,7 @@ class HouseplanCard extends LitElement {
this._showToast(this._t('gs.align_done', {
n: String(d.report.moved),
m: String(d.report.migrated + d.report.canonicalized
+ d.report.wallsMerged + d.report.spansMerged),
+ d.report.coordsCanonicalized + d.report.wallsMerged + d.report.spansMerged),
}));
} catch (e: any) {
if (this._alignDialog) this._alignDialog = { ...this._alignDialog, busy: false };
@@ -15291,7 +15291,8 @@ class HouseplanCard extends LitElement {
: nothing}
<p class="alignmsg">${this._t('gs.optimize_changes', {
m: String(r.migrated), c: String(r.canonicalized),
w: String(r.wallsMerged), s: String(r.spansMerged),
p: String(r.coordsCanonicalized), w: String(r.wallsMerged),
s: String(r.spansMerged),
})}</p>
${r.glowSpacesMigrated || r.glowRoomsMigrated
? html`<p class="alignmsg">${this._t('gs.optimize_glow_migration', {
+1 -1
View File
@@ -782,7 +782,7 @@
"gs.align_where": "The largest shift is in “{s}”.",
"gs.align_turned": "Openings whose angle is corrected: {n}.",
"gs.align_removed_drafts": "Invalid outlines collapsed by the grid and removed: {n}.",
"gs.optimize_changes": "Model migrations: {m}; plans canonicalized: {c}; merged real-wall fragments: {w}; virtual fragments: {s}.",
"gs.optimize_changes": "Model migrations: {m}; spaces updated: {c}; noisy coordinate values removed: {p}; merged real-wall fragments: {w}; virtual fragments: {s}.",
"gs.optimize_glow_migration": "Legacy Glow: {spaces} spaces → no data fill + independent Glow; {rooms} rooms → inherited data fill + independent Glow.",
"gs.align_warn": "Elements deliberately placed between grid nodes will move. One undo is available after the operation, only until the next plan edit.",
"gs.align_run": "Optimize",
+1 -1
View File
@@ -782,7 +782,7 @@
"gs.align_where": "Наибольший сдвиг — в пространстве «{s}».",
"gs.align_turned": "Проёмов с исправлением угла: {n}.",
"gs.align_removed_drafts": "Схлопнувшиеся на сетке некорректные контуры удалены: {n}.",
"gs.optimize_changes": "Миграций модели: {m}; канонизировано планов: {c}; объединено отрезков реальных стен: {w}; виртуальных: {s}.",
"gs.optimize_changes": "Миграций модели: {m}; обновлено пространств: {c}; устранён шум координат: {p}; объединено отрезков реальных стен: {w}; виртуальных: {s}.",
"gs.optimize_glow_migration": "Старый Glow: пространств — {spaces} → без заливки данных + независимый Glow; комнат — {rooms} → наследуемая заливка + независимый Glow.",
"gs.align_warn": "Элементы, намеренно поставленные между узлами, будут сдвинуты. После операции доступна одна отмена — только до следующего изменения плана.",
"gs.align_run": "Оптимизировать",
+51 -2
View File
@@ -38,14 +38,61 @@ const detunedLayout = () => ({
junk: { s: 'f1' }, // no coordinates: skipped
});
test('snapN is idempotent and leaves a node bit-identical', () => {
test('snapN returns the exact nearest node and is idempotent', () => {
const node = 12 / GRID_N;
assert.equal(snapN(node), node); // untouched, not "re-rounded"
assert.equal(snapN(node), node);
const canonical = 112 * S;
const noisy = 0.46666666666666673;
assert.notEqual(noisy, canonical);
assert.equal(snapN(noisy), canonical, 'a one-ULP tail must not survive Optimize');
const off = node + S / 3;
assert.ok(Math.abs(snapN(off) - node) < 1e-12);
assert.equal(snapN(snapN(off)), snapN(off));
});
test('near-node report counts only coordinate values actually written to the candidate', () => {
const canonical = 112 * S;
const noisy = 0.46666666666666673;
const spaces = [{
id: 'f1', rooms: [
{ id: 'poly', poly: [[noisy, 0.2], [0.6, 0.2], [0.6, 0.4], [0.2, 0.4]] },
{ id: 'rect', x: 0, y: 0.5, w: noisy, h: 0.2 },
],
partitions: [
{ id: 'accepted', a: [noisy, 0.2], b: [0.6, 0.2], cm: 15 },
{ id: 'rejected', a: [noisy, 0.4], b: [0.6, 0.4], cm: 15 },
],
openings: [{
id: 'outside-host', type: 'door', x: 0.5, y: 0.4, angle: 0, length: 0.05,
host: { kind: 'partition', id: 'rejected', t: 2 },
}],
wall_columns: [{ id: 'column', shape: 'circle', center: [noisy, 0.5], cm: 20 }],
decor: [
{ id: 'line', kind: 'line', x1: noisy, y1: 0.7, x2: 0.6, y2: 0.7 },
{ id: 'box', kind: 'rect', x: 0, y: 0.8, w: noisy, h: 0.1 },
{ id: 'text', kind: 'text', x: noisy, y: 0.9, text: 'x' },
],
}];
const result = alignAllToGrid(spaces, { marker: { s: 'f1', x: noisy, y: 0.5 } });
assert.equal(result.report.moved, 0, 'ULP cleanup is not a visible move');
assert.equal(result.report.maxShift, 0);
assert.equal(result.report.maxShiftCm, 0);
assert.equal(result.report.coordsCanonicalized, 8);
assert.equal(result.changed, true);
assert.equal(result.spaces[0].partitions[0].a[0], canonical);
assert.equal(result.spaces[0].partitions[1].a[0], noisy,
'hostedFit=false keeps the rejected endpoints and must not count them');
assert.equal(result.spaces[0].wall_columns[0].center[0], canonical);
assert.equal(result.layout.marker.x, canonical);
});
test('ordinary off-grid movement is not duplicated in the near-node counter', () => {
const result = alignAllToGrid([], { marker: { x: 0.2 + S / 3, y: 0.2 } });
assert.equal(result.report.moved, 1);
assert.equal(result.report.coordsCanonicalized, 0);
});
test('alignAllToGrid puts every grid-bound element on a node', () => {
const { spaces, layout, report } = alignAllToGrid(detuned().spaces, detunedLayout());
const sp = spaces[0];
@@ -111,6 +158,7 @@ test('idempotent: a second run moves nothing and changes nothing', () => {
const first = alignAllToGrid(detuned().spaces, detunedLayout());
const second = alignAllToGrid(first.spaces, first.layout);
assert.equal(second.report.moved, 0);
assert.equal(second.report.coordsCanonicalized, 0);
assert.equal(second.changed, false);
assert.deepEqual(second.spaces, first.spaces);
assert.deepEqual(second.layout, first.layout);
@@ -122,6 +170,7 @@ test('an already-aligned plan reports nothing to do', () => {
};
const r = alignAllToGrid(clean.spaces, { d1: { s: 'f1', x: 0.25, y: 0.5 } });
assert.equal(r.report.moved, 0);
assert.equal(r.report.coordsCanonicalized, 0);
assert.equal(r.changed, false);
assert.ok(r.report.total >= 2); // it still LOOKED at everything
});
+13
View File
@@ -27,6 +27,19 @@ test('i18n: placeholders match between languages', () => {
}
});
test('Optimize distinguishes updated spaces from cleaned coordinate noise', () => {
assert.equal(
en['gs.optimize_changes'],
'Model migrations: {m}; spaces updated: {c}; noisy coordinate values removed: {p}; merged real-wall fragments: {w}; virtual fragments: {s}.',
);
assert.equal(
ru['gs.optimize_changes'],
'Миграций модели: {m}; обновлено пространств: {c}; устранён шум координат: {p}; объединено отрезков реальных стен: {w}; виртуальных: {s}.',
);
assert.match(cardSource, /p: String\(r\.coordsCanonicalized\)/);
assert.match(cardSource, /d\.report\.coordsCanonicalized \+ d\.report\.wallsMerged/);
});
test('i18n: every literal help call has body and full aria keys in both languages', () => {
const allCalls = cardSource.match(/this\._help\(/g) || [];
const helpKeys = [...cardSource.matchAll(/this\._help\('([^']+\.help)'\)/g)].map((match) => match[1]);
+50
View File
@@ -4,6 +4,7 @@ import assert from 'node:assert/strict';
import {
collapseIsolatedWallThicknessIslands, optimizePlans, PLAN_MODEL_VERSION,
} from '../test-build/plan-optimizer.js';
import { unionBodies } from '../test-build/physical-geometry.js';
import { GRID_PITCH, GRID_STEP_N as S, NORM_W } from '../test-build/space-geometry.js';
import { wallKey } from '../test-build/wall-thickness.js';
@@ -17,6 +18,20 @@ const room = (id, x0, x1, openTo) => ({
const exactWall = (a, b, cm) => ({ key: wallKey(a, b, S), a, b, cm });
// Privacy-minimised six-room topology from #218/#223. The relevant stored ULP
// tails stay literal so this fixture proves that Optimize repairs the source,
// not merely that render-time boolean normalisation remains resilient.
const noisySixRoomFloor = [
[[0.46666666666666673, 0.7083333333333334], [0.6125, 0.9],
[0.4666666666666667, 1], [0.46666666666666673, 0.9]],
[[0.1625, 0.3], [0.3458333333333333, 0],
[0.46666666666666673, 1], [0.3458333333333333, 1]],
[[0.7, 0], [0.8, 0], [0.8, 0.7083333333333334], [0.7, 0.7083333333333334]],
[[0.7, 0.7083333333333335], [0.8, 0.7083333333333335], [0.8, 1], [0.7, 1]],
[[0.85, 0], [0.9, 0], [0.9, 0.4], [0.85, 0.4]],
[[0.85, 0.5], [0.9, 0.5], [0.9, 1], [0.85, 1]],
];
const microIntervalFixture = (length = S / 3, middleCm = 15, rightCm = 22) => {
const x0 = 0.2, split = 0.5, x1 = 0.8, y = 0.2;
return {
@@ -57,6 +72,41 @@ test('Optimize collapses one isolated thickness micro-interval and is idempotent
assert.deepEqual(second.config, first.config);
});
test('Optimize canonicalizes the six-room ULP source without claiming a visible move', () => {
const config = {
model_version: PLAN_MODEL_VERSION,
spaces: [{
id: 'noisy', title: 'Noisy', view_box: [0, 0, 1, 1], cell_cm: 5,
rooms: noisySixRoomFloor.map((poly, index) => ({ id: `room-${index}`, poly })),
}],
markers: [], settings: {}, future: { kept: true },
};
const before = structuredClone(config);
const first = optimizePlans(config, {});
assert.deepEqual(config, before, 'preview never mutates the noisy source');
assert.equal(first.changed, true);
assert.equal(first.report.moved, 0);
assert.equal(first.report.maxShift, 0);
assert.equal(first.report.maxShiftCm, 0);
assert.ok(first.report.coordsCanonicalized > 0);
assert.deepEqual(first.config.future, { kept: true });
for (const item of first.config.spaces[0].rooms) {
for (const [x, y] of item.poly) {
assert.equal(x, Math.round(x / S) * S);
assert.equal(y, Math.round(y / S) * S);
}
}
assert.ok(unionBodies(first.config.spaces[0].rooms.map((item) => item.poly)),
'downstream boolean geometry accepts the exact candidate');
const second = optimizePlans(first.config, first.layout);
assert.equal(second.changed, false);
assert.equal(second.report.coordsCanonicalized, 0);
assert.deepEqual(second.config, first.config);
assert.deepEqual(second.layout, first.layout);
});
test('micro-interval cleanup has a strict half-step boundary at both coordinate scales', () => {
for (const length of [S / 3, S / 2, S / 2 + S / 100]) {
const fixture = microIntervalFixture(length);