mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 12:18:51 +00:00
fix: keep opening HA references after marker deletion
Issue: #104 User-Visible: yes
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -0,0 +1,140 @@
|
||||
// #104 pre-beta smoke: an opening owns exact contact/lock references
|
||||
// independently of the standalone marker tombstone. This file is added during
|
||||
// implementation but intentionally runs only in the pre-beta smoke gate.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const settle = async () => {
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
};
|
||||
const openingRefs = () => c._serverCfg.spaces
|
||||
.find((space) => space.id === 'f1').openings
|
||||
.map(({ id, contact, lock }) => ({ id, contact, lock }));
|
||||
|
||||
const space = c._serverCfg.spaces.find((item) => item.id === 'f1');
|
||||
space.openings = [
|
||||
{
|
||||
id: 'hp104-a', type: 'door', x: 0.22, y: 0.14, angle: 0, length: 0.09,
|
||||
contact: 'binary_sensor.window', lock: 'lock.front_door',
|
||||
},
|
||||
{
|
||||
id: 'hp104-b', type: 'door', x: 0.40, y: 0.14, angle: 0, length: 0.09,
|
||||
contact: 'binary_sensor.window', lock: 'lock.front_door',
|
||||
},
|
||||
];
|
||||
const refsBefore = JSON.stringify(openingRefs());
|
||||
c._serverCfg = {
|
||||
...c._serverCfg,
|
||||
markers: [
|
||||
{ id: 'window-gone', binding: 'entity:binary_sensor.window', removed: true, hidden: true },
|
||||
{ id: 'lock-gone', binding: 'device:d_lock', removed: true, hidden: true },
|
||||
],
|
||||
};
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
c.hass = { ...c.hass };
|
||||
await settle();
|
||||
|
||||
const contactValues = c._contactCandidates().map((item) => item.value);
|
||||
const lockValues = c._lockCandidates().map((item) => item.value);
|
||||
const openings = c._openingsR;
|
||||
const result = {
|
||||
entityTombstoneCandidate: contactValues.includes('binary_sensor.window'),
|
||||
deviceTombstoneCandidate: lockValues.includes('lock.front_door'),
|
||||
markerContributionsStaySuppressed:
|
||||
!c._planEntityAvailable('binary_sensor.window')
|
||||
&& !c._planEntityAvailable('lock.front_door'),
|
||||
exactOpeningReferencesStayActive:
|
||||
c._openingEntityAvailable('binary_sensor.window')
|
||||
&& c._openingEntityAvailable('lock.front_door'),
|
||||
sharedContactDrivesBoth: openings.length === 2
|
||||
&& openings.every((opening) => c._openingAmt(opening) === 1),
|
||||
sharedLockRendersTwice: sr().querySelectorAll('.oplock').length === 2,
|
||||
deletionKeepsOpeningFields: JSON.stringify(openingRefs()) === refsBefore,
|
||||
};
|
||||
|
||||
// The opening info card remains the one deliberate lock surface.
|
||||
c._openingInfo = openings[0];
|
||||
await settle();
|
||||
result.infoHasContactAndLock = sr().querySelectorAll('.oprow').length === 2;
|
||||
result.infoHasOneLockAction = sr().querySelectorAll('.lockact').length === 1;
|
||||
const calls = [];
|
||||
c.hass = {
|
||||
...c.hass,
|
||||
callService: async (domain, service, data) => calls.push({ domain, service, data }),
|
||||
};
|
||||
c._lockAction('lock.front_door', 'lock');
|
||||
result.tombstonedMarkerDoesNotBlockExplicitLock = calls.length === 1
|
||||
&& calls[0].domain === 'lock' && calls[0].service === 'lock';
|
||||
|
||||
// Re-adding replaces only the marker record; exact opening fields remain
|
||||
// byte-for-byte the same and therefore cannot be duplicated or retargeted.
|
||||
c._serverCfg = {
|
||||
...c._serverCfg,
|
||||
markers: [
|
||||
{ id: 'window-back', binding: 'entity:binary_sensor.window', hidden: false },
|
||||
{ id: 'lock-back', binding: 'device:d_lock', hidden: false },
|
||||
],
|
||||
};
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
await settle();
|
||||
result.readdKeepsOpeningFields = JSON.stringify(openingRefs()) === refsBefore;
|
||||
|
||||
// Active registry rows with unknown transport state keep the reference but
|
||||
// expose no lock action; disabled and orphaned exact rows fail closed.
|
||||
c.hass = {
|
||||
...c.hass,
|
||||
devices: {
|
||||
...c.hass.devices,
|
||||
hp104_disabled_parent: { id: 'hp104_disabled_parent', disabled_by: 'user' },
|
||||
},
|
||||
entities: {
|
||||
...c.hass.entities,
|
||||
'lock.hp104_disabled': {
|
||||
entity_id: 'lock.hp104_disabled', device_id: null, disabled_by: 'user',
|
||||
},
|
||||
'lock.hp104_disabled_parent': {
|
||||
entity_id: 'lock.hp104_disabled_parent', device_id: 'hp104_disabled_parent', disabled_by: null,
|
||||
},
|
||||
'lock.hp104_orphan': {
|
||||
entity_id: 'lock.hp104_orphan', device_id: 'hp104_missing_parent', disabled_by: null,
|
||||
},
|
||||
},
|
||||
states: {
|
||||
...c.hass.states,
|
||||
'lock.front_door': { ...c.hass.states['lock.front_door'], state: 'unavailable' },
|
||||
'lock.hp104_disabled': { entity_id: 'lock.hp104_disabled', state: 'locked', attributes: {} },
|
||||
'lock.hp104_disabled_parent': {
|
||||
entity_id: 'lock.hp104_disabled_parent', state: 'locked', attributes: {},
|
||||
},
|
||||
'lock.hp104_orphan': { entity_id: 'lock.hp104_orphan', state: 'locked', attributes: {} },
|
||||
},
|
||||
callService: async (domain, service, data) => calls.push({ domain, service, data }),
|
||||
};
|
||||
await settle();
|
||||
c._openingInfo = c._openingsR[0];
|
||||
await settle();
|
||||
const unavailableCandidates = c._lockCandidates().map((item) => item.value);
|
||||
result.unavailableRemainsCandidate = unavailableCandidates.includes('lock.front_door');
|
||||
result.unavailableShowsUnknownWithoutAction = sr().querySelector('.oplock.unknown') != null
|
||||
&& sr().querySelector('.lockact') == null;
|
||||
result.disabledAndOrphanedAreRejected = !unavailableCandidates.includes('lock.hp104_disabled')
|
||||
&& !unavailableCandidates.includes('lock.hp104_disabled_parent')
|
||||
&& !unavailableCandidates.includes('lock.hp104_orphan');
|
||||
const callsBeforeRejected = calls.length;
|
||||
c._lockAction('lock.hp104_disabled', 'lock');
|
||||
c._lockAction('lock.hp104_disabled_parent', 'lock');
|
||||
c._lockAction('lock.hp104_orphan', 'lock');
|
||||
result.rejectedLocksNeverCallService = calls.length === callsBeforeRejected;
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
File diff suppressed because one or more lines are too long
Vendored
+6
-6
File diff suppressed because one or more lines are too long
+13
-1
@@ -245,7 +245,13 @@ 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` remain persisted and become active again after re-add.
|
||||
such as `controls` and live text remain persisted and become active again after
|
||||
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.
|
||||
|
||||
@@ -413,6 +419,12 @@ Its default width in the editor is 300 cm. `openingAmount` (pure) maps the conta
|
||||
drag along walls (continuous re-snap, saved on release), click → status card (250 ms timer),
|
||||
double click → properties dialog. In markup mode the "Opening" tool handles clicks instead.
|
||||
|
||||
Contact and lock are exact HA references owned by the opening, not aliases of
|
||||
standalone markers. Their candidate/action path follows HA binding status while
|
||||
their render path follows the frozen active-registry projection; neither path
|
||||
consults marker tombstones. Plan-level consumers keep the tombstone policy
|
||||
described above.
|
||||
|
||||
For a wall with thickness, one `OpeningWallIndex` resolves the atomic wall
|
||||
interval and adjacent room on each side of the centreline. Opening symbols,
|
||||
wall cuts and room-coloured tunnel patches all consume this association; none
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Deleting a standalone sensor or lock marker no longer breaks its explicit
|
||||
door, window or gate binding: the entity stays available in opening settings
|
||||
and continues to drive its state without restoring the marker on the plan
|
||||
([#104](https://github.com/Matysh/houseplan-card/issues/104)).
|
||||
- Fixed a crash after deleting the last space: the supported empty state now
|
||||
stays usable so a new space can be added ([#111](https://github.com/Matysh/houseplan-card/issues/111)).
|
||||
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Удаление самостоятельного маркера датчика или замка больше не разрывает его
|
||||
явную привязку к двери, окну или воротам: сущность остаётся доступной в
|
||||
настройках проёма и продолжает показывать состояние, не возвращая маркер на
|
||||
план ([#104](https://github.com/Matysh/houseplan-card/issues/104)).
|
||||
- Исправлено падение после удаления последнего пространства: предусмотренное
|
||||
пустое состояние остаётся рабочим, и из него можно добавить новое пространство
|
||||
([#111](https://github.com/Matysh/houseplan-card/issues/111)).
|
||||
|
||||
+9
-4
@@ -104,10 +104,15 @@ the old behaviour until an editing client materialises it.
|
||||
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.
|
||||
- References in openings, live text and another marker's persisted `controls`
|
||||
are retained but become inactive (no lock/contact badge, `—`, or control
|
||||
action respectively). Adding the binding again restores them without
|
||||
reconstructing configuration or performing an unrelated Save first.
|
||||
- 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
|
||||
state. The tombstone still suppresses every marker-level contribution and is
|
||||
not removed or rewritten by choosing the entity in an opening.
|
||||
- References in live text and another marker's persisted `controls` are
|
||||
retained but become inactive (`—` or no control action respectively).
|
||||
Adding the binding again restores those references without reconstructing
|
||||
configuration or performing an unrelated Save first.
|
||||
- The same inactive rule applies while a saved binding is HA-disabled. The
|
||||
marker, room metrics, Glow/light fill, registry-wide climate, live text,
|
||||
openings, controls, vacuum puck and trails all ignore it. Reactivation of the
|
||||
|
||||
+4
-2
@@ -98,8 +98,10 @@ metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
|
||||
- **True plan deletion** (v1.60.0-beta.1, 2026-08-07): confirmed Delete beside Hide/Show;
|
||||
a minimal `marker.removed` tombstone prevents auto-rediscovery but exposes
|
||||
the binding to Add. Deleted devices are absent from every plan aggregate and
|
||||
linked presentation; layout/files/trails are cleaned and stale layout writes
|
||||
are rejected.
|
||||
linked marker presentation; layout/files/trails are cleaned and stale layout
|
||||
writes are rejected. Exact contact/lock references owned by architectural
|
||||
openings remain active without restoring the standalone marker; live text
|
||||
and other marker controls retain the older re-add-to-reactivate contract.
|
||||
- **Yellow = working right now** (v1.51.0): climate uses `hvac_action` when
|
||||
available and falls back to a current advertised non-off HVAC mode only when
|
||||
the integration omits the action; service switches can no longer become
|
||||
|
||||
+12
-5
@@ -392,7 +392,11 @@ Glow и солнечные лучи, но при Resize комнаты оста
|
||||
| Открывается в другую сторону | Да | Да | Да; по умолчанию — наружу |
|
||||
| Замок `lock.*` | Да | Нет | Да |
|
||||
|
||||
Контакт выбирается из подходящих `binary_sensor` и дверных `cover`. Состояние датчика анимирует створку/полотно. Инверсия нужна, если интеграция отдаёт противоположную логику.
|
||||
Контакт выбирается из подходящих `binary_sensor` и дверных `cover`. Состояние
|
||||
датчика анимирует створку/полотно. Инверсия нужна, если интеграция отдаёт
|
||||
противоположную логику. Контактный датчик и замок — самостоятельные точные
|
||||
привязки проёма: удаление отдельного маркера той же сущности с плана не убирает
|
||||
её из этих списков и не выключает уже сохранённую связь.
|
||||
|
||||
### Поведение в разных режимах
|
||||
|
||||
@@ -491,10 +495,13 @@ House Plan читает реестры устройств, сущностей и
|
||||
Удаление и скрытие — разные операции. Скрытый маркер можно показать через
|
||||
**Скрытые и деактивированные**, и он продолжает участвовать в предусмотренных для скрытых
|
||||
устройств данных. Удалённый маркер там не показывается, не участвует в LQI,
|
||||
температуре, влажности, свете, Glow, карточке комнаты, управлении и живых
|
||||
ссылках плана. Его привязка снова появляется в списке **Добавить**. Повторное
|
||||
добавление создаёт новую позицию; сохранённая конфигурация проёмов и текстовых
|
||||
переменных снова оживает. Позиция, вложения и след пылесоса удаляются.
|
||||
температуре, влажности, свете, Glow, карточке комнаты, управлении, текстовых
|
||||
переменных и внешних controls других маркеров. Его привязка снова появляется в
|
||||
списке **Добавить**. Контактный датчик или замок, явно выбранный в свойствах
|
||||
проёма, остаётся отдельной рабочей связью и не восстанавливает удалённый маркер.
|
||||
Повторное добавление создаёт маркеру новую позицию и снова оживляет сохранённые
|
||||
текстовые переменные и controls, но не дублирует и не переписывает связь проёма.
|
||||
Позиция, вложения и след пылесоса при удалении маркера удаляются.
|
||||
|
||||
### Скрытые устройства
|
||||
|
||||
|
||||
@@ -515,6 +515,35 @@ export function resolveHaBindingStatus(
|
||||
return { kind: 'unverified', reason: 'registry_unavailable', enabledEntityIds: [], allEntityIds: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Availability of an exact entity explicitly assigned to an architectural
|
||||
* opening. This is deliberately independent of plan-marker tombstones: the
|
||||
* opening is its own configured consumer of the HA entity. Registry status
|
||||
* still rejects disabled, orphaned and unverified references.
|
||||
*/
|
||||
export function openingEntityAvailable(
|
||||
hass: any,
|
||||
entityId: string | null | undefined,
|
||||
snapshot = haRegistrySnapshot(hass),
|
||||
): boolean {
|
||||
return !!entityId
|
||||
&& resolveHaBindingStatus(hass, `entity:${entityId}`, snapshot).kind === 'active';
|
||||
}
|
||||
|
||||
/**
|
||||
* Frame-local counterpart of `openingEntityAvailable`. The caller supplies an
|
||||
* immutable active-registry projection, so both the registry row and state
|
||||
* must belong to the same painted frame. Registry-less render parity is #117.
|
||||
*/
|
||||
export function renderOpeningEntityAvailable(
|
||||
projectedHass: any,
|
||||
entityId: string | null | undefined,
|
||||
): boolean {
|
||||
return !!entityId
|
||||
&& !!projectedHass?.entities?.[entityId]
|
||||
&& !!projectedHass?.states?.[entityId];
|
||||
}
|
||||
|
||||
export function haRegistryDiagnostics(hass: any): {
|
||||
access: HaRegistrySnapshot['access']; authoritative: boolean; revision: number;
|
||||
lastSuccess: number; error?: string;
|
||||
|
||||
+20
-9
@@ -175,7 +175,8 @@ import {
|
||||
import {
|
||||
acquireHaRegistries, activeRegistryHass, cacheHaBindingStatuses,
|
||||
fullRegistryHass, haRegistryBuildSignature, haRegistryDiagnostics, haRegistrySnapshot,
|
||||
refreshHaRegistries, resolveHaBindingStatus,
|
||||
openingEntityAvailable, refreshHaRegistries, renderOpeningEntityAvailable,
|
||||
resolveHaBindingStatus,
|
||||
type HaBindingStatus, type HaRegistrySnapshot,
|
||||
} from './ha-binding-status';
|
||||
import type { DecorShape, DecorStyle } from './editors/decor/types';
|
||||
@@ -10362,7 +10363,7 @@ class HouseplanCard extends LitElement {
|
||||
private _contactCandidates(): { value: string; label: string }[] {
|
||||
const out: [string, string, number][] = [];
|
||||
for (const eid of Object.keys(this.hass.states)) {
|
||||
if (!this._planEntityAvailable(eid)) continue;
|
||||
if (!this._openingEntityAvailable(eid)) continue;
|
||||
const dom = eid.split('.')[0];
|
||||
if (dom !== 'binary_sensor' && dom !== 'cover') continue;
|
||||
const st = this.hass.states[eid];
|
||||
@@ -10378,7 +10379,7 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
private _lockCandidates(): { value: string; label: string }[] {
|
||||
return Object.keys(this.hass.states)
|
||||
.filter((eid) => eid.startsWith('lock.') && this._planEntityAvailable(eid))
|
||||
.filter((eid) => eid.startsWith('lock.') && this._openingEntityAvailable(eid))
|
||||
.map((eid) => ({ value: eid, label: this.hass.states[eid]?.attributes?.friendly_name || eid }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
}
|
||||
@@ -15512,7 +15513,7 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
/** Live state of an opening's contact, 0..1 drawn amount. */
|
||||
private _openingAmt(o: OpeningCfg): number {
|
||||
const st = o.contact && this._renderEntityAvailable(o.contact)
|
||||
const st = o.contact && this._renderOpeningEntityAvailable(o.contact)
|
||||
? this._renderPlanHass.states[o.contact]?.state
|
||||
: null;
|
||||
return openingAmount(o.type, st, !!o.invert);
|
||||
@@ -15532,6 +15533,16 @@ class HouseplanCard extends LitElement {
|
||||
return !!this._renderPlanHass.entities?.[eid] && !!this._renderPlanHass.states?.[eid];
|
||||
}
|
||||
|
||||
/** Exact opening references ignore marker tombstones but keep HA status. */
|
||||
private _openingEntityAvailable(eid: string | null | undefined): boolean {
|
||||
return openingEntityAvailable(this.hass, eid, this._haRegistry);
|
||||
}
|
||||
|
||||
/** Opening availability from the same immutable projection as the frame. */
|
||||
private _renderOpeningEntityAvailable(eid: string | null | undefined): boolean {
|
||||
return renderOpeningEntityAvailable(this._renderPlanHass, eid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Doors, windows and gates, drawn in plan (SVG) coordinates so they scale and
|
||||
* pan with the plan. Doors/windows follow the easy-floorplan (MIT) symbol
|
||||
@@ -15571,7 +15582,7 @@ class HouseplanCard extends LitElement {
|
||||
const openingWallIndex = this._openingWallIndexFor(this._spaceModel(), openCuts).value;
|
||||
return svg`${items.map((o) => {
|
||||
const amt = this._openingAmt(o);
|
||||
const active = amt > 0 && !!o.contact && this._renderEntityAvailable(o.contact);
|
||||
const active = amt > 0 && !!o.contact && this._renderOpeningEntityAvailable(o.contact);
|
||||
const tone = active ? 'var(--hp-open)' : base;
|
||||
// A normal door/window uses the selected room-side face. A gate's
|
||||
// architectural symbol sits and opens on the opposite (exterior) face;
|
||||
@@ -15617,7 +15628,7 @@ class HouseplanCard extends LitElement {
|
||||
/** Padlock badges for door-like openings with a lock entity. */
|
||||
private _renderOpeningLocks(view: { x: number; y: number; w: number; h: number }): TemplateResult {
|
||||
const items = this._openingsR.filter(
|
||||
(o) => o.type !== 'window' && o.lock && this._renderEntityAvailable(o.lock),
|
||||
(o) => o.type !== 'window' && o.lock && this._renderOpeningEntityAvailable(o.lock),
|
||||
);
|
||||
if (!items.length) return html``;
|
||||
const openCuts = this._openPairs().flatMap((pair) => pair.segs);
|
||||
@@ -15662,7 +15673,7 @@ class HouseplanCard extends LitElement {
|
||||
// button is a deliberate, labeled control inside an opened card, the same
|
||||
// contract as Home Assistant's own more-info dialog. Unlocking additionally
|
||||
// asks for confirmation; locking does not (locking is never destructive).
|
||||
if (!this._planEntityAvailable(entityId)) return;
|
||||
if (!this._openingEntityAvailable(entityId)) return;
|
||||
if (action === 'unlock') {
|
||||
const name = this.hass?.states?.[entityId]?.attributes?.friendly_name || entityId;
|
||||
if (!confirm(this._t('confirm.unlock', { name }))) return;
|
||||
@@ -15672,8 +15683,8 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
private _renderOpeningInfoCard(): TemplateResult {
|
||||
const o = this._openingInfo!;
|
||||
const contact = o.contact && this._planEntityAvailable(o.contact) ? o.contact : null;
|
||||
const lock = o.lock && this._planEntityAvailable(o.lock) ? o.lock : null;
|
||||
const contact = o.contact && this._openingEntityAvailable(o.contact) ? o.contact : null;
|
||||
const lock = o.lock && this._openingEntityAvailable(o.lock) ? o.lock : null;
|
||||
const cSt = contact ? this.hass.states[contact]?.state : null;
|
||||
const amt = this._openingAmt(o);
|
||||
const lSt = lock ? this.hass.states[lock]?.state : null;
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
activeRegistryHass,
|
||||
cacheHaBindingStatuses,
|
||||
haRegistrySnapshot,
|
||||
openingEntityAvailable,
|
||||
renderOpeningEntityAvailable,
|
||||
resolveHaBindingStatus,
|
||||
} from '../test-build/ha-binding-status.js';
|
||||
|
||||
@@ -61,6 +63,8 @@ test('limited registry never guesses missing rows are disabled or orphaned', ()
|
||||
assert.equal(resolveHaBindingStatus(hass, 'entity:switch.live_only', snapshot).kind, 'active');
|
||||
assert.equal(resolveHaBindingStatus(hass, 'entity:switch.unknown', snapshot).kind, 'unverified');
|
||||
assert.equal(resolveHaBindingStatus(hass, 'device:unknown', snapshot).kind, 'unverified');
|
||||
assert.equal(openingEntityAvailable(hass, 'switch.live_only', snapshot), true);
|
||||
assert.equal(openingEntityAvailable(hass, 'switch.unknown', snapshot), false);
|
||||
});
|
||||
|
||||
test('authoritative registry accepts an exact live YAML entity without a registry row', () => {
|
||||
@@ -170,9 +174,14 @@ test('limited live rows cannot override the last authoritative disabled result',
|
||||
getItem: () => '{}',
|
||||
setItem: () => undefined,
|
||||
};
|
||||
cacheHaBindingStatuses(new Map([['device:cached-disabled', {
|
||||
kind: 'ha_disabled', reason: 'all_entities', enabledEntityIds: [], allEntityIds: ['switch.cached'],
|
||||
}]]));
|
||||
cacheHaBindingStatuses(new Map([
|
||||
['device:cached-disabled', {
|
||||
kind: 'ha_disabled', reason: 'all_entities', enabledEntityIds: [], allEntityIds: ['switch.cached'],
|
||||
}],
|
||||
['entity:switch.cached', {
|
||||
kind: 'ha_disabled', reason: 'entity', enabledEntityIds: [], allEntityIds: ['switch.cached'],
|
||||
}],
|
||||
]));
|
||||
const devices = { 'cached-disabled': { id: 'cached-disabled', disabled_by: null } };
|
||||
const entities = {
|
||||
'switch.cached': { entity_id: 'switch.cached', device_id: 'cached-disabled', disabled_by: null },
|
||||
@@ -181,6 +190,7 @@ test('limited live rows cannot override the last authoritative disabled result',
|
||||
assert.deepEqual(resolveHaBindingStatus(hass, 'device:cached-disabled', limited(devices, entities)), {
|
||||
kind: 'ha_disabled', reason: 'all_entities', enabledEntityIds: [], allEntityIds: ['switch.cached'],
|
||||
});
|
||||
assert.equal(openingEntityAvailable(hass, 'switch.cached', limited(devices, entities)), false);
|
||||
globalThis.localStorage = previousStorage;
|
||||
});
|
||||
|
||||
@@ -193,3 +203,63 @@ test('entity with a missing parent reports device_missing', () => {
|
||||
'device_missing',
|
||||
);
|
||||
});
|
||||
|
||||
test('opening entity availability ignores marker lifecycle and follows exact HA status', () => {
|
||||
const devices = {
|
||||
active: { id: 'active', disabled_by: null },
|
||||
disabled: { id: 'disabled', disabled_by: 'user' },
|
||||
};
|
||||
const entities = {
|
||||
'binary_sensor.door': {
|
||||
entity_id: 'binary_sensor.door', device_id: 'active', disabled_by: null,
|
||||
},
|
||||
'lock.front': { entity_id: 'lock.front', device_id: 'active', disabled_by: null },
|
||||
'lock.disabled_entity': {
|
||||
entity_id: 'lock.disabled_entity', device_id: 'active', disabled_by: 'user',
|
||||
},
|
||||
'lock.disabled_parent': {
|
||||
entity_id: 'lock.disabled_parent', device_id: 'disabled', disabled_by: null,
|
||||
},
|
||||
'lock.orphan': { entity_id: 'lock.orphan', device_id: 'missing', disabled_by: null },
|
||||
};
|
||||
const hass = {
|
||||
devices, entities,
|
||||
states: {
|
||||
'binary_sensor.door': { state: 'unavailable' },
|
||||
'lock.front': { state: 'locked' },
|
||||
'lock.disabled_entity': { state: 'locked' },
|
||||
'lock.disabled_parent': { state: 'locked' },
|
||||
'lock.orphan': { state: 'locked' },
|
||||
},
|
||||
};
|
||||
const snapshot = full(devices, entities);
|
||||
|
||||
// Marker lifecycle is intentionally not an input: the opening is an exact
|
||||
// independent consumer before deletion, after either tombstone and re-add.
|
||||
for (const _markerLifecycle of ['present', 'entity-tombstone', 'device-tombstone', 're-added']) {
|
||||
assert.equal(openingEntityAvailable(hass, 'binary_sensor.door', snapshot), true);
|
||||
assert.equal(openingEntityAvailable(hass, 'lock.front', snapshot), true);
|
||||
}
|
||||
assert.equal(openingEntityAvailable(hass, 'lock.disabled_entity', snapshot), false);
|
||||
assert.equal(openingEntityAvailable(hass, 'lock.disabled_parent', snapshot), false);
|
||||
assert.equal(openingEntityAvailable(hass, 'lock.orphan', snapshot), false);
|
||||
assert.equal(openingEntityAvailable(hass, 'lock.missing', snapshot), false);
|
||||
assert.equal(openingEntityAvailable(hass, null, snapshot), false);
|
||||
});
|
||||
|
||||
test('opening render availability requires one frozen registry-backed frame', () => {
|
||||
const frame = {
|
||||
entities: {
|
||||
'binary_sensor.door': { entity_id: 'binary_sensor.door' },
|
||||
'lock.no_state': { entity_id: 'lock.no_state' },
|
||||
},
|
||||
states: {
|
||||
'binary_sensor.door': { state: 'unknown' },
|
||||
'binary_sensor.yaml_only': { state: 'on' },
|
||||
},
|
||||
};
|
||||
assert.equal(renderOpeningEntityAvailable(frame, 'binary_sensor.door'), true);
|
||||
assert.equal(renderOpeningEntityAvailable(frame, 'lock.no_state'), false);
|
||||
assert.equal(renderOpeningEntityAvailable(frame, 'binary_sensor.yaml_only'), false, '#117 owns YAML parity');
|
||||
assert.equal(renderOpeningEntityAvailable(frame, ''), false);
|
||||
});
|
||||
|
||||
@@ -63,7 +63,7 @@ test('RenderDeviceSnapshot keeps immutable facts and excludes live HA capabiliti
|
||||
});
|
||||
|
||||
const methodBody = (source, name) => {
|
||||
const start = source.indexOf(`private ${name}(`);
|
||||
const start = source.search(new RegExp(`private\\s+(?:async\\s+)?${name}\\(`));
|
||||
assert.notEqual(start, -1, `${name} exists`);
|
||||
const tail = source.slice(start + 1);
|
||||
const next = tail.search(/\n (?:private|protected|public)\s/);
|
||||
@@ -95,3 +95,44 @@ test('the card gates snapshot positions on the render model', () => {
|
||||
'the previous unconditional position path must not return',
|
||||
);
|
||||
});
|
||||
|
||||
test('opening references use their own availability policy without weakening plan tombstones', () => {
|
||||
const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
|
||||
for (const name of [
|
||||
'_contactCandidates', '_lockCandidates', '_openingAmt', '_renderOpenings',
|
||||
'_renderOpeningLocks', '_renderOpeningInfoCard', '_lockAction',
|
||||
]) {
|
||||
assert.match(
|
||||
methodBody(source, name),
|
||||
/_openingEntityAvailable|_renderOpeningEntityAvailable/,
|
||||
`${name} uses the explicit opening-reference policy`,
|
||||
);
|
||||
}
|
||||
const planAvailability = methodBody(source, '_planEntityAvailable');
|
||||
const renderAvailability = methodBody(source, '_renderEntityAvailable');
|
||||
assert.match(planAvailability, /isRemovedPlanEntity/);
|
||||
assert.match(renderAvailability, /isRemovedPlanEntity/);
|
||||
assert.doesNotMatch(planAvailability, /openingEntityAvailable/);
|
||||
assert.doesNotMatch(renderAvailability, /renderOpeningEntityAvailable/);
|
||||
});
|
||||
|
||||
test('lock actuation remains guarded inside the one sanctioned opening-card method', () => {
|
||||
const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
|
||||
const action = methodBody(source, '_lockAction');
|
||||
const guardAt = action.indexOf('_openingEntityAvailable(entityId)');
|
||||
const confirmAt = action.indexOf('confirm(');
|
||||
const serviceAt = action.indexOf("callService?.('lock'");
|
||||
assert.ok(guardAt >= 0 && guardAt < confirmAt && confirmAt < serviceAt);
|
||||
assert.equal(source.match(/callService\?\.\('lock'/g)?.length, 1);
|
||||
assert.equal(source.match(/this\._lockAction\(/g)?.length, 1, 'only the opening card button calls it');
|
||||
});
|
||||
|
||||
test('marker delete/re-add and opening save remain separate config transactions', () => {
|
||||
const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
|
||||
const saveOpening = methodBody(source, '_saveOpening');
|
||||
const saveMarker = methodBody(source, '_saveMarker');
|
||||
assert.match(saveOpening, /sp\.openings/);
|
||||
assert.doesNotMatch(saveOpening, /cfg\.markers|this\._markers/);
|
||||
assert.match(saveMarker, /cfg\.markers/);
|
||||
assert.doesNotMatch(saveMarker, /\.openings/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user