mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
File diff suppressed because one or more lines are too long
@@ -894,7 +894,7 @@ def build_space_merge(
|
||||
for field in (
|
||||
"area", "controls", "tap_action", "tap_target", "tap_confirm",
|
||||
"vacuum", "is_light", "use_climate_temp", "glow_color",
|
||||
"glow_radius_cm", "light_entity", "hidden", "removed",
|
||||
"glow_radius_cm", "light_entity", "toggle_entity", "hidden", "removed",
|
||||
"value_badge",
|
||||
):
|
||||
marker.pop(field, None)
|
||||
|
||||
@@ -189,7 +189,7 @@ def validate_marker_value_badges(
|
||||
def validate_marker_light_entities(
|
||||
config: dict, previous: dict | None = None, *, validate_all: bool = False
|
||||
) -> None:
|
||||
"""Validate new/changed leading-light choices without rejecting dormant data.
|
||||
"""Validate new/changed light/switch choices without rejecting dormant data.
|
||||
|
||||
The top-level schema must stay lossless: an old or future literal that the
|
||||
current frontend cannot edit may round-trip unchanged. Imports validate the
|
||||
@@ -206,16 +206,26 @@ def validate_marker_light_entities(
|
||||
marker, marker_id, old_by_id, old_markers, new_ids,
|
||||
consumed_old_ids, validate_all,
|
||||
)
|
||||
value = marker.get("light_entity")
|
||||
old_value = None if validate_all else (old_marker or {}).get("light_entity")
|
||||
if not validate_all and value == old_value:
|
||||
continue
|
||||
if value is None:
|
||||
continue
|
||||
if not isinstance(value, str) or not _LIGHT_ENTITY_RE.fullmatch(value):
|
||||
raise MarkerControlError(
|
||||
"invalid_light_entity", "Leading light entity must be light.* or switch.*"
|
||||
)
|
||||
for field, code, message in (
|
||||
(
|
||||
"light_entity",
|
||||
"invalid_light_entity",
|
||||
"Leading light entity must be light.* or switch.*",
|
||||
),
|
||||
(
|
||||
"toggle_entity",
|
||||
"invalid_toggle_entity",
|
||||
"Toggle entity must be light.* or switch.*",
|
||||
),
|
||||
):
|
||||
value = marker.get(field)
|
||||
old_value = None if validate_all else (old_marker or {}).get(field)
|
||||
if not validate_all and value == old_value:
|
||||
continue
|
||||
if value is None:
|
||||
continue
|
||||
if not isinstance(value, str) or not _LIGHT_ENTITY_RE.fullmatch(value):
|
||||
raise MarkerControlError(code, message)
|
||||
|
||||
|
||||
def validate_marker_controls(
|
||||
@@ -939,6 +949,9 @@ MARKER_SCHEMA = vol.Schema(
|
||||
# Semantic delta validation below the schema preserves unknown/future
|
||||
# literals until that exact field is edited (lossless config doctrine).
|
||||
vol.Optional("light_entity"): object,
|
||||
# Exact own entity selected for Toggle. Delta validation preserves an
|
||||
# untouched future literal while bounding every new/changed value.
|
||||
vol.Optional("toggle_entity"): object,
|
||||
vol.Optional("value_badge"): vol.Any(
|
||||
None,
|
||||
vol.Schema(
|
||||
|
||||
@@ -580,6 +580,25 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
if (visibleRole.top < visibleBody.top - 1 || visibleRadius.bottom > visibleBody.bottom + 1)
|
||||
throw new Error('golden viewport does not show the complete device light-source controls');
|
||||
}
|
||||
if (scenario.deviceToggleEntity) {
|
||||
const dialog = card.renderRoot.querySelector('hp-dialog');
|
||||
const body = dialog?.querySelector('.body');
|
||||
const select = dialog?.querySelector('#marker-toggle-entity');
|
||||
const warning = dialog?.querySelector('.markertoggleentity [role="status"]');
|
||||
const childLock = 'switch.golden_washer_child_lock';
|
||||
if (!body || !select || select.options.length !== 3)
|
||||
throw new Error('golden toggle-entity selector is incomplete');
|
||||
if (scenario.deviceToggleEntity === 'selected'
|
||||
&& (select.value !== childLock || warning))
|
||||
throw new Error('golden selected toggle entity is not projected');
|
||||
if (scenario.deviceToggleEntity === 'stale'
|
||||
&& (select.value !== '' || !warning?.textContent?.includes('switch.golden_washer_removed')))
|
||||
throw new Error('golden stale toggle entity warning is missing');
|
||||
const bodyRect = body.getBoundingClientRect();
|
||||
const selectRect = select.getBoundingClientRect();
|
||||
body.scrollTop += selectRect.top - bodyRect.top - 12;
|
||||
await frame();
|
||||
}
|
||||
if (scenario.openHelp) {
|
||||
const help = card.renderRoot.querySelector(`hp-help[data-help-key="${scenario.openHelp}"]`);
|
||||
await help?.updateComplete;
|
||||
|
||||
+15
-1
@@ -1,7 +1,7 @@
|
||||
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
|
||||
|
||||
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
|
||||
export const GOLDEN_MATRIX_VERSION = 25;
|
||||
export const GOLDEN_MATRIX_VERSION = 26;
|
||||
|
||||
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
|
||||
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
|
||||
@@ -248,6 +248,20 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
dialog: 'device', deviceId: 'golden-light-two', deviceLightControls: true,
|
||||
deviceName: 'Контроллер освещения гостиной с намеренно очень длинным названием',
|
||||
language: 'ru', theme: 'dark', viewport: { width: 390, height: 1000 }, ...page },
|
||||
{ id: 'toggle-entity-dialog-desktop-en', fixture: 'visual', applianceLifecycle: true,
|
||||
space: 'golden-appliance', dialog: 'device', deviceId: 'golden-washer',
|
||||
deviceToggleEntity: 'selected', markerOverrides: [{
|
||||
id: 'golden-washer', binding: 'device:golden-washer', tap_action: 'toggle',
|
||||
toggle_entity: 'switch.golden_washer_child_lock',
|
||||
}],
|
||||
language: 'en', theme: 'light', viewport: { width: 1180, height: 1100 }, ...page },
|
||||
{ id: 'toggle-entity-dialog-mobile-ru', fixture: 'visual', applianceLifecycle: true,
|
||||
space: 'golden-appliance', dialog: 'device', deviceId: 'golden-washer',
|
||||
deviceToggleEntity: 'stale', markerOverrides: [{
|
||||
id: 'golden-washer', binding: 'device:golden-washer', tap_action: 'toggle',
|
||||
toggle_entity: 'switch.golden_washer_removed',
|
||||
}],
|
||||
language: 'ru', theme: 'dark', viewport: { width: 390, height: 1000 }, ...page },
|
||||
{ id: 'device-help-popover-light-ru', fixture: 'visual', space: 'golden-lighting',
|
||||
dialog: 'device', deviceId: 'golden-light-two', deviceLightControls: true,
|
||||
openHelp: 'marker.glow_mode.help', helpTextRegion: { key: 'marker.glow_mode.help', minPixels: 30 },
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
// #178: production-bundle contract for the exact Toggle-entity selector.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 1180, height: 1000 });
|
||||
const result = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const card = window.__card;
|
||||
const root = () => card.shadowRoot || card.renderRoot;
|
||||
const base = card._devices.find((device) =>
|
||||
device.bindingKind === 'device' && device.primary?.startsWith('light.'));
|
||||
const primary = base.primary;
|
||||
const secondary = 'switch.issue178_child_lock';
|
||||
|
||||
card.hass = {
|
||||
...card.hass,
|
||||
states: {
|
||||
...card.hass.states,
|
||||
[secondary]: {
|
||||
entity_id: secondary,
|
||||
state: 'off',
|
||||
attributes: { friendly_name: 'Child lock' },
|
||||
},
|
||||
},
|
||||
entities: {
|
||||
...card.hass.entities,
|
||||
[secondary]: {
|
||||
entity_id: secondary,
|
||||
device_id: base.bindingRef,
|
||||
platform: 'issue178',
|
||||
disabled_by: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
await card.updateComplete;
|
||||
|
||||
// Keep the browser smoke independent from registry loading order while the
|
||||
// production resolver, dialog and persistence helpers remain unmodified.
|
||||
const realPreview = card._markerPreviewDevice.bind(card);
|
||||
card._markerPreviewDevice = (draft) => draft.devId === base.id ? {
|
||||
...base,
|
||||
tapAction: draft.tapActionTouched ? draft.tapAction : 'toggle',
|
||||
entities: [primary, secondary],
|
||||
marker: {
|
||||
...(base.marker || {}),
|
||||
id: base.id,
|
||||
binding: `device:${base.bindingRef}`,
|
||||
tap_action: 'toggle',
|
||||
toggle_entity: draft.toggleEntity || null,
|
||||
},
|
||||
} : realPreview(draft);
|
||||
|
||||
card._openMarkerDialog({
|
||||
...base,
|
||||
tapAction: 'toggle',
|
||||
entities: [primary, secondary],
|
||||
marker: {
|
||||
...(base.marker || {}), id: base.id, binding: `device:${base.bindingRef}`,
|
||||
tap_action: 'toggle', toggle_entity: primary,
|
||||
},
|
||||
});
|
||||
await card.updateComplete;
|
||||
let select = root().querySelector('#marker-toggle-entity');
|
||||
if (!select) throw new Error(JSON.stringify({
|
||||
dialog: card._markerDialog,
|
||||
effectiveTapAction: card._effectiveMarkerTapAction(card._markerDialog),
|
||||
preview: card._markerPreviewDevice(card._markerDialog),
|
||||
selectIds: [...root().querySelectorAll('select')].map((item) => item.id),
|
||||
}));
|
||||
out.selectorForMultiple = !!select && select.options.length === 3;
|
||||
out.savedSelectionProjected = select?.value === primary;
|
||||
|
||||
select.value = secondary;
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
await card.updateComplete;
|
||||
select = root().querySelector('#marker-toggle-entity');
|
||||
const hint = root().querySelector('#marker-toggle-hint')?.textContent || '';
|
||||
out.selectionUpdatesDraft = card._markerDialog.toggleEntity === secondary
|
||||
&& select?.value === secondary;
|
||||
out.selectionUpdatesHintBeforeSave = hint.includes(secondary)
|
||||
&& card._markerDialog.tapHintAnnouncement.includes(secondary);
|
||||
out.writerPersistsSelection = card._markerToggleEntityFields(card._markerDialog)
|
||||
.toggle_entity === secondary;
|
||||
|
||||
// Re-open the persisted shape, then simulate a temporarily missing entity.
|
||||
card._markerDialog = null;
|
||||
card._openMarkerDialog({
|
||||
...base,
|
||||
tapAction: 'toggle',
|
||||
entities: [primary, secondary],
|
||||
marker: {
|
||||
...(base.marker || {}), id: base.id, binding: `device:${base.bindingRef}`,
|
||||
tap_action: 'toggle', toggle_entity: secondary,
|
||||
},
|
||||
});
|
||||
await card.updateComplete;
|
||||
out.reopenRestoresSelection = root().querySelector('#marker-toggle-entity')?.value === secondary;
|
||||
|
||||
card._markerDialog = {
|
||||
...card._markerDialog,
|
||||
toggleEntity: 'switch.issue178_removed',
|
||||
toggleEntityTouched: false,
|
||||
originalHasToggleEntity: true,
|
||||
originalToggleEntity: 'switch.issue178_removed',
|
||||
};
|
||||
await card.updateComplete;
|
||||
const warning = root().querySelector('.markertoggleentity [role="status"]');
|
||||
out.staleWarnsAndShowsAuto = root().querySelector('#marker-toggle-entity')?.value === ''
|
||||
&& warning?.textContent?.includes('switch.issue178_removed');
|
||||
out.staleLiteralSurvives = card._markerToggleEntityFields(card._markerDialog)
|
||||
.toggle_entity === 'switch.issue178_removed';
|
||||
|
||||
card._markerDialog = null;
|
||||
card._markerPreviewDevice = realPreview;
|
||||
const single = card._devices.find((device) =>
|
||||
device.bindingKind === 'device' && device.entities.filter((eid) =>
|
||||
eid.startsWith('light.') || eid.startsWith('switch.')).length === 1);
|
||||
card._openMarkerDialog({
|
||||
...single,
|
||||
tapAction: 'toggle',
|
||||
marker: {
|
||||
...(single.marker || {}), id: single.id, binding: `device:${single.bindingRef}`,
|
||||
tap_action: 'toggle',
|
||||
},
|
||||
});
|
||||
await card.updateComplete;
|
||||
out.singleEntityHidesSelector = !root().querySelector('#marker-toggle-entity');
|
||||
card._markerDialog = null;
|
||||
await card.updateComplete;
|
||||
return out;
|
||||
});
|
||||
|
||||
checkAll(result, {});
|
||||
await finish(browser, result);
|
||||
File diff suppressed because one or more lines are too long
Vendored
+116
-96
File diff suppressed because one or more lines are too long
@@ -957,6 +957,11 @@ hash falls back to the default.
|
||||
service catalog is a second fail-closed guard. A click resolves the current
|
||||
marker by id rather than using a retained #73 visual snapshot; the snapshot
|
||||
remains valid only for read-only presentation.
|
||||
Optional `marker.toggle_entity` is an exact own `light.*`/`switch.*` override
|
||||
layered before the legacy own-role resolver. Its absence leaves legacy
|
||||
single/group membership unchanged; an active explicit choice also joins an
|
||||
explicit controls group, while stale values fall back without being erased.
|
||||
It is deliberately independent from visual `marker.light_entity`.
|
||||
- **Resolved device state** (2026-08-06): HA provides states per entity, not
|
||||
one state per device. `resolvedDeviceStateEntities` therefore starts from
|
||||
uncategorised registry entities, resolves one functional role (whole-device
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Composite Home Assistant devices with several light/switch entities now let
|
||||
you choose the exact entity operated by **Toggle state**. The dialog previews
|
||||
the selected target immediately, preserves missing choices with a warning,
|
||||
and can include the selected own entity in an explicitly configured group
|
||||
without changing existing plans ([#178](https://github.com/Matysh/houseplan-card/issues/178)).
|
||||
- A virtual Always-light linked to a real relay now follows that relay instead
|
||||
of keeping an independent manual state. Tapping either marker operates the
|
||||
real Home Assistant device, while Glow, Light fill, room counts and both
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- У составных устройств Home Assistant с несколькими сущностями света/реле
|
||||
теперь можно выбрать точную сущность для действия **«Переключить состояние»**.
|
||||
Диалог сразу показывает новую цель, сохраняет пропавший выбор с
|
||||
предупреждением и может включить выбранную собственную сущность в явно
|
||||
настроенную группу без изменения старых планов
|
||||
([#178](https://github.com/Matysh/houseplan-card/issues/178)).
|
||||
- Виртуальный источник с ролью **«Всегда»**, связанный с реальным реле, теперь
|
||||
следует состоянию реле вместо отдельного ручного состояния. Нажатие на любой
|
||||
из двух маркеров управляет реальным устройством Home Assistant, а Glow,
|
||||
|
||||
@@ -181,6 +181,18 @@ and temporarily fall back to the normal deterministic selection; merely
|
||||
opening or saving another field does not erase them. Older frontends ignore the
|
||||
unknown field and may erase it only if they reconstruct that marker.
|
||||
|
||||
`marker.toggle_entity` optionally stores the exact own `light.*`/`switch.*`
|
||||
operated by Toggle. Absence/null keeps the historical single-target resolver
|
||||
and external-only controls groups bit-for-bit. A present active choice is exact:
|
||||
temporary missing/unavailable/secure state never retargets it to a sibling. A
|
||||
choice no longer belonging to the marker remains stored, warns in the dialog
|
||||
and temporarily uses the historical fallback. New/changed values are
|
||||
domain-bounded by lossless delta validation; an untouched future literal can
|
||||
round-trip. Full and space transfer copy the entity id literally, while a
|
||||
duplicate marker virtualised during space import drops the HA-dependent field.
|
||||
Older frontends ignore it and may erase it if they reconstruct the marker.
|
||||
`light_entity`, `toggle_entity` and `tap_target` are independent.
|
||||
|
||||
`marker.controls[]` additionally accepts `marker:<marker_id>` links to forced
|
||||
plan sources. Runtime and old frontends continue to filter those strings out of
|
||||
HA service calls. New writes validate target existence, forced-source role,
|
||||
|
||||
@@ -352,6 +352,15 @@ A light defaults to Toggle; other devices default to the House Plan card. An
|
||||
unsupported Toggle remains a visible no-op and is never changed into another
|
||||
action behind the user's back.
|
||||
|
||||
When a device-bound marker has two or more own `light.*`/`switch.*` entities,
|
||||
**Entity to toggle** appears below Toggle. It selects the exact own channel and
|
||||
updates the target hint before Save. **Automatic** keeps the previous binding /
|
||||
functional-role rules. A missing saved entity stays configured, shows a
|
||||
warning and temporarily falls back; returning the same entity restores the
|
||||
choice. This setting is independent from **Leading light entity**. With an
|
||||
explicit external controls group, an explicitly selected own entity joins the
|
||||
group; without a selection existing groups remain external-only.
|
||||
|
||||

|
||||
|
||||
<!-- docs-section: visual-states -->
|
||||
|
||||
@@ -647,6 +647,16 @@ HA. «Показать» заблокировано до активации. П
|
||||
|
||||
У устройства, чья основная функция — `light.*`, действием по умолчанию уже является **Переключить состояние**. У остальных устройств по умолчанию открывается внутренняя карточка.
|
||||
|
||||
Если у маркера с привязкой к устройству есть две или больше собственных
|
||||
сущностей `light.*`/`switch.*`, под действием появляется **«Переключаемая
|
||||
сущность»**. Она выбирает точный канал и меняет подсказку цели ещё до
|
||||
сохранения. **«Автоматически»** сохраняет прежние правила binding /
|
||||
функциональной роли. Исчезнувший сохранённый ID не стирается: диалог показывает
|
||||
предупреждение и временный fallback, а возврат того же ID восстанавливает выбор.
|
||||
Эта настройка не зависит от **«Ведущей сущности света»**. При явно настроенной
|
||||
внешней группе выбранная собственная сущность входит в группу; без явного
|
||||
выбора старые группы остаются только внешними.
|
||||
|
||||
Вариант **Переключить состояние** доступен для любого маркера, в том числе
|
||||
виртуального. Выбор всегда сохраняется. Если у маркера нет переключаемой цели,
|
||||
под настройкой прямо написано, что по нажатию ничего не произойдёт; House Plan
|
||||
|
||||
@@ -339,6 +339,9 @@ RU и EN получают полный паритет для:
|
||||
одинаково на desktop и touch: используется native `<select>` с обычным tap,
|
||||
связанным `<label>` и без hover-only affordance. Селектор не добавляет жестов
|
||||
на плане, drag/pinch/pan, long-press либо собственную touch-геометрию.
|
||||
Runtime-эффект сохранённого выбора одинаков для pointer и touch: тап по marker
|
||||
в обычном View/kiosk проходит через один `resolveToggleIntent()` и не имеет
|
||||
отдельной ветки по типу устройства ввода.
|
||||
|
||||
На узкой ширине label, option text, help и stale-warning не должны выходить за
|
||||
границы диалога или перекрывать footer. Длинный friendly name может быть
|
||||
|
||||
+39
-3
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
import {
|
||||
forcedLightEntityOf,
|
||||
ownControllableEntities,
|
||||
incomingLightControls,
|
||||
persistedExternalControls,
|
||||
resolvedDeviceStateEntities,
|
||||
@@ -391,6 +392,23 @@ function ownRoleCandidates(device: DevItem, registryHass: any): string[] {
|
||||
].filter((eid): eid is string => !!eid))];
|
||||
}
|
||||
|
||||
/** Own light/switch choices exposed by the Toggle selector. An entity binding
|
||||
* is already an exact user choice and must not silently expand to registry
|
||||
* siblings; a device binding keeps the established deterministic order. */
|
||||
export function toggleEntityCandidates(device: DevItem): string[] {
|
||||
if (device.bindingKind === 'entity') {
|
||||
return device.bindingRef && isControllable(device.bindingRef) ? [device.bindingRef] : [];
|
||||
}
|
||||
if (device.bindingKind === 'virtual') return [];
|
||||
return ownControllableEntities(device);
|
||||
}
|
||||
|
||||
/** A stale explicit choice remains persisted but is never sent to a service. */
|
||||
function selectedToggleEntity(device: DevItem): string | null {
|
||||
const selected = device.marker?.toggle_entity;
|
||||
return selected && toggleEntityCandidates(device).includes(selected) ? selected : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the first supported member of the already-selected functional role.
|
||||
* Missing/unavailable/secure members remain the explained target and never
|
||||
@@ -570,7 +588,9 @@ function resolveIncomingControllers(
|
||||
);
|
||||
}
|
||||
|
||||
function resolveControls(options: ResolveToggleOptions): ResolvedToggleIntent {
|
||||
function resolveControls(
|
||||
options: ResolveToggleOptions, selectedOwnEntity: string | null = null,
|
||||
): ResolvedToggleIntent {
|
||||
const { hass, device, devices } = options;
|
||||
const registryHass = options.registryHass || hass;
|
||||
const refs = persistedExternalControls(
|
||||
@@ -594,6 +614,14 @@ function resolveControls(options: ResolveToggleOptions): ResolvedToggleIntent {
|
||||
const entries: GroupEntityRef[] = [];
|
||||
const skippedTargets: SkippedToggleTarget[] = [];
|
||||
|
||||
if (selectedOwnEntity) {
|
||||
entries.push({
|
||||
entityId: selectedOwnEntity,
|
||||
via: device.bindingKind === 'entity' ? 'binding' : 'device-role',
|
||||
ref: selectedOwnEntity,
|
||||
});
|
||||
}
|
||||
|
||||
for (const ref of refs) {
|
||||
if (!ref.startsWith('marker:')) {
|
||||
if (!isControllable(ref)) {
|
||||
@@ -683,7 +711,7 @@ export function resolveToggleIntent(options: ResolveToggleOptions): ResolvedTogg
|
||||
const refs = persistedExternalControls(
|
||||
device.marker?.binding, device.marker?.controls ?? device.controls, device.entities,
|
||||
);
|
||||
if (refs.length) return resolveControls(options);
|
||||
if (refs.length) return resolveControls(options, selectedToggleEntity(device));
|
||||
}
|
||||
|
||||
if (origin === 'legacy-cover') {
|
||||
@@ -698,7 +726,15 @@ export function resolveToggleIntent(options: ResolveToggleOptions): ResolvedTogg
|
||||
: emptyIntent(origin, 'no-actionable-entity');
|
||||
}
|
||||
|
||||
const own = resolveOwnEntity(hass, registryHass, device);
|
||||
const selected = selectedToggleEntity(device);
|
||||
const own = selected
|
||||
? resolveEntity(
|
||||
hass,
|
||||
registryHass,
|
||||
selected,
|
||||
device.bindingKind === 'entity' ? 'binding' : 'device-role',
|
||||
)
|
||||
: resolveOwnEntity(hass, registryHass, device);
|
||||
if (!own) {
|
||||
return emptyIntent(origin, device.virtual || device.bindingKind === 'virtual'
|
||||
? 'no-actionable-entity' : 'no-binding');
|
||||
|
||||
@@ -216,6 +216,7 @@ export interface LightSourceDevice {
|
||||
room_id?: string | null;
|
||||
is_light?: boolean | null;
|
||||
light_entity?: string | null;
|
||||
toggle_entity?: string | null;
|
||||
controls?: string[] | null;
|
||||
glow_radius_cm?: number | null;
|
||||
glow_color?: { c: string; bri?: number | null } | null;
|
||||
|
||||
@@ -109,9 +109,11 @@ import {
|
||||
import {
|
||||
formatToggleIntent, projectedTapAction, resolveToggleIntent,
|
||||
sameToggleOperationTargets, toggleCoverEntity, toggleIntentName, toggleOperation,
|
||||
toggleEntityCandidates,
|
||||
type ResolvedToggleIntent, type ToggleNextEffect, type ToggleNoneReason,
|
||||
type ToggleSkipReason,
|
||||
} from './device-toggle';
|
||||
import { toggleEntityWriteFields } from './marker-toggle-entity';
|
||||
import {
|
||||
adoptVirtualLightServerSnapshot,
|
||||
applyVirtualLightEvent,
|
||||
@@ -1485,6 +1487,10 @@ class HouseplanCard extends LitElement {
|
||||
originalTapAction: string | null | undefined;
|
||||
/** Snapshot announced only after a user edit; live HA ticks do not rewrite it. */
|
||||
tapHintAnnouncement: string;
|
||||
toggleEntity: string;
|
||||
toggleEntityTouched: boolean;
|
||||
originalHasToggleEntity: boolean;
|
||||
originalToggleEntity: string | null | undefined;
|
||||
tapTarget: string; // 'run': automation./script./scene. entity id
|
||||
tapConfirm: boolean; // ask before toggle/run
|
||||
runFilter: string;
|
||||
@@ -11925,6 +11931,7 @@ class HouseplanCard extends LitElement {
|
||||
const hasGlowColor = Object.prototype.hasOwnProperty.call(marker || {}, 'glow_color');
|
||||
const hasValueBadge = Object.prototype.hasOwnProperty.call(marker || {}, 'value_badge');
|
||||
const hasTapAction = Object.prototype.hasOwnProperty.call(marker || {}, 'tap_action');
|
||||
const hasToggleEntity = Object.prototype.hasOwnProperty.call(marker || {}, 'toggle_entity');
|
||||
const glowOverride = normalizeGlowColorOverride(marker?.glow_color);
|
||||
const currentBadge = this._devicePresentation(d, true).valueBadge;
|
||||
const badgeCandidates = valueBadgeCandidates(this._planHass, d, this._devices);
|
||||
@@ -11950,6 +11957,10 @@ class HouseplanCard extends LitElement {
|
||||
originalHasTapAction: hasTapAction,
|
||||
originalTapAction: d.marker?.tap_action,
|
||||
tapHintAnnouncement: '',
|
||||
toggleEntity: marker?.toggle_entity || '',
|
||||
toggleEntityTouched: false,
|
||||
originalHasToggleEntity: hasToggleEntity,
|
||||
originalToggleEntity: marker?.toggle_entity,
|
||||
tapTarget: d.marker?.tap_target || '',
|
||||
tapConfirm: d.marker?.tap_confirm === true,
|
||||
runFilter: '',
|
||||
@@ -12003,6 +12014,8 @@ class HouseplanCard extends LitElement {
|
||||
display: 'badge', rippleColor: '', rippleSize: 3, size: 1, angle: 0,
|
||||
tapAction: 'info', tapActionTouched: false,
|
||||
originalHasTapAction: false, originalTapAction: undefined, tapHintAnnouncement: '',
|
||||
toggleEntity: '', toggleEntityTouched: false,
|
||||
originalHasToggleEntity: false, originalToggleEntity: undefined,
|
||||
tapTarget: '', tapConfirm: false, runFilter: '',
|
||||
controls: [], controlsFilter: '',
|
||||
lightRole: 'auto', lightRoleTouched: false,
|
||||
@@ -12382,6 +12395,18 @@ class HouseplanCard extends LitElement {
|
||||
return { tap_action: d.tapAction || null };
|
||||
}
|
||||
|
||||
/** Preserve absence and stale/future literals until this exact selector is edited. */
|
||||
private _markerToggleEntityFields(
|
||||
d: NonNullable<HouseplanCard['_markerDialog']>,
|
||||
): Pick<Marker, 'toggle_entity'> | Record<string, never> {
|
||||
return toggleEntityWriteFields({
|
||||
touched: d.toggleEntityTouched,
|
||||
originalHas: d.originalHasToggleEntity,
|
||||
original: d.originalToggleEntity,
|
||||
value: d.toggleEntity,
|
||||
});
|
||||
}
|
||||
|
||||
private async _saveMarker(): Promise<void> {
|
||||
const dlg = this._markerDialog;
|
||||
if (!dlg || dlg.busy) return;
|
||||
@@ -12456,6 +12481,7 @@ class HouseplanCard extends LitElement {
|
||||
size: dlg.size !== 1 ? dlg.size : null,
|
||||
angle: dlg.angle ? dlg.angle : null,
|
||||
...this._markerTapActionFields(dlg),
|
||||
...this._markerToggleEntityFields(dlg),
|
||||
tap_target: effectiveTapAction === 'run' ? dlg.tapTarget || null : null,
|
||||
tap_confirm: dlg.tapConfirm ? true : null,
|
||||
controls: controls.length ? controls : null,
|
||||
@@ -17752,6 +17778,7 @@ class HouseplanCard extends LitElement {
|
||||
size: d.size !== 1 ? d.size : null,
|
||||
angle: d.angle || null,
|
||||
...this._markerTapActionFields(d),
|
||||
...this._markerToggleEntityFields(d),
|
||||
tap_target: effectiveTapAction === 'run' ? d.tapTarget || null : null,
|
||||
tap_confirm: d.tapConfirm ? true : null,
|
||||
controls: controls.length ? controls : null,
|
||||
@@ -18168,6 +18195,17 @@ class HouseplanCard extends LitElement {
|
||||
// registry order used for the remaining candidates.
|
||||
const effectiveLeading = previewDevice ? forcedLightEntityOf(previewDevice) || '' : '';
|
||||
const staleLeading = !!d.lightEntity && !leadingEntities.includes(d.lightEntity);
|
||||
const toggleEntities = previewDevice ? toggleEntityCandidates(previewDevice) : [];
|
||||
const staleToggleEntity = !!d.toggleEntity && !toggleEntities.includes(d.toggleEntity);
|
||||
const automaticToggleIntent = effectiveTapAction === 'toggle'
|
||||
? this._toggleIntentForDialog({ ...d, toggleEntity: '', toggleEntityTouched: true })
|
||||
: null;
|
||||
const automaticToggleTarget = automaticToggleIntent
|
||||
? [...automaticToggleIntent.targets, ...automaticToggleIntent.skippedTargets]
|
||||
.map((target) => target.entityId || ('ref' in target ? target.ref : ''))
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
: '';
|
||||
const curLabel = (() => {
|
||||
if (isVirtual) return null;
|
||||
const found = cands.find((c) => c.value === d.binding);
|
||||
@@ -18302,6 +18340,42 @@ class HouseplanCard extends LitElement {
|
||||
</option>`,
|
||||
)}
|
||||
</select>
|
||||
${effectiveTapAction === 'toggle'
|
||||
&& (toggleEntities.length > 1 || staleToggleEntity)
|
||||
? html`<div class="markerhelpfield markertoggleentity">
|
||||
<div class="markerhelplabel">
|
||||
<label for="marker-toggle-entity">${this._t('marker.toggle_entity_label')}</label>
|
||||
${this._help('marker.toggle_entity.help')}
|
||||
</div>
|
||||
<select id="marker-toggle-entity" class="areasel"
|
||||
@change=${(e: Event) => {
|
||||
const next = {
|
||||
...d,
|
||||
toggleEntity: (e.target as HTMLSelectElement).value,
|
||||
toggleEntityTouched: true,
|
||||
};
|
||||
this._markerDialog = this._announceToggleDraft(next);
|
||||
}}>
|
||||
<option value="" ?selected=${staleToggleEntity || !d.toggleEntity}>
|
||||
${this._t('marker.toggle_entity_auto', {
|
||||
entity: automaticToggleTarget || this._t('marker.toggle_entity_none'),
|
||||
})}
|
||||
</option>
|
||||
${toggleEntities.map((eid) => html`<option value=${eid}
|
||||
?selected=${!staleToggleEntity && eid === d.toggleEntity}>
|
||||
${this.hass.states[eid]?.attributes?.friendly_name
|
||||
|| this._fullRegistryHass.entities[eid]?.name || eid} · ${eid}
|
||||
</option>`)}
|
||||
</select>
|
||||
${staleToggleEntity ? html`<p class="muted markerlightwarning" role="status">
|
||||
<ha-icon icon="mdi:alert-outline"></ha-icon>
|
||||
${this._t('marker.toggle_entity_missing', {
|
||||
entity: d.toggleEntity,
|
||||
fallback: automaticToggleTarget || this._t('marker.toggle_entity_none'),
|
||||
})}
|
||||
</p>` : nothing}
|
||||
</div>`
|
||||
: nothing}
|
||||
${effectiveTapAction === 'toggle'
|
||||
? html`<div id="marker-toggle-hint" class="rhint togglehint">
|
||||
${toggleHintLines.map((line) => html`<div>${line}</div>`)}
|
||||
|
||||
@@ -588,6 +588,12 @@
|
||||
"marker.light_entity_auto": "Automatic ({entity})",
|
||||
"marker.light_entity_none": "no controllable entity",
|
||||
"marker.light_entity_missing": "The saved entity {entity} is unavailable. House Plan temporarily uses {fallback}; the saved choice will be restored if it returns.",
|
||||
"marker.toggle_entity_label": "Entity to toggle",
|
||||
"marker.toggle_entity.help": "For a composite device, choose the exact own light or switch operated by a tap. Automatic keeps the previous target rules and is independent from the leading light entity.",
|
||||
"marker.toggle_entity.help.aria": "Help: entity to toggle",
|
||||
"marker.toggle_entity_auto": "Automatic ({entity})",
|
||||
"marker.toggle_entity_none": "no own controllable entity",
|
||||
"marker.toggle_entity_missing": "The saved entity {entity} is no longer among this marker’s selectable channels. House Plan temporarily uses {fallback}; the saved choice will be restored if it returns.",
|
||||
"marker.glow_color_label": "Glow colour and brightness",
|
||||
"marker.glow_mode.help": "Use live source values, override only its colour, or fix both colour and brightness. The minimum is 1%; to disable the source, choose “Never”.",
|
||||
"marker.glow_mode.help.aria": "Help: glow colour and brightness",
|
||||
|
||||
@@ -588,6 +588,12 @@
|
||||
"marker.light_entity_auto": "Автоматически ({entity})",
|
||||
"marker.light_entity_none": "нет управляемой сущности",
|
||||
"marker.light_entity_missing": "Сохранённая сущность {entity} недоступна. Временно используется {fallback}; выбор восстановится, если сущность вернётся.",
|
||||
"marker.toggle_entity_label": "Переключаемая сущность",
|
||||
"marker.toggle_entity.help": "Для составного устройства выберите собственную лампу или переключатель, которой управляет нажатие. Автоматический вариант сохраняет прежние правила цели и не зависит от ведущей сущности света.",
|
||||
"marker.toggle_entity.help.aria": "Подсказка: переключаемая сущность",
|
||||
"marker.toggle_entity_auto": "Автоматически ({entity})",
|
||||
"marker.toggle_entity_none": "нет собственной управляемой сущности",
|
||||
"marker.toggle_entity_missing": "Сохранённой сущности {entity} больше нет среди выбираемых каналов этого маркера. Временно используется {fallback}; выбор восстановится, если сущность вернётся.",
|
||||
"marker.glow_color_label": "Цвет и яркость свечения",
|
||||
"marker.glow_mode.help": "Используйте данные источника, задайте только цвет или зафиксируйте цвет и яркость. Минимум — 1%; чтобы отключить источник, выберите «Никогда».",
|
||||
"marker.glow_mode.help.aria": "Подсказка: цвет и яркость свечения",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Marker } from './types';
|
||||
|
||||
export interface ToggleEntityWriteState {
|
||||
touched: boolean;
|
||||
originalHas: boolean;
|
||||
original: string | null | undefined;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/** Lossless transactional write policy for marker.toggle_entity. */
|
||||
export function toggleEntityWriteFields(
|
||||
state: ToggleEntityWriteState,
|
||||
): Pick<Marker, 'toggle_entity'> | Record<string, never> {
|
||||
if (!state.touched) {
|
||||
return state.originalHas ? { toggle_entity: state.original ?? null } : {};
|
||||
}
|
||||
return state.value ? { toggle_entity: state.value } : {};
|
||||
}
|
||||
@@ -150,6 +150,9 @@ export interface Marker {
|
||||
/** Explicit leading controllable entity for an Always source. Missing keeps
|
||||
* the compatibility fallback (entity binding -> primary -> first control). */
|
||||
light_entity?: string | null;
|
||||
/** Exact own light/switch selected for Toggle. Missing/null keeps the
|
||||
* historical action resolver and external-only controls groups. */
|
||||
toggle_entity?: string | null;
|
||||
/** Optional, user-controlled value satellite around the device face.
|
||||
* Missing keeps the legacy temperature/humidity compatibility heuristic. */
|
||||
value_badge?: MarkerValueBadge | null;
|
||||
|
||||
@@ -474,6 +474,120 @@ test('explicit leading light entity drives the same entity used by the presentat
|
||||
assert.deepEqual(toggleCommandEntityIds(intent.command), ['switch.power']);
|
||||
});
|
||||
|
||||
test('issue 178: an explicit toggle entity selects one exact composite-device channel', () => {
|
||||
const h = hass({
|
||||
'switch.power': state('switch.power', 'on'),
|
||||
'switch.child_lock': state('switch.child_lock', 'off'),
|
||||
});
|
||||
const base = device({
|
||||
bindingKind: 'device', bindingRef: 'washer', primary: 'switch.power',
|
||||
entities: ['switch.power', 'switch.child_lock'],
|
||||
marker: { id: 'marker', binding: 'device:washer', tap_action: 'toggle' },
|
||||
});
|
||||
const childLock = {
|
||||
...base, marker: { ...base.marker, toggle_entity: 'switch.child_lock' },
|
||||
};
|
||||
assert.deepEqual(toggleCommandEntityIds(resolveToggleIntent({
|
||||
hass: h, devices: [childLock], device: childLock,
|
||||
}).command), ['switch.child_lock']);
|
||||
|
||||
const power = { ...base, marker: { ...base.marker, toggle_entity: 'switch.power' } };
|
||||
assert.deepEqual(toggleCommandEntityIds(resolveToggleIntent({
|
||||
hass: h, devices: [power], device: power,
|
||||
}).command), ['switch.power']);
|
||||
});
|
||||
|
||||
test('issue 178: stale selection falls back while an active missing target never retargets', () => {
|
||||
const h = hass({ 'switch.power': state('switch.power', 'off') });
|
||||
h.entities['switch.child_lock'] = {
|
||||
entity_id: 'switch.child_lock', platform: 'test', disabled_by: null,
|
||||
};
|
||||
const base = device({
|
||||
bindingKind: 'device', bindingRef: 'washer', primary: 'switch.power',
|
||||
entities: ['switch.power', 'switch.child_lock'],
|
||||
marker: { id: 'marker', binding: 'device:washer', tap_action: 'toggle' },
|
||||
});
|
||||
const activeMissing = {
|
||||
...base, marker: { ...base.marker, toggle_entity: 'switch.child_lock' },
|
||||
};
|
||||
const exact = resolveToggleIntent({ hass: h, devices: [activeMissing], device: activeMissing });
|
||||
assert.equal(exact.command, null);
|
||||
assert.equal(exact.noneReason, 'unavailable');
|
||||
assert.equal(exact.skippedTargets[0].entityId, 'switch.child_lock');
|
||||
|
||||
const stale = {
|
||||
...base, marker: { ...base.marker, toggle_entity: 'switch.removed' },
|
||||
};
|
||||
const fallback = resolveToggleIntent({ hass: h, devices: [stale], device: stale });
|
||||
assert.deepEqual(toggleCommandEntityIds(fallback.command), ['switch.power']);
|
||||
});
|
||||
|
||||
test('issue 178: an entity binding stays exact even with a sibling toggle selection', () => {
|
||||
const h = hass({
|
||||
'switch.bound': state('switch.bound', 'off'),
|
||||
'switch.sibling': state('switch.sibling', 'on'),
|
||||
});
|
||||
const d = device({
|
||||
bindingKind: 'entity', bindingRef: 'switch.bound', primary: 'switch.bound',
|
||||
entities: ['switch.bound', 'switch.sibling'],
|
||||
marker: {
|
||||
id: 'marker', binding: 'entity:switch.bound', tap_action: 'toggle',
|
||||
toggle_entity: 'switch.sibling',
|
||||
},
|
||||
});
|
||||
assert.deepEqual(toggleCommandEntityIds(resolveToggleIntent({
|
||||
hass: h, devices: [d], device: d,
|
||||
}).command), ['switch.bound']);
|
||||
});
|
||||
|
||||
test('issue 178: explicit own selection joins controls without changing legacy groups', () => {
|
||||
const h = hass({
|
||||
'switch.power': state('switch.power', 'off'),
|
||||
'switch.child_lock': state('switch.child_lock', 'on'),
|
||||
'light.external': state('light.external', 'off'),
|
||||
});
|
||||
const base = device({
|
||||
bindingKind: 'device', bindingRef: 'washer', primary: 'switch.power',
|
||||
entities: ['switch.power', 'switch.child_lock'], controls: ['light.external'],
|
||||
marker: {
|
||||
id: 'marker', binding: 'device:washer', tap_action: 'toggle',
|
||||
controls: ['light.external'],
|
||||
},
|
||||
});
|
||||
const legacy = resolveToggleIntent({ hass: h, devices: [base], device: base });
|
||||
assert.deepEqual(toggleCommandEntityIds(legacy.command), ['light.external']);
|
||||
assert.equal(legacy.command.service, 'turn_on');
|
||||
|
||||
const selected = {
|
||||
...base, marker: { ...base.marker, toggle_entity: 'switch.child_lock' },
|
||||
};
|
||||
const group = resolveToggleIntent({ hass: h, devices: [selected], device: selected });
|
||||
assert.deepEqual(toggleCommandEntityIds(group.command), ['light.external', 'switch.child_lock']);
|
||||
assert.equal(group.command.service, 'turn_off');
|
||||
});
|
||||
|
||||
test('issue 178: unavailable selected own group member is skipped without replacing it', () => {
|
||||
const h = hass({
|
||||
'switch.power': state('switch.power', 'on'),
|
||||
'switch.child_lock': state('switch.child_lock', 'unavailable'),
|
||||
'light.external': state('light.external', 'off'),
|
||||
});
|
||||
const d = device({
|
||||
bindingKind: 'device', bindingRef: 'washer', primary: 'switch.power',
|
||||
entities: ['switch.power', 'switch.child_lock'], controls: ['light.external'],
|
||||
marker: {
|
||||
id: 'marker', binding: 'device:washer', tap_action: 'toggle',
|
||||
toggle_entity: 'switch.child_lock', controls: ['light.external'],
|
||||
},
|
||||
});
|
||||
const intent = resolveToggleIntent({ hass: h, devices: [d], device: d });
|
||||
assert.deepEqual(toggleCommandEntityIds(intent.command), ['light.external']);
|
||||
assert.equal(intent.command.service, 'turn_on');
|
||||
assert.deepEqual(intent.skippedTargets.map((target) => [target.entityId, target.reason]), [
|
||||
['switch.child_lock', 'unavailable'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('group domain remains a universal power target', () => {
|
||||
const entityId = 'group.downstairs';
|
||||
const h = hass({ [entityId]: state(entityId, 'on') });
|
||||
|
||||
@@ -209,7 +209,7 @@ test('sun-ray golden requires browser-painted light from a state-only sun entity
|
||||
assert.ok(scenario);
|
||||
const fixture = prepareGoldenFixture(scenario);
|
||||
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
|
||||
assert.equal(GOLDEN_MATRIX_VERSION, 25);
|
||||
assert.equal(GOLDEN_MATRIX_VERSION, 26);
|
||||
assert.equal(space.settings.sun_rays, true);
|
||||
assert.equal(scenario.northDeg, 90,
|
||||
'the sign-sensitive golden must keep a non-zero north direction');
|
||||
@@ -378,6 +378,18 @@ test('device dialog goldens expose the complete light-source controls at desktop
|
||||
'the harness must activate the declared light-controls scenario flag');
|
||||
});
|
||||
|
||||
test('toggle-entity dialog goldens cover selected and stale states across themes and widths', () => {
|
||||
const dialogs = GOLDEN_SCENARIOS.filter((scenario) =>
|
||||
scenario.id.startsWith('toggle-entity-dialog-'));
|
||||
assert.equal(dialogs.length, 2);
|
||||
assert.deepEqual(new Set(dialogs.map((scenario) => scenario.deviceToggleEntity)),
|
||||
new Set(['selected', 'stale']));
|
||||
assert.deepEqual(new Set(dialogs.map((scenario) => scenario.language)), new Set(['en', 'ru']));
|
||||
assert.deepEqual(new Set(dialogs.map((scenario) => scenario.theme)), new Set(['light', 'dark']));
|
||||
assert.equal(dialogs.some((scenario) => scenario.viewport.width < 600), true);
|
||||
assert.equal(dialogs.some((scenario) => scenario.viewport.width >= 1000), true);
|
||||
});
|
||||
|
||||
test('help affordance golden covers an open text-bearing surface in both themes', () => {
|
||||
const help = GOLDEN_SCENARIOS.filter((scenario) => scenario.openHelp);
|
||||
assert.equal(help.length >= 2, true);
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { toggleEntityWriteFields } from '../test-build/marker-toggle-entity.js';
|
||||
|
||||
test('issue 178: untouched toggle entity preserves absence, null and stale literals', () => {
|
||||
assert.deepEqual(toggleEntityWriteFields({
|
||||
touched: false, originalHas: false, original: undefined, value: '',
|
||||
}), {});
|
||||
assert.deepEqual(toggleEntityWriteFields({
|
||||
touched: false, originalHas: true, original: null, value: '',
|
||||
}), { toggle_entity: null });
|
||||
assert.deepEqual(toggleEntityWriteFields({
|
||||
touched: false, originalHas: true, original: 'switch.removed', value: 'switch.removed',
|
||||
}), { toggle_entity: 'switch.removed' });
|
||||
});
|
||||
|
||||
test('issue 178: touched selection writes an exact id and Auto restores absence', () => {
|
||||
assert.deepEqual(toggleEntityWriteFields({
|
||||
touched: true, originalHas: false, original: undefined, value: 'switch.child_lock',
|
||||
}), { toggle_entity: 'switch.child_lock' });
|
||||
assert.deepEqual(toggleEntityWriteFields({
|
||||
touched: true, originalHas: true, original: 'switch.child_lock', value: '',
|
||||
}), {});
|
||||
});
|
||||
@@ -36,7 +36,7 @@ test('persisted dynamic marker selects project their selected option explicitly'
|
||||
block.match(/\bid="([^"]+)"/)?.[1] || '', block,
|
||||
]));
|
||||
for (const id of [
|
||||
'marker-room', 'marker-tap-action', 'marker-light-entity', 'marker-display',
|
||||
'marker-room', 'marker-tap-action', 'marker-toggle-entity', 'marker-light-entity', 'marker-display',
|
||||
'marker-value-badge-source', 'marker-value-badge-position',
|
||||
]) {
|
||||
const block = byId.get(id);
|
||||
|
||||
@@ -685,8 +685,23 @@ def test_export_rejects_boolean_stored_model_before_schema_coercion(tmp_path: Pa
|
||||
assert invalid.value.code == "invalid_config"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["full", "space"])
|
||||
def test_export_copies_toggle_entity_literal(tmp_path: Path, kind: str) -> None:
|
||||
config = _config()
|
||||
config["markers"][0]["toggle_entity"] = "switch.channel_2"
|
||||
document, _ = create_export(
|
||||
SimpleNamespace(instance_id="instance-a"),
|
||||
{"config": config}, {"layout": {}}, kind=kind,
|
||||
space_id="ground" if kind == "space" else None,
|
||||
card_version="1.65.0", config_root=tmp_path,
|
||||
)
|
||||
marker = document["payload"]["config"]["markers"][0]
|
||||
assert marker["toggle_entity"] == "switch.channel_2"
|
||||
|
||||
|
||||
def test_space_import_remaps_owned_ids_and_duplicate_policy(tmp_path: Path) -> None:
|
||||
document = _document(tmp_path, "space")
|
||||
document["payload"]["config"]["markers"][0]["toggle_entity"] = "switch.channel_2"
|
||||
current = _config()
|
||||
current["spaces"][0]["id"] = "existing"
|
||||
current["spaces"][0]["title"] = "Ground"
|
||||
@@ -704,7 +719,9 @@ def test_space_import_remaps_owned_ids_and_duplicate_policy(tmp_path: Path) -> N
|
||||
copied = virtual["markers"][-1]
|
||||
assert copied["binding"] == "virtual"
|
||||
assert copied["display"] == "static_icon"
|
||||
assert "controls" not in copied and vdetails["virtualized"] == 1
|
||||
assert "controls" not in copied
|
||||
assert "toggle_entity" not in copied
|
||||
assert vdetails["virtualized"] == 1
|
||||
|
||||
|
||||
def test_preview_is_owner_bound_and_foreign_full_drops_discovery_lists(tmp_path: Path) -> None:
|
||||
|
||||
@@ -1829,6 +1829,36 @@ def test_light_entity_is_domain_bounded_only_when_new_or_changed():
|
||||
v.validate_marker_light_entities(old, validate_all=True)
|
||||
|
||||
|
||||
def test_toggle_entity_is_domain_bounded_only_when_new_or_changed():
|
||||
cfg = v.CONFIG_SCHEMA({"spaces": [], "markers": [
|
||||
{"id": "washer", "binding": "device:washer",
|
||||
"toggle_entity": "switch.child_lock"},
|
||||
]})
|
||||
assert cfg["markers"][0]["toggle_entity"] == "switch.child_lock"
|
||||
|
||||
old = {"markers": [
|
||||
{"id": "washer", "binding": "device:washer", "toggle_entity": "sensor.future"},
|
||||
]}
|
||||
v.validate_marker_light_entities(old, old)
|
||||
|
||||
with pytest.raises(v.MarkerControlError) as changed:
|
||||
v.validate_marker_light_entities({"markers": [
|
||||
{"id": "washer", "binding": "device:washer", "toggle_entity": "sensor.changed"},
|
||||
]}, old)
|
||||
assert changed.value.code == "invalid_toggle_entity"
|
||||
|
||||
for invalid in ("light.UPPER", "switch.bad-id", "marker:other", 7):
|
||||
with pytest.raises(v.MarkerControlError) as imported:
|
||||
v.validate_marker_light_entities({"markers": [
|
||||
{"id": "washer", "binding": "device:washer", "toggle_entity": invalid},
|
||||
]}, validate_all=True)
|
||||
assert imported.value.code == "invalid_toggle_entity"
|
||||
|
||||
with pytest.raises(v.MarkerControlError) as full_import:
|
||||
v.validate_marker_light_entities(old, validate_all=True)
|
||||
assert full_import.value.code == "invalid_toggle_entity"
|
||||
|
||||
|
||||
def test_issue_90_value_badge_validation_is_strict_only_when_changed():
|
||||
valid = {
|
||||
"enabled": True,
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
},
|
||||
"include": [
|
||||
"src/color.ts", "src/logic.ts", "src/glow-blend.ts", "src/device-visual.ts", "src/device-pulse.ts", "src/device-presentation.ts",
|
||||
"src/device-face.ts", "src/device-toggle.ts", "src/activity-runtime.ts",
|
||||
"src/device-face.ts", "src/device-toggle.ts", "src/marker-toggle-entity.ts", "src/activity-runtime.ts",
|
||||
"src/ha-binding-status.ts",
|
||||
"src/integration-provider.ts", "src/vacuum.ts",
|
||||
"src/sun.ts",
|
||||
|
||||
Reference in New Issue
Block a user