mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
fix: preserve markers when deleting final space
Issue: #244 User-Visible: yes
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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)",
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
Vendored
+3
-3
File diff suppressed because one or more lines are too long
+4
-3
@@ -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
|
||||
|
||||
@@ -14,8 +14,10 @@
|
||||
только отсутствующее размещение и возвращается через свою Area HA либо в
|
||||
первое пространство. Предпросмотр отдельно показывает исправленные и
|
||||
неразрешённые ссылки. Пространство с активными устройствами удалить нельзя,
|
||||
импорт пространства исправляет совпавшие ссылки в текущем плане, а редактор
|
||||
карточки предупреждает, если `default_floor` указывает на отсутствующее
|
||||
пока остаётся другое пространство; единственное оставшееся можно удалить без
|
||||
потери привязок и настроек маркеров. Импорт пространства исправляет совпавшие
|
||||
ссылки в текущем плане, а редактор карточки предупреждает, если `default_floor`
|
||||
указывает на отсутствующее
|
||||
пространство
|
||||
([#244](https://github.com/Matysh/houseplan-card/issues/244)).
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -326,10 +326,13 @@ desktop: для точного рисования, Resize, модификато
|
||||
миграции.
|
||||
|
||||
Удаление пространства блокируется, пока хотя бы одно активное устройство
|
||||
ссылается на это пространство, его комнату или сохранённую на нём позицию.
|
||||
Сначала перенесите или удалите такие устройства; затем подтверждённое удаление
|
||||
уберёт пространство и принадлежащую ему раскладку. Файл подложки и вложения при
|
||||
этом автоматически не удаляются.
|
||||
ссылается на это пространство, его комнату или сохранённую на нём позицию и при
|
||||
этом остаётся другое пространство. Сначала перенесите или удалите такие
|
||||
устройства; затем подтверждённое удаление уберёт пространство и принадлежащую
|
||||
ему раскладку. Единственное оставшееся пространство можно удалить после
|
||||
подтверждения: затронутые устройства сохранят привязки к HA, иконки, действия и
|
||||
настройки, но останутся без размещения. Файл подложки и вложения при этом
|
||||
автоматически не удаляются.
|
||||
|
||||
<!-- docs-section: plan-tools -->
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user