mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 11:18:48 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d295e7356 | ||
|
|
38f74e0209 | ||
|
|
73944bf58a | ||
|
|
8c9e5feae3 | ||
|
|
981d3d6ccd | ||
|
|
965711ee20 | ||
|
|
c0fff33322 | ||
|
|
3a81dd6223 |
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
Vendored
+5
-5
File diff suppressed because one or more lines are too long
+11
-1
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- «Оптимизировать планы» теперь устраняет микроскопический floating-point шум
|
||||
сохранённых координат сетки, даже если визуально ничего не сдвигается. В
|
||||
предпросмотре отдельно показаны обновлённые пространства и очищенные
|
||||
координаты, а повторный запуск становится точным no-op
|
||||
([#223](https://github.com/Matysh/houseplan-card/issues/223)).
|
||||
- Размещение отдельной сущности Home Assistant больше не оставляет рядом
|
||||
дублирующий автоматический маркер всего родительского устройства. В
|
||||
auto-marker теперь входят только оставшиеся активные видимые сущности, а при
|
||||
|
||||
+1
-1
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1270,14 +1270,19 @@ show_signal: true
|
||||
|---|---|
|
||||
| Миграции модели | Старые, однозначно преобразуемые поля переводятся в текущий формат; для `entity:switch.*` собственный switch в старом `controls` становится ролью «Всегда» (для `device:*` это делает сохранение настроек устройства, где доступен реестр HA) |
|
||||
| Масштаб сетки | Некорректный старый `cell_cm` приводится к допустимому диапазону 0,1–1000 см |
|
||||
| Комнаты | Вершины округляются к сетке |
|
||||
| Комнаты | Вершины записываются точными узлами сетки; микроскопический floating-point шум также устраняется без видимого сдвига |
|
||||
| Декор и мебель | Положение и размеры округляются к сетке |
|
||||
| Устройства и подписи комнат | Позиции округляются к сетке |
|
||||
| Проёмы | Возвращаются на ближайшую стену, смещение вдоль стены округляется, угол исправляется |
|
||||
| Стены | Одинаковые соседние участки толщины объединяются. Изолированный участок другой толщины короче половины шага сетки также схлопывается, только если с обеих сторон находятся участки одной толщины и на его концах нет вершины комнаты или границы проёма |
|
||||
| Виртуальные стены | Соседние/перекрывающиеся участки объединяются и приводятся к общей границе |
|
||||
|
||||
Перед записью диалог показывает количество затрагиваемых элементов, максимальный сдвиг в сантиметрах и пространство с этим сдвигом.
|
||||
Перед записью диалог показывает количество затрагиваемых элементов,
|
||||
максимальный сдвиг в сантиметрах и пространство с этим сдвигом. Отдельная
|
||||
строка различает пространства с обновлённым представлением стен/связей и
|
||||
координаты, в которых устранён только вычислительный шум. Поэтому возможен
|
||||
честный предпросмотр «сдвинуто элементов — 0» с ненулевым числом очищенных
|
||||
координат. Повторный Optimize над результатом ничего не предлагает.
|
||||
|
||||
### Что оптимизация сохраняет
|
||||
|
||||
|
||||
@@ -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 снята решением ревьюера с
|
||||
записью (см. раздел «Находки») — правка не требуется.
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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. Продуктовых вопросов нет; эти предположения технические и могут быть свободно
|
||||
скорректированы на ревью без дополнительного решения владельца.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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": "Оптимизировать",
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user