mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
fix: keep Optimize idempotent across storage reload
Issue: #248 User-Visible: yes
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
|
||||
// #223: explicit Optimize removes stored ULP noise without claiming a visible
|
||||
// move. Exercise the production bundle's Preview → Cancel → Apply → Undo flow.
|
||||
// #223/#248: explicit Optimize removes stored ULP noise once and stays a no-op
|
||||
// after the backend's nine-decimal write, event reload and cold reload.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 920, height: 840 });
|
||||
@@ -27,8 +27,25 @@ const out = await page.evaluate(async () => {
|
||||
markers: [], settings: {},
|
||||
};
|
||||
const clone = (value) => JSON.parse(JSON.stringify(value));
|
||||
const isCanonical = (value) => value === Math.round(value / S) * S;
|
||||
const canonicalNumber = (value) => {
|
||||
const sign = value < 0 || Object.is(value, -0) ? -1 : 1;
|
||||
const result = sign * (Math.floor(Math.abs(value) * 1e9 + 0.5) / 1e9);
|
||||
return result === 0 ? 0 : result;
|
||||
};
|
||||
const canonicalConfig = (source) => {
|
||||
const value = clone(source);
|
||||
for (const space of value.spaces || []) {
|
||||
for (const room of space.rooms || []) {
|
||||
if (room.poly) room.poly = room.poly.map(([x, y]) => [canonicalNumber(x), canonicalNumber(y)]);
|
||||
}
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const canonicalLayout = (source) => Object.fromEntries(Object.entries(clone(source))
|
||||
.map(([id, pos]) => [id, { ...pos, x: canonicalNumber(pos.x), y: canonicalNumber(pos.y) }]));
|
||||
const isCanonical = (value) => value === canonicalNumber(value);
|
||||
let serverConfig = clone(original), serverLayout = {}, backup = null;
|
||||
let configRev = 1, layoutRev = 1;
|
||||
let lastToast = '';
|
||||
const sent = [];
|
||||
const baseCall = card.hass.callWS.bind(card.hass);
|
||||
@@ -37,19 +54,27 @@ const out = await page.evaluate(async () => {
|
||||
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 };
|
||||
backup = {
|
||||
config: canonicalConfig(serverConfig), layout: canonicalLayout(serverLayout),
|
||||
};
|
||||
serverConfig = canonicalConfig(message.config);
|
||||
serverLayout = canonicalLayout(message.layout);
|
||||
configRev++; layoutRev++;
|
||||
return { ok: true, config_rev: configRev, layout_rev: layoutRev, 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 };
|
||||
configRev++; layoutRev++;
|
||||
return { ok: true, config_rev: configRev, layout_rev: layoutRev, can_undo: false };
|
||||
}
|
||||
if (message.type === 'houseplan/config/get')
|
||||
return { config: clone(serverConfig), rev: 3, can_write: true };
|
||||
return {
|
||||
config: clone(serverConfig), rev: configRev, can_write: true,
|
||||
can_optimize_undo: !!backup, undo_kind: backup ? 'optimize' : null,
|
||||
};
|
||||
if (message.type === 'houseplan/layout/get')
|
||||
return { layout: clone(serverLayout), rev: 3 };
|
||||
return { layout: clone(serverLayout), rev: layoutRev };
|
||||
return baseCall(message);
|
||||
},
|
||||
};
|
||||
@@ -83,19 +108,36 @@ const out = await page.evaluate(async () => {
|
||||
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.applyGeometryEqualsBackendTarget = card._serverCfg.model_version === serverConfig.model_version
|
||||
&& JSON.stringify(card._serverCfg.spaces) === JSON.stringify(serverConfig.spaces);
|
||||
result.applyLayoutEqualsBackendTarget = JSON.stringify(card._layout)
|
||||
=== JSON.stringify(serverLayout);
|
||||
result.applyPreservesUnknownFields = card._serverCfg.spaces[0].future?.kept === true;
|
||||
result.toastExplainsZeroMoveCleanup = lastToast.includes('0 elements moved')
|
||||
&& !lastToast.includes('0 records maintained');
|
||||
|
||||
await Promise.all([card._reloadConfigOnly(true), card._reloadLayoutOnly()]);
|
||||
card._openAlignDialog(); await card.updateComplete;
|
||||
result.secondRunIsExactNoOp = card._alignDialog?.changed === false
|
||||
result.serverEventReloadIsExactNoOp = card._alignDialog?.changed === false
|
||||
&& card._alignDialog.report.coordsCanonicalized === 0
|
||||
&& !card.renderRoot.querySelector('hp-dialog .btn.on');
|
||||
card._alignDialog = null; await card.updateComplete;
|
||||
|
||||
card._serverCfg = null; card._layout = {};
|
||||
card._cfgContentFingerprint = ''; card._layoutContentFingerprint = '';
|
||||
card._loadOk = false;
|
||||
await card._loadFromServer();
|
||||
card._openAlignDialog(); await card.updateComplete;
|
||||
result.coldReloadIsExactNoOp = 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);
|
||||
const canonicalOriginal = canonicalConfig(original);
|
||||
result.undoRestoresCanonicalSnapshot = card._serverCfg.model_version === canonicalOriginal.model_version
|
||||
&& JSON.stringify(card._serverCfg.spaces) === JSON.stringify(canonicalOriginal.spaces);
|
||||
result.undoIsOneDeep = card._canOptimizeUndo === false;
|
||||
return result;
|
||||
});
|
||||
|
||||
File diff suppressed because one or more lines are too long
Vendored
+45
-45
File diff suppressed because one or more lines are too long
+14
-2
@@ -507,6 +507,16 @@ either store changes, commits both revisions, and retains one snapshot.
|
||||
has changed since the optimization. A crash between store writes is
|
||||
completed from the intent on the next integration setup.
|
||||
|
||||
The pair returned by `optimizePlans` is canonicalized through the same
|
||||
nine-decimal allowlist as the storage writers **before** `changed` is computed.
|
||||
This boundary is required because the normalized grid step `1 / 240` has no
|
||||
finite decimal representation: a raw binary grid node and its persisted JSON
|
||||
number are semantically identical but not `===`. Update-event reload and a cold
|
||||
read therefore receive exactly the pair the preview already retained, and a
|
||||
second run cannot manufacture fresh coordinate noise (#248). Counters describe
|
||||
only the final persisted delta; an intermediate double which canonicalizes
|
||||
back to the input is not reported as work.
|
||||
|
||||
Guarantees are covered by `test/align-grid.test.mjs` and the orchestration/
|
||||
idempotence case in `test/plan-optimizer.test.mjs`:
|
||||
|
||||
@@ -520,8 +530,10 @@ idempotence case in `test/plan-optimizer.test.mjs`:
|
||||
it unfixable);
|
||||
* 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
|
||||
`coordsCanonicalized: 0`, and returns objects deep-equal to the first run's;
|
||||
* **idempotent across storage**: a second run in memory, after the nine-decimal
|
||||
writer round-trip, after update-event reload or after a cold read reports
|
||||
`moved: 0`, `changed: false`, and `coordsCanonicalized: 0`, and returns
|
||||
objects deep-equal to the first persisted result;
|
||||
* the report is an **upper bound**, not a sample (AUD-158B1-01).
|
||||
|
||||
Before a changed preview can expose Apply, `checkOptimizeGeometry(config)`
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Repeating “Optimize plans” immediately after a successful run now correctly
|
||||
reports that there is nothing to fix, including after the backend update
|
||||
event or a page reload. Optimize compares and retains the same nine-decimal
|
||||
geometry representation that the integration persists, so the binary
|
||||
`1/240` grid step no longer returns as freshly removed coordinate noise
|
||||
([#248](https://github.com/Matysh/houseplan-card/issues/248)).
|
||||
|
||||
- “Optimize plans” now restores device references left behind by deleted or
|
||||
independently imported spaces. Exact import copies keep their positions;
|
||||
other active devices safely lose only the missing placement and return via
|
||||
|
||||
@@ -8,6 +8,14 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Повторный запуск «Оптимизировать планы» сразу после успешной оптимизации
|
||||
теперь честно сообщает, что исправлять нечего, в том числе после события
|
||||
обновления backend или перезагрузки страницы. Optimize сравнивает и оставляет
|
||||
то же девятизнаковое представление геометрии, которое сохраняет интеграция,
|
||||
поэтому двоичный шаг сетки `1/240` больше не возвращается как заново
|
||||
устранённый шум координат
|
||||
([#248](https://github.com/Matysh/houseplan-card/issues/248)).
|
||||
|
||||
- «Оптимизировать планы» теперь восстанавливает ссылки устройств, оставшиеся
|
||||
после удаления или независимого импорта пространства. Для точной импортной
|
||||
копии сохраняются позиции; в остальных случаях активное устройство теряет
|
||||
|
||||
@@ -69,6 +69,12 @@ unknown fields in canonical representation, not the invisible noisy IEEE-754
|
||||
tail. A repeated canonical Save with the current revision is a no-op and does
|
||||
not invalidate the one-deep maintenance backup.
|
||||
|
||||
Optimize itself returns and compares that storage-canonical config/layout pair,
|
||||
not the higher-precision intermediate produced by the `1 / 240` grid formula.
|
||||
Consequently the normal commit, durable pending recovery, update-event reload
|
||||
and a cold read all converge on one JSON value set; feeding any of them back to
|
||||
Optimize is a no-op (#248).
|
||||
|
||||
## Open-passage opening type (#157)
|
||||
|
||||
`space.openings[].type` additionally accepts the literal `passage`. Its
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Version | **v1.67.0-beta.2 candidate** everywhere (manifest, const.py, package.json, CARD_VERSION) — second prerelease of the v1.67 line |
|
||||
| Current local cycle | v1.67.0-beta.2 packages three reviewed changes after beta.1: decor is composited above room fills (#231), opening symbols are centred across wall depth (#242), and real mouse dragging reorders space tabs with an exact insertion indicator (#243). Publication remains gated by the full exact-SHA Validate. |
|
||||
| Current local cycle | v1.67.0-beta.2 is published. Current `dev` restores orphaned device references during Optimize (#244); the #248 issue branch additionally makes Optimize idempotent across its nine-decimal backend write and reload boundary. The next prerelease remains gated by review and the full exact-SHA Validate. |
|
||||
| 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) |
|
||||
|
||||
@@ -2246,6 +2246,18 @@ require hands on real hardware — they remain for the human pass.
|
||||
test_ha_import_export; mutations: `schema-quantization-removed`,
|
||||
`frontend-writes-raw-coords`, `quantization-hits-allowlist`,
|
||||
`import-path-bypasses-schema`; pre-release: golden verify].
|
||||
- [ ] **Optimize remains idempotent across storage and reload (#248)**: its
|
||||
final config/layout pair equals the shared nine-decimal writer target;
|
||||
a second run in memory, after schema/storage round-trip, after update
|
||||
events and after a cold reload returns `changed:false`, zero change
|
||||
counters and a deep-equal pair. The shared two-scale fixture is consumed
|
||||
independently by Node and Python; the Optimize handler records the exact
|
||||
pair in pending and both final stores, and startup recovery converges on
|
||||
it [unit: plan-optimizer + coordinate-canonicalization; backend:
|
||||
test_coordinate_canonicalization + test_ha_websocket +
|
||||
test_ha_import_export; auto: smoke_optimize_coordinate_canonicalization;
|
||||
mutations: `optimize-storage-boundary-removed`,
|
||||
`optimize-config-storage-half-raw`, `optimize-layout-storage-half-raw`].
|
||||
- [ ] 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
|
||||
|
||||
@@ -1377,7 +1377,8 @@ show_signal: true
|
||||
строка различает пространства с обновлённым представлением стен/связей и
|
||||
координаты, в которых устранён только вычислительный шум. Поэтому возможен
|
||||
честный предпросмотр «сдвинуто элементов — 0» с ненулевым числом очищенных
|
||||
координат. Повторный Optimize над результатом ничего не предлагает.
|
||||
координат. Повторный Optimize над результатом ничего не предлагает — ни сразу,
|
||||
ни после серверного события обновления или перезагрузки страницы.
|
||||
Неразрешённые позиции и вложенные сопоставления робота не удаляются: диалог
|
||||
показывает их отдельным предупреждением даже тогда, когда применять больше
|
||||
нечего.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
- Оценка: пользовательская ценность 6/10 · ценность для разработки 8/10 · сложность 4/10 · риск 4/10
|
||||
- Issue: [#248](https://github.com/Matysh/houseplan-card/issues/248)
|
||||
- Ветка: `issue/248-optimize-idempotence`
|
||||
- Статус ТЗ: первая редакция, на ревью
|
||||
- Статус ТЗ: утверждено (SPEC-REVIEW-248-r1 green), реализовано в ветке задачи
|
||||
|
||||
Канонические документы: `docs/SCOPE.md`, `docs/CANVAS.md`,
|
||||
`docs/CONFIG-COMPATIBILITY.md`, `docs/TOUCH-SUPPORT.md`,
|
||||
@@ -250,8 +250,8 @@ Implementation-коммит получает `User-Visible: yes` и включа
|
||||
- `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` со ссылкой на #248;
|
||||
- `docs/CANVAS.md` — идемпотентность через storage/reload boundary;
|
||||
- `docs/CONFIG-COMPATIBILITY.md` — композиция Optimize с девятизнаковым writer;
|
||||
- `docs/USER-GUIDE.ru.md` — второй запуск после reload сообщает, что чинить
|
||||
нечего;
|
||||
- `docs/USER-GUIDE.ru.md` — существующая фраза об идемпотентности точечно
|
||||
уточнена границей reload (Low из SPEC-REVIEW-248-r1);
|
||||
- `docs/TESTING.md` — shared fixture, backend pair/recovery и mutation coverage;
|
||||
- `docs/STATUS.md` — только если текущая unreleased-сводка перечисляет
|
||||
исправления этого уровня;
|
||||
|
||||
@@ -286,6 +286,45 @@ export const MUTANTS = [
|
||||
+ ' return Math.abs(s - v) <= EPS ? v : s;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'optimize-storage-boundary-removed',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
+ '&& node --test --test-name-pattern="issue 248 Optimize stays" '
|
||||
+ 'test/plan-optimizer.test.mjs',
|
||||
because: 'the pure Optimize candidate must already equal the nine-decimal pair that the '
|
||||
+ 'backend writes, or a server-event reload turns 1/240 into fresh coordinate noise (#248)',
|
||||
patches: [{
|
||||
file: 'src/plan-optimizer.ts',
|
||||
find: ' const persistedConfig = canonicalizeConfigGeometry(config);\n'
|
||||
+ ' const persistedLayout = canonicalizeLayoutGeometry(aligned.layout);',
|
||||
replace: ' const persistedConfig = config;\n'
|
||||
+ ' const persistedLayout = aligned.layout;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'optimize-config-storage-half-raw',
|
||||
guard: 'node scripts/backend-test-guard.mjs storage_helpers_are_the_final_canonical_barrier '
|
||||
+ 'tests_backend/test_coordinate_canonicalization.py',
|
||||
because: 'the durable config half must remain the exact canonical target recorded by '
|
||||
+ 'Optimize intent even for internal writers which bypass WebSocket schema (#248)',
|
||||
patches: [{
|
||||
file: 'custom_components/houseplan/store.py',
|
||||
find: ' canonical_config = canonicalize_config_geometry(config)',
|
||||
replace: ' canonical_config = config',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'optimize-layout-storage-half-raw',
|
||||
guard: 'node scripts/backend-test-guard.mjs storage_helpers_are_the_final_canonical_barrier '
|
||||
+ 'tests_backend/test_coordinate_canonicalization.py',
|
||||
because: 'the durable layout half must remain the exact canonical target recorded by '
|
||||
+ 'Optimize intent even for internal writers which bypass WebSocket schema (#248)',
|
||||
patches: [{
|
||||
file: 'custom_components/houseplan/store.py',
|
||||
find: ' out["layout"] = canonicalize_layout_geometry(layout)',
|
||||
replace: ' out["layout"] = layout',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'union-quantization-removed',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
|
||||
+50
-18
@@ -9,6 +9,9 @@
|
||||
*/
|
||||
|
||||
import { alignAllToGrid, type AlignReport } from './align-grid';
|
||||
import {
|
||||
canonicalizeConfigGeometry, canonicalizeLayoutGeometry,
|
||||
} from './coordinate-canonicalization';
|
||||
import {
|
||||
DECOR_TEXT_BASE, decorTextScale, liveTextReference, liveTextToken, roomPoly,
|
||||
} from './logic';
|
||||
@@ -529,36 +532,65 @@ export function optimizePlans(
|
||||
if (canonicalAfter !== canonicalBefore) canonicalized++;
|
||||
}
|
||||
|
||||
// The storage barrier (#224) is part of Optimize's idempotence contract.
|
||||
// A 1/240 grid node has no finite decimal representation: comparing or
|
||||
// returning the raw binary result would let the backend's nine-decimal
|
||||
// canonical form look dirty again after the update event reloads it (#248).
|
||||
// Canonicalise the complete pair before both the diff and the return so the
|
||||
// preview, durable intent, live stores and next preview all see one target.
|
||||
const persistedConfig = canonicalizeConfigGeometry(config);
|
||||
const persistedLayout = canonicalizeLayoutGeometry(aligned.layout);
|
||||
|
||||
// A version marker is bookkeeping, not maintenance by itself. Persist it
|
||||
// only alongside a real config/layout transformation; otherwise an already
|
||||
// canonical plan would forever offer an Optimize action that changes no
|
||||
// user data. Never downgrade a model written by a newer client.
|
||||
const meaningfulChanged = JSON.stringify(config) !== original
|
||||
|| JSON.stringify(aligned.layout) !== originalLayout;
|
||||
const meaningfulChanged = JSON.stringify(persistedConfig) !== original
|
||||
|| JSON.stringify(persistedLayout) !== originalLayout;
|
||||
if (modelFrom < PLAN_MODEL_VERSION && meaningfulChanged) {
|
||||
config.model_version = PLAN_MODEL_VERSION;
|
||||
persistedConfig.model_version = PLAN_MODEL_VERSION;
|
||||
}
|
||||
|
||||
const changed = JSON.stringify(config) !== original
|
||||
|| JSON.stringify(aligned.layout) !== originalLayout;
|
||||
const modelTo = Number.isInteger(Number(config.model_version))
|
||||
? Number(config.model_version)
|
||||
const changed = JSON.stringify(persistedConfig) !== original
|
||||
|| JSON.stringify(persistedLayout) !== originalLayout;
|
||||
const modelTo = Number.isInteger(Number(persistedConfig.model_version))
|
||||
? Number(persistedConfig.model_version)
|
||||
: modelFrom;
|
||||
// Passes may briefly produce a more precise double which the shared storage
|
||||
// boundary maps straight back to the input. Such internal work is not a
|
||||
// persisted change and must not leak into the user-visible report.
|
||||
const persistedAlignReport: AlignReport = changed ? alignReport : {
|
||||
...alignReport,
|
||||
moved: 0,
|
||||
coordsCanonicalized: 0,
|
||||
maxShift: 0,
|
||||
maxShiftCm: 0,
|
||||
maxSpace: '',
|
||||
rotated: 0,
|
||||
removedDrafts: 0,
|
||||
};
|
||||
const persistedReferences: SpaceReferenceReport = changed ? references.report : {
|
||||
...references.report,
|
||||
spaceRefsRemapped: 0,
|
||||
roomRefsRemapped: 0,
|
||||
positionsRemapped: 0,
|
||||
markersDetached: 0,
|
||||
};
|
||||
return {
|
||||
config,
|
||||
layout: aligned.layout,
|
||||
config: persistedConfig,
|
||||
layout: persistedLayout,
|
||||
report: {
|
||||
...alignReport,
|
||||
...persistedAlignReport,
|
||||
modelFrom,
|
||||
modelTo,
|
||||
migrated,
|
||||
glowSpacesMigrated: migration.glowSpaces,
|
||||
glowRoomsMigrated: migration.glowRooms,
|
||||
canonicalized,
|
||||
wallsMerged,
|
||||
spansMerged,
|
||||
partitionsMerged,
|
||||
...references.report,
|
||||
migrated: changed ? migrated : 0,
|
||||
glowSpacesMigrated: changed ? migration.glowSpaces : 0,
|
||||
glowRoomsMigrated: changed ? migration.glowRooms : 0,
|
||||
canonicalized: changed ? canonicalized : 0,
|
||||
wallsMerged: changed ? wallsMerged : 0,
|
||||
spansMerged: changed ? spansMerged : 0,
|
||||
partitionsMerged: changed ? partitionsMerged : 0,
|
||||
...persistedReferences,
|
||||
},
|
||||
changed,
|
||||
};
|
||||
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"input": {
|
||||
"config": {
|
||||
"model_version": 7,
|
||||
"spaces": [
|
||||
{
|
||||
"id": "fine",
|
||||
"title": "Fine grid",
|
||||
"cell_cm": 3,
|
||||
"rooms": [
|
||||
{
|
||||
"id": "fine-room",
|
||||
"name": "Fine room",
|
||||
"poly": [
|
||||
[0.004166666766666667, 0],
|
||||
[0.008333333233333333, 0],
|
||||
[0.008333333233333333, 0.008333333433333333],
|
||||
[0.004166666766666667, 0.008333333433333333]
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "regular",
|
||||
"title": "Regular grid",
|
||||
"cell_cm": 5,
|
||||
"rooms": [
|
||||
{
|
||||
"id": "regular-room",
|
||||
"name": "Regular room",
|
||||
"poly": [
|
||||
[0.1000000001, 0.1],
|
||||
[0.1999999999, 0.1],
|
||||
[0.1999999999, 0.2000000001],
|
||||
[0.1000000001, 0.2000000001]
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"markers": [],
|
||||
"settings": {}
|
||||
},
|
||||
"layout": {
|
||||
"sensor.fine": {
|
||||
"s": "fine",
|
||||
"x": 0.004166666766666667,
|
||||
"y": 0.008333333433333333
|
||||
},
|
||||
"sensor.regular": {
|
||||
"s": "regular",
|
||||
"x": 0.1000000001,
|
||||
"y": 0.1999999999
|
||||
}
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"config": {
|
||||
"model_version": 7,
|
||||
"spaces": [
|
||||
{
|
||||
"id": "fine",
|
||||
"title": "Fine grid",
|
||||
"cell_cm": 3,
|
||||
"rooms": [
|
||||
{
|
||||
"id": "fine-room",
|
||||
"name": "Fine room",
|
||||
"poly": [
|
||||
[0.004166667, 0],
|
||||
[0.008333333, 0],
|
||||
[0.008333333, 0.008333333],
|
||||
[0.004166667, 0.008333333]
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "regular",
|
||||
"title": "Regular grid",
|
||||
"cell_cm": 5,
|
||||
"rooms": [
|
||||
{
|
||||
"id": "regular-room",
|
||||
"name": "Regular room",
|
||||
"poly": [
|
||||
[0.1, 0.1],
|
||||
[0.2, 0.1],
|
||||
[0.2, 0.2],
|
||||
[0.1, 0.2]
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"markers": [],
|
||||
"settings": {}
|
||||
},
|
||||
"layout": {
|
||||
"sensor.fine": {
|
||||
"s": "fine",
|
||||
"x": 0.004166667,
|
||||
"y": 0.008333333
|
||||
},
|
||||
"sensor.regular": {
|
||||
"s": "regular",
|
||||
"x": 0.1,
|
||||
"y": 0.2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import {
|
||||
collapseIsolatedWallThicknessIslands, optimizePlans, PLAN_MODEL_VERSION,
|
||||
} from '../test-build/plan-optimizer.js';
|
||||
import {
|
||||
canonicalizeConfigGeometry, canonicalizeLayoutGeometry, canonicalizeNumber,
|
||||
} from '../test-build/coordinate-canonicalization.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';
|
||||
@@ -18,6 +22,28 @@ const room = (id, x0, x1, openTo) => ({
|
||||
|
||||
const exactWall = (a, b, cm) => ({ key: wallKey(a, b, S), a, b, cm });
|
||||
|
||||
const coordinateFixture = JSON.parse(readFileSync(
|
||||
new URL('./fixtures/coordinate-canonicalization.json', import.meta.url),
|
||||
'utf8',
|
||||
));
|
||||
const storageRoundtripFixture = JSON.parse(readFileSync(
|
||||
new URL('./fixtures/optimize-storage-roundtrip.json', import.meta.url),
|
||||
'utf8',
|
||||
));
|
||||
|
||||
const assertNoPersistedChanges = (result) => {
|
||||
assert.equal(result.changed, false);
|
||||
for (const field of [
|
||||
'moved', 'coordsCanonicalized', 'rotated', 'removedDrafts', 'migrated',
|
||||
'glowSpacesMigrated', 'glowRoomsMigrated', 'canonicalized', 'wallsMerged',
|
||||
'spansMerged', 'partitionsMerged', 'spaceRefsRemapped', 'roomRefsRemapped',
|
||||
'positionsRemapped', 'markersDetached',
|
||||
]) assert.equal(result.report[field], 0, `${field} must describe the persisted delta`);
|
||||
assert.equal(result.report.maxShift, 0);
|
||||
assert.equal(result.report.maxShiftCm, 0);
|
||||
assert.equal(result.report.maxSpace, '');
|
||||
};
|
||||
|
||||
// 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.
|
||||
@@ -93,8 +119,8 @@ test('Optimize canonicalizes the six-room ULP source without claiming a visible
|
||||
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.equal(x, canonicalizeNumber(Math.round(x / S) * S));
|
||||
assert.equal(y, canonicalizeNumber(Math.round(y / S) * S));
|
||||
}
|
||||
}
|
||||
assert.ok(unionBodies(first.config.spaces[0].rooms.map((item) => item.poly)),
|
||||
@@ -107,6 +133,50 @@ test('Optimize canonicalizes the six-room ULP source without claiming a visible
|
||||
assert.deepEqual(second.layout, first.layout);
|
||||
});
|
||||
|
||||
test('issue 248 Optimize stays a no-op across the nine-decimal storage round-trip', () => {
|
||||
const inputBefore = structuredClone(storageRoundtripFixture.input);
|
||||
const first = optimizePlans(
|
||||
storageRoundtripFixture.input.config,
|
||||
storageRoundtripFixture.input.layout,
|
||||
);
|
||||
|
||||
assert.deepEqual(storageRoundtripFixture.input, inputBefore, 'preview must keep fixture input');
|
||||
assert.equal(first.changed, true);
|
||||
assert.ok(first.report.coordsCanonicalized > 0);
|
||||
assert.deepEqual(first.config, storageRoundtripFixture.expected.config);
|
||||
assert.deepEqual(first.layout, storageRoundtripFixture.expected.layout);
|
||||
assert.deepEqual(canonicalizeConfigGeometry(first.config), first.config);
|
||||
assert.deepEqual(canonicalizeLayoutGeometry(first.layout), first.layout);
|
||||
|
||||
const inMemorySecond = optimizePlans(first.config, first.layout);
|
||||
assertNoPersistedChanges(inMemorySecond);
|
||||
assert.deepEqual(inMemorySecond.config, first.config);
|
||||
assert.deepEqual(inMemorySecond.layout, first.layout);
|
||||
|
||||
const backendEcho = optimizePlans(
|
||||
canonicalizeConfigGeometry(first.config),
|
||||
canonicalizeLayoutGeometry(first.layout),
|
||||
);
|
||||
assertNoPersistedChanges(backendEcho);
|
||||
assert.deepEqual(backendEcho.config, first.config);
|
||||
assert.deepEqual(backendEcho.layout, first.layout);
|
||||
});
|
||||
|
||||
test('issue 248 every persisted geometry surface converges at every supported scale', () => {
|
||||
for (const cellCm of [1, 3, 5, 1000]) {
|
||||
const config = structuredClone(coordinateFixture.configInput);
|
||||
config.spaces[0].cell_cm = cellCm;
|
||||
const first = optimizePlans(config, coordinateFixture.layoutInput);
|
||||
assert.deepEqual(canonicalizeConfigGeometry(first.config), first.config, `config ${cellCm}`);
|
||||
assert.deepEqual(canonicalizeLayoutGeometry(first.layout), first.layout, `layout ${cellCm}`);
|
||||
|
||||
const second = optimizePlans(first.config, first.layout);
|
||||
assertNoPersistedChanges(second);
|
||||
assert.deepEqual(second.config, first.config, `config round-trip ${cellCm}`);
|
||||
assert.deepEqual(second.layout, first.layout, `layout round-trip ${cellCm}`);
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -33,6 +33,12 @@ FIXTURE = (
|
||||
/ "fixtures"
|
||||
/ "coordinate-canonicalization.json"
|
||||
)
|
||||
OPTIMIZE_ROUNDTRIP_FIXTURE = (
|
||||
Path(__file__).parents[1]
|
||||
/ "test"
|
||||
/ "fixtures"
|
||||
/ "optimize-storage-roundtrip.json"
|
||||
)
|
||||
|
||||
|
||||
def _fixture() -> dict:
|
||||
@@ -76,6 +82,17 @@ def test_backend_schemas_apply_the_same_allowlist() -> None:
|
||||
assert POS_SCHEMA(fixture["layoutInput"]["virtual"]) == fixture["layoutExpected"]["virtual"]
|
||||
|
||||
|
||||
def test_optimize_roundtrip_fixture_has_one_backend_canonical_target() -> None:
|
||||
"""#248: Python consumes the same cross-runtime target as the Node test."""
|
||||
fixture = json.loads(OPTIMIZE_ROUNDTRIP_FIXTURE.read_text(encoding="utf-8"))
|
||||
source = fixture["input"]
|
||||
expected = fixture["expected"]
|
||||
assert canonicalize_config_geometry(source["config"]) == expected["config"]
|
||||
assert canonicalize_layout_geometry(source["layout"]) == expected["layout"]
|
||||
assert CONFIG_SCHEMA(source["config"]) == expected["config"]
|
||||
assert LAYOUT_SCHEMA(source["layout"]) == expected["layout"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_storage_helpers_are_the_final_canonical_barrier(monkeypatch) -> None:
|
||||
fixture = _fixture()
|
||||
|
||||
@@ -1627,6 +1627,49 @@ async def test_setup_recovers_durable_import_pair(
|
||||
assert "optimize_pending" not in layout_data
|
||||
|
||||
|
||||
async def test_setup_recovers_exact_optimize_storage_roundtrip_pair(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""#248: startup recovery converges on the same shared canonical target."""
|
||||
fixture = json.loads((
|
||||
Path(__file__).parents[1]
|
||||
/ "test"
|
||||
/ "fixtures"
|
||||
/ "optimize-storage-roundtrip.json"
|
||||
).read_text(encoding="utf-8"))
|
||||
expected = fixture["expected"]
|
||||
|
||||
entry = await _setup(hass)
|
||||
rt = get_data(hass)
|
||||
assert rt is not None
|
||||
await rt.config_store.async_save({"config": DEFAULT_CONFIG, "rev": 1})
|
||||
await rt.store.async_save({
|
||||
"layout": {},
|
||||
"rev": 1,
|
||||
"optimize_backup": {"sentinel": True},
|
||||
"optimize_pending": {
|
||||
"kind": "optimize",
|
||||
"config": expected["config"],
|
||||
"layout": expected["layout"],
|
||||
"config_rev": 2,
|
||||
"layout_rev": 2,
|
||||
"clear_backup": False,
|
||||
},
|
||||
})
|
||||
|
||||
assert await hass.config_entries.async_reload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
recovered = get_data(hass)
|
||||
assert recovered is not None
|
||||
config_data = await recovered.config_store.async_load()
|
||||
layout_data = await recovered.store.async_load()
|
||||
assert config_data == {"config": expected["config"], "rev": 2}
|
||||
assert layout_data["layout"] == expected["layout"]
|
||||
assert layout_data["rev"] == 2
|
||||
assert layout_data["optimize_backup"] == {"sentinel": True}
|
||||
assert "optimize_pending" not in layout_data
|
||||
|
||||
|
||||
async def test_apply_rechecks_plan_file_under_the_write_lock(
|
||||
hass: HomeAssistant, tmp_path: Path,
|
||||
) -> None:
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
"""WebSocket API tests (CI): layout ops, config rev conflict, not_ready gate."""
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -530,6 +534,64 @@ async def test_optimize_undo_restores_geometry_but_not_legacy_noisy_bits(
|
||||
assert restored_layout["lamp"]["x"] == -0.12345679
|
||||
|
||||
|
||||
async def test_plan_optimize_persists_exact_storage_roundtrip_target(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, monkeypatch
|
||||
) -> None:
|
||||
"""#248: intent, normal commit and reads expose one canonical pair."""
|
||||
from custom_components.houseplan.store import get_data
|
||||
|
||||
fixture = json.loads((
|
||||
Path(__file__).parents[1]
|
||||
/ "test"
|
||||
/ "fixtures"
|
||||
/ "optimize-storage-roundtrip.json"
|
||||
).read_text(encoding="utf-8"))
|
||||
source = fixture["input"]
|
||||
expected = fixture["expected"]
|
||||
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
runtime = get_data(hass)
|
||||
assert runtime is not None
|
||||
|
||||
layout_writes = []
|
||||
real_layout_save = runtime.store.async_save
|
||||
|
||||
async def capture_layout_write(value: dict) -> None:
|
||||
layout_writes.append(copy.deepcopy(value))
|
||||
await real_layout_save(value)
|
||||
|
||||
monkeypatch.setattr(runtime.store, "async_save", capture_layout_write)
|
||||
await client.send_json_auto_id({
|
||||
"type": "houseplan/plan/optimize",
|
||||
"config": source["config"],
|
||||
"layout": source["layout"],
|
||||
"expected_config_rev": 0,
|
||||
"expected_layout_rev": 0,
|
||||
})
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
assert response["result"]["config_rev"] == 1
|
||||
assert response["result"]["layout_rev"] == 1
|
||||
|
||||
intent_write = next(
|
||||
item for item in layout_writes if "optimize_pending" in item
|
||||
)
|
||||
pending = intent_write["optimize_pending"]
|
||||
final_config = await runtime.config_store.async_load()
|
||||
final_layout = await runtime.store.async_load()
|
||||
|
||||
assert pending["config"] == expected["config"]
|
||||
assert pending["layout"] == expected["layout"]
|
||||
assert final_config["config"] == expected["config"]
|
||||
assert final_layout["layout"] == expected["layout"]
|
||||
assert "optimize_pending" not in final_layout
|
||||
assert json.dumps(pending["config"], sort_keys=True, separators=(",", ":")) == \
|
||||
json.dumps(final_config["config"], sort_keys=True, separators=(",", ":"))
|
||||
assert json.dumps(pending["layout"], sort_keys=True, separators=(",", ":")) == \
|
||||
json.dumps(final_layout["layout"], sort_keys=True, separators=(",", ":"))
|
||||
|
||||
|
||||
async def test_config_set_validates_new_marker_light_links(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator,
|
||||
) -> None:
|
||||
|
||||
Reference in New Issue
Block a user