mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 12:49:56 +00:00
fix: restore one child of a deleted device
Issue: #262 User-Visible: yes
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -35,15 +35,18 @@ Object.assign(out, await page.evaluate(async () => {
|
||||
await c.updateComplete;
|
||||
window.confirm = nativeConfirm;
|
||||
};
|
||||
const tombstones = () => (c._serverCfg.markers || []).filter((m) => m.removed === true);
|
||||
const markers = () => c._serverCfg.markers || [];
|
||||
const tombstones = () => markers().filter((m) => m.removed === true);
|
||||
const liveMarkers = (binding) => markers().filter((m) => m.removed !== true && m.binding === binding);
|
||||
|
||||
// --- 1. удалили устройство — оно снова предлагается ----------------------
|
||||
const device = c._devices.find((d) => d.bindingKind === 'device' && d.bindingRef && !d.hidden);
|
||||
o.standHasDeviceMarker = !!device;
|
||||
const deviceBinding = `device:${device.bindingRef}`;
|
||||
const childEntity = Object.entries(c._planHass.entities)
|
||||
.find(([, reg]) => reg.device_id === device.bindingRef)?.[0];
|
||||
.find(([, reg]) => reg.device_id === device.bindingRef && !reg.hidden)?.[0];
|
||||
o.standHasChildEntity = !!childEntity;
|
||||
const childBinding = `entity:${childEntity}`;
|
||||
|
||||
o.placedDeviceNotOffered = !(await offered(false)).includes(deviceBinding);
|
||||
await remove(device);
|
||||
@@ -67,16 +70,62 @@ Object.assign(out, await page.evaluate(async () => {
|
||||
return c._markerDialog.showEntities;
|
||||
})()) === false;
|
||||
|
||||
// --- 3. известный дефект #262 -------------------------------------------
|
||||
// Надгробие ключуется по точной строке привязки, поэтому `device:<id>`
|
||||
// разблокирует только само устройство: `isRemovedPlanEntity` продолжает
|
||||
// выкидывать из списка все его дочерние сущности. Здесь закреплено ТЕКУЩЕЕ
|
||||
// поведение — починят #262, и смок покраснеет, потребовав перевернуть
|
||||
// проверку. Молча пройти фикс мимо покрытия не сможет.
|
||||
o.knownDefect262ChildEntityBlocked =
|
||||
!(await offered(true)).includes(`entity:${childEntity}`);
|
||||
// --- 3. #262: из удалённого parent возвращается одна exact entity --------
|
||||
// Parent tombstone открывает переход в picker, но сохраняется после Save:
|
||||
// выбранная X оживает, siblings и автоматический device:D — нет.
|
||||
o.deletedDeviceChildHiddenWithoutCheckbox =
|
||||
!(await offered(false)).includes(childBinding);
|
||||
o.deletedDeviceChildOfferedWithCheckbox =
|
||||
(await offered(true)).includes(childBinding);
|
||||
|
||||
const saveChild = async () => {
|
||||
await openAdd(true);
|
||||
const reg = c._planHass.entities[childEntity];
|
||||
const state = c._planHass.states[childEntity];
|
||||
c._markerDialog = {
|
||||
...c._markerDialog,
|
||||
bindingMode: 'ha',
|
||||
binding: childBinding,
|
||||
name: reg?.name || state?.attributes?.friendly_name || childEntity,
|
||||
};
|
||||
await c._saveMarker();
|
||||
await c.updateComplete;
|
||||
return liveMarkers(childBinding)[0];
|
||||
};
|
||||
|
||||
const childMarker = await saveChild();
|
||||
o.childSaveCreatesOneLiveMarker = liveMarkers(childBinding).length === 1
|
||||
&& !tombstones().some((m) => m.binding === childBinding);
|
||||
o.childSaveKeepsParentTombstone = tombstones().some((m) => m.binding === deviceBinding);
|
||||
const childDevice = () => c._devices.find((d) =>
|
||||
d.bindingKind === 'entity' && d.bindingRef === childEntity);
|
||||
o.childBuiltWithoutAutoParent = !!childDevice()
|
||||
&& !c._devices.some((d) => d.bindingKind === 'device' && d.bindingRef === device.bindingRef);
|
||||
const childPos = childMarker && c._layout[childMarker.id];
|
||||
o.childGetsFreshLayoutPosition = !!childPos
|
||||
&& Number.isFinite(childPos.x) && Number.isFinite(childPos.y) && !!childPos.s;
|
||||
c._setMode('view');
|
||||
await c.updateComplete;
|
||||
o.childDrawnInView = !!c.renderRoot.querySelector(
|
||||
`.dev[data-id="${CSS.escape(childMarker.id)}"]`,
|
||||
);
|
||||
|
||||
o.parentStillOfferedAfterChildSave = (await offered(false)).includes(deviceBinding);
|
||||
o.liveChildRemovedFromPicker = !(await offered(true)).includes(childBinding);
|
||||
|
||||
// Повторный Delete → Add той же X обязан быть идемпотентным и не затрагивать
|
||||
// соседний parent tombstone.
|
||||
await remove(childDevice());
|
||||
o.childDeleteLeavesBothTombstones = tombstones().some((m) => m.binding === deviceBinding)
|
||||
&& tombstones().some((m) => m.binding === childBinding);
|
||||
o.deletedChildOfferedAgain = (await offered(true)).includes(childBinding);
|
||||
await saveChild();
|
||||
o.secondChildSaveIsIdempotent = liveMarkers(childBinding).length === 1
|
||||
&& !tombstones().some((m) => m.binding === childBinding)
|
||||
&& tombstones().some((m) => m.binding === deviceBinding);
|
||||
|
||||
// --- 4. повторное добавление возвращает устройство в список размещённых --
|
||||
// Явные device:D + entity:X сосуществуют по принятому контракту #226.
|
||||
await openAdd(false);
|
||||
c._markerDialog = { ...c._markerDialog, bindingMode: 'ha', binding: deviceBinding,
|
||||
name: device.name };
|
||||
@@ -85,6 +134,9 @@ Object.assign(out, await page.evaluate(async () => {
|
||||
o.readdReplacesTombstone = !tombstones().some((m) => m.binding === deviceBinding)
|
||||
&& (c._serverCfg.markers || []).some((m) => m.binding === deviceBinding);
|
||||
o.readdRemovesFromPicker = !(await offered(false)).includes(deviceBinding);
|
||||
o.explicitParentAndChildCoexist = c._devices.some((d) =>
|
||||
d.bindingKind === 'device' && d.bindingRef === device.bindingRef)
|
||||
&& !!childDevice();
|
||||
|
||||
c._markerDialog = null;
|
||||
c._setMode('view');
|
||||
|
||||
Vendored
+4
-4
File diff suppressed because one or more lines are too long
+11
-5
@@ -306,16 +306,22 @@ references and duplicates by name|area. Manual files: transactional HTTP upload
|
||||
HA binding against automatic discovery while intentionally exposing that same
|
||||
binding to the Add picker. A device tombstone excludes all data of that device;
|
||||
an entity tombstone excludes the standalone entity binding but does not mutate
|
||||
the same entity out of a still-live parent device. Runtime-filtered references
|
||||
such as `controls` and live text remain persisted and become active again after
|
||||
re-add. Exact `opening.contact` / `opening.lock` fields are a separate
|
||||
the same entity out of a still-live parent device. A live exact `entity:X`
|
||||
marker is the one narrow override: it may coexist with a `device:D` tombstone,
|
||||
restoring X while the parent claim continues to suppress D and every sibling
|
||||
without its own live exact marker. The Add picker exposes active children of a
|
||||
device tombstone only behind **Show entities**, so that combination is reachable
|
||||
without weakening ordinary runtime deletion. Runtime-filtered references such
|
||||
as `controls` and live text remain persisted and become active again after
|
||||
exact re-add. Exact `opening.contact` / `opening.lock` fields are a separate
|
||||
architectural-object role: their HA availability ignores marker tombstones but
|
||||
still uses `resolveHaBindingStatus()` to reject disabled, orphaned or unverified
|
||||
entities. Their painted state comes from the immutable active-registry frame,
|
||||
not directly from live `hass`. Re-adding a marker therefore cannot duplicate or
|
||||
rewrite an opening reference.
|
||||
Re-adding replaces the tombstone; virtual markers need no tombstone because
|
||||
they have no discovery source.
|
||||
Re-adding the same binding replaces its tombstone. Re-adding a child entity of
|
||||
a tombstoned device preserves the parent tombstone instead; virtual markers
|
||||
need no tombstone because they have no discovery source.
|
||||
|
||||
## Server-side configuration (current shape, v1.51+)
|
||||
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- After deleting a complete HA device from the plan, any one of its active
|
||||
entities can now be added back separately through **Add → Show entities**.
|
||||
The selected entity returns as a normal live marker with a fresh position,
|
||||
while the parent device and unselected siblings stay deleted; the complete
|
||||
device remains available for an intentional later re-add
|
||||
([#262](https://github.com/Matysh/houseplan-card/issues/262)).
|
||||
- Thick T-junctions no longer show white triangular gaps on the exterior side
|
||||
when three or more walls of different thickness meet. The bounded bevel now
|
||||
preserves valid masonry and paper up to the same limit on both sides of the
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- После удаления целого HA-устройства с плана теперь можно отдельно вернуть
|
||||
любую его активную сущность через **Добавить → Показывать сущности**.
|
||||
Выбранная сущность появляется как обычный живой маркер на свежей позиции, а
|
||||
родительское устройство и невыбранные siblings остаются удалёнными; само
|
||||
устройство по-прежнему доступно для осознанного последующего возврата
|
||||
([#262](https://github.com/Matysh/houseplan-card/issues/262)).
|
||||
- В толстых T-стыках больше не появляются белые треугольные щели с наружной
|
||||
стороны, когда сходятся три и более стены разной толщины. Ограниченная фаска
|
||||
теперь сохраняет допустимую кладку и подложку до одного предела по обе стороны
|
||||
|
||||
+12
-4
@@ -26,6 +26,12 @@ as the SEEDER of initial hidden flags.
|
||||
automatic discovery cannot immediately recreate the deleted device. The
|
||||
same binding remains available in Add; saving it again replaces the
|
||||
tombstone and starts with a fresh position.
|
||||
- A `device:D` tombstone also exposes D's active child entities in Add when
|
||||
**Show entities** is enabled. Saving one `entity:X` keeps the parent
|
||||
tombstone and restores only X: the live exact entity binding overrides the
|
||||
tombstone for X, while D and every sibling without its own live marker stay
|
||||
deleted. Adding D later is still an explicit exact re-add and may coexist
|
||||
with X under the rule below.
|
||||
- A live `entity:X` marker owns X inside its automatic parent `device:D`. A
|
||||
residual auto-device contains only active, HA-visible siblings not owned by
|
||||
other entity markers and disappears when that set is empty. A user-hidden
|
||||
@@ -110,10 +116,12 @@ the old behaviour until an editing client materialises it.
|
||||
server while the tombstone exists.
|
||||
- A deleted **device binding** is excluded from room LQI, light-source
|
||||
resolution, room light statistics/fill/Glow, registry-wide climate averages
|
||||
and explicit room sources. An **entity binding** tombstone suppresses that
|
||||
standalone plan object; it does not remove the same entity from the data of
|
||||
a still-live parent HA device. Tombstones are binding-scoped, not mutations
|
||||
of the HA registry.
|
||||
and explicit room sources, except for an exact child entity restored as a
|
||||
live marker as described above. That exception is exact: it restores X's
|
||||
normal marker-level state and aggregates, never the parent or its siblings.
|
||||
An **entity binding** tombstone suppresses that standalone plan object; it
|
||||
does not remove the same entity from the data of a still-live parent HA
|
||||
device. Tombstones are binding-scoped, not mutations of the HA registry.
|
||||
- Exact `opening.contact` and `opening.lock` references are independent of a
|
||||
standalone plan marker: deleting that marker does not remove the entity from
|
||||
the opening picker and does not stop the saved opening from following its HA
|
||||
|
||||
@@ -1573,6 +1573,12 @@ separately promised workflows:
|
||||
return on rebuild/reload, but its binding is offered by Add. Re-add it:
|
||||
one marker only, fresh centred/grid position, no tombstone
|
||||
[auto: smoke_hidden_flag; unit + manual]
|
||||
- [ ] Delete `device:D`, enable Show entities in Add and restore only child
|
||||
`entity:X`: X receives one live marker and a fresh position; the parent
|
||||
tombstone remains, so D and sibling Y do not return or contribute to
|
||||
aggregates. Delete/re-add X is idempotent; explicitly re-adding D later
|
||||
leaves the intentional D + X pair from #226
|
||||
[auto: smoke_binding_picker; unit: devices.test.mjs; mutation x4]
|
||||
- [ ] Delete an entity marker and a virtual marker: the entity is offered by
|
||||
Add (with Show entities when applicable); the virtual marker is gone and
|
||||
can be recreated manually. The exact deleted entity remains offered even
|
||||
|
||||
@@ -401,6 +401,12 @@ as an intentional configuration. Deleting an entity marker returns the entity
|
||||
to automatic parent discovery; its binding tombstone does not remove registry
|
||||
data from the live HA device.
|
||||
|
||||
After deleting a complete HA device, you can restore only one of its entities:
|
||||
open **Add**, enable **Show entities**, and select that entity. House Plan
|
||||
returns the selected marker with a fresh position while the complete device
|
||||
and its other entities remain deleted. The complete device stays available in
|
||||
Add if you later decide to restore it explicitly as well.
|
||||
|
||||
### Device editor
|
||||
|
||||
- drag a marker to save its server-side position;
|
||||
|
||||
@@ -669,6 +669,12 @@ House Plan читает реестры устройств, сущностей и
|
||||
удаления entity-marker сущность снова доступна автоматическому родителю:
|
||||
binding tombstone не вырезает её из живого устройства HA.
|
||||
|
||||
После удаления целого HA-устройства можно вернуть только одну его сущность:
|
||||
откройте **Добавить**, включите **Показывать сущности** и выберите нужную
|
||||
сущность. House Plan вернёт выбранный маркер на свежую позицию, а всё устройство
|
||||
и остальные его сущности останутся удалёнными. Само устройство продолжит
|
||||
предлагаться в **Добавить**, если позднее вы решите явно вернуть и его.
|
||||
|
||||
### Редактор устройств
|
||||
|
||||
| Действие | Результат |
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
- Issue: [#262](https://github.com/Matysh/houseplan-card/issues/262)
|
||||
- Ветка: `issue/262-readd-child-entity`
|
||||
- Статус: `S3-spec`
|
||||
- Статус: `S6-in-progress`, ТЗ одобрено
|
||||
- Ревью ТЗ: [`docs/reviews/SPEC-REVIEW-262-r1.md`](../reviews/SPEC-REVIEW-262-r1.md),
|
||||
зелёный вердикт
|
||||
- Приоритет: `P2`
|
||||
- Тип: `bug`
|
||||
- Трек: обычный
|
||||
@@ -439,4 +441,3 @@ records schema-valid и не удаляются старой версией, э
|
||||
координат D → X нет.
|
||||
5. Полноценное добавление D после X оставляет оба explicit marker по уже
|
||||
утверждённому #226; скрытое автоматическое объединение было бы новым UX.
|
||||
|
||||
|
||||
@@ -40,6 +40,61 @@ import { fileURLToPath } from 'node:url';
|
||||
// `find` обязан встречаться в файле ровно один раз: патч, который ложится «куда
|
||||
// попало», проверяет не то, что объявлен проверять. Это контролирует --check.
|
||||
export const MUTANTS = [
|
||||
{
|
||||
id: 'device-tombstone-blocks-child-picker',
|
||||
guard: 'node demo/smoke_binding_picker.mjs',
|
||||
because: 'a device tombstone must expose an active child in Add when Show entities is on; '
|
||||
+ 'restoring only the exact device reproduces the user-visible dead end from #262',
|
||||
patches: [{
|
||||
file: 'src/houseplan-card.ts',
|
||||
find: ' if (isRemovedPlanEntity(h, eid, removed)\n'
|
||||
+ ' && !removedBindings.has(v) && !childOfRemovedDevice) continue;',
|
||||
replace: ' if (isRemovedPlanEntity(h, eid, removed)\n'
|
||||
+ ' && !removedBindings.has(v)) continue;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'live-child-still-suppressed-by-parent-tombstone',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
+ '&& node --test --test-name-pattern="issue 262" test/devices.test.mjs',
|
||||
because: 'fixing only the picker lets the person select X but buildDevices and every '
|
||||
+ 'availability consumer still discard it after Save; a live exact child must win (#262)',
|
||||
patches: [{
|
||||
file: 'src/devices.ts',
|
||||
find: ' if (removed.liveEntities.has(eid)) return false;\n',
|
||||
replace: '',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'parent-tombstone-restores-all-siblings',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
+ '&& node --test --test-name-pattern="issue 262" test/devices.test.mjs',
|
||||
because: 'one restored child is an exact override, not permission for every sibling and '
|
||||
+ 'room aggregate of the deleted parent device to return (#262)',
|
||||
patches: [{
|
||||
file: 'src/devices.ts',
|
||||
find: ' if (removed.liveEntities.has(eid)) return false;\n',
|
||||
replace: ' if (removed.liveEntities.size) return false;\n',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'child-readd-clears-parent-tombstone',
|
||||
guard: 'node demo/smoke_binding_picker.mjs',
|
||||
because: 'saving one child must preserve the device tombstone; dropping all tombstones '
|
||||
+ 'resurrects the automatic parent and the siblings the person deliberately removed (#262)',
|
||||
patches: [{
|
||||
file: 'src/houseplan-card.ts',
|
||||
find: ` cfg.markers = cfg.markers.filter(
|
||||
(m) => m.id !== id && m.id !== oldId
|
||||
&& (marker.binding === 'virtual' || m.binding !== marker.binding),
|
||||
);`,
|
||||
replace: ` cfg.markers = cfg.markers.filter(
|
||||
(m) => m.id !== id && m.id !== oldId
|
||||
&& (marker.binding === 'virtual' || m.binding !== marker.binding)
|
||||
&& (!marker.binding.startsWith('entity:') || m.removed !== true),
|
||||
);`,
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'orphan-space-detach-disabled',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
|
||||
+15
-4
@@ -840,6 +840,9 @@ export function resolveIcon(hass: any, name: string, model: string | undefined,
|
||||
export interface RemovedPlanBindings {
|
||||
devices: Set<string>;
|
||||
entities: Set<string>;
|
||||
/** Exact live entity markers that intentionally override an entity or
|
||||
* parent-device tombstone without restoring the rest of that device. */
|
||||
liveEntities: Set<string>;
|
||||
}
|
||||
|
||||
export interface DeletePlanMarkerResult {
|
||||
@@ -934,23 +937,31 @@ export function deletePlanMarkerRecords(
|
||||
export function removedPlanBindings(markers?: readonly Marker[] | null): RemovedPlanBindings {
|
||||
const devices = new Set<string>();
|
||||
const entities = new Set<string>();
|
||||
const liveEntities = new Set<string>();
|
||||
for (const m of markers || []) {
|
||||
if (m?.removed !== true) continue;
|
||||
const i = String(m.binding || '').indexOf(':');
|
||||
if (i < 1) continue;
|
||||
const kind = m.binding.slice(0, i);
|
||||
const ref = m.binding.slice(i + 1);
|
||||
if (!ref) continue;
|
||||
if (m?.removed !== true) {
|
||||
if (kind === 'entity') liveEntities.add(ref);
|
||||
continue;
|
||||
}
|
||||
if (kind === 'device') devices.add(ref);
|
||||
else if (kind === 'entity') entities.add(ref);
|
||||
}
|
||||
return { devices, entities };
|
||||
return { devices, entities, liveEntities };
|
||||
}
|
||||
|
||||
/** Whether an HA entity is suppressed by an entity or whole-device tombstone. */
|
||||
export function isRemovedPlanEntity(
|
||||
hass: any, eid: string, removed: RemovedPlanBindings,
|
||||
): boolean {
|
||||
// A person may restore one exact child without restoring its deleted parent.
|
||||
// This exception must stay binding-scoped: siblings remain suppressed by the
|
||||
// device tombstone until they receive their own live markers (#262).
|
||||
if (removed.liveEntities.has(eid)) return false;
|
||||
if (removed.entities.has(eid)) return true;
|
||||
const deviceId = hass?.entities?.[eid]?.device_id;
|
||||
return !!deviceId && removed.devices.has(deviceId);
|
||||
@@ -1393,8 +1404,8 @@ export function areaClimateMap(
|
||||
// A device tombstone suppresses all of its data. An entity tombstone only
|
||||
// suppresses a standalone entity; inside its live parent device it remains
|
||||
// available to device state, cards and room aggregates.
|
||||
if ((reg.device_id && removed.devices.has(reg.device_id))
|
||||
|| (!reg.device_id && removed.entities.has(eid))) continue;
|
||||
if ((reg.device_id && removed.devices.has(reg.device_id) && !removed.liveEntities.has(eid))
|
||||
|| (!reg.device_id && removed.entities.has(eid) && !removed.liveEntities.has(eid))) continue;
|
||||
const dev = reg.device_id ? hass.devices?.[reg.device_id] : null;
|
||||
const area = reg.area_id || dev?.area_id || null;
|
||||
if (!area) continue;
|
||||
|
||||
@@ -13518,7 +13518,13 @@ class HouseplanCard extends LitElement {
|
||||
for (const [eid, reg] of Object.entries<any>(h.entities)) {
|
||||
const v = 'entity:' + eid;
|
||||
if (taken.has(v) || seen.has(v) || (reg.hidden && !removedBindings.has(v))) continue;
|
||||
if (isRemovedPlanEntity(h, eid, removed) && !removedBindings.has(v)) continue;
|
||||
// A deleted device remains offered as a whole, but the person may also
|
||||
// restore just one of its children. The runtime override begins only
|
||||
// after that exact entity marker is saved; this picker-only exception
|
||||
// is what makes the intentional transition possible (#262).
|
||||
const childOfRemovedDevice = !!reg.device_id && removed.devices.has(reg.device_id);
|
||||
if (isRemovedPlanEntity(h, eid, removed)
|
||||
&& !removedBindings.has(v) && !childOfRemovedDevice) continue;
|
||||
const stt = h.states[eid];
|
||||
const label = reg.name || stt?.attributes?.friendly_name || eid;
|
||||
const dev = reg.device_id ? h.devices[reg.device_id] : null;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
removeMarkerControlReferences,
|
||||
rewriteMarkerControlReferences, markerControlWouldCycle, resolveDeviceLightSettings,
|
||||
incomingLightControls,
|
||||
removedPlanBindings, isRemovedPlanEntity, isRemovedPlanSource,
|
||||
} from '../test-build/devices.js';
|
||||
import { compileIconRules, iconFor } from '../test-build/rules.js';
|
||||
|
||||
@@ -51,6 +52,36 @@ test('deletePlanMarkerRecords deduplicates HA binding and leaves old-card-safe t
|
||||
assert.deepEqual(new Set(result.cleanupIds), new Set(['old', 'new']));
|
||||
});
|
||||
|
||||
test('issue 262 live child overrides a parent tombstone without restoring siblings', () => {
|
||||
const h = mkHass({
|
||||
devices: { hub: dev('hub', 'Voice satellite', 'Satellite', 'living') },
|
||||
entities: {
|
||||
'sensor.voice_level': { entity_id: 'sensor.voice_level', device_id: 'hub' },
|
||||
'switch.mic_mute': { entity_id: 'switch.mic_mute', device_id: 'hub' },
|
||||
},
|
||||
});
|
||||
const removed = removedPlanBindings([
|
||||
{ id: 'hub', binding: 'device:hub', removed: true },
|
||||
{ id: 'stale-x', binding: 'entity:sensor.voice_level', removed: true },
|
||||
{ id: 'live-x', binding: 'entity:sensor.voice_level' },
|
||||
]);
|
||||
|
||||
assert.equal(isRemovedPlanEntity(h, 'sensor.voice_level', removed), false);
|
||||
assert.equal(isRemovedPlanEntity(h, 'switch.mic_mute', removed), true);
|
||||
assert.equal(isRemovedPlanSource(h, 'entity:sensor.voice_level', [
|
||||
{ id: 'hub', binding: 'device:hub', removed: true },
|
||||
{ id: 'live-x', binding: 'entity:sensor.voice_level' },
|
||||
]), false);
|
||||
assert.equal(isRemovedPlanSource(h, 'entity:switch.mic_mute', [
|
||||
{ id: 'hub', binding: 'device:hub', removed: true },
|
||||
{ id: 'live-x', binding: 'entity:sensor.voice_level' },
|
||||
]), true);
|
||||
assert.equal(isRemovedPlanSource(h, 'device:hub', [
|
||||
{ id: 'hub', binding: 'device:hub', removed: true },
|
||||
{ id: 'live-x', binding: 'entity:sensor.voice_level' },
|
||||
]), true);
|
||||
});
|
||||
|
||||
test('buildDevices: devices outside bound areas are dropped', () => {
|
||||
const h = mkHass({ devices: {
|
||||
a: dev('a', 'Lamp', 'Bulb', 'living'),
|
||||
@@ -468,6 +499,36 @@ test('entity tombstone does not strip that entity from a live parent device', ()
|
||||
assert.equal(parent.primary, 'sensor.box_temp');
|
||||
});
|
||||
|
||||
test('issue 262 parent tombstone builds only the explicitly restored child entity', () => {
|
||||
const h = mkHass({
|
||||
devices: { hub: dev('hub', 'Voice satellite', 'Satellite', 'living') },
|
||||
entities: {
|
||||
'sensor.voice_level': { entity_id: 'sensor.voice_level', device_id: 'hub', platform: 'demo' },
|
||||
'switch.mic_mute': { entity_id: 'switch.mic_mute', device_id: 'hub', platform: 'demo' },
|
||||
},
|
||||
states: {
|
||||
'sensor.voice_level': { state: '42', attributes: { friendly_name: 'Voice level' } },
|
||||
'switch.mic_mute': { state: 'off', attributes: { friendly_name: 'Mic mute' } },
|
||||
},
|
||||
});
|
||||
const child = { id: 'lg_sensor.voice_level', binding: 'entity:sensor.voice_level', hidden: false };
|
||||
const withParentTombstone = buildDevices(baseCtx(h, { markers: [
|
||||
{ id: 'hub', binding: 'device:hub', removed: true, hidden: true },
|
||||
child,
|
||||
], settings: { filter_seeded: true } }));
|
||||
assert.deepEqual(withParentTombstone.map((item) => item.bindingKind + ':' + item.bindingRef), [
|
||||
'entity:sensor.voice_level',
|
||||
]);
|
||||
|
||||
const bothExplicit = buildDevices(baseCtx(h, { markers: [
|
||||
{ id: 'hub', binding: 'device:hub', hidden: false },
|
||||
child,
|
||||
], settings: { filter_seeded: true } }));
|
||||
assert.deepEqual(new Set(bothExplicit.map((item) => item.bindingKind + ':' + item.bindingRef)), new Set([
|
||||
'device:hub', 'entity:sensor.voice_level',
|
||||
]));
|
||||
});
|
||||
|
||||
test('buildDevices: virtual marker lands in its room; entity marker resolves name from state', () => {
|
||||
const h = mkHass({
|
||||
entities: { 'sensor.avg': { entity_id: 'sensor.avg', platform: 'min_max', area_id: 'kitchen' } },
|
||||
@@ -1302,6 +1363,25 @@ test('areaClimateMap: whole device tombstone wins; entity tombstone stays bindin
|
||||
assert.deepEqual(areaClimateMap(hass, undefined, markers).get('living'), { temp: 30, hum: null });
|
||||
});
|
||||
|
||||
test('issue 262 room climate restores only the exact live child of a tombstoned device', () => {
|
||||
const hass = {
|
||||
devices: { hub: { id: 'hub', name: 'Climate hub', area_id: 'living' } },
|
||||
entities: {
|
||||
'sensor.room_temperature': { device_id: 'hub', platform: 'demo' },
|
||||
'sensor.sibling_temperature': { device_id: 'hub', platform: 'demo' },
|
||||
},
|
||||
states: {
|
||||
'sensor.room_temperature': { state: '20', attributes: { device_class: 'temperature' } },
|
||||
'sensor.sibling_temperature': { state: '40', attributes: { device_class: 'temperature' } },
|
||||
},
|
||||
};
|
||||
const markers = [
|
||||
{ id: 'hub', binding: 'device:hub', removed: true },
|
||||
{ id: 'room-temp', binding: 'entity:sensor.room_temperature' },
|
||||
];
|
||||
assert.deepEqual(areaClimateMap(hass, undefined, markers).get('living'), { temp: 20, hum: null });
|
||||
});
|
||||
|
||||
test('resolvedLightSources: one source set feeds room fill, card, glow and controls', () => {
|
||||
const hass = { states: {
|
||||
'light.auto': { state: 'on' },
|
||||
|
||||
Reference in New Issue
Block a user