Fix atomic model-v8 draft writes

Issue: #314
User-Visible: yes
This commit is contained in:
Matysh
2026-08-26 09:13:50 +03:00
parent 5da7812099
commit 2347e8df88
16 changed files with 657 additions and 198 deletions
File diff suppressed because one or more lines are too long
+16 -27
View File
@@ -102,8 +102,13 @@ def _legacy_wall_model_projection(config: dict) -> dict:
return projected
def _legacy_wall_geometry_projection(config: dict) -> list[dict]:
"""Only geometry whose edit requires a matching v8 identity update."""
def _catalog_coupled_wall_geometry_projection(config: dict) -> list[dict]:
"""Legacy geometry whose edit must also update the v8 contour catalogue.
Drafts, partitions and columns carry their own ids. Openings carry an
explicit v8 host and are checked against it by CONFIG_SCHEMA. None of those
independent objects requires a different contour wall_segments catalogue.
"""
result: list[dict] = []
for space in config.get("spaces") or []:
rooms = []
@@ -111,28 +116,11 @@ def _legacy_wall_geometry_projection(config: dict) -> list[dict]:
rooms.append({key: copy.deepcopy(room.get(key)) for key in (
"id", "poly", "x", "y", "w", "h", "open_to"
) if key in room})
openings = []
for opening in space.get("openings") or []:
openings.append({key: copy.deepcopy(opening.get(key)) for key in (
"id", "type", "x", "y", "angle", "length"
) if key in opening})
drafts = []
for draft in space.get("room_drafts") or []:
drafts.append({
"id": draft.get("id"),
"points": copy.deepcopy(draft.get("points")),
"segments": [
{"cm": segment.get("cm")} for segment in draft.get("segments") or []
],
})
result.append({
"id": space.get("id"),
"rooms": rooms,
"walls": copy.deepcopy(space.get("walls") or []),
"open_spans": copy.deepcopy(space.get("open_spans") or []),
"room_drafts": drafts,
"partitions": copy.deepcopy(space.get("partitions") or []),
"openings": openings,
})
return result
@@ -186,12 +174,12 @@ def validate_wall_model_transition(config: dict, previous: dict | None) -> None:
except (TypeError, ValueError):
return # CONFIG_SCHEMA owns malformed values.
previous = previous or {}
geometry_changed = (
_legacy_wall_geometry_projection(config)
!= _legacy_wall_geometry_projection(previous)
contour_geometry_changed = (
_catalog_coupled_wall_geometry_projection(config)
!= _catalog_coupled_wall_geometry_projection(previous)
)
if old_model >= 8 and new_model < 8:
if not geometry_changed:
if not contour_geometry_changed:
_restore_wall_model_fields(config, previous or {})
# Re-run semantic parity after hydration; previous identity is
# accepted only when it still matches the submitted projections.
@@ -202,12 +190,13 @@ def validate_wall_model_transition(config: dict, previous: dict | None) -> None:
raise WallModelClientOutdatedError(
f"stored model={old_model}; submitted model={new_model}"
)
if old_model >= 8 and new_model >= 8 and geometry_changed:
if old_model >= 8 and new_model >= 8 and contour_geometry_changed:
# The realistic stale-client case echoes model_version and the unknown
# catalogue verbatim while changing rooms/walls/openings. Let the
# catalogue verbatim while changing room/contour geometry. Let the
# frontend show the dedicated reload guidance instead of a generic
# schema error. A current writer necessarily changes the catalogue in
# the same transaction.
# schema error. Independent drafts, partitions, columns and explicitly
# hosted openings are validated by CONFIG_SCHEMA without requiring a
# contour catalogue change (#314).
if _wall_catalog_projection(config) == _wall_catalog_projection(previous):
raise WallModelClientOutdatedError(
f"stored model={old_model}; unchanged wall catalogue"
+227
View File
@@ -0,0 +1,227 @@
/** #314: model-v8 draft identities survive writes/Undo; rejected writes fail closed. */
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 760 });
const result = await page.evaluate(async () => {
const out = {};
const card = window.__card;
const clone = (value) => JSON.parse(JSON.stringify(value));
const sleep = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));
const emptyConfig = () => ({
model_version: 8,
spaces: [{
id: 'v8-draft', title: 'v8 draft', cell_cm: 5,
view_box: [0, 0, 1, 0.7], rooms: [], wall_segments: [],
}],
markers: [], settings: {},
});
let server;
let rev;
let writes;
let writeBehavior;
const installWs = () => {
const fallback = card.hass.callWS.bind(card.hass);
card.hass = { ...card.hass, callWS: async (message) => {
if (message.type === 'houseplan/config/set') {
const candidate = clone(message.config);
writes.push(candidate);
if (writeBehavior) await writeBehavior(candidate, message);
const missingDraftId = candidate.spaces.some((space) =>
(space.room_drafts || []).some((draft) =>
(draft.segments || []).some((segment) =>
typeof segment.id !== 'string' || !segment.id)));
if (missingDraftId) {
const error = new Error('v8 draft wall segments require ids');
error.code = 'invalid_format';
throw error;
}
server = candidate;
rev += 1;
return { ok: true, rev };
}
if (message.type === 'houseplan/config/get') {
return { config: clone(server), rev };
}
return fallback(message);
} };
};
const reset = async () => {
card._saveConfigDebounced.cancel();
await card._writeChain.catch(() => undefined);
server = emptyConfig();
rev = 1;
writes = [];
writeBehavior = null;
card._serverCfg = clone(server);
card._cfgRev = rev;
card._cfgContentFingerprint = '';
card._pendingPhysicalWrites.clear();
card._geometryHistory.clear();
card._clearGeometryGesture();
card._toast = '';
card._space = 'v8-draft';
card._layout = {};
card._modelCache = null;
card._wallUnionCache = null;
card._physicalBodiesCache = null;
card._frame = null;
card._cfgEpoch += 1;
card._drawWallField = '15';
card._setMode('plan');
card._tool = 'draw';
await card.updateComplete;
};
const persistPath = async (points) => {
card._path = points.map((point) => [...point]);
card._draftSegmentCms = points.slice(1).map(() => 15);
card._persistActiveDraftSegment();
card._saveConfigDebounced.flush();
await sleep(0);
};
const settleWrites = async () => {
await card._writeChain.catch(() => undefined);
await sleep(40);
await card._writeChain.catch(() => undefined);
};
const clickPoint = async (x, y, extra = {}) => {
const stage = (card.shadowRoot || card.renderRoot).querySelector('.stage');
const rect = stage.getBoundingClientRect();
const view = card._viewOr(card._baseVb());
card._markupClick(new MouseEvent('click', {
clientX: rect.left + ((x - view.x) / view.w) * rect.width,
clientY: rect.top + ((y - view.y) / view.h) * rect.height,
bubbles: true, ...extra,
}));
await card.updateComplete;
};
installWs();
// Write-time sanitation must keep the segment corresponding to each
// surviving edge, rather than positional-copying the skipped zero edge.
await reset();
card._serverCfg.spaces[0].room_drafts = [{
id: 'draft-sanitize',
points: [[0.1, 0.1], [0.1, 0.1], [0.3, 0.1], [0.3, 0.1], [0.3, 0.3]],
segments: [
{ id: 'zero-a', cm: 11 }, { id: 'edge-a', cm: 21 },
{ id: 'zero-b', cm: 12 }, { id: 'edge-b', cm: 22 },
],
}, {
id: 'draft-intentional', points: [[0.6, 0.1], [0.7, 0.1]],
segments: [{ id: 'draft-intentional-edge', cm: 18 }],
}];
card._serverCfg.spaces[0].partitions = [{
id: 'partition-intentional', a: [0.6, 0.3], b: [0.7, 0.3], cm: 20,
}];
card._dropLegacySegments();
const sanitized = card._serverCfg.spaces[0].room_drafts[0];
out.sanitationKeepsCarrierIds = JSON.stringify(
sanitized.segments.map((segment) => [segment.id, segment.cm]),
) === JSON.stringify([['edge-a', 21], ['edge-b', 22]]);
out.sanitationPreservesExistingIndependentObjects =
card._serverCfg.spaces[0].partitions?.[0]?.id === 'partition-intentional'
&& card._serverCfg.spaces[0].room_drafts?.[1]?.id === 'draft-intentional'
&& card._serverCfg.spaces[0].room_drafts[1].segments[0].id === 'draft-intentional-edge';
// Successful physical writes carry model-v8 IDs all the way to the fake
// backend, and Undo keeps the identity of every surviving edge.
await reset();
await persistPath([[100, 100], [300, 100], [300, 300]]);
await settleWrites();
const initialIds = server.spaces[0].room_drafts[0].segments.map((segment) => segment.id);
card._undoPoint();
card._saveConfigDebounced.flush();
await settleWrites();
const undone = server.spaces[0].room_drafts[0];
out.successfulWriteHasStableIds = initialIds.length === 2
&& initialIds.every((id) => typeof id === 'string' && !!id)
&& undone.segments.length === 1 && undone.segments[0].id === initialIds[0];
// Exercise the real Walls click/close/promote path, not only its persistence
// primitive. Every completed edge crosses fake config/set independently.
await reset();
await clickPoint(100, 100);
for (const point of [[300, 100], [300, 300], [100, 300], [100, 100]]) {
await clickPoint(point[0], point[1]);
card._saveConfigDebounced.flush();
await settleWrites();
}
const openedRoomDialog = !!card._roomDialog;
card._nameSel = 'Persisted room';
card._saveRoom();
card._saveConfigDebounced.flush();
await settleWrites();
await card._reloadConfigOnly(true);
const savedSpace = card._serverCfg.spaces[0];
out.roomCloseOpensDialog = openedRoomDialog;
out.roomSurvivesCloseAndReload = savedSpace.rooms.some((room) => room.name === 'Persisted room');
out.roomCloseLeavesNoDebris = !savedSpace.room_drafts?.length && !savedSpace.partitions?.length;
out.roomCloseHasOnlySuccessToast = card._toast
=== card._t('toast.wall_rooms_saved', { n: 1 });
// Two edits made while F1 is in flight are serialized. If F1 succeeds, F2
// retains both accepted edges and their identities.
await reset();
let releaseFirst;
const firstGate = new Promise((resolve) => { releaseFirst = resolve; });
let writeNumber = 0;
writeBehavior = async () => {
writeNumber += 1;
if (writeNumber === 1) await firstGate;
};
await persistPath([[100, 100], [300, 100]]);
await sleep(20);
await persistPath([[100, 100], [300, 100], [300, 300]]);
await sleep(20);
out.successQueueIsSerialized = writes.length === 1;
releaseFirst();
await settleWrites();
const queuedDraft = server.spaces[0].room_drafts[0];
out.successQueueRetainsF2 = writes.length === 2
&& queuedDraft.segments.length === 2
&& queuedDraft.segments.every((segment) => typeof segment.id === 'string' && !!segment.id);
// A rejected F1 invalidates the whole optimistic transaction, including F2
// authored on the same unaccepted baseline. The plan is safe before the
// best-effort config/get finishes and cannot later create a ghost partition.
await reset();
let rejectFirst;
const rejectGate = new Promise((resolve) => { rejectFirst = resolve; });
writeNumber = 0;
writeBehavior = async () => {
writeNumber += 1;
if (writeNumber === 1) {
await rejectGate;
const error = new Error('server rejected draft');
error.code = 'invalid_format';
throw error;
}
};
await persistPath([[100, 100], [300, 100]]);
await sleep(20);
await persistPath([[100, 100], [300, 100], [300, 300]]);
await sleep(20);
rejectFirst();
await sleep(20);
out.rejectionRollsBackSynchronously = !card._serverCfg.spaces[0].room_drafts?.length
&& card._path.length === 0
&& card._pendingPhysicalWrites.size === 0
&& !card._geometryHistory.undoName && !card._geometryHistory.redoName;
await settleWrites();
card._finishWallChain();
out.rejectionCannotCreateGhostPartition = !card._serverCfg.spaces[0].room_drafts?.length
&& !card._serverCfg.spaces[0].partitions?.length
&& !server.spaces[0].room_drafts?.length
&& !server.spaces[0].partitions?.length;
return out;
});
await finish(browser, checkAll(result));
+69 -69
View File
File diff suppressed because one or more lines are too long
+13
View File
@@ -411,6 +411,13 @@ resulting wall or partition. Ambiguity fails closed with no partial config,
history or revision update. `scripts/mutation-gate.mjs` guards every structural
writer entrance.
Draft sanitation and Undo preserve the complete record of every surviving
segment, including its stable v8 ID; only a genuinely new edge receives a new
identity. The backend stale-client guard compares only room/compatibility
contour geometry with `wall_segments[]`. Drafts, partitions, columns and
explicitly hosted openings own their identity and may be written without a
contour-catalog change, subject to the full schema (#314).
Room-boundary walls remain *derived* from room outlines (`roomEdges`, deduped by
`segKey`), so deleting a room keeps the boundaries its neighbours still
contribute. Three explicitly typed exceptions are stored per space:
@@ -979,6 +986,12 @@ single promise: one `config/set` in flight, each carrying the revision the
previous one returned. The debounce still spaces out *when* a write starts;
what it cannot do — and used to be relied on for — is keep two writes from
overlapping, which produced a self-inflicted conflict and lost the newer edit.
Physical edits additionally form a pending transaction per space. A successful
write clears only the exact accepted fingerprint, so a newer queued edit stays
pending. A rejected write synchronously restores the earliest server-backed
snapshot for every affected space, clears its gestures and geometry history,
then best-effort reloads authoritative config. Thus a newer edit made while the
rejected request was in flight cannot survive on an unaccepted base (#314).
**Persisted coordinates have one lattice-aware write boundary** (#291).
`canonicalizeConfigGeometry()` / `canonicalizeLayoutGeometry()` /
+5
View File
@@ -11,6 +11,11 @@
independent wall winning an exact overlap. Zero stays refused for
standalone walls — a wall without thickness does not exist
([#313](https://github.com/Matysh/houseplan-card/issues/313)).
- Drawing rooms on model-v8 plans is reliable again: unfinished wall chains
keep their segment identity through intermediate saves and Undo, while a
rejected save immediately restores the last server-accepted geometry instead
of leaving a temporary room or stray partition on the plan
([#314](https://github.com/Matysh/houseplan-card/issues/314)).
## v1.68.0-beta.1 — 2026-08-26
+5
View File
@@ -16,6 +16,11 @@
комнат; при точном наложении клик достаётся отдельно стоящей стене. Ноль
для неё по-прежнему отклоняется — стены без толщины не существует
([#313](https://github.com/Matysh/houseplan-card/issues/313)).
- Рисование комнат на планах model v8 снова работает надёжно: незавершённая
цепочка стен сохраняет ID сегментов при промежуточных сохранениях и отмене, а
отклонённая сервером запись сразу возвращает последнюю принятую геометрию,
не оставляя временную комнату или случайную перегородку
([#314](https://github.com/Matysh/houseplan-card/issues/314)).
## v1.68.0-beta.1 — 2026-08-26
+9
View File
@@ -69,6 +69,15 @@ rejects the write instead of allowing thickness, draft identity or an opening
host to detach. A migration or transition conflict is fail-closed: the previous
config and revision remain unchanged.
The stale-client comparison is deliberately limited to contour-coupled legacy
geometry: rooms, compatibility `walls` and `open_spans`. Self-identifying
`room_drafts`, `partitions`, `wall_columns` and explicitly hosted `openings` may
change without rewriting the room-wall catalog, but still pass the complete v8
schema. Write-time sanitation and Undo preserve every surviving draft-segment
ID. If a physical `config/set` is rejected, the frontend restores the earliest
snapshot in that pending batch before attempting a best-effort authoritative
reload; rejected draft geometry cannot be promoted by a later gesture (#314).
## Canonical geometry on write (#224, #291)
Config and layout schemas canonicalize only named persisted numbers. Lattice
+20
View File
@@ -57,6 +57,26 @@
`tests_backend/test_validation.py`. HA import/export coverage runs in the
normal Linux/CI Home Assistant harness when unavailable natively.
## Atomic model-v8 draft writes (#314)
- [ ] `demo/smoke_v8_draft_write.mjs` proves that write sanitation preserves
the carrier ID after duplicate adjacent points, Undo keeps all surviving
IDs, and two successful queued physical writes retain the newer edge.
- [ ] The same fake-WS smoke rejects the first in-flight physical write and
observes synchronous rollback of its whole pending batch: active path,
pending map and command history are empty, and finishing the tool cannot
create a ghost partition.
- [ ] `tests_backend/test_wall_segment_model.py` accepts valid independent v8
draft/partition/column/hosted-opening changes with an unchanged contour
catalog, while the existing current/downgraded stale-contour negatives
and complete `CONFIG_SCHEMA` checks remain fail-closed.
- [ ] Local commands: `npm run typecheck`, `npm run bundle:sync`,
`node demo/smoke_v8_draft_write.mjs`, targeted backend pytest and
`node scripts/check-docs.mjs --external`.
- [ ] Mutations `v8-draft-sanitation-shifts-segment-identity` and
`v8-rejected-physical-write-keeps-optimistic-draft` prove that the pure
ID fixture and browser rollback scenario fail on the original defects.
## Resize: реальный pointer pipeline (#293)
- [ ] `demo/smoke_resize_pointer_real_plan.mjs` загружает tracked fixture
+12 -12
View File
@@ -2,7 +2,7 @@
"version": 1,
"fixture": "synthetic-only",
"chromium": "151.0.7922.34",
"sourceFingerprint": "c512f2c8165733033d1fe90cd894611d362da4d8d65580ef4a56bae93b07e444",
"sourceFingerprint": "cd14953b3d076cfa936f2d3d9f614bc245e14f13374594bf93868cc86bb14a95",
"captureScriptSha256": "ce2e9542fed9dade3085be87d16f69adb2ac8262893ad78ad966b1b9673f2983",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -14,7 +14,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "c512f2c8165733033d1fe90cd894611d362da4d8d65580ef4a56bae93b07e444",
"sourceSha256": "cd14953b3d076cfa936f2d3d9f614bc245e14f13374594bf93868cc86bb14a95",
"imageSha256": "36223106c073f07d8cc3ecf8eaab37192ebb2687daba65c5c21047d0b7890de0"
},
"view-touch": {
@@ -25,7 +25,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "c512f2c8165733033d1fe90cd894611d362da4d8d65580ef4a56bae93b07e444",
"sourceSha256": "cd14953b3d076cfa936f2d3d9f614bc245e14f13374594bf93868cc86bb14a95",
"imageSha256": "41e3ba67f8db0e98f26f484293af83ef937c369ca5ca6a59a3350d8954c906f4"
},
"space-create": {
@@ -36,7 +36,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "c512f2c8165733033d1fe90cd894611d362da4d8d65580ef4a56bae93b07e444",
"sourceSha256": "cd14953b3d076cfa936f2d3d9f614bc245e14f13374594bf93868cc86bb14a95",
"imageSha256": "c33a7279165a4cec6fa6fadb6fd08cd967e082a17fe101ef442d27d36ae59b6b"
},
"room-contour-close": {
@@ -47,7 +47,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "c512f2c8165733033d1fe90cd894611d362da4d8d65580ef4a56bae93b07e444",
"sourceSha256": "cd14953b3d076cfa936f2d3d9f614bc245e14f13374594bf93868cc86bb14a95",
"imageSha256": "b4777162eae89e0d95801721330bcd74ba761624b82b3362069d7e2d38317e08"
},
"plan-context-tray": {
@@ -58,7 +58,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "c512f2c8165733033d1fe90cd894611d362da4d8d65580ef4a56bae93b07e444",
"sourceSha256": "cd14953b3d076cfa936f2d3d9f614bc245e14f13374594bf93868cc86bb14a95",
"imageSha256": "c0e28edf82f45ccc6df568d3023b681e9e4394262ad34c1605fe9eacdb57a390"
},
"device-editor": {
@@ -69,8 +69,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "c512f2c8165733033d1fe90cd894611d362da4d8d65580ef4a56bae93b07e444",
"imageSha256": "d0ffd31ce80bfde21ab75da356a5fc1af38246f2b301030880320620c228d89d"
"sourceSha256": "cd14953b3d076cfa936f2d3d9f614bc245e14f13374594bf93868cc86bb14a95",
"imageSha256": "827a5f0cd6f1c122a63a51d83569a2dbd87b70b3753266a3eb0a9f2986b1b911"
},
"device-display-preview": {
"file": "06-device-display-preview.png",
@@ -80,7 +80,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "c512f2c8165733033d1fe90cd894611d362da4d8d65580ef4a56bae93b07e444",
"sourceSha256": "cd14953b3d076cfa936f2d3d9f614bc245e14f13374594bf93868cc86bb14a95",
"imageSha256": "2cdabae1f89c3286e4fac0ce30f757ee1690b707ab8a5488748b7cd420626160"
},
"background-editor": {
@@ -91,7 +91,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "c512f2c8165733033d1fe90cd894611d362da4d8d65580ef4a56bae93b07e444",
"sourceSha256": "cd14953b3d076cfa936f2d3d9f614bc245e14f13374594bf93868cc86bb14a95",
"imageSha256": "30147bb00a90eea7136b4cee30995f6e6a9217b5132f3e8d3ad7471413b1af8a"
},
"room-card": {
@@ -102,7 +102,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "c512f2c8165733033d1fe90cd894611d362da4d8d65580ef4a56bae93b07e444",
"sourceSha256": "cd14953b3d076cfa936f2d3d9f614bc245e14f13374594bf93868cc86bb14a95",
"imageSha256": "029a3e69ec647a8a370d99e6bb7f9225833c526739076022f6b52ba54bff30ea"
},
"device-info": {
@@ -113,7 +113,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "c512f2c8165733033d1fe90cd894611d362da4d8d65580ef4a56bae93b07e444",
"sourceSha256": "cd14953b3d076cfa936f2d3d9f614bc245e14f13374594bf93868cc86bb14a95",
"imageSha256": "dd492f53150b7149085daada5cce9eeae9bde9e7ea1d86679a54b3041f72f517"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
# Issue #314 — атомарная запись v8 drafts и независимой геометрии
Статус документа: ТЗ на ревью.
Статус документа: согласовано, реализация начата.
Issue: [#314](https://github.com/Matysh/houseplan-card/issues/314)
Предшественник: [#282](https://github.com/Matysh/houseplan-card/issues/282)
Целевая ветка: `issue/314-v8-draft-write-regression`
+24
View File
@@ -2987,6 +2987,30 @@ export const MUTANTS = [
replace: ' wallSegmentsMigrated = 0;',
}],
},
{
id: 'v8-draft-sanitation-shifts-segment-identity',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="draft sanitation drops only" '
+ 'test/wall-segment-model.test.mjs',
because: 'skipping a duplicate adjacent point must drop its own zero edge instead of '
+ 'shifting the following model-v8 segment id onto the wrong carrier (#314)',
patches: [{
file: 'src/wall-segment-model.ts',
find: ' const source = draft.segments?.[index - 1];',
replace: ' const source = draft.segments?.[segments.length];',
}],
},
{
id: 'v8-rejected-physical-write-keeps-optimistic-draft',
guard: 'npm run bundle:sync && node demo/smoke_v8_draft_write.mjs',
because: 'a rejected config/set must synchronously discard its whole pending physical batch '
+ 'before the active draft can be promoted into a ghost partition (#314)',
patches: [{
file: 'src/houseplan-card.ts',
find: ' const rolledBack = this._rollbackRejectedPhysicalWrites(strictEntries);',
replace: ' const rolledBack = false;',
}],
},
];
// --- механика ---------------------------------------------------------------
+77 -19
View File
@@ -199,7 +199,8 @@ import {
import { optimizePlans, type OptimizeReport } from './plan-optimizer';
import {
adoptWallSegmentModelCandidateInPlace, commitWallSegmentModel,
fixedTopologyWallLineageHints, wallModelOffGridValueCount, WallSegmentModelError,
fixedTopologyWallLineageHints, sanitizeRoomDraftPath,
wallModelOffGridValueCount, WallSegmentModelError,
} from './wall-segment-model';
import { snapNearAxisEndpoint } from './near-axis';
import type { SpaceReferenceRepairContext } from './space-reference-repair';
@@ -2753,6 +2754,8 @@ class HouseplanCard extends LitElement {
const sp = this._curSpaceCfg as any;
const i = (sp.room_drafts || []).findIndex((d: any) => d.id === this._activeDraftId);
if (i >= 0) {
const previousSegments = Array.isArray(sp.room_drafts[i]?.segments)
? sp.room_drafts[i].segments : [];
if (this._path.length < 2) {
sp.room_drafts.splice(i, 1);
if (!sp.room_drafts.length) delete sp.room_drafts;
@@ -2761,7 +2764,12 @@ class HouseplanCard extends LitElement {
sp.room_drafts[i] = {
id: this._activeDraftId,
points: this._path.map((p) => [p[0] / NORM_W, p[1] / NORM_W]),
segments: this._draftSegmentCms.map((cm) => ({ cm })),
// Undo removes the terminal edge; it must not rename every edge
// that survived it. The v8 identity barrier only creates an id
// for a genuinely new segment (#314).
segments: this._draftSegmentCms.map((cm, index) => ({
...(previousSegments[index] || {}), cm,
})),
};
}
this._commitPhysicalGeometry(this._t('history.draft_segment_delete'), before);
@@ -7090,17 +7098,8 @@ class HouseplanCard extends LitElement {
d && validId(d.id) && Array.isArray(d.points)
&& d.points.length >= 2 && d.points.every(point) && keepId(d.id))
.map((d: any) => {
const points: number[][] = [[Number(d.points[0][0]), Number(d.points[0][1])]];
const segments: Array<{ cm: number }> = [];
for (let i = 1; i < d.points.length; i++) {
const next = [Number(d.points[i][0]), Number(d.points[i][1])];
if (this._samePt(points[points.length - 1], next)) continue;
points.push(next);
segments.push({
cm: Math.max(1, Math.min(100, Number(d.segments?.[i - 1]?.cm) || 15)),
});
}
return { id: d.id, points, segments };
const sanitized = sanitizeRoomDraftPath(d);
return { id: d.id, ...sanitized };
})
.filter((d: any) => d.points.length >= 2);
if (!(sp as any).room_drafts.length) delete (sp as any).room_drafts;
@@ -7134,6 +7133,46 @@ class HouseplanCard extends LitElement {
fingerprint: string; before: SpaceGeometryState;
}>();
/**
* A rejected config/set accepted no part of its payload. Restore every
* physical space carried by that request to the earliest server-backed
* snapshot, including newer edits made on the same unaccepted base while the
* request was in flight. Ambiguous persisted walls are never inspected or
* cleaned here: this is transaction rollback, not repair (#314).
*/
private _rollbackRejectedPhysicalWrites(
entries: Array<[string, { fingerprint: string; before: SpaceGeometryState }]>,
): boolean {
if (!this._serverCfg || !entries.length) return false;
let restored = false;
for (const [spaceId] of entries) {
const pending = this._pendingPhysicalWrites.get(spaceId);
if (!pending) continue;
restored = this._restoreGeometryStateInConfig(this._serverCfg, pending.before) || restored;
this._pendingPhysicalWrites.delete(spaceId);
}
if (!restored) return false;
this._clearGeometryGesture();
this._geometryHistory.clear();
this._cfgEpoch++;
this._modelCache = null;
this._wallUnionCache = null;
this._physicalBodiesCache = null;
this._frame = null;
this._regSignature = '';
this._cfgContentFingerprint = contentFingerprint(this._serverCfg);
this._maybeRebuildDevices();
this.requestUpdate();
return true;
}
/** Revalidate the synchronous rollback after every write already queued. */
private async _reloadRejectedPhysicalWrite(): Promise<void> {
const queued = this._writeChain;
await queued.catch(() => undefined);
await this._reloadConfigOnly(true);
}
/** A config write is in flight — the card must not adopt a server revision. */
private get _cfgWriting(): boolean {
return this._writesPending > 0;
@@ -7171,9 +7210,23 @@ class HouseplanCard extends LitElement {
this._serverCfg = candidate;
}
this._cfgContentFingerprint = candidateFingerprint;
const r = await this.hass.callWS({
type: 'houseplan/config/set', config: candidate, expected_rev: this._cfgRev,
});
let r: any;
try {
r = await this.hass.callWS({
type: 'houseplan/config/set', config: candidate, expected_rev: this._cfgRev,
});
} catch (error) {
const rolledBack = this._rollbackRejectedPhysicalWrites(strictEntries);
if (!rolledBack) throw error;
// HA normally rejects with an Error-like object, but integrations and
// test doubles may reject with a string. Never lose the rollback tag:
// the caller still has to schedule an authoritative re-read after the
// synchronous, fail-closed restoration (#314).
const failure: any = error && typeof error === 'object'
? error : Object.assign(new Error(String(error)), { cause: error });
failure.physicalGeometryRolledBack = true;
throw failure;
}
this._cfgRev = r?.rev ?? this._cfgRev + 1;
for (const [spaceId, accepted] of strictEntries) {
if (this._pendingPhysicalWrites.get(spaceId)?.fingerprint === accepted.fingerprint)
@@ -7605,6 +7658,7 @@ class HouseplanCard extends LitElement {
private _saveConfigDebounced = debounce(() => {
if (!this._serverCfg) return;
this._writeConfig().catch((e: any) => {
const physicalRollback = e?.physicalGeometryRolledBack === true;
if (e?.code === 'geometry-unsafe') {
return;
} else if (e?.code === 'wall_model_client_outdated') {
@@ -7612,11 +7666,14 @@ class HouseplanCard extends LitElement {
} else if (e?.code === 'conflict') {
// a real one now: another window wrote between our read and our write
this._showToast(this._t('toast.conflict'));
this._cancelPath();
this._reloadConfigOnly(true);
if (!physicalRollback) {
this._cancelPath();
void this._reloadConfigOnly(true);
}
} else {
this._showToast(this._t('toast.cfg_save_failed', { err: this._errText(e) }));
}
if (physicalRollback) void this._reloadRejectedPhysicalWrite();
});
}, 500);
@@ -15318,7 +15375,8 @@ class HouseplanCard extends LitElement {
// write is still out must not race it into a self-inflicted conflict
await this._writeConfig();
} catch (e: any) {
if (e?.code === 'conflict') await this._reloadConfigOnly();
if (e?.physicalGeometryRolledBack) await this._reloadRejectedPhysicalWrite();
else if (e?.code === 'conflict') await this._reloadConfigOnly();
throw e;
}
}
+26 -1
View File
@@ -10,7 +10,7 @@
import {
canonicalizeConfigGeometryInPlace, LATTICE_NOISE_STEPS,
} from './coordinate-canonicalization';
import { roomPoly } from './logic';
import { roomPoly, samePoint } from './logic';
import { sanitizeOpenSpans } from './open-spans';
import { GRID_STEP_N } from './space-geometry';
import {
@@ -79,6 +79,31 @@ const canonicalSpan = (a: number[], b: number[]): [Point, Point] => (
);
const lengthOf = (a: number[], b: number[]): number => Math.hypot(b[0] - a[0], b[1] - a[1]);
/**
* Remove zero-length adjacent draft edges without moving identity to a
* neighbouring carrier. The caller owns whole-record validation; this pure
* write-boundary helper owns the index relationship between points and
* segments (#314).
*/
export const sanitizeRoomDraftPath = (draft: any): {
points: number[][];
segments: Array<{ id?: string; cm: number; [key: string]: any }>;
} => {
const points: number[][] = [[Number(draft.points[0][0]), Number(draft.points[0][1])]];
const segments: Array<{ id?: string; cm: number; [key: string]: any }> = [];
for (let index = 1; index < draft.points.length; index++) {
const next = [Number(draft.points[index][0]), Number(draft.points[index][1])];
if (samePoint(points[points.length - 1], next)) continue;
points.push(next);
const source = draft.segments?.[index - 1];
segments.push({
...(source && typeof source === 'object' ? source : {}),
cm: Math.max(1, Math.min(100, Number(source?.cm) || 15)),
});
}
return { points, segments };
};
/** Unique authored/derived contour coordinates that are materially off-grid. */
export const wallModelOffGridValueCount = (
space: any, additionalPoints: readonly (readonly number[])[] = [],
+15
View File
@@ -8,6 +8,7 @@ import {
commitWallSegmentModel,
deterministicWallSegmentId,
fixedTopologyWallLineageHints,
sanitizeRoomDraftPath,
wallModelOffGridValueCount,
WallSegmentModelError,
WALL_SEGMENT_MODEL_VERSION,
@@ -215,6 +216,20 @@ test('draft segment ids materialise once and remain stable', () => {
);
});
test('draft sanitation drops only the segment carried by a duplicate point', () => {
const draft = sanitizeRoomDraftPath({
points: [[0, 0], [0, 0], [1, 0], [1, 0], [1, 1]],
segments: [
{ id: 'zero-a', cm: 11 }, { id: 'edge-a', cm: 21 },
{ id: 'zero-b', cm: 12 }, { id: 'edge-b', cm: 22 },
],
});
assert.deepEqual(draft.points, [[0, 0], [1, 0], [1, 1]]);
assert.deepEqual(draft.segments, [
{ id: 'edge-a', cm: 21 }, { id: 'edge-b', cm: 22 },
]);
});
test('post-v8 atoms use fresh identity while promoted draft carriers keep theirs', () => {
const base = commitWallSegmentModel(configOf({
id: 'floor', title: 'Floor', rooms: [rectangle('room')],
+69
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import copy
import json
import math
from pathlib import Path
import pytest
@@ -202,3 +203,71 @@ def test_stale_client_echoing_v8_catalog_gets_the_named_error() -> None:
non_structural = copy.deepcopy(previous)
non_structural["settings"]["language"] = "ru"
validate_wall_model_transition(non_structural, previous)
def test_current_v8_independent_geometry_does_not_require_contour_catalog_change() -> None:
"""Drafts, partitions, columns and hosted openings own their identity (#314)."""
previous, _ = commit_wall_segment_model(_config({
"id": "floor", "rooms": [_room("room")],
}))
previous_catalog = copy.deepcopy(previous["spaces"][0]["wall_segments"])
candidates = []
draft = copy.deepcopy(previous)
draft["spaces"][0]["room_drafts"] = [{
"id": "draft-new", "points": [[2, 0], [3, 0]],
"segments": [{"id": "draft-segment-new", "cm": 15}],
}]
candidates.append(draft)
partition = copy.deepcopy(previous)
partition["spaces"][0]["partitions"] = [{
"id": "partition-new", "a": [2, 0], "b": [3, 0], "cm": 15,
}]
candidates.append(partition)
column = copy.deepcopy(previous)
column["spaces"][0]["wall_columns"] = [{
"id": "column-new", "shape": "square", "center": [2, 2],
"cm": 30, "angle": 0,
}]
candidates.append(column)
opening = copy.deepcopy(previous)
host = opening["spaces"][0]["wall_segments"][0]
dx = float(host["b"][0]) - float(host["a"][0])
dy = float(host["b"][1]) - float(host["a"][1])
opening["spaces"][0]["openings"] = [{
"id": "opening-new", "type": "door",
"x": (float(host["a"][0]) + float(host["b"][0])) / 2,
"y": (float(host["a"][1]) + float(host["b"][1])) / 2,
"angle": math.degrees(math.atan2(dy, dx)), "length": 0.2,
"host": {"kind": "wall", "id": host["id"], "t": 0.5},
}]
candidates.append(opening)
for candidate in candidates:
assert candidate["spaces"][0]["wall_segments"] == previous_catalog
validate_wall_model_transition(candidate, previous)
assert CONFIG_SCHEMA(candidate) == candidate
def test_downgraded_independent_partition_round_trip_is_hydrated() -> None:
previous, _ = commit_wall_segment_model(_config({
"id": "floor", "rooms": [_room("room")],
}))
legacy = copy.deepcopy(previous)
legacy.pop("model_version")
legacy["spaces"][0].pop("wall_segments")
for room in legacy["spaces"][0]["rooms"]:
room.pop("wall_ids")
legacy["spaces"][0]["partitions"] = [{
"id": "partition-legacy", "a": [2, 0], "b": [3, 0], "cm": 15,
}]
validate_wall_model_transition(legacy, previous)
assert legacy["model_version"] == WALL_SEGMENT_MODEL_VERSION
assert legacy["spaces"][0]["wall_segments"] == previous["spaces"][0]["wall_segments"]
assert CONFIG_SCHEMA(legacy) == legacy