mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
fix: separate controller and target availability
Issue: #251 User-Visible: yes
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -398,7 +398,8 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
|||||||
{ id: 'golden-climate', binding: 'device:golden-climate' },
|
{ id: 'golden-climate', binding: 'device:golden-climate' },
|
||||||
{ id: 'golden-left-temperature', binding: 'device:golden-left-temperature', display: 'value' },
|
{ id: 'golden-left-temperature', binding: 'device:golden-left-temperature', display: 'value' },
|
||||||
{ id: 'golden-right-temperature', binding: 'device:golden-right-temperature', display: 'static_icon' },
|
{ id: 'golden-right-temperature', binding: 'device:golden-right-temperature', display: 'static_icon' },
|
||||||
{ id: 'golden-left-linkquality', binding: 'device:golden-left-linkquality', value_badge: {
|
{ id: 'golden-left-linkquality', binding: 'device:golden-left-linkquality',
|
||||||
|
tap_action: 'toggle', controls: ['light.golden_light_three'], value_badge: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
source: { kind: 'entity_state', entity_id: 'sensor.golden_left_linkquality' },
|
source: { kind: 'entity_state', entity_id: 'sensor.golden_left_linkquality' },
|
||||||
position: 'right',
|
position: 'right',
|
||||||
@@ -412,7 +413,7 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
|||||||
stateOverrides: {
|
stateOverrides: {
|
||||||
'light.golden_light_one': { attributes: { lqi: 40 } },
|
'light.golden_light_one': { attributes: { lqi: 40 } },
|
||||||
'light.golden_light_two': { attributes: { lqi: 41 } },
|
'light.golden_light_two': { attributes: { lqi: 41 } },
|
||||||
'light.golden_light_three': { attributes: { lqi: 180 } },
|
'light.golden_light_three': { state: 'unavailable', attributes: { lqi: 180 } },
|
||||||
'sensor.golden_left_temperature': { state: 'Complete long localized state' },
|
'sensor.golden_left_temperature': { state: 'Complete long localized state' },
|
||||||
'sensor.golden_right_temperature': { state: 'unavailable' },
|
'sensor.golden_right_temperature': { state: 'unavailable' },
|
||||||
},
|
},
|
||||||
|
|||||||
+47
-1
@@ -59,10 +59,56 @@ const res = await page.evaluate(async () => {
|
|||||||
out.aggregateActivityRuns = c._stateClass(dev2()).includes('activity-running');
|
out.aggregateActivityRuns = c._stateClass(dev2()).includes('activity-running');
|
||||||
await setSt({ [lights[0]]: 'off' });
|
await setSt({ [lights[0]]: 'off' });
|
||||||
out.stateOff = c._stateClass(dev2()) === '';
|
out.stateOff = c._stateClass(dev2()) === '';
|
||||||
|
|
||||||
|
// #251: target connectivity never paints a virtual/healthy controller
|
||||||
|
// unavailable. A completely non-executable configured group explains the
|
||||||
|
// no-op through the standard local toast, without service or press feedback.
|
||||||
|
const targetLabel = (entityId) => c.hass.entities?.[entityId]?.name
|
||||||
|
|| c.hass.entities?.[entityId]?.original_name
|
||||||
|
|| c.hass.states?.[entityId]?.attributes?.friendly_name
|
||||||
|
|| entityId;
|
||||||
|
await setSt({ [lights[0]]: 'unavailable', [lights[1]]: 'unavailable' });
|
||||||
|
c._cancelDevicePressFeedback();
|
||||||
|
const unavailableCalls = calls.length;
|
||||||
|
c._clickDevice(new MouseEvent('click'), dev2());
|
||||||
|
await c.updateComplete;
|
||||||
|
const pluralToast = `Targets are unavailable: ${lights.map(targetLabel).join(', ')}. No action was performed`;
|
||||||
|
out.unavailableTargetsStayNeutral = c._stateClass(dev2()) === '';
|
||||||
|
out.unavailableTargetsNoService = calls.length === unavailableCalls;
|
||||||
|
out.unavailableTargetsNoPress = c._devicePressAnimations.size === 0;
|
||||||
|
out.unavailableTargetsPluralToast = sr().querySelector('.toast')?.textContent?.trim() === pluralToast;
|
||||||
|
|
||||||
|
// One target uses the named singular copy. The same branch must win when
|
||||||
|
// the target disappears after a confirmation dialog was opened.
|
||||||
|
const cfg = c._serverCfg.markers.find((m) => m.name === 'Выключатель');
|
||||||
|
cfg.controls = [lights[0]];
|
||||||
|
c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
|
||||||
|
c._clickDevice(new MouseEvent('click'), dev2());
|
||||||
|
await c.updateComplete;
|
||||||
|
const singularToast = `Target “${targetLabel(lights[0])}” is unavailable — no action was performed`;
|
||||||
|
out.unavailableTargetSingularToast = sr().querySelector('.toast')?.textContent?.trim() === singularToast;
|
||||||
|
|
||||||
|
await setSt({ [lights[0]]: 'off' });
|
||||||
|
cfg.tap_confirm = true;
|
||||||
|
c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
|
||||||
|
c._clickDevice(new MouseEvent('click'), dev2());
|
||||||
|
const confirm = c._tapConfirm;
|
||||||
|
out.availableTargetOpensConfirm = confirm?.kind === 'toggle';
|
||||||
|
await setSt({ [lights[0]]: 'unavailable' });
|
||||||
|
const confirmCalls = calls.length;
|
||||||
|
confirm?.exec();
|
||||||
|
await c.updateComplete;
|
||||||
|
out.confirmRaceNoService = calls.length === confirmCalls;
|
||||||
|
out.confirmRaceUnavailableToast = sr().querySelector('.toast')?.textContent?.trim() === singularToast;
|
||||||
|
cfg.tap_confirm = false;
|
||||||
|
cfg.controls = [...lights];
|
||||||
|
c._tapConfirm = null;
|
||||||
|
c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
|
||||||
|
await setSt({ [lights[0]]: 'off', [lights[1]]: 'off' });
|
||||||
|
|
||||||
// #94/#73 boundary: a complete visual frame may temporarily retain the old
|
// #94/#73 boundary: a complete visual frame may temporarily retain the old
|
||||||
// DevItem while live config has already changed. The click must resolve the
|
// DevItem while live config has already changed. The click must resolve the
|
||||||
// current marker by id and never call the controls captured by that frame.
|
// current marker by id and never call the controls captured by that frame.
|
||||||
const cfg = c._serverCfg.markers.find((m) => m.name === 'Выключатель');
|
|
||||||
const staleSource = dev2();
|
const staleSource = dev2();
|
||||||
const visuallyStale = {
|
const visuallyStale = {
|
||||||
...staleSource,
|
...staleSource,
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Vendored
+70
-70
File diff suppressed because one or more lines are too long
@@ -1080,7 +1080,14 @@ its configured space.
|
|||||||
de-duplicates and filters to currently controllable lights/switches. For an
|
de-duplicates and filters to currently controllable lights/switches. For an
|
||||||
explicit `tap_action=toggle`, `resolveToggleIntent` executes the available
|
explicit `tap_action=toggle`, `resolveToggleIntent` executes the available
|
||||||
subset with HA-group semantics and reports missing/disabled/unsupported refs;
|
subset with HA-group semantics and reports missing/disabled/unsupported refs;
|
||||||
icon state/tint mirrors the effective light graph.
|
icon working state mirrors the effective light graph. Controller availability
|
||||||
|
is deliberately separate (#251): at least one live own active entity
|
||||||
|
(including battery/LQI/update diagnostics) keeps a physical controller
|
||||||
|
available, while an all-unavailable target graph is neutral. A virtual
|
||||||
|
controller is available by definition. An explicit Toggle whose configured
|
||||||
|
group has no executable unavailable/missing/HA-disabled target produces the
|
||||||
|
card's standard local explanatory toast and no service/press feedback;
|
||||||
|
partial groups keep executing their available subset.
|
||||||
- **Universal device action** (#94): `src/device-toggle.ts` is the only authority
|
- **Universal device action** (#94): `src/device-toggle.ts` is the only authority
|
||||||
for toggle origin, exact target, capability/security filtering, next effect
|
for toggle origin, exact target, capability/security filtering, next effect
|
||||||
and service command. The dialog hint, click path, confirmation re-resolution
|
and service command. The dialog hint, click path, confirmation re-resolution
|
||||||
|
|||||||
@@ -10,6 +10,13 @@
|
|||||||
strongly asymmetric junctions cannot push floor outside the building or
|
strongly asymmetric junctions cannot push floor outside the building or
|
||||||
leave a missing floor pocket
|
leave a missing floor pocket
|
||||||
([#249](https://github.com/Matysh/houseplan-card/issues/249)).
|
([#249](https://github.com/Matysh/houseplan-card/issues/249)).
|
||||||
|
- A wireless switch or other controller no longer looks offline merely because
|
||||||
|
its configured lamp or light group is unavailable. Fading now reflects the
|
||||||
|
controller's own live entities (including battery and Zigbee LQI), while the
|
||||||
|
targets still determine the yellow working state. Tapping a controller when
|
||||||
|
none of its configured targets can be reached now names the unavailable
|
||||||
|
target and explains that no action was performed
|
||||||
|
([#251](https://github.com/Matysh/houseplan-card/issues/251)).
|
||||||
|
|
||||||
## v1.67.0-beta.3 — 2026-08-23
|
## v1.67.0-beta.3 — 2026-08-23
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,13 @@
|
|||||||
ту же ограниченную геометрию стен, поэтому сильно различающаяся толщина в
|
ту же ограниченную геометрию стен, поэтому сильно различающаяся толщина в
|
||||||
узле не выводит пол за контур здания и не оставляет пропущенный участок пола
|
узле не выводит пол за контур здания и не оставляет пропущенный участок пола
|
||||||
([#249](https://github.com/Matysh/houseplan-card/issues/249)).
|
([#249](https://github.com/Matysh/houseplan-card/issues/249)).
|
||||||
|
- Беспроводной выключатель или другой контроллер больше не выглядит
|
||||||
|
недоступным только из-за недоступности настроенной лампы или группы света.
|
||||||
|
Полупрозрачность теперь определяется собственными живыми сущностями
|
||||||
|
контроллера, включая батарею и Zigbee LQI, а цели по-прежнему определяют
|
||||||
|
жёлтое рабочее состояние. Если ни одна настроенная цель недоступна, нажатие
|
||||||
|
теперь называет её и объясняет, что действие не выполнено
|
||||||
|
([#251](https://github.com/Matysh/houseplan-card/issues/251)).
|
||||||
|
|
||||||
## v1.67.0-beta.3 — 2026-08-23
|
## v1.67.0-beta.3 — 2026-08-23
|
||||||
|
|
||||||
|
|||||||
+12
-1
@@ -1366,7 +1366,18 @@ separately promised workflows:
|
|||||||
the click opens info as usual; the info card lists targets with states;
|
the click opens info as usual; the info card lists targets with states;
|
||||||
locks/other domains are filtered out of controls. Glow is spatial: the
|
locks/other domains are filtered out of controls. Glow is spatial: the
|
||||||
controller casts no pool and the real lamp marker owns it even when the
|
controller casts no pool and the real lamp marker owns it even when the
|
||||||
controller is encountered first [auto: smoke_controls; unit: devices.test.mjs]
|
controller is encountered first. Controller availability is independent
|
||||||
|
(#251): live battery/LQI/update keeps it neutral and opaque when every
|
||||||
|
target is unavailable, all unavailable own entities fade it even if a
|
||||||
|
target is on, and a virtual controller remains available. A fully
|
||||||
|
unavailable configured group shows the named singular/plural local toast
|
||||||
|
without service, confirmation or press feedback; partial groups still
|
||||||
|
execute silently, and a target lost after confirmation uses the same
|
||||||
|
unavailable toast [auto: smoke_controls; unit: devices.test.mjs,
|
||||||
|
device-presentation.test.mjs, device-toggle.test.mjs; golden:
|
||||||
|
device-icon-state-table light/dark; mutation: controller-availability-follows-target,
|
||||||
|
controller-diagnostics-do-not-prove-online, unavailable-toggle-stays-silent,
|
||||||
|
partial-group-shows-noop-toast]
|
||||||
- [ ] Linked manual virtual light (#174): an exact #107 virtual Always-light
|
- [ ] Linked manual virtual light (#174): an exact #107 virtual Always-light
|
||||||
with an incoming controller follows the real HA driver despite a saved
|
with an incoming controller follows the real HA driver despite a saved
|
||||||
manual off-bit. Clicking either marker operates the real relay and one HA
|
manual off-bit. Clicking either marker operates the real relay and one HA
|
||||||
|
|||||||
@@ -444,6 +444,12 @@ 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
|
unsupported Toggle remains a visible no-op and is never changed into another
|
||||||
action behind the user's back.
|
action behind the user's back.
|
||||||
|
|
||||||
|
If every explicitly configured `controls` target is unavailable, missing or
|
||||||
|
disabled in HA, a short tap sends no service call and the standard local House
|
||||||
|
Plan message names the target and explains that no action was performed. A
|
||||||
|
partially available group still operates only its available subset, so it does
|
||||||
|
not show the misleading no-action message.
|
||||||
|
|
||||||
When a device-bound marker has two or more own `light.*`/`switch.*` entities,
|
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
|
**Entity to toggle** appears below Toggle. It selects the exact own channel and
|
||||||
updates the target hint before Save. **Automatic** keeps the previous binding /
|
updates the target hint before Save. **Automatic** keeps the previous binding /
|
||||||
@@ -473,6 +479,13 @@ stable core, icon or value, and optional activity pulse. Visual priority is
|
|||||||
| Faded | Data unavailable | All relevant entities unknown, unavailable or absent |
|
| Faded | Data unavailable | All relevant entities unknown, unavailable or absent |
|
||||||
| Neutral | No alarm, work or open condition | Off, closed, idle, standby, docked |
|
| Neutral | No alarm, work or open condition | Off, closed, idle, standby, docked |
|
||||||
|
|
||||||
|
For a controller with `controls`, target work and controller availability are
|
||||||
|
independent. The controlled lights still decide whether the marker is yellow,
|
||||||
|
but only the controller's own active entities decide whether it fades. A live
|
||||||
|
battery, Zigbee LQI or update entity therefore keeps a wireless switch neutral
|
||||||
|
and opaque when all of its lamps are unavailable. A controller with no live own
|
||||||
|
entity fades even if a target is on; a virtual controller is always available.
|
||||||
|
|
||||||
For a composite appliance with a dedicated Power switch, Power=`on` alone
|
For a composite appliance with a dedicated Power switch, Power=`on` alone
|
||||||
remains neutral. If Home Assistant also exposes a strict lifecycle entity such
|
remains neutral. If Home Assistant also exposes a strict lifecycle entity such
|
||||||
as Status/Run state/Job state, active values (`start`, `running`, `washing`,
|
as Status/Run state/Job state, active values (`start`, `running`, `washing`,
|
||||||
|
|||||||
+17
-3
@@ -827,13 +827,18 @@ House Plan заново определяет состояние и направ
|
|||||||
не подменяет такое действие открытием карточки. Привязка к одной HA-сущности
|
не подменяет такое действие открытием карточки. Привязка к одной HA-сущности
|
||||||
всегда точная: неподдерживаемый датчик не будет незаметно заменён соседним реле
|
всегда точная: неподдерживаемый датчик не будет незаметно заменён соседним реле
|
||||||
того же устройства. Временно отсутствующие или деактивированные цели остаются
|
того же устройства. Временно отсутствующие или деактивированные цели остаются
|
||||||
в конфигурации, но не получают service call.
|
в конфигурации, но не получают service call. Если недоступна вся явно
|
||||||
|
настроенная группа `controls`, короткое нажатие называет недоступную цель в
|
||||||
|
системном сообщении House Plan и поясняет, что действие не выполнено. При
|
||||||
|
частично доступной группе команда по-прежнему отправляется только доступным
|
||||||
|
целям, поэтому сообщение «действие не выполнено» не показывается.
|
||||||
|
|
||||||
Если указаны несколько управляемых источников света:
|
Если указаны несколько управляемых источников света:
|
||||||
|
|
||||||
- когда включён хотя бы один, нажатие выключает все;
|
- когда включён хотя бы один, нажатие выключает все;
|
||||||
- когда выключены все, нажатие включает все;
|
- когда выключены все, нажатие включает все;
|
||||||
- визуальное состояние маркера объединяет эти источники;
|
- жёлтое рабочее состояние маркера объединяет эти источники, но
|
||||||
|
полупрозрачность зависит только от собственных сущностей контроллера;
|
||||||
- если доступна только часть группы, подсказка перечисляет пропуски, а вызов
|
- если доступна только часть группы, подсказка перечисляет пропуски, а вызов
|
||||||
получает ровно показанное доступное подмножество; собственная сущность
|
получает ровно показанное доступное подмножество; собственная сущность
|
||||||
контроллера не подставляется вместо исчезнувших настроенных целей.
|
контроллера не подставляется вместо исчезнувших настроенных целей.
|
||||||
@@ -858,6 +863,15 @@ hover → состояние → нейтрально**. Если все сущ
|
|||||||
настройки. У медиаплеера `off` используется то же приглушённое отображение, что
|
настройки. У медиаплеера `off` используется то же приглушённое отображение, что
|
||||||
`unknown` и `unavailable`, без отдельного визуального состояния.
|
`unknown` и `unavailable`, без отдельного визуального состояния.
|
||||||
|
|
||||||
|
У маркера-контроллера с `controls` это два независимых факта: лампы определяют
|
||||||
|
жёлтое состояние «работает», а доступность определяют только собственные
|
||||||
|
активные сущности контроллера. Живой `battery`, `linkquality` или `update`
|
||||||
|
удерживает беспроводной выключатель непрозрачным, даже если все лампы
|
||||||
|
недоступны; в таком случае выключатель нейтрален, а не жёлтый. Если ни одна
|
||||||
|
собственная сущность не имеет живого состояния, контроллер бледнеет даже при
|
||||||
|
включённой цели. Виртуальный контроллер не имеет физического HA-устройства и
|
||||||
|
считается доступным.
|
||||||
|
|
||||||
Hover включается только после события от настоящей мыши на устройстве, которое
|
Hover включается только после события от настоящей мыши на устройстве, которое
|
||||||
поддерживает точный hover. Касание пальцем или пером сразу снимает подсказку и
|
поддерживает точный hover. Касание пальцем или пером сразу снимает подсказку и
|
||||||
подсветку и не оставляет их «прилипшими»; последующее движение реальной мыши
|
подсветку и не оставляет их «прилипшими»; последующее движение реальной мыши
|
||||||
@@ -872,7 +886,7 @@ Hover включается только после события от наст
|
|||||||
| Красная подложка замка | Замок разблокирован | lock unlocked/open |
|
| Красная подложка замка | Замок разблокирован | lock unlocked/open |
|
||||||
| Зелёная подложка замка | Замок заблокирован | lock locked |
|
| Зелёная подложка замка | Замок заблокирован | lock locked |
|
||||||
| Оранжевая подложка | Физически открыто | Датчик двери/окна/гаража `on`, клапан open/opening/closing |
|
| Оранжевая подложка | Физически открыто | Датчик двери/окна/гаража `on`, клапан open/opening/closing |
|
||||||
| Бледный маркер | Данные недоступны либо медиаплеер выключен | Все рабочие сущности маркера `unknown`, `unavailable` или отсутствуют; `media_player.* = off` |
|
| Бледный маркер | Данные самого устройства недоступны либо медиаплеер выключен | Все собственные рабочие сущности маркера `unknown`, `unavailable` или отсутствуют; `media_player.* = off`. Недоступная цель `controls` сама по себе маркер не бледнит |
|
||||||
| Нейтральный | Нет аварии, работы и открытого состояния | Выключено, закрыто, idle/standby/docked и т. п. |
|
| Нейтральный | Нет аварии, работы и открытого состояния | Выключено, закрыто, idle/standby/docked и т. п. |
|
||||||
|
|
||||||
`automation.* = on` означает «автоматизация включена», а не «исполняется», поэтому жёлтая подложка не появляется. Для climate распознанный `idle` всегда нейтрален; неизвестные служебные значения action от сторонних интеграций игнорируются и не блокируют проверку HVAC-режима. `cover.*` специально не получает оранжевую подложку за открытое состояние: открытие показывается формой иконки.
|
`automation.* = on` означает «автоматизация включена», а не «исполняется», поэтому жёлтая подложка не появляется. Для climate распознанный `idle` всегда нейтрален; неизвестные служебные значения action от сторонних интеграций игнорируются и не блокируют проверку HVAC-режима. `cover.*` специально не получает оранжевую подложку за открытое состояние: открытие показывается формой иконки.
|
||||||
|
|||||||
@@ -168,6 +168,11 @@ Glow, room fill и statistics продолжают читать resolved light g
|
|||||||
4. показывает существующий локальный House Plan toast с `role="alert"` и
|
4. показывает существующий локальный House Plan toast с `role="alert"` и
|
||||||
`aria-live="assertive"`.
|
`aria-live="assertive"`.
|
||||||
|
|
||||||
|
Фразу владельца «стандартными средствами HA» здесь намеренно реализует
|
||||||
|
существующий локальный toast карточки по образцу `toast.error`,
|
||||||
|
`toast.tap_target_changed` и других системных объяснений House Plan. Новый
|
||||||
|
нативный глобальный `hass-notification` не вводится.
|
||||||
|
|
||||||
Точный текст:
|
Точный текст:
|
||||||
|
|
||||||
| Случай | RU | EN |
|
| Случай | RU | EN |
|
||||||
@@ -184,6 +189,11 @@ Glow, room fill и statistics продолжают читать resolved light g
|
|||||||
`secure`, `unsupported`, отсутствием configured target или отсутствующим
|
`secure`, `unsupported`, отсутствием configured target или отсутствующим
|
||||||
service capability, существующее поведение сохраняется.
|
service capability, существующее поведение сохраняется.
|
||||||
|
|
||||||
|
Для смешанного нулевого набора `secure` + unavailable/missing/HA-disabled toast
|
||||||
|
показывается по именам только недоступных целей; secure-цели не называются и не
|
||||||
|
становятся исполняемыми. Наличие `unsupported` среди пропусков сохраняет
|
||||||
|
существующий no-op без этого toast.
|
||||||
|
|
||||||
### 7.2. Partial group и гонки
|
### 7.2. Partial group и гонки
|
||||||
|
|
||||||
Если доступна хотя бы одна цель, выполняется ровно существующее доступное
|
Если доступна хотя бы одна цель, выполняется ровно существующее доступное
|
||||||
|
|||||||
@@ -1038,6 +1038,58 @@ export const MUTANTS = [
|
|||||||
replace: '.dev.unavail:hover {',
|
replace: '.dev.unavail:hover {',
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'controller-availability-follows-target',
|
||||||
|
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||||
|
+ '&& node --test --test-name-pattern="issue 251 separates controller availability" '
|
||||||
|
+ 'test/device-presentation.test.mjs',
|
||||||
|
because: 'a live controller must not inherit unavailable from its controlled lamp; '
|
||||||
|
+ 'the focused matrix keeps own availability and target working as separate facts (#251)',
|
||||||
|
patches: [{
|
||||||
|
file: 'src/device-presentation.ts',
|
||||||
|
find: " ? { ...combined, availability: controllerAvailability(hass, d) }",
|
||||||
|
replace: ' ? combined',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'controller-diagnostics-do-not-prove-online',
|
||||||
|
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||||
|
+ '&& node --test --test-name-pattern="issue 251 separates controller availability" '
|
||||||
|
+ 'test/device-presentation.test.mjs',
|
||||||
|
because: 'battery, LQI and update are the available evidence for event-only wireless '
|
||||||
|
+ 'controllers; excluding those siblings restores the field defect (#251)',
|
||||||
|
patches: [{
|
||||||
|
file: 'src/device-presentation.ts',
|
||||||
|
find: ' const live = (d.entities || []).some((eid) => {',
|
||||||
|
replace: " const live = (d.entities || []).filter((eid) => !eid.startsWith('sensor.') "
|
||||||
|
+ "&& !eid.startsWith('update.')).some((eid) => {",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'unavailable-toggle-stays-silent',
|
||||||
|
guard: 'node demo/smoke_controls.mjs',
|
||||||
|
because: 'a configured group with no available target must explain the safe no-op instead '
|
||||||
|
+ 'of returning silently before service, confirmation and press feedback (#251)',
|
||||||
|
patches: [{
|
||||||
|
file: 'src/houseplan-card.ts',
|
||||||
|
find: ' this._showUnavailableToggleTargets(initial);',
|
||||||
|
replace: ' // Mutant: restore the historical quiet no-op.',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'partial-group-shows-noop-toast',
|
||||||
|
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||||
|
+ '&& node --test --test-name-pattern="issue 251 classifies only unavailable" '
|
||||||
|
+ 'test/device-toggle.test.mjs',
|
||||||
|
because: 'a partial group did execute its available subset and must never claim that no '
|
||||||
|
+ 'action happened merely because another target was skipped (#251)',
|
||||||
|
patches: [{
|
||||||
|
file: 'src/device-toggle.ts',
|
||||||
|
find: " if (!intent || intent.kind !== 'group' || toggleOperation(intent)\n"
|
||||||
|
+ " || intent.noneReason !== 'configured-targets-missing') return [];",
|
||||||
|
replace: " if (!intent || intent.kind !== 'group') return [];",
|
||||||
|
}],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'device-marker-lqi-low-boundary-shifted',
|
id: 'device-marker-lqi-low-boundary-shifted',
|
||||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
combineVisualSamples, entityVisualSample, entityVisualSamplesForDevice,
|
combineVisualSamples, entityVisualSample, entityVisualSamplesForDevice,
|
||||||
isApplianceLifecycleEntity, isDevicePowerSwitch,
|
isApplianceLifecycleEntity, isDevicePowerSwitch,
|
||||||
type DeviceActivity, type DeviceVisualState, type EntityVisualSample,
|
type DeviceActivity, type DeviceAvailability, type DeviceVisualState, type EntityVisualSample,
|
||||||
} from './device-visual';
|
} from './device-visual';
|
||||||
import {
|
import {
|
||||||
hassValue, lightColorOf, lqiColor, normalizeDeviceDisplay, stateIcon, valueWithUnit,
|
hassValue, lightColorOf, lqiColor, normalizeDeviceDisplay, stateIcon, valueWithUnit,
|
||||||
@@ -215,6 +215,25 @@ function sourceStateText(hass: any, eid: string): string {
|
|||||||
return String(st.state ?? '');
|
return String(st.state ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Availability of the physical controller itself (#251).
|
||||||
|
*
|
||||||
|
* HA exposes entity states rather than one device state. Controls are not
|
||||||
|
* controller evidence: a live battery/LQI/update sibling is, while an
|
||||||
|
* event-only `unknown` entity is not. A virtual controller has no physical HA
|
||||||
|
* binding which can go offline, so it is available by definition.
|
||||||
|
*/
|
||||||
|
export function controllerAvailability(hass: any, d: DevItem): DeviceAvailability {
|
||||||
|
if (d.virtual || d.bindingKind === 'virtual' || d.marker?.binding === 'virtual') {
|
||||||
|
return 'available';
|
||||||
|
}
|
||||||
|
const live = (d.entities || []).some((eid) => {
|
||||||
|
const state = String(hass?.states?.[eid]?.state ?? '').trim().toLowerCase();
|
||||||
|
return state !== '' && state !== 'unknown' && state !== 'unavailable';
|
||||||
|
});
|
||||||
|
return live ? 'available' : 'unavailable';
|
||||||
|
}
|
||||||
|
|
||||||
function sourceOf(
|
function sourceOf(
|
||||||
hass: any, eid: string, role: PresentationSourceRole, sample?: EntityVisualSample,
|
hass: any, eid: string, role: PresentationSourceRole, sample?: EntityVisualSample,
|
||||||
): ResolvedPresentationSource {
|
): ResolvedPresentationSource {
|
||||||
@@ -602,7 +621,12 @@ export function resolveDevicePresentation(
|
|||||||
? 'unlocked'
|
? 'unlocked'
|
||||||
: lockSource.state.toLowerCase() === 'locked' ? 'locked' : null
|
: lockSource.state.toLowerCase() === 'locked' ? 'locked' : null
|
||||||
: null;
|
: null;
|
||||||
let visual = combined;
|
// A controller mirrors target work, not target connectivity. Its faded
|
||||||
|
// state describes only the bound controller; Glow/fill/statistics continue
|
||||||
|
// to consume the unchanged target graph.
|
||||||
|
let visual = sources.sourceKind === 'controls'
|
||||||
|
? { ...combined, availability: controllerAvailability(hass, d) }
|
||||||
|
: combined;
|
||||||
if (effectiveHidden) visual = { availability: 'available', status: 'neutral', activity: 'none' };
|
if (effectiveHidden) visual = { availability: 'available', status: 'neutral', activity: 'none' };
|
||||||
else if (staticIcon) visual = { availability: 'available', status: 'neutral', activity: 'none' };
|
else if (staticIcon) visual = { availability: 'available', status: 'neutral', activity: 'none' };
|
||||||
else if (combined.status !== 'alarm' && !options.liveStates) {
|
else if (combined.status !== 'alarm' && !options.liveStates) {
|
||||||
|
|||||||
@@ -766,6 +766,38 @@ export function toggleOperation(intent: ResolvedToggleIntent | null): ToggleOper
|
|||||||
return intent.command ? { kind: 'ha-service', command: intent.command } : null;
|
return intent.command ? { kind: 'ha-service', command: intent.command } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TARGET_UNAVAILABLE_NOTICE_REASONS = new Set<ToggleSkipReason>([
|
||||||
|
'missing', 'ha-disabled', 'unavailable',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Names for the #251 explanatory no-op toast.
|
||||||
|
*
|
||||||
|
* Only configured control groups qualify. Unsupported targets keep their
|
||||||
|
* existing explained/quiet path; secure targets may coexist with unavailable
|
||||||
|
* ones but are never named or made actionable by this projection.
|
||||||
|
*/
|
||||||
|
export function unavailableToggleTargetNames(
|
||||||
|
intent: ResolvedToggleIntent | null,
|
||||||
|
): string[] {
|
||||||
|
if (!intent || intent.kind !== 'group' || toggleOperation(intent)
|
||||||
|
|| intent.noneReason !== 'configured-targets-missing') return [];
|
||||||
|
if (!intent.skippedTargets.length || intent.skippedTargets.some((target) =>
|
||||||
|
!TARGET_UNAVAILABLE_NOTICE_REASONS.has(target.reason) && target.reason !== 'secure'
|
||||||
|
)) return [];
|
||||||
|
|
||||||
|
const names: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const target of intent.skippedTargets) {
|
||||||
|
if (!TARGET_UNAVAILABLE_NOTICE_REASONS.has(target.reason)) continue;
|
||||||
|
const name = String(target.name || target.entityId || target.ref || '').trim();
|
||||||
|
if (!name || seen.has(name)) continue;
|
||||||
|
seen.add(name);
|
||||||
|
names.push(name);
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ToggleConfirmationFormatter {
|
export interface ToggleConfirmationFormatter {
|
||||||
/** Localized/formatted current state for one executable target. */
|
/** Localized/formatted current state for one executable target. */
|
||||||
state: (target: ResolvedToggleTarget) => string;
|
state: (target: ResolvedToggleTarget) => string;
|
||||||
|
|||||||
+18
-2
@@ -117,7 +117,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
formatToggleConfirmation, formatToggleIntent, projectedTapAction, resolveToggleIntent,
|
formatToggleConfirmation, formatToggleIntent, projectedTapAction, resolveToggleIntent,
|
||||||
sameToggleOperationTargets, toggleCoverEntity, toggleIntentName, toggleOperation,
|
sameToggleOperationTargets, toggleCoverEntity, toggleIntentName, toggleOperation,
|
||||||
toggleEntityCandidates,
|
toggleEntityCandidates, unavailableToggleTargetNames,
|
||||||
type ResolvedToggleIntent, type ResolvedToggleTarget,
|
type ResolvedToggleIntent, type ResolvedToggleTarget,
|
||||||
type ToggleNextEffect, type ToggleNoneReason,
|
type ToggleNextEffect, type ToggleNoneReason,
|
||||||
type ToggleSkipReason,
|
type ToggleSkipReason,
|
||||||
@@ -4929,7 +4929,11 @@ class HouseplanCard extends LitElement {
|
|||||||
};
|
};
|
||||||
if (action === 'toggle') {
|
if (action === 'toggle') {
|
||||||
const initial = this._toggleIntent(actionDevice);
|
const initial = this._toggleIntent(actionDevice);
|
||||||
if (!initial || !toggleOperation(initial)) return; // configured no-target is an intentional, quiet no-op
|
if (!initial) return;
|
||||||
|
if (!toggleOperation(initial)) {
|
||||||
|
this._showUnavailableToggleTargets(initial);
|
||||||
|
return; // other configured no-target outcomes remain intentional quiet no-ops
|
||||||
|
}
|
||||||
const execute = (intent: ResolvedToggleIntent): void => {
|
const execute = (intent: ResolvedToggleIntent): void => {
|
||||||
const operation = toggleOperation(intent);
|
const operation = toggleOperation(intent);
|
||||||
if (!operation) return;
|
if (!operation) return;
|
||||||
@@ -4968,6 +4972,8 @@ class HouseplanCard extends LitElement {
|
|||||||
exec: () => {
|
exec: () => {
|
||||||
const currentDevice = this._devices.find((item) => item.id === actionDevice.id);
|
const currentDevice = this._devices.find((item) => item.id === actionDevice.id);
|
||||||
const current = currentDevice ? this._toggleIntent(currentDevice) : null;
|
const current = currentDevice ? this._toggleIntent(currentDevice) : null;
|
||||||
|
if (current && !toggleOperation(current)
|
||||||
|
&& this._showUnavailableToggleTargets(current)) return;
|
||||||
if (!current || !sameToggleOperationTargets(initial, current)) {
|
if (!current || !sameToggleOperationTargets(initial, current)) {
|
||||||
this._showToast(this._t('toast.tap_target_changed'));
|
this._showToast(this._t('toast.tap_target_changed'));
|
||||||
return;
|
return;
|
||||||
@@ -5022,6 +5028,16 @@ class HouseplanCard extends LitElement {
|
|||||||
this._infoCard = actionDevice;
|
this._infoCard = actionDevice;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Explain a safe controls no-op through the card's standard local toast. */
|
||||||
|
private _showUnavailableToggleTargets(intent: ResolvedToggleIntent): boolean {
|
||||||
|
const names = unavailableToggleTargetNames(intent);
|
||||||
|
if (!names.length) return false;
|
||||||
|
this._showToast(names.length === 1
|
||||||
|
? this._t('toast.toggle_target_unavailable', { name: names[0] })
|
||||||
|
: this._t('toast.toggle_targets_unavailable', { names: names.join(', ') }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private _keyDevice(ev: KeyboardEvent, d: DevItem): void {
|
private _keyDevice(ev: KeyboardEvent, d: DevItem): void {
|
||||||
if (ev.key !== 'Enter' && ev.key !== ' ') return;
|
if (ev.key !== 'Enter' && ev.key !== ' ') return;
|
||||||
if (this._mode !== 'view' && this._mode !== 'devices') return;
|
if (this._mode !== 'view' && this._mode !== 'devices') return;
|
||||||
|
|||||||
@@ -324,6 +324,8 @@
|
|||||||
"toast.space_deleted": "Space deleted",
|
"toast.space_deleted": "Space deleted",
|
||||||
"toast.delete_failed": "Delete failed: {err}",
|
"toast.delete_failed": "Delete failed: {err}",
|
||||||
"toast.error": "Error: {err}",
|
"toast.error": "Error: {err}",
|
||||||
|
"toast.toggle_target_unavailable": "Target “{name}” is unavailable — no action was performed",
|
||||||
|
"toast.toggle_targets_unavailable": "Targets are unavailable: {names}. No action was performed",
|
||||||
"toast.file_failed": "File \"{name}\" was not uploaded: {err}",
|
"toast.file_failed": "File \"{name}\" was not uploaded: {err}",
|
||||||
"toast.files_attached": "Files attached: {n}",
|
"toast.files_attached": "Files attached: {n}",
|
||||||
"err.unknown": "unknown error",
|
"err.unknown": "unknown error",
|
||||||
|
|||||||
@@ -324,6 +324,8 @@
|
|||||||
"toast.space_deleted": "Пространство удалено",
|
"toast.space_deleted": "Пространство удалено",
|
||||||
"toast.delete_failed": "Ошибка удаления: {err}",
|
"toast.delete_failed": "Ошибка удаления: {err}",
|
||||||
"toast.error": "Ошибка: {err}",
|
"toast.error": "Ошибка: {err}",
|
||||||
|
"toast.toggle_target_unavailable": "Цель «{name}» недоступна — действие не выполнено",
|
||||||
|
"toast.toggle_targets_unavailable": "Цели недоступны: {names}. Действие не выполнено",
|
||||||
"toast.file_failed": "Файл «{name}» не загружен: {err}",
|
"toast.file_failed": "Файл «{name}» не загружен: {err}",
|
||||||
"toast.files_attached": "Прикреплено файлов: {n}",
|
"toast.files_attached": "Прикреплено файлов: {n}",
|
||||||
"err.unknown": "неизвестная ошибка",
|
"err.unknown": "неизвестная ошибка",
|
||||||
|
|||||||
@@ -257,6 +257,99 @@ test('issue 174 linked manual source and controller presentation follow the real
|
|||||||
assert.equal(control.visual.status, 'neutral');
|
assert.equal(control.visual.status, 'neutral');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('issue 251 separates controller availability from controlled target status', () => {
|
||||||
|
const own = [
|
||||||
|
'event.wall_action', 'sensor.wall_battery', 'sensor.wall_linkquality', 'update.wall',
|
||||||
|
];
|
||||||
|
const h = hass({
|
||||||
|
'event.wall_action': state('event.wall_action', 'unknown'),
|
||||||
|
'sensor.wall_battery': state('sensor.wall_battery', '100'),
|
||||||
|
'sensor.wall_linkquality': state('sensor.wall_linkquality', '164'),
|
||||||
|
'update.wall': state('update.wall', 'off'),
|
||||||
|
'light.wall_group': state('light.wall_group', 'unavailable', { friendly_name: 'Wall lights' }),
|
||||||
|
}, Object.fromEntries([...own, 'light.wall_group'].map((entity_id) => [entity_id, {
|
||||||
|
entity_id,
|
||||||
|
device_id: own.includes(entity_id) ? 'wall' : 'lights',
|
||||||
|
platform: 'demo',
|
||||||
|
}])));
|
||||||
|
const controller = device({
|
||||||
|
id: 'wall', name: 'Wall switch', entities: own, primary: 'event.wall_action',
|
||||||
|
bindingRef: 'wall',
|
||||||
|
bindingStatus: { kind: 'active', enabledEntityIds: own, allEntityIds: own },
|
||||||
|
marker: {
|
||||||
|
id: 'wall', binding: 'device:wall', tap_action: 'toggle', controls: ['light.wall_group'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = resolveDevicePresentation(h, controller, options);
|
||||||
|
assert.equal(result.sourceKind, 'controls');
|
||||||
|
assert.deepEqual(result.visual, {
|
||||||
|
availability: 'available', status: 'neutral', activity: 'none',
|
||||||
|
});
|
||||||
|
assert.ok(!result.classes.includes('unavail'));
|
||||||
|
assert.ok(!result.classes.includes('on'));
|
||||||
|
|
||||||
|
h.states['light.wall_group'].state = 'on';
|
||||||
|
result = resolveDevicePresentation(h, controller, options);
|
||||||
|
assert.equal(result.visual.availability, 'available');
|
||||||
|
assert.equal(result.visual.status, 'working');
|
||||||
|
assert.ok(result.classes.includes('on'));
|
||||||
|
|
||||||
|
for (const entityId of ['sensor.wall_battery', 'sensor.wall_linkquality', 'update.wall']) {
|
||||||
|
h.states[entityId].state = 'unavailable';
|
||||||
|
}
|
||||||
|
result = resolveDevicePresentation(h, controller, options);
|
||||||
|
assert.equal(result.visual.availability, 'unavailable');
|
||||||
|
assert.equal(result.visual.status, 'working', 'target work remains a separate fact');
|
||||||
|
assert.ok(result.classes.includes('unavail'));
|
||||||
|
assert.ok(!result.classes.includes('on'));
|
||||||
|
assert.equal(deviceA11yState(result), 'unavailable');
|
||||||
|
assert.equal(result.pulse.kind, 'none');
|
||||||
|
|
||||||
|
const staticLiveDisabled = resolveDevicePresentation(h, controller, {
|
||||||
|
...options, liveStates: false,
|
||||||
|
});
|
||||||
|
assert.deepEqual(staticLiveDisabled.visual, {
|
||||||
|
availability: 'available', status: 'neutral', activity: 'none',
|
||||||
|
});
|
||||||
|
|
||||||
|
h.states['light.wall_group'].state = 'unavailable';
|
||||||
|
const eventOnly = resolveDevicePresentation(h, {
|
||||||
|
...controller,
|
||||||
|
entities: ['event.wall_action'],
|
||||||
|
bindingStatus: {
|
||||||
|
kind: 'active', enabledEntityIds: ['event.wall_action'], allEntityIds: ['event.wall_action'],
|
||||||
|
},
|
||||||
|
}, options);
|
||||||
|
assert.equal(eventOnly.visual.availability, 'unavailable');
|
||||||
|
assert.equal(eventOnly.visual.status, 'neutral');
|
||||||
|
|
||||||
|
const virtual = resolveDevicePresentation(h, device({
|
||||||
|
id: 'virtual-control', name: 'Virtual control', virtual: true,
|
||||||
|
entities: [], primary: null, bindingKind: 'virtual', bindingRef: 'virtual-control',
|
||||||
|
marker: {
|
||||||
|
id: 'virtual-control', binding: 'virtual', tap_action: 'toggle',
|
||||||
|
controls: ['light.wall_group'],
|
||||||
|
},
|
||||||
|
}), options);
|
||||||
|
assert.equal(virtual.sourceKind, 'controls');
|
||||||
|
assert.equal(virtual.visual.availability, 'available');
|
||||||
|
assert.equal(virtual.visual.status, 'neutral');
|
||||||
|
|
||||||
|
h.states['binary_sensor.wall_smoke'] = state(
|
||||||
|
'binary_sensor.wall_smoke', 'on', { device_class: 'smoke' },
|
||||||
|
);
|
||||||
|
h.entities['binary_sensor.wall_smoke'] = {
|
||||||
|
entity_id: 'binary_sensor.wall_smoke', device_id: 'wall', platform: 'demo',
|
||||||
|
};
|
||||||
|
const alarm = resolveDevicePresentation(h, {
|
||||||
|
...controller, entities: [...own, 'binary_sensor.wall_smoke'],
|
||||||
|
}, options);
|
||||||
|
assert.equal(alarm.visual.status, 'alarm');
|
||||||
|
assert.equal(alarm.visual.availability, 'available');
|
||||||
|
assert.ok(alarm.classes.includes('alarm'));
|
||||||
|
});
|
||||||
|
|
||||||
test('passive sensor source keeps its normal scalar value and never probes marker ids', () => {
|
test('passive sensor source keeps its normal scalar value and never probes marker ids', () => {
|
||||||
const hits = [];
|
const hits = [];
|
||||||
const h = hass(new Proxy({
|
const h = hass(new Proxy({
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
toggleCommandEntityIds,
|
toggleCommandEntityIds,
|
||||||
toggleCoverEntity,
|
toggleCoverEntity,
|
||||||
toggleOriginOf,
|
toggleOriginOf,
|
||||||
|
unavailableToggleTargetNames,
|
||||||
} from '../test-build/device-toggle.js';
|
} from '../test-build/device-toggle.js';
|
||||||
|
|
||||||
const confirmationFormatter = {
|
const confirmationFormatter = {
|
||||||
@@ -312,6 +313,62 @@ test('explicit controls execute only the available subset and report skipped tar
|
|||||||
assert.deepEqual(intent.skippedTargets.map((item) => item.reason), ['unavailable', 'missing']);
|
assert.deepEqual(intent.skippedTargets.map((item) => item.reason), ['unavailable', 'missing']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('issue 251 classifies only unavailable configured groups for the no-op toast', () => {
|
||||||
|
const h = hass({
|
||||||
|
'light.dead': state('light.dead', 'unavailable', { friendly_name: 'Dead lamp' }),
|
||||||
|
'light.live': state('light.live', 'off', { friendly_name: 'Live lamp' }),
|
||||||
|
}, {
|
||||||
|
'light.dead': { entity_id: 'light.dead', platform: 'demo', name: 'Dead lamp' },
|
||||||
|
'light.live': { entity_id: 'light.live', platform: 'demo', name: 'Live lamp' },
|
||||||
|
'light.missing': { entity_id: 'light.missing', platform: 'demo', name: 'Missing lamp' },
|
||||||
|
});
|
||||||
|
const controls = (refs) => device({
|
||||||
|
id: 'controller', name: 'Controller', controls: refs,
|
||||||
|
marker: {
|
||||||
|
id: 'controller', binding: 'virtual', tap_action: 'toggle', controls: refs,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const oneDevice = controls(['light.dead']);
|
||||||
|
const one = resolveToggleIntent({ hass: h, devices: [oneDevice], device: oneDevice });
|
||||||
|
assert.deepEqual(unavailableToggleTargetNames(one), ['Dead lamp']);
|
||||||
|
|
||||||
|
const groupDevice = controls(['light.missing', 'light.dead', 'light.dead']);
|
||||||
|
const group = resolveToggleIntent({ hass: h, devices: [groupDevice], device: groupDevice });
|
||||||
|
assert.deepEqual(unavailableToggleTargetNames(group), ['Missing lamp', 'Dead lamp']);
|
||||||
|
|
||||||
|
const partialDevice = controls(['light.dead', 'light.live']);
|
||||||
|
const partial = resolveToggleIntent({ hass: h, devices: [partialDevice], device: partialDevice });
|
||||||
|
assert.ok(toggleOperation(partial));
|
||||||
|
assert.deepEqual(unavailableToggleTargetNames(partial), []);
|
||||||
|
|
||||||
|
const exact = device({
|
||||||
|
bindingKind: 'entity', bindingRef: 'light.dead', primary: 'light.dead',
|
||||||
|
entities: ['light.dead'], marker: { id: 'exact', binding: 'entity:light.dead' },
|
||||||
|
});
|
||||||
|
assert.deepEqual(unavailableToggleTargetNames(resolveToggleIntent({
|
||||||
|
hass: h, devices: [exact], device: exact,
|
||||||
|
})), [], 'an unavailable own binding is not an unavailable controls group');
|
||||||
|
|
||||||
|
const mixedSecure = {
|
||||||
|
...group,
|
||||||
|
skippedTargets: [
|
||||||
|
...group.skippedTargets,
|
||||||
|
{ ref: 'marker:secure', entityId: 'cover.gate', name: 'Gate', reason: 'secure' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
assert.deepEqual(unavailableToggleTargetNames(mixedSecure), ['Missing lamp', 'Dead lamp']);
|
||||||
|
|
||||||
|
const mixedUnsupported = {
|
||||||
|
...group,
|
||||||
|
skippedTargets: [
|
||||||
|
...group.skippedTargets,
|
||||||
|
{ ref: 'sensor.mode', entityId: 'sensor.mode', name: 'Mode', reason: 'unsupported' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
assert.deepEqual(unavailableToggleTargetNames(mixedUnsupported), []);
|
||||||
|
});
|
||||||
|
|
||||||
test('a group turns everything off when any available target is on', () => {
|
test('a group turns everything off when any available target is on', () => {
|
||||||
const h = hass({
|
const h = hass({
|
||||||
'light.a': state('light.a', 'off'),
|
'light.a': state('light.a', 'off'),
|
||||||
|
|||||||
@@ -450,6 +450,12 @@ test('device icon state-table goldens cover both themes and design facets', () =
|
|||||||
assert.equal(fixture.states['light.golden_light_one'].attributes.lqi, 40);
|
assert.equal(fixture.states['light.golden_light_one'].attributes.lqi, 40);
|
||||||
assert.equal(fixture.states['light.golden_light_two'].attributes.lqi, 41);
|
assert.equal(fixture.states['light.golden_light_two'].attributes.lqi, 41);
|
||||||
assert.equal(fixture.states['light.golden_light_three'].attributes.lqi, 180);
|
assert.equal(fixture.states['light.golden_light_three'].attributes.lqi, 180);
|
||||||
|
assert.equal(fixture.states['light.golden_light_three'].state, 'unavailable');
|
||||||
|
const targetAvailabilityController = fixture.config.markers.find(
|
||||||
|
(marker) => marker.id === 'golden-left-linkquality',
|
||||||
|
);
|
||||||
|
assert.equal(targetAvailabilityController.tap_action, 'toggle');
|
||||||
|
assert.deepEqual(targetAvailabilityController.controls, ['light.golden_light_three']);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
fixture.config.markers.find((marker) => marker.id === 'golden-left-temperature').display,
|
fixture.config.markers.find((marker) => marker.id === 'golden-left-temperature').display,
|
||||||
'value',
|
'value',
|
||||||
|
|||||||
@@ -27,6 +27,27 @@ test('i18n: placeholders match between languages', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('issue 251 unavailable controls toast has exact singular and plural copy', () => {
|
||||||
|
assert.equal(
|
||||||
|
en['toast.toggle_target_unavailable'],
|
||||||
|
'Target “{name}” is unavailable — no action was performed',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
ru['toast.toggle_target_unavailable'],
|
||||||
|
'Цель «{name}» недоступна — действие не выполнено',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
en['toast.toggle_targets_unavailable'],
|
||||||
|
'Targets are unavailable: {names}. No action was performed',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
ru['toast.toggle_targets_unavailable'],
|
||||||
|
'Цели недоступны: {names}. Действие не выполнено',
|
||||||
|
);
|
||||||
|
assert.match(cardSource, /toast\.toggle_target_unavailable/);
|
||||||
|
assert.match(cardSource, /toast\.toggle_targets_unavailable/);
|
||||||
|
});
|
||||||
|
|
||||||
test('Optimize distinguishes updated spaces from cleaned coordinate noise', () => {
|
test('Optimize distinguishes updated spaces from cleaned coordinate noise', () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
en['gs.optimize_changes'],
|
en['gs.optimize_changes'],
|
||||||
|
|||||||
Reference in New Issue
Block a user