Files
houseplan-card/test/device-presentation.test.mjs
T
Claude 99e1765d22 feat: режим «Значение + статичный значок» (#588)
Пятый вариант «Отображение»: маркер показывает выбранное значение и при этом
никогда не меняет цвет — ни по состоянию, ни по тревоге, ни по недоступности,
ни под цвет RGB-света, ни по активности.

Четыре прежних режима задавали одним выбором две независимые вещи: что
нарисовано внутри маркера и красится ли он состоянием. Поэтому вместо
сравнения с одним токеном появились два производных предиката рядом с
`normalizeDeviceDisplay` — `displayWantsValue` и `displayIsNeutral`, — и их
спрашивают политика, слой пульсации, внешний бейдж, редактор и три ветки
живого слоя пылесоса.

Две ловушки, из-за которых режим не сводится к одной строке в словаре:

- быстрый путь «статичному маркеру источники не нужны» (`sourceDetails: false`)
  — это основной путь рендера плана, карточки пространства и PDF. Он оставлен
  только режиму без значения: иначе число пропало бы именно на плане и
  осталось в предпросмотре редактора;
- три ветки живого пылесоса сравнивают режим строкой и не читают политику,
  поэтому зелёная политика их не гарантирует. Свидетель держит две
  конфигурации карты: сопоставленная доказывает puck и след, несопоставленная
  — бейдж маршрута (при совпадающей калибровке маршрут `ready`, и бейджа не
  было бы ни в одном режиме). Бейдж проверяется при наполненном буфере
  позиций, иначе его отсутствие объяснялось бы первой веткой.

Потолок initial View перецентрирован 291_700 → 292_400 без изменения общего
бюджета: измеренный факт 291 346 Б оставлял под прежним центром 354 Б —
внутри шумовой полосы метрики.

Эталон: в `device-icon-state-table-{light,dark}` включённая RGB-лампа
переведена в новый режим. Кадры обязаны разойтись; приёмка — отдельным
коммитом класса D с полного линуксового артефакта Validate.

Issue: #588
User-Visible: yes
2026-09-18 13:40:57 +03:00

1488 lines
61 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
activitySourceSignature,
deviceAccessibleLabel,
deviceA11yState,
markerLqiBand,
markerLqiColor,
presentationSourceSignature,
resolveDevicePresentation,
resolvePresentationSources,
} from '../test-build/device-presentation.js';
import { lqiColor } from '../test-build/logic.js';
import {
resolveBindingProviders,
resolveEntityProvider,
} from '../test-build/integration-provider.js';
import {
valueBadgeCandidates, valueBadgeTitle, valueBadgeWriteFields, valueSourceWriteFields,
} from '../test-build/device-value-badge.js';
import {
buildDevices, deviceFromMarkerDraft, resolvedLightSources,
} from '../test-build/devices.js';
const state = (entity_id, value, attributes = {}) => ({ entity_id, state: value, attributes });
const hass = (states, entities = {}) => ({
states,
entities,
devices: {},
config: { unit_system: { temperature: '°C' } },
formatEntityState: (item) => item.state === 'on' ? 'On' : item.state,
localize: (key) => key === 'state.default.on' ? 'On'
: key === 'state.default.off' ? 'Off' : undefined,
});
const device = (overrides = {}) => ({
id: 'd1',
name: 'Relay',
model: '',
area: 'room',
space: 'floor',
icon: 'mdi:toggle-switch',
entities: ['switch.relay'],
primary: 'switch.relay',
bindingKind: 'device',
bindingRef: 'd1',
bindingStatus: { kind: 'active', enabledEntityIds: ['switch.relay'], allEntityIds: ['switch.relay'] },
marker: { id: 'd1', binding: 'device:d1' },
...overrides,
});
const options = {
liveStates: true,
showTemperature: true,
showSignal: true,
};
test('#565 accessible device label removes only whole repeated segments', () => {
assert.equal(deviceAccessibleLabel([
'Sink leak sensor', ' Alarm ', 'alarm', '', null, false,
'LQI 117', 'medium signal',
]), 'Sink leak sensor, Alarm, LQI 117, medium signal');
assert.equal(deviceAccessibleLabel([
'Alarm sensor', 'Alarm', 'Alarm active', 'LQI 117', 'LQI 117, medium signal',
]), 'Alarm sensor, Alarm, Alarm active, LQI 117, LQI 117, medium signal');
assert.equal(deviceAccessibleLabel([
' Door sensor ', 'Open', 'Recent opening', 'OPEN',
]), 'Door sensor, Open, Recent opening');
for (const language of ['en', 'ru', 'de', 'fr']) {
const dictionary = JSON.parse(readFileSync(
new URL(`../src/i18n/${language}.json`, import.meta.url), 'utf8',
));
const alarm = dictionary['marker.state_a11y_alarm'];
assert.equal(alarm, dictionary['marker.pulse_a11y_alarm']);
assert.equal(deviceAccessibleLabel(['Leak sensor', alarm, alarm]).split(', ').length, 2,
`${language}: alarm is spoken once`);
}
});
test('marker LQI keeps semantic bands while its colour uses the continuous gradient', () => {
assert.deepEqual([0, 40, 41, 179, 180].map(markerLqiBand), [
'low', 'low', 'mid', 'mid', 'high',
]);
for (const lqi of [0, 40, 41, 110, 179, 180, 255]) {
assert.equal(markerLqiColor(lqi), lqiColor(lqi));
}
assert.notEqual(markerLqiColor(41), markerLqiColor(42));
assert.equal(lqiColor(110), 'hsl(60, 85%, 55%)');
});
test('lock presentation distinguishes locked and unlocked without changing generic open', () => {
const h = hass({
'lock.front': state('lock.front', 'locked'),
}, {
'lock.front': { entity_id: 'lock.front', device_id: 'd1', platform: 'demo' },
});
const lock = device({
icon: 'mdi:lock', entities: ['lock.front'], primary: 'lock.front',
});
const locked = resolveDevicePresentation(h, lock, options);
assert.equal(locked.lockState, 'locked');
assert.ok(locked.classes.includes('lock-locked'));
assert.equal(deviceA11yState(locked), 'locked');
h.states['lock.front'].state = 'unlocked';
const unlocked = resolveDevicePresentation(h, lock, options);
assert.equal(unlocked.lockState, 'unlocked');
assert.ok(unlocked.classes.includes('lock-unlocked'));
assert.equal(deviceA11yState(unlocked), 'unlocked');
});
test('shares one lifecycle presentation between full and space cards', () => {
const h = hass({
'sensor.washer_status': state('sensor.washer_status', 'start'),
'switch.washer_power': state('switch.washer_power', 'on'),
'switch.washer_child_lock': state('switch.washer_child_lock', 'off'),
}, {
'sensor.washer_status': {
entity_id: 'sensor.washer_status', device_id: 'd1', platform: 'demo',
translation_key: 'status',
},
'switch.washer_power': {
entity_id: 'switch.washer_power', device_id: 'd1', platform: 'demo',
original_name: 'Power',
},
'switch.washer_child_lock': {
entity_id: 'switch.washer_child_lock', device_id: 'd1', platform: 'demo',
},
});
const washer = device({
name: 'Washer',
icon: 'mdi:washing-machine',
entities: ['switch.washer_child_lock', 'sensor.washer_status', 'switch.washer_power'],
allEntities: ['switch.washer_child_lock', 'sensor.washer_status', 'switch.washer_power'],
primary: 'switch.washer_power',
marker: { id: 'd1', binding: 'device:d1', display: 'value' },
});
const full = resolveDevicePresentation(h, washer, options);
const space = resolveDevicePresentation(h, washer, { ...options });
const projection = (value) => ({
sources: value.visualSources.map((source) => [source.eid, source.role]),
visual: value.visual,
reason: value.explanation.reason,
valueText: value.valueText,
});
assert.deepEqual(projection(full), projection(space));
assert.deepEqual(full.visualSources.map((source) => [source.eid, source.role]), [
['sensor.washer_status', 'lifecycle'],
['switch.washer_power', 'power_gate'],
]);
assert.equal(full.visual.status, 'working');
assert.equal(full.explanation.reason, 'working');
assert.equal(full.valueText, 'On', 'the historical Power value remains unambiguous');
const deviceVisual = readFileSync(new URL('../src/device-visual.ts', import.meta.url), 'utf8');
const presentation = readFileSync(new URL('../src/device-presentation.ts', import.meta.url), 'utf8');
const fullCard = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
const spaceCard = readFileSync(new URL('../src/space-card.ts', import.meta.url), 'utf8');
assert.match(deviceVisual, /const LIFECYCLE_WORKING_STATES/);
assert.doesNotMatch(presentation, /const LIFECYCLE_WORKING_STATES/);
assert.doesNotMatch(spaceCard, /const LIFECYCLE_WORKING_STATES/);
assert.match(fullCard, /resolveDevicePresentation\(/);
assert.match(spaceCard, /resolveDevicePresentation\(/);
});
test('passive source and its controller share the plan-wide derived state', () => {
const h = hass({
'switch.wall': state('switch.wall', 'on'),
});
const controller = device({
id: 'controller',
name: 'Wall switch',
entities: ['switch.wall'],
primary: 'switch.wall',
bindingRef: 'switch.wall',
marker: {
id: 'controller', binding: 'entity:switch.wall', controls: ['marker:bulb'],
},
});
const bulb = device({
id: 'bulb',
name: 'Dumb bulb',
entities: [],
primary: null,
bindingKind: 'virtual',
bindingRef: 'bulb',
marker: { id: 'bulb', binding: 'virtual:bulb', is_light: true },
});
const target = resolvePresentationSources(h, bulb, [controller, bulb]);
const switchPresentation = resolvePresentationSources(h, controller, [controller, bulb]);
assert.equal(target.sourceKind, 'light');
assert.equal(target.visualSources[0].eid, 'marker:bulb');
assert.equal(target.visualSources[0].sample.status, 'working');
assert.equal(switchPresentation.sourceKind, 'controls');
assert.equal(switchPresentation.visualSources[0].eid, 'marker:bulb');
assert.equal(switchPresentation.visualSources[0].sample.status, 'working');
});
test('issue 107 manual virtual source owns its face despite saved outgoing controls', () => {
const h = hass({
'light.ceiling': state('light.ceiling', 'on'),
});
const lamp = device({
id: 'manual-lamp',
name: 'Manual lamp',
entities: [],
primary: null,
bindingKind: 'virtual',
bindingRef: 'manual-lamp',
marker: {
id: 'manual-lamp',
binding: 'virtual',
is_light: true,
tap_action: 'toggle',
controls: ['light.ceiling'],
},
});
const offState = { rev: 2, configRev: 1, off: new Set(['manual-lamp']) };
const offSources = resolvedLightSources(h, [lamp], null, offState);
const off = resolveDevicePresentation(h, lamp, {
...options,
lightDevices: [lamp],
lightSources: offSources,
});
assert.equal(off.sourceKind, 'light');
assert.deepEqual(off.visualSources.map((source) => source.eid), ['marker:manual-lamp']);
assert.equal(off.visual.status, 'neutral');
assert.ok(!off.classes.includes('on'));
const onSources = resolvedLightSources(
h, [lamp], null, { rev: 3, configRev: 1, off: new Set() },
);
const on = resolveDevicePresentation(h, lamp, {
...options,
lightDevices: [lamp],
lightSources: onSources,
});
assert.equal(on.sourceKind, 'light');
assert.equal(on.visual.status, 'working');
assert.ok(on.classes.includes('on'));
});
test('issue 174 linked manual source and controller presentation follow the real relay', () => {
const h = hass({ 'switch.wall': state('switch.wall', 'on') });
const controller = device({
id: 'controller', name: 'Wall relay', entities: ['switch.wall'], primary: 'switch.wall',
bindingRef: 'switch.wall',
marker: {
id: 'controller', binding: 'entity:switch.wall', controls: ['marker:lamp'],
},
});
const lamp = device({
id: 'lamp', name: 'Dumb lamp', entities: [], primary: null,
bindingKind: 'virtual', bindingRef: 'lamp',
marker: {
id: 'lamp', binding: 'virtual', is_light: true, tap_action: 'toggle', controls: [],
},
});
const devices = [controller, lamp];
const manualOff = { rev: 7, configRev: 2, off: new Set(['lamp']) };
let graph = resolvedLightSources(h, devices, null, manualOff);
let target = resolveDevicePresentation(h, lamp, {
...options, lightDevices: devices, lightSources: graph,
});
let control = resolveDevicePresentation(h, controller, {
...options, lightDevices: devices, lightSources: graph,
});
assert.equal(target.visual.status, 'working');
assert.equal(control.visual.status, 'working');
h.states['switch.wall'].state = 'off';
graph = resolvedLightSources(h, devices, null, manualOff);
target = resolveDevicePresentation(h, lamp, {
...options, lightDevices: devices, lightSources: graph,
});
control = resolveDevicePresentation(h, controller, {
...options, lightDevices: devices, lightSources: graph,
});
assert.equal(target.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('issue 318 keeps an active entityless physical controller available', () => {
const h = hass({
'light.wall_group': state('light.wall_group', 'on', { friendly_name: 'Wall lights' }),
}, {
'light.wall_group': {
entity_id: 'light.wall_group', device_id: 'lights', platform: 'demo',
},
});
const controller = device({
id: 'entityless-wall', name: 'Entityless wall switch', entities: [], primary: null,
bindingRef: 'entityless-wall',
bindingStatus: { kind: 'active', enabledEntityIds: [], allEntityIds: [] },
marker: {
id: 'entityless-wall', binding: 'device:entityless-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: 'working', activity: 'running',
});
assert.ok(result.classes.includes('on'));
assert.ok(!result.classes.includes('unavail'));
assert.equal(deviceA11yState(result), 'working');
h.states['light.wall_group'].state = 'off';
result = resolveDevicePresentation(h, controller, options);
assert.deepEqual(result.visual, {
availability: 'available', status: 'neutral', activity: 'none',
});
assert.ok(!result.classes.includes('on'));
assert.ok(!result.classes.includes('unavail'));
assert.equal(deviceA11yState(result), 'neutral');
h.states['light.wall_group'].state = 'unavailable';
result = resolveDevicePresentation(h, controller, options);
assert.deepEqual(result.visual, {
availability: 'available', status: 'neutral', activity: 'none',
});
delete h.states['light.wall_group'];
result = resolveDevicePresentation(h, controller, options);
assert.equal(result.visual.availability, 'available');
assert.equal(result.visual.status, 'neutral');
const runtimeFiltered = resolveDevicePresentation(h, {
...controller,
controls: [],
}, options);
assert.equal(runtimeFiltered.sourceKind, 'none');
assert.deepEqual(runtimeFiltered.visual, {
availability: 'available', status: 'neutral', activity: 'none',
});
h.states['light.wall_group'] = state('light.wall_group', 'on');
const provenOffline = resolveDevicePresentation(h, {
...controller,
entities: ['event.entityless_wall_action'],
primary: 'event.entityless_wall_action',
bindingStatus: {
kind: 'active',
enabledEntityIds: ['event.entityless_wall_action'],
allEntityIds: ['event.entityless_wall_action'],
},
}, options);
assert.equal(provenOffline.visual.availability, 'unavailable');
assert.equal(provenOffline.visual.status, 'working');
assert.ok(provenOffline.classes.includes('unavail'));
assert.ok(!provenOffline.classes.includes('on'));
assert.equal(deviceA11yState(provenOffline), 'unavailable');
const entityBinding = resolveDevicePresentation(h, {
...controller,
bindingKind: 'entity', bindingRef: 'switch.entityless_wall',
marker: {
...controller.marker,
binding: 'entity:switch.entityless_wall',
},
}, options);
assert.equal(entityBinding.visual.availability, 'unavailable');
const orphaned = resolveDevicePresentation(h, {
...controller,
bindingStatus: { kind: 'orphaned', reason: 'device_missing' },
}, options);
assert.equal(orphaned.visual.availability, 'unavailable');
const virtual = resolveDevicePresentation(h, {
...controller,
virtual: true,
bindingKind: 'virtual', bindingRef: '',
marker: { ...controller.marker, binding: 'virtual' },
}, options);
assert.equal(virtual.visual.availability, 'available');
assert.equal(virtual.visual.status, 'working');
});
test('issue 274 keeps a wireless controller available and gives its draft the full marker roster', () => {
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', 'off', { friendly_name: 'Wall lights' }),
}, {
'event.wall_action': {
entity_id: 'event.wall_action', device_id: 'wall', platform: 'mqtt',
},
'sensor.wall_battery': {
entity_id: 'sensor.wall_battery', device_id: 'wall', platform: 'mqtt',
entity_category: 'diagnostic',
},
'sensor.wall_linkquality': {
entity_id: 'sensor.wall_linkquality', device_id: 'wall', platform: 'mqtt',
entity_category: 'diagnostic',
},
'update.wall': {
entity_id: 'update.wall', device_id: 'wall', platform: 'mqtt',
entity_category: 'config',
},
'light.wall_group': {
entity_id: 'light.wall_group', platform: 'group', area_id: 'kitchen',
},
});
h.devices.wall = {
id: 'wall', name: 'Wall switch', model: 'Wireless switch', area_id: 'kitchen',
identifiers: [['mqtt', 'wall']], entry_type: null, via_device_id: null,
};
h.areas = { kitchen: { area_id: 'kitchen', name: 'Kitchen' } };
const marker = {
id: 'wall-marker', binding: 'device:wall', tap_action: 'toggle',
controls: ['light.wall_group'],
};
const markers = [
marker,
{ id: 'removed-group', binding: 'entity:light.wall_group', removed: true, hidden: true },
];
const context = {
hass: h,
areaToSpace: { kitchen: 'floor' },
settings: { filter_seeded: true },
excluded: new Set(),
showAll: false,
firstSpaceId: 'floor',
loc: (key) => key,
};
const saved = buildDevices({ ...context, markers })
.find((item) => item.id === marker.id);
const draft = deviceFromMarkerDraft({ ...context, marker, siblingMarkers: markers });
assert.ok(saved);
assert.ok(draft);
assert.deepEqual(saved.entities, own);
assert.deepEqual(draft.entities, own);
assert.deepEqual(draft.controls, saved.controls, 'draft honours the same tombstones as the plan');
const savedPresentation = resolveDevicePresentation(h, saved, {
...options, lightDevices: [saved],
});
const draftPresentation = resolveDevicePresentation(h, draft, {
...options, designPreview: true, lightDevices: [draft],
});
for (const result of [savedPresentation, draftPresentation]) {
assert.deepEqual(result.visual, {
availability: 'available', status: 'neutral', activity: 'none',
});
assert.ok(!result.classes.includes('unavail'));
assert.equal(result.lqiText, '164');
}
assert.deepEqual(
{
sourceKind: draftPresentation.sourceKind,
visual: draftPresentation.visual,
classes: draftPresentation.classes,
lqiText: draftPresentation.lqiText,
},
{
sourceKind: savedPresentation.sourceKind,
visual: savedPresentation.visual,
classes: savedPresentation.classes,
lqiText: savedPresentation.lqiText,
},
);
});
test('passive sensor source keeps its normal scalar value and never probes marker ids', () => {
const hits = [];
const h = hass(new Proxy({
'sensor.lux': state('sensor.lux', '234', { unit_of_measurement: 'lx' }),
}, {
get(target, key) {
if (String(key).startsWith('marker:')) hits.push(key);
return target[key];
},
}));
const sensor = device({
id: 'lux', name: 'Lux', primary: 'sensor.lux', entities: ['sensor.lux'],
marker: {
id: 'lux', binding: 'entity:sensor.lux', is_light: true, display: 'value',
},
});
const presentation = resolveDevicePresentation(h, sensor, options);
assert.equal(presentation.valueText, '234 lx');
assert.deepEqual(hits, []);
});
test('secondary humidity diagnostics do not create a legacy humidity satellite', () => {
const h = hass({
'switch.relay': state('switch.relay', 'on'),
'sensor.relay_humidity': state('sensor.relay_humidity', '49', {
device_class: 'humidity', unit_of_measurement: '%',
}),
});
const relay = device({
entities: ['switch.relay', 'sensor.relay_humidity'],
primary: 'switch.relay',
});
const presentation = resolveDevicePresentation(h, relay, options);
assert.equal(presentation.humText, null);
});
test('derived marker-state badge follows a stateful Always source', () => {
const h = hass({ 'light.bulb': state('light.bulb', 'on') });
const bulb = device({
id: 'bulb', name: 'Bulb', primary: 'light.bulb', entities: ['light.bulb'],
marker: { id: 'bulb', binding: 'entity:light.bulb', is_light: true },
});
const controller = device({
id: 'controller', primary: 'switch.wall', entities: ['switch.wall'],
marker: {
id: 'controller', binding: 'entity:switch.wall',
value_badge: {
enabled: true,
source: { kind: 'derived_marker_state', ref: 'marker:bulb' },
position: 'right',
},
},
});
const graph = resolvedLightSources(h, [controller, bulb]);
const presentation = resolveDevicePresentation(h, controller, {
...options, lightDevices: [controller, bulb], lightSources: graph,
});
assert.equal(presentation.valueBadge?.availability, 'available');
assert.equal(presentation.valueBadge?.text, 'On');
});
test('ordinary presentation stays on the local light path', () => {
const h = hass({ 'switch.relay': state('switch.relay', 'on') });
const forbiddenGlobalGraph = new Proxy([], {
get(target, prop, receiver) {
if (prop === Symbol.iterator) throw new Error('ordinary marker traversed the global graph');
return Reflect.get(target, prop, receiver);
},
});
const sources = resolvePresentationSources(h, device(), forbiddenGlobalGraph);
assert.equal(sources.sourceKind, 'device_role');
assert.equal(sources.visualSources[0].eid, 'switch.relay');
});
test('always-static display suppresses every live visual but keeps diagnostics', () => {
const h = hass({
'binary_sensor.smoke': state('binary_sensor.smoke', 'on', { device_class: 'smoke' }),
}, {
'binary_sensor.smoke': { entity_id: 'binary_sensor.smoke', device_id: 'd1', platform: 'demo' },
});
const marker = device({
icon: 'mdi:smoke-detector',
entities: ['binary_sensor.smoke'],
primary: 'binary_sensor.smoke',
marker: {
id: 'd1', binding: 'device:d1', display: 'static_icon',
ripple_color: '#00ff00', size: 1.75, angle: 125, vacuum: { live: true },
},
});
const result = resolveDevicePresentation(h, marker, options);
assert.equal(result.display, 'static_icon');
assert.equal(result.visual.status, 'neutral');
assert.equal(result.visual.activity, 'none');
assert.equal(result.icon, 'mdi:smoke-detector');
assert.equal(result.valueText, null);
assert.equal(result.tempText, null);
assert.equal(result.humText, null);
assert.equal(result.lqiText, null);
assert.equal(result.lightColor, null);
assert.equal(result.scale, 1.75);
assert.equal(result.angle, 125);
assert.equal(result.rippleColor, null);
assert.equal(result.vacuumLive, false);
assert.deepEqual(result.classes, ['static-icon']);
assert.equal(result.explanation.reason, 'static_icon');
assert.equal(result.visualSources[0].eid, 'binary_sensor.smoke');
});
test('always-static light keeps configured geometry but suppresses RGB and state colour', () => {
const h = hass(
{
'light.rgb': state('light.rgb', 'on', {
rgb_color: [12, 140, 250], brightness: 210,
}),
},
{ 'light.rgb': { entity_id: 'light.rgb', device_id: 'd1', platform: 'demo' } },
);
const base = device({
icon: 'mdi:lightbulb', entities: ['light.rgb'], primary: 'light.rgb',
marker: { id: 'd1', binding: 'device:d1', size: 2.25, angle: 315 },
});
const dynamic = resolveDevicePresentation(h, base, options);
const fixed = resolveDevicePresentation(h, {
...base, marker: { ...base.marker, display: 'static_icon' },
}, options);
assert.equal(dynamic.visual.status, 'working');
assert.ok(dynamic.lightColor);
assert.ok(dynamic.classes.includes('on'));
assert.equal(fixed.lightColor, null);
assert.equal(fixed.visual.status, 'neutral');
assert.deepEqual(fixed.classes, ['static-icon']);
assert.equal(fixed.icon, 'mdi:lightbulb');
assert.equal(fixed.scale, 2.25);
assert.equal(fixed.angle, 315);
});
test('hostile stored ripple color cannot add CSS and does not mask a safe HA light color', () => {
const h = hass(
{ 'light.rgb': state('light.rgb', 'on', { rgb_color: [12, 140, 250] }) },
{ 'light.rgb': { entity_id: 'light.rgb', device_id: 'd1', platform: 'demo' } },
);
const result = resolveDevicePresentation(h, device({
icon: 'mdi:lightbulb', entities: ['light.rgb'], primary: 'light.rgb',
marker: {
id: 'd1', binding: 'device:d1', display: 'icon_ripple',
ripple_color: 'red;position:fixed;inset:0',
},
}), options);
assert.equal(result.rippleColor, 'rgb(12, 140, 250)');
const noLight = resolveDevicePresentation(hass({}), device({
marker: {
id: 'd1', binding: 'device:d1', display: 'icon_ripple',
ripple_color: '#123456;position:fixed',
},
}), options);
assert.equal(noLight.rippleColor, null);
});
test('static mode cannot revive HA-disabled or orphaned lifecycle diagnostics', () => {
const h = hass({});
const disabled = resolveDevicePresentation(h, device({
bindingStatus: {
kind: 'ha_disabled', reason: 'device', enabledEntityIds: [], allEntityIds: ['switch.relay'],
},
entities: [], primary: undefined,
marker: { id: 'd1', binding: 'device:d1', display: 'static_icon' },
}), options);
assert.equal(disabled.effectiveHidden, true);
assert.equal(disabled.explanation.reason, 'ha_disabled');
assert.deepEqual(disabled.classes, []);
const orphaned = resolveDevicePresentation(h, device({
bindingStatus: { kind: 'orphaned', enabledEntityIds: [], allEntityIds: [] },
entities: [], primary: undefined,
marker: { id: 'd1', binding: 'device:d1', display: 'static_icon' },
}), options);
assert.equal(orphaned.orphaned, true);
assert.equal(orphaned.explanation.reason, 'orphaned');
});
test('switching through static mode cannot reuse a finite activity window', () => {
const h = hass(
{ 'binary_sensor.motion': state('binary_sensor.motion', 'on', { device_class: 'motion' }) },
{ 'binary_sensor.motion': { entity_id: 'binary_sensor.motion', device_id: 'd1', platform: 'demo' } },
);
const base = device({
icon: 'mdi:motion-sensor', entities: ['binary_sensor.motion'], primary: 'binary_sensor.motion',
marker: { id: 'd1', binding: 'device:d1', display: 'icon_ripple' },
});
const sourceSignature = activitySourceSignature(h, base);
const active = resolveDevicePresentation(h, base, {
...options,
now: 2_000,
activityRuntime: {
sources: sourceSignature, flashTs: 1_000, flashKind: 'event', gen: 2,
},
});
assert.equal(active.activity, 'event');
assert.equal(active.pulse.kind, 'short');
assert.equal(active.pulse.reason, 'event');
assert.ok(active.classes.includes('activity-gen2'));
const fixed = resolveDevicePresentation(h, {
...base, marker: { ...base.marker, display: 'static_icon' },
}, {
...options,
now: 2_000,
activityRuntime: {
sources: sourceSignature, flashTs: 1_000, flashKind: 'event', gen: 2,
},
});
assert.equal(fixed.activity, 'none');
assert.deepEqual(fixed.classes, ['static-icon']);
const restored = resolveDevicePresentation(h, base, {
...options, now: 2_000, activityRuntime: null,
});
assert.equal(restored.activity, 'none');
assert.ok(!restored.classes.some((name) => name.startsWith('activity-')));
});
test('always-static source short-circuit is plan-only and lifecycle still wins', () => {
const h = hass(
{ 'switch.relay': state('switch.relay', 'unavailable') },
{ 'switch.relay': { entity_id: 'switch.relay', device_id: 'd1', platform: 'demo' } },
);
const marker = device({ marker: { id: 'd1', binding: 'device:d1', display: 'static_icon' } });
const plan = resolveDevicePresentation(h, marker, { ...options, sourceDetails: false });
assert.equal(plan.visual.status, 'neutral');
assert.deepEqual(plan.visualSources, []);
assert.deepEqual(plan.classes, ['static-icon']);
const hidden = resolveDevicePresentation(h, { ...marker, hidden: true, userHidden: true }, options);
assert.equal(hidden.effectiveHidden, true);
});
test('always-static vacuum keeps its dock face but exposes no live overlay', () => {
const h = hass(
{ 'vacuum.robot': state('vacuum.robot', 'cleaning') },
{ 'vacuum.robot': { entity_id: 'vacuum.robot', device_id: 'd1', platform: 'demo' } },
);
const base = device({
icon: 'mdi:robot-vacuum',
entities: ['vacuum.robot'],
primary: 'vacuum.robot',
marker: { id: 'd1', binding: 'device:d1', vacuum: { live: true } },
});
const dynamic = resolveDevicePresentation(h, base, options);
const fixed = resolveDevicePresentation(h, {
...base, marker: { ...base.marker, display: 'static_icon' },
}, options);
assert.equal(dynamic.visual.status, 'working');
assert.equal(dynamic.vacuumLive, true);
assert.equal(fixed.visual.status, 'neutral');
assert.equal(fixed.vacuumLive, false);
assert.equal(fixed.explanation.reason, 'static_icon');
});
test('one projection supplies working state and localized text value', () => {
const h = hass(
{ 'switch.relay': state('switch.relay', 'on') },
{ 'switch.relay': { entity_id: 'switch.relay', device_id: 'd1', platform: 'demo' } },
);
const marker = device({ marker: { id: 'd1', binding: 'device:d1', display: 'value' } });
const result = resolveDevicePresentation(h, marker, options);
assert.equal(result.valueText, 'On');
assert.equal(result.visual.status, 'working');
assert.ok(result.classes.includes('on'));
assert.equal(result.valueSource?.eid, 'switch.relay');
});
test('value mode refuses to choose the first of multiple equal control sources', () => {
const h = hass({
'switch.relay': state('switch.relay', 'off'),
'light.one': state('light.one', 'on'),
'light.two': state('light.two', 'off'),
}, {
'switch.relay': { entity_id: 'switch.relay', device_id: 'd1', platform: 'demo' },
'light.one': { entity_id: 'light.one', platform: 'hue' },
'light.two': { entity_id: 'light.two', platform: 'hue' },
});
const marker = device({
controls: ['light.one', 'light.two'],
marker: {
id: 'd1', binding: 'device:d1', display: 'value', controls: ['light.one', 'light.two'],
},
});
const result = resolveDevicePresentation(h, marker, options);
assert.equal(result.valueText, null);
assert.equal(result.fallbackReason, 'value_ambiguous_sources');
assert.equal(result.visualSources.length, 2);
});
test('issue 378 explicit cover position uses the exact value-badge formatter', () => {
const h = hass({
'cover.awning': state('cover.awning', 'open', { current_position: 42, supported_features: 15 }),
}, {
'cover.awning': { entity_id: 'cover.awning', device_id: 'd1', platform: 'demo' },
});
const source = { kind: 'entity_attribute', entity_id: 'cover.awning', attribute: 'current_position' };
const awning = device({
name: 'Awning', icon: 'mdi:awning', entities: ['cover.awning'], primary: 'cover.awning',
marker: {
id: 'd1', binding: 'device:d1', display: 'value', value_source: source,
value_badge: { enabled: true, source, position: 'right' },
},
});
const result = resolveDevicePresentation(h, awning, options);
assert.equal(result.valueText, '42 %');
assert.equal(result.valueFullText, '42 %');
assert.equal(result.valueBadge?.text, result.valueText);
assert.equal(result.valueSource?.sourceKey, 'attr:cover.awning:current_position');
assert.equal(result.sourceKind, 'cover');
assert.deepEqual(
valueBadgeCandidates(h, awning).find((candidate) => candidate.key === result.valueSource?.sourceKey)?.source,
source,
);
});
test('issue 378 explicit unavailable source shows a dash and restores without auto fallback', () => {
const h = hass({}, {
'cover.awning': { entity_id: 'cover.awning', device_id: 'd1', platform: 'demo' },
});
const awning = device({
name: 'Awning', icon: 'mdi:awning', entities: ['cover.awning'], primary: 'cover.awning',
marker: {
id: 'd1', binding: 'device:d1', display: 'value',
value_source: { kind: 'entity_attribute', entity_id: 'cover.awning', attribute: 'current_position' },
},
});
const missing = resolveDevicePresentation(h, awning, options);
assert.equal(missing.valueText, '—');
assert.equal(missing.valueFullText, 'Unavailable');
assert.equal(missing.fallbackReason, 'value_no_state');
assert.equal(missing.valueSource?.sourceKey, 'attr:cover.awning:current_position');
assert.ok(missing.decisionIds.includes('content.value'));
h.states['cover.awning'] = state('cover.awning', 'open', {
current_position: 55, supported_features: 15,
});
const restored = resolveDevicePresentation(h, awning, options);
assert.equal(restored.valueText, '55 %');
assert.equal(restored.fallbackReason, null);
h.states['cover.awning'] = state('cover.awning', 'unavailable', {
current_position: 55, supported_features: 15,
});
const staleAttribute = resolveDevicePresentation(h, awning, options);
assert.equal(staleAttribute.valueText, '—');
assert.equal(staleAttribute.fallbackReason, 'value_no_state');
const legacy = resolveDevicePresentation(hass({}, h.entities), {
...awning, marker: { id: 'd1', binding: 'device:d1', display: 'value' },
}, options);
assert.equal(legacy.valueText, null);
assert.equal(legacy.fallbackReason, 'value_no_state');
});
test('issue 378 explicit non-scalar value stays selected and reports the stable reason', () => {
const h = hass({
'cover.awning': state('cover.awning', 'open', { current_position: { invalid: true } }),
}, {
'cover.awning': { entity_id: 'cover.awning', device_id: 'd1', platform: 'demo' },
});
const result = resolveDevicePresentation(h, device({
entities: ['cover.awning'], primary: 'cover.awning',
marker: {
id: 'd1', binding: 'device:d1', display: 'value',
value_source: { kind: 'entity_attribute', entity_id: 'cover.awning', attribute: 'current_position' },
},
}), options);
assert.equal(result.valueText, '—');
assert.equal(result.fallbackReason, 'value_non_scalar');
});
test('issue 378 derived sources share the plan graph and suppress duplicate LQI', () => {
const h = hass({
'switch.driver': state('switch.driver', 'on'),
'sensor.linkquality': state('sensor.linkquality', '150', { unit_of_measurement: 'lqi' }),
}, {
'switch.driver': { entity_id: 'switch.driver', device_id: 'lamp', platform: 'demo' },
'sensor.linkquality': { entity_id: 'sensor.linkquality', device_id: 'd1', platform: 'demo' },
});
const lamp = device({
id: 'lamp', name: 'Lamp', entities: ['switch.driver'], primary: 'switch.driver',
bindingRef: 'lamp', marker: { id: 'lamp', binding: 'device:lamp', is_light: true },
});
const controller = device({
controls: ['marker:lamp'],
marker: {
id: 'd1', binding: 'device:d1', display: 'value', controls: ['marker:lamp'],
value_source: { kind: 'derived_marker_state', ref: 'marker:lamp' },
},
});
const stateResult = resolveDevicePresentation(h, controller, { ...options, lightDevices: [controller, lamp] });
assert.equal(stateResult.valueText, 'On');
assert.equal(stateResult.valueSource?.kind, 'derived_marker_state');
const lqiResult = resolveDevicePresentation(h, device({
entities: ['sensor.linkquality'], primary: 'sensor.linkquality',
marker: { id: 'd1', binding: 'device:d1', display: 'value', value_source: { kind: 'derived_lqi' } },
}), options);
assert.equal(lqiResult.valueText, '150');
assert.equal(lqiResult.lqiText, null);
});
test('issue 378 persistence keeps untouched data and represents auto by absence', () => {
assert.deepEqual(valueSourceWriteFields({
touched: false, originalHas: false, original: undefined,
source: { kind: 'entity_state', entity_id: 'sensor.projected' },
}), {});
const future = { kind: 'future_source', payload: { preserved: true } };
assert.equal(valueSourceWriteFields({
touched: false, originalHas: true, original: future, source: null,
}).value_source, future);
assert.deepEqual(valueSourceWriteFields({
touched: true, originalHas: true, original: future, source: null,
}), {});
assert.deepEqual(valueSourceWriteFields({
touched: true, originalHas: false, original: undefined,
source: { kind: 'entity_state', entity_id: 'sensor.selected' },
}), { value_source: { kind: 'entity_state', entity_id: 'sensor.selected' } });
});
test('hidden design preview is visible without mutating the hidden contract', () => {
const h = hass(
{ 'switch.relay': state('switch.relay', 'on') },
{ 'switch.relay': { entity_id: 'switch.relay', device_id: 'd1', platform: 'demo' } },
);
const marker = device({
hidden: true,
userHidden: true,
marker: { id: 'd1', binding: 'device:d1', hidden: true },
});
const plan = resolveDevicePresentation(h, marker, options);
const preview = resolveDevicePresentation(h, marker, { ...options, designPreview: true });
assert.equal(plan.effectiveHidden, true);
assert.equal(preview.effectiveHidden, false);
assert.ok(preview.explanation.notices.includes('hidden_design_preview'));
assert.equal(preview.visual.status, 'working');
});
test('activity source signature ignores control ordering but changes with role graph', () => {
const h = hass({
'switch.relay': state('switch.relay', 'off'),
'light.one': state('light.one', 'off'),
'light.two': state('light.two', 'off'),
}, {
'switch.relay': { entity_id: 'switch.relay', device_id: 'd1', platform: 'demo' },
'light.one': { entity_id: 'light.one', platform: 'hue' },
'light.two': { entity_id: 'light.two', platform: 'hue' },
});
const one = device({ controls: ['light.one', 'light.two'] });
const two = device({ controls: ['light.two', 'light.one'] });
const own = device({ controls: [] });
assert.equal(presentationSourceSignature(h, one), presentationSourceSignature(h, two));
assert.notEqual(presentationSourceSignature(h, one), presentationSourceSignature(h, own));
});
test('integration provenance prefers config-entry ownership and entity platform', () => {
const registry = {
revision: 4,
authoritative: true,
devices: { d1: { id: 'd1', config_entry_id: 'ce1', identifiers: [['fallback', 'd1']] } },
entities: {
'switch.relay': {
entity_id: 'switch.relay', device_id: 'd1', platform: 'localtuya', config_entry_id: 'ce1', disabled_by: null,
},
},
};
const metadata = {
revision: 1,
loaded: true,
configEntries: { ce1: { entry_id: 'ce1', domain: 'localtuya', title: 'Boiler room' } },
manifests: { localtuya: { domain: 'localtuya', name: 'Local Tuya' } },
};
const h = { devices: registry.devices, entities: registry.entities };
assert.deepEqual(
resolveBindingProviders(h, 'device:d1', registry, metadata).map((item) => item.domain),
['localtuya'],
);
assert.equal(resolveEntityProvider(h, 'switch.relay', registry, metadata)?.domain, 'localtuya');
assert.equal(resolveBindingProviders(h, 'virtual', registry, metadata)[0].domain, 'houseplan');
});
test('derived temperature and humidity retain compact plan formatting', () => {
const tempHass = hass({
'sensor.temp': state('sensor.temp', '22.44', { device_class: 'temperature', unit_of_measurement: '°C' }),
}, {
'sensor.temp': { entity_id: 'sensor.temp', device_id: 'd1', platform: 'demo' },
});
const temperature = resolveDevicePresentation(tempHass, device({
icon: 'mdi:thermometer', entities: ['sensor.temp'], primary: 'sensor.temp',
marker: { id: 'd1', binding: 'device:d1', display: 'value' },
}), options);
assert.equal(temperature.valueText, '22.4°');
const humidityHass = hass({
'sensor.humidity': state('sensor.humidity', '47.7', { device_class: 'humidity', unit_of_measurement: '%' }),
}, {
'sensor.humidity': { entity_id: 'sensor.humidity', device_id: 'd1', platform: 'demo' },
});
const humidity = resolveDevicePresentation(humidityHass, device({
entities: ['sensor.humidity'], primary: 'sensor.humidity',
marker: { id: 'd1', binding: 'device:d1', display: 'value' },
}), options);
assert.equal(humidity.valueText, '48%');
});
test('temperature-led composite marker does not add a legacy humidity satellite', () => {
const h = hass({
'sensor.temp': state('sensor.temp', '22.4', { device_class: 'temperature', unit_of_measurement: '°C' }),
'sensor.humidity': state('sensor.humidity', '47.7', { device_class: 'humidity', unit_of_measurement: '%' }),
}, {
'sensor.temp': { entity_id: 'sensor.temp', device_id: 'd1', platform: 'demo' },
'sensor.humidity': { entity_id: 'sensor.humidity', device_id: 'd1', platform: 'demo' },
});
const result = resolveDevicePresentation(h, device({
icon: 'mdi:thermometer', primary: 'sensor.temp',
entities: ['sensor.temp', 'sensor.humidity'],
}), options);
assert.equal(result.valueBadge?.text, '22.4°');
assert.equal(result.tempText, '22.4');
assert.equal(result.humText, null);
});
test('preview explanations distinguish activity display and composite Power source', () => {
const workingHass = hass(
{ 'switch.relay': state('switch.relay', 'on') },
{ 'switch.relay': { entity_id: 'switch.relay', device_id: 'd1', platform: 'demo' } },
);
const badge = resolveDevicePresentation(workingHass, device(), options);
const ripple = resolveDevicePresentation(workingHass, device({
marker: { id: 'd1', binding: 'device:d1', display: 'icon_ripple' },
}), options);
assert.equal(badge.explanation.reason, 'working');
assert.ok(badge.explanation.notices.includes('activity_display_disabled'));
assert.equal(ripple.explanation.reason, 'working_activity');
assert.ok(!ripple.explanation.notices.includes('activity_display_disabled'));
const compositeHass = hass({
'switch.power': state('switch.power', 'on'),
'switch.voice': state('switch.voice', 'on'),
}, {
'switch.power': {
entity_id: 'switch.power', device_id: 'd1', platform: 'demo', translation_key: 'power',
},
'switch.voice': { entity_id: 'switch.voice', device_id: 'd1', platform: 'demo' },
});
const composite = resolveDevicePresentation(compositeHass, device({
entities: ['switch.voice', 'switch.power'], primary: 'switch.power',
}), options);
assert.ok(composite.explanation.notices.includes('composite_power_source'));
});
test('issue 90 explicit value badge overrides legacy gates and keeps its position', () => {
const h = hass({
'sensor.power': state('sensor.power', '0', { unit_of_measurement: 'W' }),
}, {
'sensor.power': { entity_id: 'sensor.power', device_id: 'd1', platform: 'demo' },
});
const result = resolveDevicePresentation(h, device({
entities: ['sensor.power'], primary: 'sensor.power',
marker: {
id: 'd1', binding: 'device:d1',
value_badge: {
enabled: true,
source: { kind: 'entity_state', entity_id: 'sensor.power' },
position: 'left',
},
},
}), { ...options, showTemperature: false, showSignal: false });
assert.equal(result.valueBadge?.text, '0 W');
assert.equal(result.valueBadge?.position, 'left');
assert.equal(result.valueBadge?.configured, true);
});
test('issue 90 explicit off suppresses legacy and static display preserves but hides badge', () => {
const h = hass({
'sensor.temp': state('sensor.temp', '22.4', { device_class: 'temperature', unit_of_measurement: '°C' }),
}, {
'sensor.temp': { entity_id: 'sensor.temp', device_id: 'd1', platform: 'demo' },
});
const base = device({
icon: 'mdi:thermometer', entities: ['sensor.temp'], primary: 'sensor.temp',
});
assert.equal(resolveDevicePresentation(h, base, options).valueBadge?.text, '22.4°');
assert.equal(resolveDevicePresentation(h, {
...base, marker: {
...base.marker,
value_badge: { enabled: false, source: null, position: 'right' },
},
}, options).valueBadge, null);
assert.equal(resolveDevicePresentation(h, {
...base, marker: {
...base.marker, display: 'static_icon',
value_badge: {
enabled: true,
source: { kind: 'entity_state', entity_id: 'sensor.temp' },
position: 'top',
},
},
}, options).valueBadge, null);
});
test('legacy climate-temperature flag does not fall through when the attribute is absent', () => {
const h = hass({
'climate.room': state('climate.room', 'heat', {}),
'sensor.temp': state('sensor.temp', '22.4', {
device_class: 'temperature', unit_of_measurement: '°C',
}),
}, {
'climate.room': { entity_id: 'climate.room', device_id: 'd1', platform: 'demo' },
'sensor.temp': { entity_id: 'sensor.temp', device_id: 'd1', platform: 'demo' },
});
const d = device({
icon: 'mdi:thermometer', primary: 'sensor.temp',
entities: ['climate.room', 'sensor.temp'], marker: { use_climate_temp: true },
});
assert.equal(resolveDevicePresentation(h, d, options).valueBadge, null);
});
test('an incomplete explicit enabled badge remains visible as unavailable', () => {
const h = hass({}, {});
const d = device({ marker: { value_badge: { enabled: true, position: 'top' } } });
const badge = resolveDevicePresentation(h, d, options).valueBadge;
assert.equal(badge?.text, '—');
assert.equal(badge?.availability, 'missing');
assert.equal(badge?.position, 'top');
assert.equal(badge?.source, null);
assert.equal(valueBadgeTitle(badge), 'Unavailable');
});
test('issue 90 untouched persistence gate never materializes a legacy badge', () => {
assert.deepEqual(valueBadgeWriteFields({
touched: false, originalHas: false, original: undefined,
enabled: true, source: { kind: 'entity_state', entity_id: 'sensor.temp' }, position: 'right',
}), {});
const original = {
enabled: false,
source: { kind: 'entity_state', entity_id: 'sensor.old' },
position: 'left',
future: { preserved: true },
};
assert.equal(valueBadgeWriteFields({
touched: false, originalHas: true, original,
enabled: true, source: null, position: 'top',
}).value_badge, original);
});
test('issue 90 missing source never falls back and derived LQI renders only once', () => {
const h = hass({
'sensor.value': state('sensor.value', '42'),
'sensor.linkquality': state('sensor.linkquality', '150', { unit_of_measurement: 'lqi' }),
}, {
'sensor.value': { entity_id: 'sensor.value', device_id: 'd1', platform: 'demo' },
'sensor.linkquality': { entity_id: 'sensor.linkquality', device_id: 'd1', platform: 'demo' },
});
const missing = resolveDevicePresentation(h, device({
entities: ['sensor.value'], primary: 'sensor.value',
marker: {
id: 'd1', binding: 'device:d1',
value_badge: {
enabled: true,
source: { kind: 'entity_state', entity_id: 'sensor.missing' },
position: 'right',
},
},
}), options);
assert.equal(missing.valueBadge?.text, '—');
assert.equal(missing.valueBadge?.availability, 'missing');
const lqi = resolveDevicePresentation(h, device({
entities: ['sensor.value', 'sensor.linkquality'], primary: 'sensor.value',
marker: {
id: 'd1', binding: 'device:d1',
value_badge: { enabled: true, source: { kind: 'derived_lqi' }, position: 'bottom' },
},
}), options);
assert.equal(lqi.valueBadge?.text, '150');
assert.equal(lqi.lqiText, null);
});
test('cover presentation follows the same service target selected by toggle resolution', () => {
const h = hass({
'cover.curtain': state('cover.curtain', 'opening', {
device_class: 'curtain', supported_features: 15,
}),
'sensor.position': state('sensor.position', '45'),
}, {
'cover.curtain': { entity_id: 'cover.curtain', device_id: 'd1', platform: 'demo' },
'sensor.position': { entity_id: 'sensor.position', device_id: 'd1', platform: 'demo' },
});
const result = resolvePresentationSources(h, device({
entities: ['sensor.position', 'cover.curtain'], primary: 'sensor.position',
}));
assert.equal(result.sourceKind, 'cover');
assert.equal(result.visualSources.length, 1);
assert.equal(result.visualSources[0].eid, 'cover.curtain');
assert.equal(result.visualSources[0].state, 'opening');
});
test('presentation role uses the full registry projection with a frozen state snapshot', () => {
const planHass = hass({
'sensor.position': state('sensor.position', '45'),
'cover.curtain': state('cover.curtain', 'opening', {
device_class: 'curtain', supported_features: 15,
}),
});
const registryHass = hass(planHass.states, {
'sensor.position': {
entity_id: 'sensor.position', device_id: 'd1', platform: 'demo',
entity_category: 'diagnostic',
},
'cover.curtain': { entity_id: 'cover.curtain', device_id: 'd1', platform: 'demo' },
});
const result = resolvePresentationSources(planHass, device({
entities: ['sensor.position', 'cover.curtain'], primary: 'sensor.position',
}), undefined, undefined, registryHass);
assert.equal(result.sourceKind, 'cover');
assert.deepEqual(result.visualSources.map((source) => source.eid), ['cover.curtain']);
});
test('an incidental cover does not hijack the light face of a mixed device', () => {
const h = hass({
'light.mixed': state('light.mixed', 'on'),
'cover.mixed': state('cover.mixed', 'opening', {
device_class: 'curtain', supported_features: 15,
}),
}, {
'light.mixed': { entity_id: 'light.mixed', device_id: 'd1', platform: 'demo' },
'cover.mixed': { entity_id: 'cover.mixed', device_id: 'd1', platform: 'demo' },
});
const plain = resolvePresentationSources(h, device({
entities: ['light.mixed', 'cover.mixed'], primary: 'light.mixed', tapAction: null,
}));
assert.equal(plain.sourceKind, 'light');
assert.deepEqual(plain.visualSources.map((source) => source.eid), ['light.mixed']);
const explicitCover = resolvePresentationSources(h, device({
entities: ['light.mixed', 'cover.mixed'], primary: 'light.mixed', tapAction: 'cover',
}));
assert.equal(explicitCover.sourceKind, 'cover');
assert.equal(explicitCover.visualSources[0].eid, 'cover.mixed');
});
test('an explicitly primary cover keeps the face when a light sibling exists', () => {
const h = hass({
'cover.mixed': state('cover.mixed', 'opening', {
device_class: 'curtain', supported_features: 15,
}),
'light.mixed': state('light.mixed', 'on'),
}, {
'cover.mixed': { entity_id: 'cover.mixed', device_id: 'd1', platform: 'demo' },
'light.mixed': { entity_id: 'light.mixed', device_id: 'd1', platform: 'demo' },
});
const result = resolvePresentationSources(h, device({
entities: ['cover.mixed', 'light.mixed'], primary: 'cover.mixed', tapAction: 'info',
}));
assert.equal(result.sourceKind, 'cover');
assert.deepEqual(result.visualSources.map((source) => source.eid), ['cover.mixed']);
});
test('an incidental cover does not hijack another whole-device role on info tap', () => {
const h = hass({
'media_player.screen': state('media_player.screen', 'playing'),
'cover.screen': state('cover.screen', 'opening', {
device_class: 'curtain', supported_features: 15,
}),
}, {
'media_player.screen': { entity_id: 'media_player.screen', device_id: 'd1', platform: 'demo' },
'cover.screen': { entity_id: 'cover.screen', device_id: 'd1', platform: 'demo' },
});
const result = resolvePresentationSources(h, device({
entities: ['cover.screen', 'media_player.screen'],
primary: 'media_player.screen', tapAction: 'info',
}));
assert.equal(result.sourceKind, 'device_role');
assert.deepEqual(result.visualSources.map((source) => source.eid), ['media_player.screen']);
});
test('issue 369г: устройство с полностью отключёнными сущностями не выглядит живым', () => {
const h = hass({
'light.wall_group': state('light.wall_group', 'on', { friendly_name: 'Wall lights' }),
}, {
'light.wall_group': {
entity_id: 'light.wall_group', device_id: 'lights', platform: 'demo',
},
});
// (i) истинно безростерный active-контроллер — по-прежнему available (#318)
const bare = device({
id: 'bare', name: 'Bare controller', entities: [], primary: null,
bindingRef: 'bare',
bindingStatus: { kind: 'active', enabledEntityIds: [], allEntityIds: [] },
marker: { id: 'bare', binding: 'device:bare', tap_action: 'toggle', controls: ['light.wall_group'] },
});
assert.equal(resolveDevicePresentation(h, bare, options).visual.availability, 'available');
// (ii) реестр непуст, но всё отключено пользователем — НЕ available
const optedOut = device({
id: 'opted', name: 'Opted-out device', entities: [], primary: null,
bindingRef: 'opted',
bindingStatus: {
kind: 'active', enabledEntityIds: [], allEntityIds: ['switch.opted_main'],
},
marker: { id: 'opted', binding: 'device:opted', tap_action: 'toggle', controls: ['light.wall_group'] },
});
assert.notEqual(
resolveDevicePresentation(h, optedOut, options).visual.availability, 'available',
'ростер, сознательно выключенный пользователем, — не свидетельство жизни',
);
// (iii) ha_disabled — как и раньше, не available
const disabled = device({
id: 'disabled', name: 'Disabled device', entities: [], primary: null,
bindingRef: 'disabled',
bindingStatus: {
kind: 'ha_disabled', reason: 'device_disabled',
enabledEntityIds: [], allEntityIds: ['switch.d_main'],
},
marker: { id: 'disabled', binding: 'device:disabled', tap_action: 'toggle', controls: ['light.wall_group'] },
});
// ha_disabled гасится на уровне lifecycle: маркер скрыт и нейтрален целиком
const disabledResult = resolveDevicePresentation(h, disabled, options);
assert.equal(disabledResult.effectiveHidden, true);
assert.equal(disabledResult.visual.status, 'neutral');
});
// #588 AC3. `sourceDetails: false` — основной путь рендера плана, карточки
// пространства и PDF. Быстрый путь «статичному маркеру источники не нужны»
// принадлежит только режиму без значения; распространить его на новый режим
// значит потерять число ровно там, где на него смотрят, и сохранить его в
// предпросмотре редактора, где источники резолвятся всегда.
test('#588 AC3: value_static_icon резолвит значение и на быстром пути плана', () => {
const h = hass({
'cover.door': state('cover.door', 'open', { device_class: 'door', current_position: 42 }),
}, { 'cover.door': { entity_id: 'cover.door', device_id: 'd1', platform: 'demo' } });
const base = device({
icon: 'mdi:door', entities: ['cover.door'], primary: 'cover.door',
marker: {
id: 'd1', binding: 'device:d1',
value_source: { kind: 'entity_attribute', entity_id: 'cover.door', attribute: 'current_position' },
},
});
const planOptions = { ...options, sourceDetails: false };
const valueStatic = resolveDevicePresentation(h, {
...base, marker: { ...base.marker, display: 'value_static_icon' },
}, planOptions);
assert.equal(valueStatic.valueText, '42 %');
assert.equal(valueStatic.valueSource?.eid, 'cover.door');
assert.ok(valueStatic.visualSources.length > 0);
// Тот же маркер в «Всегда статичный значок» остаётся без источников —
// экономия быстрого пути не потеряна.
const staticIcon = resolveDevicePresentation(h, {
...base, marker: { ...base.marker, display: 'static_icon' },
}, planOptions);
assert.equal(staticIcon.valueText, null);
assert.equal(staticIcon.valueSource, null);
assert.deepEqual(staticIcon.visualSources, []);
// AC2 на том же пути: в автоматическом режиме недоступное значение откатывает
// лицо к значку и называет причину. Без причины человек видит пустой значок и
// не узнаёт, почему числа нет, — а режим выглядит исправным. Явно выбранный
// источник ведёт себя иначе (`—`), и это проверено выше по контракту К1.
const offline = hass({
'cover.door': state('cover.door', 'unavailable', { device_class: 'door' }),
}, { 'cover.door': { entity_id: 'cover.door', device_id: 'd1', platform: 'demo' } });
const fallback = resolveDevicePresentation(offline, {
...base,
marker: { id: 'd1', binding: 'device:d1', display: 'value_static_icon' },
}, planOptions);
assert.equal(fallback.valueText, null);
assert.equal(fallback.fallbackReason, 'value_no_state');
});
// #588 AC2/AC4. Тревога — самый дорогой случай: она пробивает и выключенные
// живые состояния, и отсутствие активности. «Никогда не меняет цвет» читается
// буквально, поэтому её здесь нет ни в подложке, ни в пульсации, ни в бейдже.
test('#588 AC2/AC4: тревожный источник не красит и не пульсирует value_static_icon', () => {
const h = hass({
'binary_sensor.smoke': state('binary_sensor.smoke', 'on', { device_class: 'smoke' }),
'sensor.device_linkquality': state('sensor.device_linkquality', '40'),
}, {
'binary_sensor.smoke': { entity_id: 'binary_sensor.smoke', device_id: 'd1', platform: 'demo' },
'sensor.device_linkquality': {
entity_id: 'sensor.device_linkquality', device_id: 'd1', platform: 'demo',
},
});
const base = device({
icon: 'mdi:smoke-detector',
entities: ['binary_sensor.smoke', 'sensor.device_linkquality'],
primary: 'binary_sensor.smoke',
bindingStatus: {
kind: 'active',
enabledEntityIds: ['binary_sensor.smoke', 'sensor.device_linkquality'],
allEntityIds: ['binary_sensor.smoke', 'sensor.device_linkquality'],
},
marker: {
id: 'd1', binding: 'device:d1', display: 'value_static_icon',
value_badge: { enabled: true, source: { kind: 'derived_lqi' }, position: 'right' },
},
});
const result = resolveDevicePresentation(h, base, options);
assert.equal(result.visual.status, 'neutral');
assert.equal(result.pulse.kind, 'none');
assert.deepEqual(result.classes, ['static-icon']);
assert.equal(result.lqiText, null);
assert.equal(result.valueBadge, null);
assert.equal(result.rippleColor, null);
assert.equal(deviceA11yState(result), 'neutral');
assert.equal(result.explanation.reason, 'value_static_icon');
assert.ok(result.valueText, 'значение остаётся видимым');
// Тот же прибор в обычном режиме тревожится — иначе фикстура доказывала бы
// только то, что тревоги в ней нет.
const alarmed = resolveDevicePresentation(h, {
...base, marker: { ...base.marker, display: 'badge' },
}, options);
assert.equal(alarmed.visual.status, 'alarm');
assert.equal(alarmed.pulse.kind, 'alarm');
assert.ok(alarmed.valueBadge);
});