fix: materialize empty wall catalog for new spaces

Issue: #324
User-Visible: yes
This commit is contained in:
Matysh
2026-08-27 16:33:47 +03:00
parent 1edf5cb3a7
commit 69e410fea3
10 changed files with 105 additions and 24 deletions
File diff suppressed because one or more lines are too long
@@ -41,6 +41,8 @@ const res = await page.evaluate(async () => {
await c._saveSpaceDialog(); await c.updateComplete;
const mixed = c._serverCfg.spaces.find((s) => s.title === 'Mixed display defaults');
out.savedMixed = [mixed?.settings?.show_borders, mixed?.settings?.show_names];
out.savedMixedHasEmptyWallCatalog = Array.isArray(mixed?.wall_segments)
&& mixed.wall_segments.length === 0;
c._openSpaceDialog('edit', mixed.id); await c.updateComplete;
out.reopenedMixed = state();
await choose('file');
@@ -78,6 +80,38 @@ const res = await page.evaluate(async () => {
const file = c._serverCfg.spaces.find((s) => s.title === 'File display defaults');
out.savedFileDefaults = [file?.settings?.show_borders, file?.settings?.show_names];
// #324: a server-rejected create must not survive merely because the dialog
// mutated the reactive config before config/set. Reproduce invalid_format,
// then let config/get return the last accepted document.
const serverTruth = structuredClone(c._serverCfg);
const baseCall = c.hass.callWS.bind(c.hass);
let rejectedCandidate = null;
let authoritativeReads = 0;
c.hass = { ...c.hass, callWS: async (message) => {
if (message.type === 'houseplan/config/set') {
rejectedCandidate = structuredClone(message.config);
const error = new Error('v8+ space requires wall_segments');
error.code = 'invalid_format';
throw error;
}
if (message.type === 'houseplan/config/get') {
authoritativeReads++;
return { config: structuredClone(serverTruth), rev: c._cfgRev, can_write: true };
}
return baseCall(message);
} };
c._openSpaceDialog('create'); await c.updateComplete;
await choose('draw');
c._spaceDialog = { ...c._spaceDialog, title: 'Rejected ghost' };
await c._saveSpaceDialog(); await c.updateComplete;
const rejectedSpace = rejectedCandidate?.spaces?.find((s) => s.title === 'Rejected ghost');
out.rejectedCandidateHasEmptyWallCatalog = Array.isArray(rejectedSpace?.wall_segments)
&& rejectedSpace.wall_segments.length === 0;
out.rejectedCreateReloadsServerTruth = authoritativeReads === 1
&& !c._serverCfg.spaces.some((s) => s.title === 'Rejected ghost');
out.rejectedCreateKeepsDialogForRetry = c._spaceDialog?.title === 'Rejected ghost'
&& c._spaceDialog.busy === false;
return out;
});
+2 -2
View File
File diff suppressed because one or more lines are too long
+5
View File
@@ -2,6 +2,11 @@
## Unreleased
- Creating a space no longer reports `v8+ space requires wall_segments` while
leaving an unsaved ghost space on screen; a failed space save now restores
the server-backed state before another create or delete
([#324](https://github.com/Matysh/houseplan-card/issues/324)).
## v1.68.0 — 2026-08-27
- Structural plan edits now rely on stable wall-segment IDs: thickness, draft
+6
View File
@@ -8,6 +8,12 @@
## Не выпущено
- Создание пространства больше не выдаёт ошибку
`v8+ space requires wall_segments` и не оставляет на экране несохранённое
пространство-призрак; после неудачного сохранения состояние возвращается к
серверному до следующего создания или удаления
([#324](https://github.com/Matysh/houseplan-card/issues/324)).
## v1.68.0 — 2026-08-27
- Структурные изменения плана теперь опираются на стабильные ID сегментов стен:
+11 -11
View File
@@ -2,7 +2,7 @@
"version": 1,
"fixture": "synthetic-only",
"chromium": "151.0.7922.34",
"sourceFingerprint": "d3f9271c3a64836abe0b0907e6f33c8c78cca844bc1cdb5f96d198d664f457bb",
"sourceFingerprint": "bc59df31ef3ffbe595e905160b5d400b2317003d3f220d4a2232fa47068677cb",
"captureScriptSha256": "ce2e9542fed9dade3085be87d16f69adb2ac8262893ad78ad966b1b9673f2983",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -14,7 +14,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "d3f9271c3a64836abe0b0907e6f33c8c78cca844bc1cdb5f96d198d664f457bb",
"sourceSha256": "bc59df31ef3ffbe595e905160b5d400b2317003d3f220d4a2232fa47068677cb",
"imageSha256": "36223106c073f07d8cc3ecf8eaab37192ebb2687daba65c5c21047d0b7890de0"
},
"view-touch": {
@@ -25,7 +25,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "d3f9271c3a64836abe0b0907e6f33c8c78cca844bc1cdb5f96d198d664f457bb",
"sourceSha256": "bc59df31ef3ffbe595e905160b5d400b2317003d3f220d4a2232fa47068677cb",
"imageSha256": "41e3ba67f8db0e98f26f484293af83ef937c369ca5ca6a59a3350d8954c906f4"
},
"space-create": {
@@ -36,7 +36,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "d3f9271c3a64836abe0b0907e6f33c8c78cca844bc1cdb5f96d198d664f457bb",
"sourceSha256": "bc59df31ef3ffbe595e905160b5d400b2317003d3f220d4a2232fa47068677cb",
"imageSha256": "0dc024726327f60f4a9ceaf3044381691f81f1090af81d1812e870f22d9343ba"
},
"room-contour-close": {
@@ -47,7 +47,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "d3f9271c3a64836abe0b0907e6f33c8c78cca844bc1cdb5f96d198d664f457bb",
"sourceSha256": "bc59df31ef3ffbe595e905160b5d400b2317003d3f220d4a2232fa47068677cb",
"imageSha256": "44cfaa95bd51e2cd628400e28db0ad8b2f0cd904385845bd402494f3f5c0d93c"
},
"plan-context-tray": {
@@ -58,7 +58,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "d3f9271c3a64836abe0b0907e6f33c8c78cca844bc1cdb5f96d198d664f457bb",
"sourceSha256": "bc59df31ef3ffbe595e905160b5d400b2317003d3f220d4a2232fa47068677cb",
"imageSha256": "e0662429b423fb74151b583dcc2c8635b001b637d03bbf7a6b16aec46399c3f8"
},
"device-editor": {
@@ -69,7 +69,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "d3f9271c3a64836abe0b0907e6f33c8c78cca844bc1cdb5f96d198d664f457bb",
"sourceSha256": "bc59df31ef3ffbe595e905160b5d400b2317003d3f220d4a2232fa47068677cb",
"imageSha256": "d0ffd31ce80bfde21ab75da356a5fc1af38246f2b301030880320620c228d89d"
},
"device-display-preview": {
@@ -80,7 +80,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "d3f9271c3a64836abe0b0907e6f33c8c78cca844bc1cdb5f96d198d664f457bb",
"sourceSha256": "bc59df31ef3ffbe595e905160b5d400b2317003d3f220d4a2232fa47068677cb",
"imageSha256": "2cdabae1f89c3286e4fac0ce30f757ee1690b707ab8a5488748b7cd420626160"
},
"background-editor": {
@@ -91,7 +91,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "d3f9271c3a64836abe0b0907e6f33c8c78cca844bc1cdb5f96d198d664f457bb",
"sourceSha256": "bc59df31ef3ffbe595e905160b5d400b2317003d3f220d4a2232fa47068677cb",
"imageSha256": "30147bb00a90eea7136b4cee30995f6e6a9217b5132f3e8d3ad7471413b1af8a"
},
"room-card": {
@@ -102,7 +102,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "d3f9271c3a64836abe0b0907e6f33c8c78cca844bc1cdb5f96d198d664f457bb",
"sourceSha256": "bc59df31ef3ffbe595e905160b5d400b2317003d3f220d4a2232fa47068677cb",
"imageSha256": "029a3e69ec647a8a370d99e6bb7f9225833c526739076022f6b52ba54bff30ea"
},
"device-info": {
@@ -113,7 +113,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "d3f9271c3a64836abe0b0907e6f33c8c78cca844bc1cdb5f96d198d664f457bb",
"sourceSha256": "bc59df31ef3ffbe595e905160b5d400b2317003d3f220d4a2232fa47068677cb",
"imageSha256": "dd492f53150b7149085daada5cce9eeae9bde9e7ea1d86679a54b3041f72f517"
}
}
+10 -9
View File
@@ -93,7 +93,7 @@ import {
} from './initial-load';
import { selectActiveSpaceModel, selectSpaceModelById } from './space-model-selection';
import {
initialSpaceDisplayDraft, switchSpacePlanSource, touchSpaceDisplay,
createEmptySpaceConfig, initialSpaceDisplayDraft, switchSpacePlanSource, touchSpaceDisplay,
} from './space-dialog';
import { mdiHomeCityOutline } from '@mdi/js';
import {
@@ -14978,14 +14978,7 @@ class HouseplanCard extends LitElement {
const cfg = this._serverCfg!;
let sp: any;
if (d.mode === 'create') {
sp = {
id: spaceId,
title: d.title.trim(),
plan_url: null,
view_box: [0, 0, 1, 1],
rooms: [],
};
sp = createEmptySpaceConfig(spaceId, d.title.trim());
cfg.spaces.push(sp);
} else {
sp = cfg.spaces.find((x: any) => x.id === spaceId);
@@ -15085,6 +15078,14 @@ class HouseplanCard extends LitElement {
}
}
} catch (e: any) {
// The dialog edits the reactive config optimistically before config/set.
// A rejected create used to leave that unsaved space on screen; the next
// delete then flushed the same malformed write and repeated its error.
// Re-adopt server truth for every failed space transaction. Conflict is
// already reloaded by _saveConfigNow; do not issue the same read twice.
// A lost response is safe too: if the server did commit, config/get
// returns it.
if (e?.code !== 'conflict') await this._reloadConfigOnly(true);
// audit L3: the dialog may have been closed (Esc) while the save was
// in flight — spreading null yields a truthy husk and the renderer
// then crashes, blanking the whole card. The toast below is the
+17
View File
@@ -8,6 +8,23 @@ export interface SpaceDisplayDraft {
displayTouched: boolean;
}
/**
* A current empty space is still a complete wall-model document. In
* particular, model v8+ forbids using an absent catalogue to mean "no walls":
* absence means an outdated/partial writer, while an empty array is the
* canonical empty catalogue.
*/
export function createEmptySpaceConfig(id: string, title: string): Record<string, unknown> {
return {
id,
title,
plan_url: null,
view_box: [0, 0, 1, 1],
rooms: [],
wall_segments: [],
};
}
/** Source-specific visible defaults for a fresh create/onboarding step. */
export function initialSpaceDisplayDraft(source: SpacePlanSource = 'file'): SpaceDisplayDraft {
const visible = source === 'draw';
+12
View File
@@ -2,11 +2,23 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {
createEmptySpaceConfig,
initialSpaceDisplayDraft,
switchSpacePlanSource,
touchSpaceDisplay,
} from '../test-build/space-dialog.js';
test('a fresh space is a complete empty v8/v9 wall-model document (#324)', () => {
assert.deepEqual(createEmptySpaceConfig('floor', 'Ground floor'), {
id: 'floor',
title: 'Ground floor',
plan_url: null,
view_box: [0, 0, 1, 1],
rooms: [],
wall_segments: [],
});
});
test('fresh create projects honest File and Draw display defaults', () => {
const file = initialSpaceDisplayDraft();
assert.deepEqual(file, {
+6
View File
@@ -1718,6 +1718,12 @@ def test_zero_wall_style_and_v9_legacy_fields_are_strict():
"spaces": [{**base, "wall_segments": []}],
}
assert v.CONFIG_SCHEMA(canonical) == canonical
with pytest.raises(vol.Invalid, match=r"v8\+ space requires wall_segments"):
v.CONFIG_SCHEMA({
**canonical,
"spaces": [{key: value for key, value in canonical["spaces"][0].items()
if key != "wall_segments"}],
})
with pytest.raises(vol.Invalid, match="legacy open boundaries"):
v.CONFIG_SCHEMA({
**canonical,