fix: keep Optimize idempotent across storage reload

Issue: #248
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-23 01:13:27 +03:00
parent 56fe4240d1
commit cbdd5a7e73
19 changed files with 636 additions and 173 deletions
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
+45 -45
View File
File diff suppressed because one or more lines are too long
+14 -2
View File
@@ -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)`
+7
View File
@@ -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
View File
@@ -8,6 +8,14 @@
## Не выпущено
- Повторный запуск «Оптимизировать планы» сразу после успешной оптимизации
теперь честно сообщает, что исправлять нечего, в том числе после события
обновления backend или перезагрузки страницы. Optimize сравнивает и оставляет
то же девятизнаковое представление геометрии, которое сохраняет интеграция,
поэтому двоичный шаг сетки `1/240` больше не возвращается как заново
устранённый шум координат
([#248](https://github.com/Matysh/houseplan-card/issues/248)).
- «Оптимизировать планы» теперь восстанавливает ссылки устройств, оставшиеся
после удаления или независимого импорта пространства. Для точной импортной
копии сохраняются позиции; в остальных случаях активное устройство теряет
+6
View File
@@ -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
View File
@@ -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) |
+12
View File
@@ -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
+2 -1
View File
@@ -1377,7 +1377,8 @@ show_signal: true
строка различает пространства с обновлённым представлением стен/связей и
координаты, в которых устранён только вычислительный шум. Поэтому возможен
честный предпросмотр «сдвинуто элементов — 0» с ненулевым числом очищенных
координат. Повторный Optimize над результатом ничего не предлагает.
координат. Повторный Optimize над результатом ничего не предлагает — ни сразу,
ни после серверного события обновления или перезагрузки страницы.
Неразрешённые позиции и вложенные сопоставления робота не удаляются: диалог
показывает их отдельным предупреждением даже тогда, когда применять больше
нечего.
+3 -3
View File
@@ -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-сводка перечисляет
исправления этого уровня;
+39
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
}
}
}
+72 -2
View File
@@ -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()
+43
View File
@@ -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:
+62
View File
@@ -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: