mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 12:18:51 +00:00
File diff suppressed because one or more lines are too long
@@ -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"
|
||||
|
||||
@@ -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));
|
||||
Vendored
+69
-69
File diff suppressed because one or more lines are too long
@@ -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()` /
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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`
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[])[] = [],
|
||||
|
||||
@@ -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')],
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user