fix: preserve markers when deleting final space

Issue: #244
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-23 00:35:48 +03:00
parent 9c2f08bd40
commit 6a151d1ead
17 changed files with 330 additions and 50 deletions
File diff suppressed because one or more lines are too long
+26 -3
View File
@@ -1390,7 +1390,9 @@ def _space_delete_candidate(
candidate_config = json.loads(json.dumps(config))
candidate_layout = json.loads(json.dumps(layout))
dependencies = _space_marker_dependencies(candidate_config, candidate_layout, space_id)
if dependencies:
spaces = candidate_config.get("spaces") or []
deleting_last_space = len(spaces) == 1 and spaces[0].get("id") == space_id
if dependencies and not deleting_last_space:
return candidate_config, candidate_layout, dependencies, 0
space = next(
(item for item in candidate_config.get("spaces") or []
@@ -1406,6 +1408,23 @@ def _space_delete_candidate(
if item.get("id") != space_id
]
for marker in candidate_config.get("markers") or []:
marker_id = str(marker.get("id")) if marker.get("id") is not None else None
marker_position = candidate_layout.get(marker_id) if marker_id is not None else None
references_deleted_space = (
marker.get("space") == space_id
or (
marker.get("room_id") is not None
and str(marker.get("room_id")) in room_ids
)
or (
isinstance(marker_position, dict)
and marker_position.get("s") == space_id
)
)
if deleting_last_space and references_deleted_space:
marker.pop("space", None)
marker.pop("room_id", None)
continue
if marker.get("removed") is not True:
continue
if marker.get("space") == space_id:
@@ -1432,7 +1451,7 @@ def _space_delete_candidate(
)
@websocket_api.async_response
async def ws_space_delete(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Delete one unoccupied space as a crash-recoverable config/layout pair."""
"""Delete one space as a crash-recoverable config/layout pair."""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only editors may delete spaces")
return
@@ -1464,7 +1483,11 @@ async def ws_space_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
target_config, target_layout, dependencies, removed_layout = (
_space_delete_candidate(current_config, current_layout, space_id)
)
if dependencies:
spaces = current_config.get("spaces") or []
deleting_last_space = (
len(spaces) == 1 and spaces[0].get("id") == space_id
)
if dependencies and not deleting_last_space:
connection.send_error(
msg["id"], "space_in_use",
f"Space is still used by {len(dependencies)} active marker(s)",
+69 -1
View File
@@ -16,9 +16,66 @@ const result = await page.evaluate(async () => {
};
const original = structuredClone(card._serverCfg);
const first = structuredClone(original.spaces[0]);
const retainedMarker = {
id: '__issue244_retained', binding: 'virtual', space: first.id,
name: 'Retained marker', icon: 'mdi:lightbulb',
actions: [{ tap: 'more-info' }], description: 'must survive final space delete',
};
let serverConfig = {
...structuredClone(original),
spaces: [first],
markers: [...(structuredClone(original.markers) || []), retainedMarker],
};
let serverLayout = {
...structuredClone(card._layout || {}),
[retainedMarker.id]: { s: first.id, x: 0.4, y: 0.6 },
};
const markersBeforeDelete = structuredClone(serverConfig.markers);
const firstRoomIds = new Set((first.rooms || []).map((room) => room.id));
const affectedMarkerIds = new Set((serverConfig.markers || [])
.filter((marker) => marker.space === first.id || firstRoomIds.has(marker.room_id)
|| serverLayout[marker.id]?.s === first.id)
.map((marker) => marker.id));
const baseCall = card.hass.callWS.bind(card.hass);
let deleteCalls = 0;
card.hass = {
...card.hass,
callWS: async (message) => {
if (message.type === 'houseplan/space/delete') {
deleteCalls++;
const target = message.space_id;
const space = serverConfig.spaces.find((item) => item.id === target);
const roomIds = new Set((space?.rooms || []).map((room) => room.id));
serverConfig = {
...serverConfig,
spaces: serverConfig.spaces.filter((item) => item.id !== target),
markers: (serverConfig.markers || []).map((marker) => {
const affected = marker.space === target || roomIds.has(marker.room_id)
|| serverLayout[marker.id]?.s === target;
if (!affected) return marker;
const detached = { ...marker };
delete detached.space;
delete detached.room_id;
return detached;
}),
};
serverLayout = Object.fromEntries(
Object.entries(serverLayout).filter(([, position]) => position?.s !== target),
);
return { ok: true, config_rev: 2, layout_rev: 2 };
}
if (message.type === 'houseplan/config/get') {
return { config: structuredClone(serverConfig), rev: 2, can_write: true };
}
if (message.type === 'houseplan/layout/get') {
return { layout: structuredClone(serverLayout), rev: 2 };
}
return baseCall(message);
},
};
// Exercise the real delete command with exactly one remaining space.
card._serverCfg = { ...structuredClone(original), spaces: [first] };
card._serverCfg = structuredClone(serverConfig);
card._space = first.id;
card._cfgEpoch++;
card.requestUpdate();
@@ -46,6 +103,17 @@ const result = await page.evaluate(async () => {
&& Object.keys(card._resumeDraftBySpace).length === 0;
out.deleteLastClosesEditDialog = card._spaceDialog === null;
out.deleteLastCancelsPendingWrite = card._saveConfigDebounced.pending() === false;
out.deleteLastUsesAuthoritativeEndpoint = deleteCalls === 1;
const retainedAfterDelete = serverConfig.markers.find((marker) => marker.id === retainedMarker.id);
out.deleteLastPreservesMarkersWithoutPlacement = serverConfig.markers.length === markersBeforeDelete.length
&& affectedMarkerIds.size > 0
&& serverConfig.markers.filter((marker) => affectedMarkerIds.has(marker.id))
.every((marker) => marker.space === undefined && marker.room_id === undefined)
&& Object.values(serverLayout).every((position) => position?.s !== first.id)
&& retainedAfterDelete?.binding === retainedMarker.binding
&& retainedAfterDelete?.icon === retainedMarker.icon
&& retainedAfterDelete?.description === retainedMarker.description
&& JSON.stringify(retainedAfterDelete?.actions) === JSON.stringify(retainedMarker.actions);
// The empty state still owns global recovery flows.
const add = root().querySelector('.empty button.btn.on');
+4
View File
@@ -12,6 +12,10 @@ const out = await page.evaluate(async () => {
spaces: [{
id: 'home', title: 'Home', view_box: [0, 0, 1, 1], cell_cm: 5,
rooms: [{ id: 'living', name: 'Living', area: null, poly: [[0, 0], [1, 0], [1, 1], [0, 1]] }],
}, {
// Keep `home` non-final so this fixture proves the normal blocker; the
// final-space safe-detach exception is covered by #113's lifecycle smoke.
id: 'other', title: 'Other', view_box: [0, 0, 1, 1], cell_cm: 5, rooms: [],
}],
markers: [
{
File diff suppressed because one or more lines are too long
+3 -3
View File
File diff suppressed because one or more lines are too long
+4 -3
View File
@@ -6,9 +6,10 @@
independently imported spaces. Exact import copies keep their positions;
other active devices safely lose only the missing placement and return via
their HA Area or the first space. The preview reports repaired and unresolved
references. Spaces that still contain active devices cannot be deleted, a
space import repairs matching target references, and the card editor warns
when `default_floor` names a missing space
references. A space that still contains active devices cannot be deleted while
another space remains; the sole remaining space can be removed without losing
marker bindings or settings. A space import repairs matching target
references, and the card editor warns when `default_floor` names a missing space
([#244](https://github.com/Matysh/houseplan-card/issues/244)).
## v1.67.0-beta.2 — 2026-08-22
+4 -2
View File
@@ -14,8 +14,10 @@
только отсутствующее размещение и возвращается через свою Area HA либо в
первое пространство. Предпросмотр отдельно показывает исправленные и
неразрешённые ссылки. Пространство с активными устройствами удалить нельзя,
импорт пространства исправляет совпавшие ссылки в текущем плане, а редактор
карточки предупреждает, если `default_floor` указывает на отсутствующее
пока остаётся другое пространство; единственное оставшееся можно удалить без
потери привязок и настроек маркеров. Импорт пространства исправляет совпавшие
ссылки в текущем плане, а редактор карточки предупреждает, если `default_floor`
указывает на отсутствующее
пространство
([#244](https://github.com/Matysh/houseplan-card/issues/244)).
+6 -4
View File
@@ -195,10 +195,12 @@ is data-driven, idempotent and runs even when `model_version` is already 7.
A one-space import uses its known id map (not a heuristic) to repair matching
orphan target references when the original space id is absent. Full restore is
unchanged. Space deletion now uses a revision-guarded config/layout transaction
and refuses active marker dependencies; removed tombstones keep their metadata
and lose only placement fields owned by the deleted space. Older clients can
read every repaired candidate because the schemas and field shapes did not
change.
and refuses active marker dependencies while another space remains; removed
tombstones keep their metadata and lose only placement fields owned by the
deleted space. Deleting the sole remaining space is the intentional exception:
all affected active and removed marker records survive with only `space` and
`room_id` cleared, preserving the empty-state contract. Older clients can read
every repaired candidate because the schemas and field shapes did not change.
Current `fill_mode` additionally accepts `custom`. Its optional color is stored
as `{c:'#RRGGBB',a:0..1}` in `space.settings.custom_fill` and, for an explicit
+9 -5
View File
@@ -2222,14 +2222,18 @@ require hands on real hardware — they remain for the human pass.
preserve the marker record; ambiguous/truncated signatures and opaque
layout are never guessed. Preview/Apply/Undo use one exact candidate and
show remaining debt even for a no-op. Space import repairs target refs by
its known map. Space delete deduplicates active marker blockers, rechecks
both revisions under the backend lock and removes owned layout without
deleting tombstone metadata. A missing `default_floor` remains raw and
its known map. With another space present, space delete deduplicates active
marker blockers; deleting the sole remaining space instead preserves every
affected active/removed marker record while clearing only `space` and
`room_id`. Both paths recheck both revisions under the backend lock and
remove owned layout without deleting marker metadata. The sole-space path
also keeps the #113 empty-state smoke green. A missing `default_floor`
remains raw and
gains a RU/EN inline warning after spaces load
[unit: space-reference-repair, plan-optimizer, space-deletion,
card-editor-validation; backend: test_ha_import_export,
test_ha_websocket; pre-release: targeted browser smoke and light/dark
golden].
test_ha_websocket; smoke: orphan-space-references + optional-space-model;
pre-release: targeted browser smoke and light/dark golden].
- [ ] **Every write prevents new ULP coordinate noise (#224)**: config/layout
schema, import, direct storage writers, startup recovery and maintenance
Undo produce the same nine-decimal allow-listed geometry as the frontend.
+5 -3
View File
@@ -277,9 +277,11 @@ Room cards are positioned and scaled on the plan. View renders only the metrics
enabled for that space.
Deleting a space is blocked while any active device still points to the space,
one of its rooms or a saved position on it. Move or delete those devices first;
then the confirmed delete removes the space-owned layout. Plan images and
attachments are not deleted automatically.
one of its rooms or a saved position on it, provided another space remains.
Move or delete those devices first; then the confirmed delete removes the
space-owned layout. The sole remaining space can still be deleted after
confirmation: affected devices keep their bindings, icons, actions and settings
but become unplaced. Plan images and attachments are not deleted automatically.
![Room card with temperature, LQI and light state](images/08-room-card.png)
+7 -4
View File
@@ -326,10 +326,13 @@ desktop: для точного рисования, Resize, модификато
миграции.
Удаление пространства блокируется, пока хотя бы одно активное устройство
ссылается на это пространство, его комнату или сохранённую на нём позицию.
Сначала перенесите или удалите такие устройства; затем подтверждённое удаление
уберёт пространство и принадлежащую ему раскладку. Файл подложки и вложения при
этом автоматически не удаляются.
ссылается на это пространство, его комнату или сохранённую на нём позицию и при
этом остаётся другое пространство. Сначала перенесите или удалите такие
устройства; затем подтверждённое удаление уберёт пространство и принадлежащую
ему раскладку. Единственное оставшееся пространство можно удалить после
подтверждения: затронутые устройства сохранят привязки к HA, иконки, действия и
настройки, но останутся без размещения. Файл подложки и вложения при этом
автоматически не удаляются.
<!-- docs-section: plan-tools -->
+25 -9
View File
@@ -35,7 +35,9 @@ Optimize сообщает только о геометрии. Удаление
пространство, а без надёжного назначения снимает только мёртвую привязку и
возвращает сам маркер в доступный план. Preview сообщает, сколько устройств
восстановлено и какие ссылки требуют внимания. Удалить занятое пространство
нельзя до переноса его маркеров. Редактор карточки постоянно показывает, какой
нельзя до переноса его маркеров, пока существует другое пространство. Последнее
пространство можно удалить после подтверждения: маркеры сохраняют настройки и
теряют только размещение. Редактор карточки постоянно показывает, какой
`default_floor` отсутствует.
## 3. Подтверждённый диагноз
@@ -71,8 +73,12 @@ Optimize сообщает только о геометрии. Удаление
3. Весь marker record не удаляется: это потеряло бы пользовательские настройки,
уничтожило virtual-маркер и не гарантировало бы автопоявление реального
устройства без HA Area.
4. Удаление пространства блокируется, пока на нём остаются активные маркеры;
UI называет количество и просит сначала перенести или удалить их.
4. Если пространств два или больше, удаление пространства блокируется, пока на
нём остаются активные маркеры; UI называет количество и просит сначала
перенести или удалить их. Исключение: последнее пространство можно удалить
после подтверждения. У всех затронутых активных и removed marker records
снимаются только `space` и `room_id`; binding, иконка, действия и остальные
настройки сохраняются. Это сохраняет empty-state контракт #113.
5. Невалидный `default_floor` получает постоянную inline-ошибку в редакторе
карточки. Дополнительный toast не показывается; безопасный fallback на первое
пространство сохраняется.
@@ -255,8 +261,8 @@ room-label position имеет приоритет над stale target label пр
## 10. Безопасное удаление пространства
Перед delete frontend считает активные marker dependencies. Маркер блокирует
удаление, если выполняется хотя бы одно:
Перед delete frontend считает активные marker dependencies. Если пространств
два или больше, маркер блокирует удаление, если выполняется хотя бы одно:
- `marker.space` равен удаляемому id;
- его `room_id` принадлежит комнате удаляемого пространства;
@@ -267,7 +273,8 @@ room-label position имеет приоритет над stale target label пр
пользовательскими устройствами: после снятия marker blockers они удаляются
вместе с пространством.
При blockers редактор пространства сохраняет диалог открытым, показывает
При blockers и наличии другого пространства редактор пространства сохраняет
диалог открытым, показывает
inline `role=alert` с количеством и инструкцией перенести/удалить устройства,
не показывает native confirm и не пишет config/layout/revisions. Delete control
остаётся доступным для активации, чтобы причина была объяснена, а не выглядит
@@ -278,11 +285,20 @@ entries с `s == spaceId`; у removed tombstones снимаются только
которые ссылались на удалённое пространство. Marker metadata и файлы не
удаляются по inference.
Если удаляется единственное оставшееся пространство, dependencies не
блокируют подтверждение. Та же авторитетная транзакция удаляет space и весь его
layout, а у каждого затронутого активного или removed marker record снимает
ровно `space` и `room_id`. Затронутым считается маркер, чьё прямое `space`,
комната либо marker-layout position принадлежит удаляемому пространству.
Binding, иконка, действия и любые остальные настройки сохраняются; незатронутые
legacy-ссылки не переписываются.
Авторитетная backend-операция повторно проверяет dependencies под write lock,
сверяет обе expected revisions и сохраняет config/layout без наблюдаемого
полусостояния. Гонка возвращает стабильный `conflict` либо `space_in_use` с
counts; frontend обновляет данные и показывает локализованную причину. Удаление
последнего пустого пространства сохраняет действующий контракт #111.
последнего пустого или занятого пространства сохраняет действующий empty-state
контракт #113.
## 11. `default_floor` в редакторе карточки
@@ -345,8 +361,8 @@ best effort на touch, поэтому новые editor controls не расш
| AC6 | Нерешённый `vacuum.segment_map` не удаляется, но считается в `nestedRefsUnresolved`; exact room map переписывает его полностью. | Vacuum reference unit. |
| AC7 | Optimize preview и toast отдельно показывают remapped/detached counters; warning виден и при `changed=false`, Apply тогда отсутствует; Cancel не пишет, Apply сохраняет exact candidate, Undo возвращает исходные ссылки. | UI unit + targeted browser/backend smoke. |
| AC8 | Space import при отсутствующем source id переписывает target marker/layout/room/vacuum refs известным `id_map`, отражает count в preview и apply; при существующем source id target не меняется. Full restore остаётся прежним. | Backend import/export tests preview/revalidate/apply. |
| AC9 | Удаление пространства с N зависимыми активными markers показывает inline blocker с N, не вызывает confirm/backend write и не меняет revisions. Один marker по трём ссылкам считается один раз. | Pure dependency unit + browser smoke. |
| AC10 | После переноса/удаления blockers delete под revision guard удаляет space и весь принадлежащий ему layout без полусостояния; concurrent change даёт `conflict`/`space_in_use`; marker metadata/files не удаляются. | Backend transaction tests + frontend smoke. |
| AC9 | При двух или более пространствах удаление пространства с N зависимыми активными markers показывает inline blocker с N, не вызывает confirm/backend write и не меняет revisions. Один marker по трём ссылкам считается один раз. | Pure dependency unit + browser smoke. |
| AC10 | После переноса/удаления blockers delete под revision guard удаляет space и весь принадлежащий ему layout без полусостояния; concurrent change даёт `conflict`/`space_in_use`; marker metadata/files не удаляются. Единственное оставшееся пространство удаляется и при dependencies: у всех затронутых active/removed markers снимаются только `space`/`room_id`, остальные настройки сохраняются, после чего действует empty-state #113. | Backend transaction tests + `smoke_orphan_space_references` + `smoke_optional_space_model`. |
| AC11 | Редактор с отсутствующим `default_floor=f1` после загрузки показывает raw id и RU/EN inline error; выбор валидного id убирает её. Runtime открывает первое пространство, toast отсутствует, несвязанное поле не стирает raw value. | Editor unit + light/dark golden/browser smoke. |
| AC12 | Уже валидные marker/layout/room refs не меняются; входы не мутируются; model version не создаёт запись сам по себе; старый schema fixture читается. | Immutability/compatibility/idempotence units. |
| AC13 | Проход линейный по spaces + rooms + markers + layout и не входит в render/state tick; permission и optimistic-lock guards не ослаблены. | Code inspection + large synthetic unit/backend test. |
+17 -6
View File
@@ -14460,11 +14460,15 @@ class HouseplanCard extends LitElement {
private async _deleteSpace(): Promise<void> {
const d = this._spaceDialog;
if (!d || d.mode !== 'edit') return;
const sp = this._serverCfg!.spaces.find((x: any) => x.id === d.spaceId);
const serverCfg = this._serverCfg;
if (!serverCfg) return;
const sp = serverCfg.spaces.find((x: any) => x.id === d.spaceId);
const dependencies = collectSpaceMarkerDependencies(
this._serverCfg, this._layout || {}, d.spaceId || '',
serverCfg, this._layout || {}, d.spaceId || '',
);
if (dependencies.count) {
const deletingLastSpace = serverCfg.spaces.length === 1
&& serverCfg.spaces[0]?.id === d.spaceId;
if (dependencies.count && !deletingLastSpace) {
this._spaceDialog = { ...d, deleteBlockers: dependencies.count };
return;
}
@@ -14496,11 +14500,18 @@ class HouseplanCard extends LitElement {
if (e?.code === 'conflict' || e?.code === 'space_in_use') {
await Promise.all([this._reloadConfigOnly(true), this._reloadLayoutOnly()]);
}
if (this._spaceDialog) {
const refreshedConfig = this._serverCfg;
if (this._spaceDialog && refreshedConfig) {
const refreshed = collectSpaceMarkerDependencies(
this._serverCfg, this._layout || {}, d.spaceId || '',
refreshedConfig, this._layout || {}, d.spaceId || '',
);
this._spaceDialog = { ...this._spaceDialog, busy: false, deleteBlockers: refreshed.count };
const stillLastSpace = refreshedConfig.spaces.length === 1
&& refreshedConfig.spaces[0]?.id === d.spaceId;
this._spaceDialog = {
...this._spaceDialog,
busy: false,
deleteBlockers: stillLastSpace ? 0 : refreshed.count,
};
}
this._showToast(this._t('toast.delete_failed', { err: this._errText(e) }));
}
+13 -1
View File
@@ -34,7 +34,9 @@ export function createSpaceDeletionCandidate(
const dependencies = collectSpaceMarkerDependencies(configIn, layoutIn, spaceId);
const config = clone(configIn);
const layout = clone(layoutIn || {});
if (dependencies.count) return { config, layout, dependencies };
const spaces = config.spaces || [];
const deletingLastSpace = spaces.length === 1 && spaces[0]?.id === spaceId;
if (dependencies.count && !deletingLastSpace) return { config, layout, dependencies };
const space = (config.spaces || []).find((item: any) => item?.id === spaceId);
const roomIds = new Set(
@@ -42,6 +44,16 @@ export function createSpaceDeletionCandidate(
);
config.spaces = (config.spaces || []).filter((item: any) => item?.id !== spaceId);
for (const marker of config.markers || []) {
const markerOwnsPosition = typeof marker?.id === 'string'
&& layout?.[marker.id]?.s === spaceId;
const referencesDeletedSpace = marker?.space === spaceId
|| (typeof marker?.room_id === 'string' && roomIds.has(marker.room_id))
|| markerOwnsPosition;
if (deletingLastSpace && referencesDeletedSpace) {
delete marker.space;
delete marker.room_id;
continue;
}
if (marker?.removed !== true) continue;
if (marker.space === spaceId) delete marker.space;
if (typeof marker.room_id === 'string' && roomIds.has(marker.room_id)) delete marker.room_id;
+41
View File
@@ -68,3 +68,44 @@ test('issue 244 deleting the last unoccupied space produces an explicit empty mo
assert.deepEqual(result.config.settings, { keep: true });
assert.deepEqual(result.layout, {});
});
test('issue 244 deleting the last occupied space detaches placement but preserves markers', () => {
const config = {
spaces: [{ id: 'only', rooms: [{ id: 'room-only' }] }],
markers: [
{
id: 'direct', binding: 'virtual', space: 'only', room_id: 'room-only',
name: 'Direct', icon: 'mdi:lightbulb', actions: [{ tap: 'more-info' }],
},
{
id: 'room', binding: 'entity:sensor.room', space: 'legacy', room_id: 'room-only',
name: 'Room', removed: true,
},
{ id: 'position', binding: 'entity:sensor.position', name: 'Position' },
{ id: 'unrelated', binding: 'virtual', space: 'legacy', name: 'Unrelated' },
],
settings: { keep: true },
};
const layout = {
direct: { s: 'only', x: 0.1, y: 0.2 },
position: { s: 'only', x: 0.3, y: 0.4 },
rl_room_only: { s: 'only', x: 0.5, y: 0.6 },
};
const result = createSpaceDeletionCandidate(config, layout, 'only');
assert.equal(result.dependencies.count, 2);
assert.deepEqual(result.config.spaces, []);
assert.deepEqual(result.layout, {});
assert.deepEqual(result.config.markers.map((marker) => ({
id: marker.id, space: marker.space, room: marker.room_id, name: marker.name,
})), [
{ id: 'direct', space: undefined, room: undefined, name: 'Direct' },
{ id: 'room', space: undefined, room: undefined, name: 'Room' },
{ id: 'position', space: undefined, room: undefined, name: 'Position' },
{ id: 'unrelated', space: 'legacy', room: undefined, name: 'Unrelated' },
]);
assert.equal(result.config.markers[0].icon, 'mdi:lightbulb');
assert.deepEqual(result.config.markers[0].actions, [{ tap: 'more-info' }]);
assert.equal(config.markers[0].space, 'only', 'input config is immutable');
assert.equal(layout.direct.s, 'only', 'input layout is immutable');
});
+91
View File
@@ -66,6 +66,52 @@ def test_issue_244_space_delete_dependency_and_tombstone_candidate() -> None:
assert config["markers"][0]["space"] == "f1"
def test_issue_244_last_occupied_space_candidate_detaches_all_affected_markers() -> None:
config = {
"spaces": [_space("only", "room-only")],
"markers": [
{
"id": "direct", "binding": "virtual", "space": "only",
"room_id": "room-only", "name": "Kept", "icon": "mdi:lightbulb",
},
{
"id": "position", "binding": "virtual", "name": "Position",
},
{
"id": "removed", "binding": "entity:light.old", "removed": True,
"space": "only", "room_id": "room-only", "name": "Tombstone",
},
{
"id": "unrelated", "binding": "virtual", "space": "legacy",
"name": "Unrelated",
},
],
"settings": {},
}
layout = {
"direct": {"s": "only", "x": 0.1, "y": 0.2},
"position": {"s": "only", "x": 0.3, "y": 0.4},
"rl_room-only": {"s": "only", "x": 0.5, "y": 0.6},
}
candidate, candidate_layout, dependencies, removed_layout = _space_delete_candidate(
config, layout, "only",
)
assert dependencies == ["direct", "position"]
assert candidate["spaces"] == []
assert candidate_layout == {}
assert removed_layout == 3
by_id = {marker["id"]: marker for marker in candidate["markers"]}
for marker_id in ("direct", "position", "removed"):
assert "space" not in by_id[marker_id]
assert "room_id" not in by_id[marker_id]
assert by_id["direct"]["icon"] == "mdi:lightbulb"
assert by_id["unrelated"]["space"] == "legacy"
assert config["markers"][0]["space"] == "only"
assert layout["direct"]["s"] == "only"
async def test_issue_244_space_delete_is_authoritative_and_revision_guarded(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator,
) -> None:
@@ -136,6 +182,51 @@ async def test_issue_244_space_delete_is_authoritative_and_revision_guarded(
assert final_layout["layout"] == {}
async def test_issue_244_last_occupied_space_delete_preserves_marker_records(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator,
) -> None:
await _setup(hass)
client = await hass_ws_client(hass)
config = {
"spaces": [_space("only", "room-only")],
"markers": [{
"id": "device", "binding": "virtual", "space": "only",
"room_id": "room-only", "name": "Kept", "icon": "mdi:lightbulb",
}],
"settings": {},
}
await client.send_json_auto_id({
"type": "houseplan/config/set", "config": config, "expected_rev": 0,
})
config_set = await client.receive_json()
assert config_set["success"]
await client.send_json_auto_id({
"type": "houseplan/layout/set",
"layout": {"device": {"s": "only", "x": 0.2, "y": 0.3}},
})
layout_set = await client.receive_json()
assert layout_set["success"]
await client.send_json_auto_id({
"type": "houseplan/space/delete", "space_id": "only",
"expected_config_rev": config_set["result"]["rev"],
"expected_layout_rev": layout_set["result"]["rev"],
})
deleted = await client.receive_json()
assert deleted["success"]
await client.send_json_auto_id({"type": "houseplan/config/get"})
final_config = (await client.receive_json())["result"]["config"]
await client.send_json_auto_id({"type": "houseplan/layout/get"})
final_layout = (await client.receive_json())["result"]["layout"]
assert final_config["spaces"] == []
assert final_config["markers"] == [{
"id": "device", "binding": "virtual", "name": "Kept",
"icon": "mdi:lightbulb",
}]
assert final_layout == {}
async def test_layout_roundtrip(hass: HomeAssistant, hass_ws_client: WebSocketGenerator) -> None:
await _setup(hass)
client = await hass_ws_client(hass)