mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
refactor: make device presentation decisions explicit
Issue: #267 User-Visible: no
This commit is contained in:
@@ -0,0 +1,629 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import { resolveDevicePresentationPolicy } from '../test-build/device-presentation-policy.js';
|
||||
import {
|
||||
activitySourceSignature, markerLqiBand, resolveDevicePresentation, resolvePresentationSources,
|
||||
} from '../test-build/device-presentation.js';
|
||||
import { buildDevices } from '../test-build/devices.js';
|
||||
import { DEVICE_PRESENTATION_DECISIONS } from './fixtures/device-presentation-decisions.mjs';
|
||||
import { MUTANTS } from '../scripts/mutation-gate.mjs';
|
||||
|
||||
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,
|
||||
});
|
||||
const device = (overrides = {}) => ({
|
||||
id: 'd1', name: 'Device', model: '', area: 'room', space: 'floor',
|
||||
icon: 'mdi:toggle-switch', entities: ['switch.main'], primary: 'switch.main',
|
||||
bindingKind: 'device', bindingRef: 'd1',
|
||||
bindingStatus: { kind: 'active', enabledEntityIds: ['switch.main'], allEntityIds: ['switch.main'] },
|
||||
marker: { id: 'd1', binding: 'device:d1' },
|
||||
...overrides,
|
||||
});
|
||||
const options = { liveStates: true, showTemperature: true, showSignal: true, now: 2_000 };
|
||||
const neutral = { availability: 'available', status: 'neutral', activity: 'none' };
|
||||
|
||||
const basePolicy = (overrides = {}) => ({
|
||||
bindingLifecycle: 'active', userHidden: false, designPreview: false,
|
||||
display: 'badge', liveStates: true, sourceVisual: neutral,
|
||||
controllerFace: false, controllerAvailability: 'available', shortActivity: null,
|
||||
valueAvailable: false, valueFallback: null, vacuumLiveRequested: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
function assertDecision(result, decision) {
|
||||
assert.ok(result.decisionIds.includes(decision),
|
||||
`${decision} missing from ${result.decisionIds.join(', ')}`);
|
||||
}
|
||||
|
||||
const rowHass = () => hass({
|
||||
'binary_sensor.door': state('binary_sensor.door', 'on', { device_class: 'door' }),
|
||||
'binary_sensor.presence': state('binary_sensor.presence', 'on', { device_class: 'presence' }),
|
||||
'binary_sensor.smoke': state('binary_sensor.smoke', 'on', { device_class: 'smoke' }),
|
||||
'cover.main': state('cover.main', 'opening', { device_class: 'garage' }),
|
||||
'event.button': state('event.button', 'unknown'),
|
||||
'light.main': state('light.main', 'on', { rgb_color: [12, 140, 250] }),
|
||||
'light.offline': state('light.offline', 'unavailable'),
|
||||
'light.second': state('light.second', 'off'),
|
||||
'lock.front': state('lock.front', 'locked'),
|
||||
'sensor.battery': state('sensor.battery', '100'),
|
||||
'sensor.device_linkquality': state('sensor.device_linkquality', '40'),
|
||||
'sensor.temperature': state('sensor.temperature', '22.4', {
|
||||
device_class: 'temperature', unit_of_measurement: '°C',
|
||||
}),
|
||||
'switch.main': state('switch.main', 'on'),
|
||||
'switch.offline': state('switch.offline', 'unavailable'),
|
||||
'vacuum.robot': state('vacuum.robot', 'cleaning'),
|
||||
}, Object.fromEntries([
|
||||
['binary_sensor.door', 'd1'], ['binary_sensor.presence', 'presence'],
|
||||
['binary_sensor.smoke', 'd1'], ['cover.main', 'd1'], ['event.button', 'd1'],
|
||||
['light.main', null], ['light.offline', null], ['light.second', null],
|
||||
['lock.front', 'd1'], ['sensor.battery', 'd1'], ['sensor.device_linkquality', 'd1'],
|
||||
['sensor.temperature', 'd1'], ['switch.main', 'd1'], ['switch.offline', 'd1'],
|
||||
['vacuum.robot', 'vacuum'],
|
||||
].map(([entity_id, device_id]) => [entity_id, {
|
||||
entity_id, device_id, platform: 'demo',
|
||||
...(entity_id === 'sensor.battery' || entity_id === 'sensor.device_linkquality'
|
||||
? { entity_category: 'diagnostic' } : {}),
|
||||
}])));
|
||||
|
||||
const policyRow = (overrides, verify) => () => {
|
||||
const result = resolveDevicePresentationPolicy(basePolicy(overrides));
|
||||
verify(result);
|
||||
return result;
|
||||
};
|
||||
|
||||
const sourceRow = (overrides, verify) => () => {
|
||||
const result = resolvePresentationSources(rowHass(), device(overrides));
|
||||
verify(result);
|
||||
return result;
|
||||
};
|
||||
|
||||
const presentationRow = (overrides, optionOverrides, verify) => () => {
|
||||
const result = resolveDevicePresentation(
|
||||
rowHass(), device(overrides), { ...options, ...optionOverrides },
|
||||
);
|
||||
verify(result);
|
||||
return result;
|
||||
};
|
||||
|
||||
test('device presentation decision document, fixture and mutation registry stay exact', () => {
|
||||
const doc = readFileSync(new URL('../docs/DEVICE-PRESENTATION.md', import.meta.url), 'utf8');
|
||||
const docIds = [...doc.matchAll(/^\| ([LSFA]\d{2}) \|/gm)].map((match) => match[1]);
|
||||
const fixtureIds = DEVICE_PRESENTATION_DECISIONS.map((row) => row.id);
|
||||
assert.deepEqual(docIds, fixtureIds);
|
||||
assert.equal(new Set(fixtureIds).size, fixtureIds.length);
|
||||
assert.deepEqual(fixtureIds, [...fixtureIds].sort((a, b) =>
|
||||
'LSFA'.indexOf(a[0]) - 'LSFA'.indexOf(b[0]) || Number(a.slice(1)) - Number(b.slice(1))));
|
||||
|
||||
const mutantIds = new Set(MUTANTS.map((mutant) => mutant.id));
|
||||
for (const row of DEVICE_PRESENTATION_DECISIONS) {
|
||||
assert.match(doc, new RegExp(`^\\| ${row.id} \\|.*\\b${row.expectedDecision.replace('.', '\\.')}`, 'm'));
|
||||
assert.ok(mutantIds.has(row.mutation), `${row.id}: unknown mutation ${row.mutation}`);
|
||||
}
|
||||
});
|
||||
|
||||
const rowRunners = {
|
||||
L01: () => {
|
||||
const roster = buildDevices({
|
||||
hass: { states: {}, entities: {}, devices: {}, areas: {} },
|
||||
areaToSpace: {}, markers: [{ id: 'gone', binding: 'virtual', removed: true }],
|
||||
settings: { filter_seeded: true }, excluded: new Set(), showAll: true,
|
||||
firstSpaceId: 'floor', loc: (key) => key, iconRules: [],
|
||||
});
|
||||
assert.deepEqual(roster, []);
|
||||
return { decisionIds: ['pre.lifecycle.removed'] };
|
||||
},
|
||||
L02: policyRow({ bindingLifecycle: 'ha_disabled' }, (r) => {
|
||||
assert.equal(r.effectiveHidden, true);
|
||||
assert.equal(r.bindingUnavailable, true);
|
||||
}),
|
||||
L03: policyRow({ bindingLifecycle: 'ha_disabled', designPreview: true }, (r) => {
|
||||
assert.equal(r.effectiveHidden, true);
|
||||
assert.deepEqual(r.visual, neutral);
|
||||
}),
|
||||
L04: policyRow({ userHidden: true }, (r) => assert.equal(r.effectiveHidden, true)),
|
||||
L05: policyRow({
|
||||
userHidden: true, designPreview: true,
|
||||
sourceVisual: { availability: 'available', status: 'working', activity: 'running' },
|
||||
}, (r) => {
|
||||
assert.equal(r.effectiveHidden, false);
|
||||
assert.equal(r.visual.status, 'working');
|
||||
}),
|
||||
L06: () => {
|
||||
const orphaned = resolveDevicePresentationPolicy(basePolicy({ bindingLifecycle: 'orphaned' }));
|
||||
const unverified = resolveDevicePresentationPolicy(basePolicy({ bindingLifecycle: 'unverified' }));
|
||||
assert.equal(orphaned.bindingUnavailable, true);
|
||||
assert.equal(unverified.bindingUnavailable, true);
|
||||
assertDecision(unverified, 'lifecycle.unverified_diagnostic');
|
||||
return orphaned;
|
||||
},
|
||||
S01: sourceRow({ entities: ['cover.main'], primary: 'cover.main', tapAction: 'info' },
|
||||
(r) => assert.equal(r.sourceKind, 'cover')),
|
||||
S02: sourceRow({
|
||||
entities: ['light.main', 'cover.main'], primary: 'light.main', tapAction: 'info',
|
||||
}, (r) => assert.equal(r.sourceKind, 'light')),
|
||||
S03: presentationRow({
|
||||
entities: ['switch.main'], primary: 'switch.main',
|
||||
marker: { id: 'd1', binding: 'device:d1', controls: ['light.main'] },
|
||||
}, {}, (r) => {
|
||||
assert.equal(r.sourceKind, 'controls');
|
||||
assert.deepEqual([r.visual.availability, r.visual.status], ['available', 'working']);
|
||||
}),
|
||||
S04: presentationRow({
|
||||
entities: ['event.button', 'sensor.battery'], primary: 'event.button',
|
||||
marker: { id: 'd1', binding: 'device:d1', controls: ['light.offline'] },
|
||||
}, {}, (r) => {
|
||||
assert.equal(r.sourceKind, 'controls');
|
||||
assert.deepEqual([r.visual.availability, r.visual.status], ['available', 'neutral']);
|
||||
}),
|
||||
S05: presentationRow({
|
||||
entities: ['switch.offline'], primary: 'switch.offline',
|
||||
marker: { id: 'd1', binding: 'device:d1', controls: ['light.main'] },
|
||||
}, {}, (r) => {
|
||||
assert.deepEqual([r.visual.availability, r.visual.status], ['unavailable', 'working']);
|
||||
assert.equal(r.pulse.kind, 'none');
|
||||
}),
|
||||
S06: presentationRow({
|
||||
virtual: true, bindingKind: 'virtual', bindingRef: '', entities: [], primary: null,
|
||||
marker: { id: 'virtual', binding: 'virtual', controls: ['light.main'] },
|
||||
}, {}, (r) => {
|
||||
assert.equal(r.sourceKind, 'controls');
|
||||
assert.deepEqual([r.visual.availability, r.visual.status], ['available', 'working']);
|
||||
}),
|
||||
S07: presentationRow({
|
||||
entities: ['event.button', 'sensor.battery'], primary: 'event.button', controls: [],
|
||||
marker: { id: 'd1', binding: 'device:d1', controls: ['light.missing'] },
|
||||
}, {}, (r) => {
|
||||
assert.equal(r.sourceKind, 'device_role');
|
||||
assert.equal(r.visual.availability, 'available');
|
||||
}),
|
||||
S08: sourceRow({
|
||||
id: 'manual', virtual: true, bindingKind: 'virtual', bindingRef: '',
|
||||
entities: [], primary: null,
|
||||
marker: {
|
||||
id: 'manual', binding: 'virtual', is_light: true, tap_action: 'toggle',
|
||||
controls: ['light.main'],
|
||||
},
|
||||
}, (r) => assert.equal(r.sourceKind, 'light')),
|
||||
S09: sourceRow({ entities: ['light.main'], primary: 'light.main' },
|
||||
(r) => assert.equal(r.sourceKind, 'light')),
|
||||
S10: sourceRow({}, (r) => assert.equal(r.sourceKind, 'device_role')),
|
||||
S11: sourceRow({ entities: [], primary: 'sensor.temperature' },
|
||||
(r) => assert.equal(r.sourceKind, 'primary')),
|
||||
S12: sourceRow({ entities: [], primary: null }, (r) => {
|
||||
assert.equal(r.sourceKind, 'none');
|
||||
assert.deepEqual(r.samples, []);
|
||||
}),
|
||||
S13: presentationRow({
|
||||
entities: ['switch.main', 'binary_sensor.smoke'], primary: 'switch.main',
|
||||
marker: { id: 'd1', binding: 'device:d1', controls: ['light.main'] },
|
||||
}, {}, (r) => {
|
||||
assert.equal(r.visual.status, 'alarm');
|
||||
assert.equal(r.criticalSources[0].eid, 'binary_sensor.smoke');
|
||||
}),
|
||||
F01: policyRow({
|
||||
sourceVisual: { availability: 'available', status: 'alarm', activity: 'none' },
|
||||
}, (r) => assert.equal(r.visual.status, 'alarm')),
|
||||
F02: policyRow({
|
||||
sourceVisual: { availability: 'unavailable', status: 'neutral', activity: 'none' },
|
||||
}, (r) => {
|
||||
assert.equal(r.visual.availability, 'unavailable');
|
||||
assert.equal(r.pulseEligible, false);
|
||||
}),
|
||||
F03: () => {
|
||||
const h = rowHass();
|
||||
const lock = device({ icon: 'mdi:lock', entities: ['lock.front'], primary: 'lock.front' });
|
||||
const locked = resolveDevicePresentation(h, lock, options);
|
||||
h.states['lock.front'].state = 'unlocked';
|
||||
const unlocked = resolveDevicePresentation(h, lock, options);
|
||||
assert.equal(locked.lockState, 'locked');
|
||||
assert.ok(locked.classes.includes('lock-locked'));
|
||||
assert.equal(unlocked.lockState, 'unlocked');
|
||||
assert.ok(unlocked.classes.includes('lock-unlocked'));
|
||||
return locked;
|
||||
},
|
||||
F04: policyRow({
|
||||
sourceVisual: { availability: 'available', status: 'working', activity: 'running' },
|
||||
}, (r) => assert.equal(r.visual.status, 'working')),
|
||||
F05: policyRow({
|
||||
sourceVisual: { availability: 'available', status: 'open', activity: 'none' },
|
||||
}, (r) => assert.equal(r.visual.status, 'open')),
|
||||
F06: policyRow({}, (r) => assert.deepEqual(r.visual, neutral)),
|
||||
F07: policyRow({
|
||||
liveStates: false,
|
||||
sourceVisual: { availability: 'available', status: 'working', activity: 'running' },
|
||||
}, (r) => assert.deepEqual(r.visual, neutral)),
|
||||
F08: policyRow({
|
||||
display: 'static_icon', valueAvailable: true, vacuumLiveRequested: true,
|
||||
sourceVisual: { availability: 'available', status: 'alarm', activity: 'running' },
|
||||
}, (r) => {
|
||||
assert.deepEqual(r.visual, neutral);
|
||||
assert.deepEqual([r.face, r.metrics, r.vacuumLive], ['icon', false, false]);
|
||||
}),
|
||||
F09: presentationRow({
|
||||
marker: { id: 'd1', binding: 'device:d1', display: 'value' },
|
||||
}, {}, (r) => assert.equal(r.valueText, 'On')),
|
||||
F10: presentationRow({
|
||||
entities: ['switch.offline'], primary: 'switch.offline',
|
||||
marker: { id: 'd1', binding: 'device:d1', display: 'value' },
|
||||
}, {}, (r) => {
|
||||
assert.equal(r.valueText, null);
|
||||
assert.equal(r.fallbackReason, 'value_no_state');
|
||||
}),
|
||||
F11: presentationRow({
|
||||
controls: ['light.main', 'light.second'],
|
||||
marker: {
|
||||
id: 'd1', binding: 'device:d1', display: 'value',
|
||||
controls: ['light.main', 'light.second'],
|
||||
},
|
||||
}, {}, (r) => assert.equal(r.fallbackReason, 'value_ambiguous_sources')),
|
||||
F12: presentationRow({
|
||||
virtual: true, bindingKind: 'virtual', bindingRef: '', entities: [], primary: null,
|
||||
marker: { id: 'virtual', binding: 'virtual', display: 'value' },
|
||||
}, {}, (r) => assert.equal(r.fallbackReason, 'value_virtual')),
|
||||
F13: presentationRow({
|
||||
icon: 'mdi:garage', entities: ['cover.main'], primary: 'cover.main', tapAction: 'info',
|
||||
}, {}, (r) => {
|
||||
assert.equal(r.sourceKind, 'cover');
|
||||
assert.notEqual(r.icon, 'mdi:garage');
|
||||
}),
|
||||
F14: presentationRow({
|
||||
icon: 'mdi:thermometer',
|
||||
entities: ['sensor.temperature', 'sensor.device_linkquality'], primary: 'sensor.temperature',
|
||||
marker: {
|
||||
id: 'd1', binding: 'device:d1',
|
||||
value_badge: { enabled: true, source: { kind: 'derived_lqi' }, position: 'bottom' },
|
||||
},
|
||||
}, {}, (r) => {
|
||||
assert.equal(r.valueBadge?.text, '40');
|
||||
assert.equal(r.lqiText, null);
|
||||
}),
|
||||
F15: presentationRow({
|
||||
icon: 'mdi:thermometer', entities: ['sensor.temperature'], primary: 'sensor.temperature',
|
||||
}, {}, (r) => assert.equal(r.tempText, '22.4')),
|
||||
F16: () => {
|
||||
assert.deepEqual([0, 40, 41, 179, 180].map(markerLqiBand),
|
||||
['low', 'low', 'mid', 'mid', 'high']);
|
||||
return { decisionIds: ['diagnostics.lqi_low'] };
|
||||
},
|
||||
F17: presentationRow({
|
||||
id: 'vacuum', icon: 'mdi:robot-vacuum', entities: ['vacuum.robot'], primary: 'vacuum.robot',
|
||||
marker: { id: 'vacuum', binding: 'device:vacuum', vacuum: { live: true } },
|
||||
}, {}, (r) => assert.equal(r.vacuumLive, true)),
|
||||
A01: presentationRow({
|
||||
icon: 'mdi:smoke-detector', entities: ['binary_sensor.smoke'], primary: 'binary_sensor.smoke',
|
||||
}, { liveStates: false }, (r) => assert.deepEqual([r.pulse.kind, r.pulse.reason], ['alarm', 'alarm'])),
|
||||
A02: presentationRow({
|
||||
id: 'presence', icon: 'mdi:motion-sensor', entities: ['binary_sensor.presence'],
|
||||
primary: 'binary_sensor.presence',
|
||||
marker: { id: 'presence', binding: 'device:presence', display: 'badge' },
|
||||
}, {}, (r) => {
|
||||
assert.equal(r.pulse.kind, 'none');
|
||||
assert.ok(r.explanation.notices.includes('activity_display_disabled'));
|
||||
}),
|
||||
A03: () => {
|
||||
const h = rowHass();
|
||||
const item = device({
|
||||
id: 'presence', entities: ['binary_sensor.presence'], primary: 'binary_sensor.presence',
|
||||
marker: { id: 'presence', binding: 'device:presence', display: 'icon_ripple' },
|
||||
});
|
||||
const sources = activitySourceSignature(h, item);
|
||||
const live = resolveDevicePresentation(h, item, {
|
||||
...options, activityRuntime: { sources, flashTs: 1_000, flashKind: 'event', gen: 2 },
|
||||
});
|
||||
const expired = resolveDevicePresentation(h, item, {
|
||||
...options, now: 5_000,
|
||||
activityRuntime: { sources, flashTs: 1_000, flashKind: 'event', gen: 2 },
|
||||
});
|
||||
assert.deepEqual([live.pulse.kind, live.pulse.reason], ['short', 'event']);
|
||||
assert.notEqual(expired.pulse.kind, 'short');
|
||||
return live;
|
||||
},
|
||||
A04: presentationRow({
|
||||
id: 'presence', entities: ['binary_sensor.presence'], primary: 'binary_sensor.presence',
|
||||
marker: { id: 'presence', binding: 'device:presence', display: 'icon_ripple' },
|
||||
}, {}, (r) => assert.deepEqual([r.pulse.kind, r.pulse.reason], ['continuous', 'presence'])),
|
||||
A05: presentationRow({
|
||||
icon: 'mdi:garage', entities: ['cover.main'], primary: 'cover.main', tapAction: 'info',
|
||||
marker: { id: 'd1', binding: 'device:d1', display: 'icon_ripple' },
|
||||
}, {}, (r) => assert.deepEqual([r.pulse.kind, r.pulse.reason], ['continuous', 'transition'])),
|
||||
A06: presentationRow({
|
||||
id: 'vacuum', entities: ['vacuum.robot'], primary: 'vacuum.robot',
|
||||
marker: { id: 'vacuum', binding: 'device:vacuum', display: 'icon_ripple' },
|
||||
}, {}, (r) => assert.deepEqual([r.pulse.kind, r.pulse.reason], ['continuous', 'running'])),
|
||||
A07: () => {
|
||||
const variants = [
|
||||
resolveDevicePresentation(rowHass(), device({
|
||||
entities: ['switch.offline'], primary: 'switch.offline',
|
||||
marker: { id: 'd1', binding: 'device:d1', display: 'icon_ripple' },
|
||||
}), options),
|
||||
resolveDevicePresentation(rowHass(), device({
|
||||
hidden: true, userHidden: true,
|
||||
marker: { id: 'd1', binding: 'device:d1', hidden: true, display: 'icon_ripple' },
|
||||
}), options),
|
||||
resolveDevicePresentation(rowHass(), device({
|
||||
bindingStatus: { kind: 'orphaned', enabledEntityIds: [], allEntityIds: [] },
|
||||
entities: [], primary: null,
|
||||
marker: { id: 'd1', binding: 'device:d1', display: 'icon_ripple' },
|
||||
}), options),
|
||||
resolveDevicePresentation(rowHass(), device({
|
||||
marker: { id: 'd1', binding: 'device:d1', display: 'static_icon' },
|
||||
}), options),
|
||||
];
|
||||
assert.ok(variants.every((r) => r.pulse.kind === 'none'));
|
||||
return variants[0];
|
||||
},
|
||||
A08: presentationRow({
|
||||
id: 'presence', entities: ['binary_sensor.presence'], primary: 'binary_sensor.presence',
|
||||
marker: { id: 'presence', binding: 'device:presence', display: 'icon_ripple' },
|
||||
}, { reducedMotion: true }, (r) => {
|
||||
assert.equal(r.pulse.kind, 'continuous');
|
||||
assert.equal(r.pulse.reducedMotionIndicator, 'dot');
|
||||
assert.equal(r.pulse.animated, false);
|
||||
}),
|
||||
};
|
||||
|
||||
test('every documented decision row executes production code and checks an observable', () => {
|
||||
assert.deepEqual(Object.keys(rowRunners), DEVICE_PRESENTATION_DECISIONS.map((row) => row.id));
|
||||
for (const row of DEVICE_PRESENTATION_DECISIONS) {
|
||||
const result = rowRunners[row.id]();
|
||||
assertDecision(result, row.expectedDecision);
|
||||
}
|
||||
});
|
||||
|
||||
test('device presentation decision table rows exercise lifecycle and ordered policy output', () => {
|
||||
const active = resolveDevicePresentationPolicy(basePolicy());
|
||||
assertDecision(active, 'lifecycle.active');
|
||||
assertDecision(active, 'status.neutral');
|
||||
assert.equal(active.effectiveHidden, false);
|
||||
|
||||
const disabled = resolveDevicePresentationPolicy(basePolicy({ bindingLifecycle: 'ha_disabled' }));
|
||||
assertDecision(disabled, 'lifecycle.ha_disabled_hidden');
|
||||
assert.equal(disabled.effectiveHidden, true);
|
||||
assert.equal(disabled.pulseEligible, false);
|
||||
|
||||
const hidden = resolveDevicePresentationPolicy(basePolicy({ userHidden: true }));
|
||||
assertDecision(hidden, 'lifecycle.user_hidden');
|
||||
assert.equal(hidden.effectiveHidden, true);
|
||||
const hiddenPreview = resolveDevicePresentationPolicy(basePolicy({
|
||||
userHidden: true, designPreview: true,
|
||||
sourceVisual: { availability: 'available', status: 'working', activity: 'running' },
|
||||
}));
|
||||
assertDecision(hiddenPreview, 'lifecycle.user_hidden_preview');
|
||||
assert.equal(hiddenPreview.visual.status, 'working');
|
||||
|
||||
const orphaned = resolveDevicePresentationPolicy(basePolicy({ bindingLifecycle: 'orphaned' }));
|
||||
assertDecision(orphaned, 'lifecycle.orphaned_diagnostic');
|
||||
assert.equal(orphaned.bindingUnavailable, true);
|
||||
const unverified = resolveDevicePresentationPolicy(basePolicy({ bindingLifecycle: 'unverified' }));
|
||||
assertDecision(unverified, 'lifecycle.unverified_diagnostic');
|
||||
assert.equal(unverified.bindingUnavailable, true);
|
||||
assert.equal(unverified.pulseEligible, false);
|
||||
|
||||
const staticFace = resolveDevicePresentationPolicy(basePolicy({
|
||||
display: 'static_icon', valueAvailable: true, vacuumLiveRequested: true,
|
||||
sourceVisual: { availability: 'available', status: 'alarm', activity: 'running' },
|
||||
}));
|
||||
assertDecision(staticFace, 'face.static');
|
||||
assert.deepEqual(staticFace.visual, neutral);
|
||||
assert.equal(staticFace.face, 'icon');
|
||||
assert.equal(staticFace.metrics, false);
|
||||
assert.equal(staticFace.vacuumLive, false);
|
||||
|
||||
const alarm = resolveDevicePresentationPolicy(basePolicy({
|
||||
liveStates: false,
|
||||
sourceVisual: { availability: 'available', status: 'alarm', activity: 'none' },
|
||||
}));
|
||||
assertDecision(alarm, 'status.alarm');
|
||||
assert.equal(alarm.visual.status, 'alarm');
|
||||
assert.equal(alarm.pulseEligible, true);
|
||||
|
||||
const noLive = resolveDevicePresentationPolicy(basePolicy({
|
||||
liveStates: false,
|
||||
sourceVisual: { availability: 'available', status: 'working', activity: 'running' },
|
||||
}));
|
||||
assertDecision(noLive, 'face.live_states_disabled');
|
||||
assert.deepEqual(noLive.visual, neutral);
|
||||
|
||||
for (const status of ['working', 'open', 'neutral']) {
|
||||
const result = resolveDevicePresentationPolicy(basePolicy({
|
||||
sourceVisual: { availability: 'available', status, activity: 'none' },
|
||||
}));
|
||||
assertDecision(result, `status.${status}`);
|
||||
}
|
||||
const unavailable = resolveDevicePresentationPolicy(basePolicy({
|
||||
sourceVisual: { availability: 'unavailable', status: 'neutral', activity: 'none' },
|
||||
}));
|
||||
assertDecision(unavailable, 'status.unavailable');
|
||||
assert.equal(unavailable.pulseEligible, false);
|
||||
|
||||
const controllerUp = resolveDevicePresentationPolicy(basePolicy({
|
||||
controllerFace: true, controllerAvailability: 'available',
|
||||
sourceVisual: { availability: 'unavailable', status: 'neutral', activity: 'none' },
|
||||
}));
|
||||
assertDecision(controllerUp, 'availability.controller_available');
|
||||
assert.equal(controllerUp.visual.availability, 'available');
|
||||
const controllerDown = resolveDevicePresentationPolicy(basePolicy({
|
||||
controllerFace: true, controllerAvailability: 'unavailable',
|
||||
sourceVisual: { availability: 'available', status: 'working', activity: 'running' },
|
||||
}));
|
||||
assertDecision(controllerDown, 'availability.controller_unavailable');
|
||||
assert.equal(controllerDown.visual.availability, 'unavailable');
|
||||
assert.equal(controllerDown.visual.status, 'working');
|
||||
|
||||
const value = resolveDevicePresentationPolicy(basePolicy({ display: 'value', valueAvailable: true }));
|
||||
assertDecision(value, 'content.value');
|
||||
assert.equal(value.face, 'value');
|
||||
for (const fallback of ['value_no_state', 'value_ambiguous_sources', 'value_non_scalar', 'value_virtual']) {
|
||||
const result = resolveDevicePresentationPolicy(basePolicy({
|
||||
display: 'value', valueAvailable: false, valueFallback: fallback,
|
||||
}));
|
||||
assert.equal(result.face, 'icon');
|
||||
assertDecision(result, `content.${fallback}`);
|
||||
}
|
||||
|
||||
const diagnostics = resolveDevicePresentationPolicy(basePolicy({ vacuumLiveRequested: true }));
|
||||
assertDecision(diagnostics, 'diagnostics.dynamic_icon');
|
||||
assertDecision(diagnostics, 'diagnostics.metrics_enabled');
|
||||
assertDecision(diagnostics, 'diagnostics.vacuum_live');
|
||||
});
|
||||
|
||||
test('source decision trace names every source winner and controller exception', () => {
|
||||
const h = hass({
|
||||
'cover.main': state('cover.main', 'opening', { device_class: 'curtain' }),
|
||||
'light.main': state('light.main', 'on'),
|
||||
'switch.main': state('switch.main', 'on'),
|
||||
'event.button': state('event.button', 'unknown'),
|
||||
'sensor.battery': state('sensor.battery', '100'),
|
||||
'binary_sensor.smoke': state('binary_sensor.smoke', 'on', { device_class: 'smoke' }),
|
||||
}, {
|
||||
'cover.main': { entity_id: 'cover.main', device_id: 'd1', platform: 'demo' },
|
||||
'light.main': { entity_id: 'light.main', device_id: 'd1', platform: 'demo' },
|
||||
'switch.main': { entity_id: 'switch.main', device_id: 'd1', platform: 'demo' },
|
||||
'event.button': { entity_id: 'event.button', device_id: 'd1', platform: 'demo' },
|
||||
'sensor.battery': { entity_id: 'sensor.battery', device_id: 'd1', platform: 'demo', entity_category: 'diagnostic' },
|
||||
'binary_sensor.smoke': { entity_id: 'binary_sensor.smoke', device_id: 'd1', platform: 'demo' },
|
||||
});
|
||||
|
||||
const cover = resolvePresentationSources(h, device({
|
||||
entities: ['cover.main'], primary: 'cover.main', tapAction: 'info',
|
||||
}));
|
||||
assertDecision(cover, 'source.cover');
|
||||
assert.equal(cover.sourceKind, 'cover');
|
||||
|
||||
const mixed = resolvePresentationSources(h, device({
|
||||
entities: ['light.main', 'cover.main'], primary: 'light.main', tapAction: 'info',
|
||||
}));
|
||||
assertDecision(mixed, 'source.cover_capability_bypassed');
|
||||
assertDecision(mixed, 'source.owned_light');
|
||||
assert.equal(mixed.sourceKind, 'light');
|
||||
|
||||
const controlled = device({ marker: {
|
||||
id: 'd1', binding: 'device:d1', controls: ['light.main'],
|
||||
} });
|
||||
const controls = resolvePresentationSources(h, controlled);
|
||||
assertDecision(controls, 'source.controls');
|
||||
assert.equal(controls.sourceKind, 'controls');
|
||||
|
||||
const virtual = resolvePresentationSources(h, device({
|
||||
virtual: true, bindingKind: 'virtual', entities: [], primary: null,
|
||||
marker: { id: 'virtual', binding: 'virtual', controls: ['light.main'] },
|
||||
}));
|
||||
assertDecision(virtual, 'source.virtual_controller');
|
||||
|
||||
const manual = resolvePresentationSources(h, device({
|
||||
id: 'manual', virtual: true, bindingKind: 'virtual', entities: [], primary: null,
|
||||
marker: {
|
||||
id: 'manual', binding: 'virtual', is_light: true, tap_action: 'toggle',
|
||||
controls: ['light.main'],
|
||||
},
|
||||
}));
|
||||
assertDecision(manual, 'source.manual_virtual_light');
|
||||
|
||||
const role = resolvePresentationSources(h, device());
|
||||
assertDecision(role, 'source.device_role');
|
||||
const primary = resolvePresentationSources(h, device({
|
||||
entities: [], primary: 'sensor.battery', marker: { id: 'd1', binding: 'device:d1' },
|
||||
}));
|
||||
assertDecision(primary, 'source.primary_fallback');
|
||||
const none = resolvePresentationSources(h, device({ entities: [], primary: null }));
|
||||
assertDecision(none, 'source.none');
|
||||
|
||||
const filtered = resolvePresentationSources(h, device({
|
||||
entities: ['event.button', 'sensor.battery'], primary: 'event.button',
|
||||
controls: [],
|
||||
marker: { id: 'd1', binding: 'device:d1', controls: ['light.missing'] },
|
||||
}));
|
||||
assertDecision(filtered, 'source.filtered_saved_controls');
|
||||
|
||||
const critical = resolvePresentationSources(h, device({
|
||||
entities: ['switch.main', 'binary_sensor.smoke'], primary: 'switch.main',
|
||||
marker: { id: 'd1', binding: 'device:d1', controls: ['light.main'] },
|
||||
}));
|
||||
assertDecision(critical, 'source.critical_sibling');
|
||||
assert.equal(critical.criticalSources[0].eid, 'binary_sensor.smoke');
|
||||
});
|
||||
|
||||
test('renderer-ready decision trace covers value, metrics, LQI, vacuum and pulse observables', () => {
|
||||
const h = hass({
|
||||
'sensor.temperature': state('sensor.temperature', '22.4', {
|
||||
device_class: 'temperature', unit_of_measurement: '°C',
|
||||
}),
|
||||
'sensor.device_linkquality': state('sensor.device_linkquality', '40'),
|
||||
'binary_sensor.presence': state('binary_sensor.presence', 'on', { device_class: 'presence' }),
|
||||
'vacuum.robot': state('vacuum.robot', 'cleaning'),
|
||||
}, {
|
||||
'sensor.temperature': { entity_id: 'sensor.temperature', device_id: 'd1', platform: 'demo' },
|
||||
'sensor.device_linkquality': { entity_id: 'sensor.device_linkquality', device_id: 'd1', platform: 'demo', entity_category: 'diagnostic' },
|
||||
'binary_sensor.presence': { entity_id: 'binary_sensor.presence', device_id: 'presence', platform: 'demo' },
|
||||
'vacuum.robot': { entity_id: 'vacuum.robot', device_id: 'vacuum', platform: 'demo' },
|
||||
});
|
||||
const temperature = resolveDevicePresentation(h, device({
|
||||
icon: 'mdi:thermometer', entities: ['sensor.temperature', 'sensor.device_linkquality'],
|
||||
primary: 'sensor.temperature',
|
||||
marker: {
|
||||
id: 'd1', binding: 'device:d1', display: 'value',
|
||||
value_badge: { enabled: true, source: { kind: 'derived_lqi' }, position: 'bottom' },
|
||||
},
|
||||
}), options);
|
||||
assert.equal(temperature.valueText, '22.4°');
|
||||
assert.equal(temperature.valueBadge?.text, '40');
|
||||
assertDecision(temperature, 'content.value');
|
||||
assertDecision(temperature, 'diagnostics.value_badge');
|
||||
assertDecision(temperature, 'diagnostics.lqi_low');
|
||||
|
||||
const vacuum = resolveDevicePresentation(h, device({
|
||||
id: 'vacuum', icon: 'mdi:robot-vacuum', entities: ['vacuum.robot'], primary: 'vacuum.robot',
|
||||
marker: { id: 'vacuum', binding: 'device:vacuum', vacuum: { live: true } },
|
||||
}), options);
|
||||
assert.equal(vacuum.vacuumLive, true);
|
||||
assertDecision(vacuum, 'diagnostics.vacuum_live');
|
||||
|
||||
const presenceDevice = device({
|
||||
id: 'presence', icon: 'mdi:motion-sensor', entities: ['binary_sensor.presence'],
|
||||
primary: 'binary_sensor.presence',
|
||||
marker: { id: 'presence', binding: 'device:presence', display: 'icon_ripple' },
|
||||
});
|
||||
const presence = resolveDevicePresentation(h, presenceDevice, options);
|
||||
assert.equal(presence.pulse.kind, 'continuous');
|
||||
assert.equal(presence.pulse.reason, 'presence');
|
||||
assertDecision(presence, 'pulse.continuous_presence');
|
||||
|
||||
const reduced = resolveDevicePresentation(h, presenceDevice, { ...options, reducedMotion: true });
|
||||
assert.equal(reduced.pulse.reducedMotionIndicator, 'dot');
|
||||
|
||||
const noRipple = resolveDevicePresentation(h, {
|
||||
...presenceDevice, marker: { ...presenceDevice.marker, display: 'badge' },
|
||||
}, options);
|
||||
assert.equal(noRipple.pulse.kind, 'none');
|
||||
assertDecision(noRipple, 'activity.pulse_suppressed');
|
||||
|
||||
const eventDevice = device({
|
||||
id: 'event', entities: ['binary_sensor.presence'], primary: 'binary_sensor.presence',
|
||||
marker: { id: 'event', binding: 'device:presence', display: 'icon_ripple' },
|
||||
});
|
||||
const signature = activitySourceSignature(h, eventDevice);
|
||||
const event = resolveDevicePresentation(h, eventDevice, {
|
||||
...options,
|
||||
activityRuntime: { sources: signature, flashTs: 1_000, flashKind: 'event', gen: 2 },
|
||||
});
|
||||
assert.equal(event.pulse.kind, 'short');
|
||||
assertDecision(event, 'pulse.short_event');
|
||||
|
||||
const expired = resolveDevicePresentation(h, eventDevice, {
|
||||
...options, now: 5_000,
|
||||
activityRuntime: { sources: signature, flashTs: 1_000, flashKind: 'event', gen: 2 },
|
||||
});
|
||||
assert.notEqual(expired.pulse.kind, 'short');
|
||||
|
||||
const sourceFiles = [
|
||||
'../src/devices.ts', '../src/houseplan-card.ts', '../src/hp-device-preview.ts',
|
||||
].map((path) => readFileSync(new URL(path, import.meta.url), 'utf8'));
|
||||
assert.match(sourceFiles[0], /if \(m\.removed\) continue/);
|
||||
assert.match(sourceFiles[1], /resolveDevicePresentation\(/);
|
||||
assert.match(sourceFiles[2], /renderDeviceFace\(shown/);
|
||||
});
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Executable index for docs/DEVICE-PRESENTATION.md.
|
||||
*
|
||||
* `expectedDecision` is a stable production trace fragment. `mutation` names
|
||||
* the focused guard that must turn red if the owning rule is weakened.
|
||||
*/
|
||||
const ROWS = [
|
||||
['L01', 'pre.lifecycle.removed', 'presentation-row-contract'],
|
||||
['L02', 'lifecycle.ha_disabled_hidden', 'device-presentation-policy-lifecycle'],
|
||||
['L03', 'lifecycle.ha_disabled_hidden', 'device-presentation-policy-lifecycle'],
|
||||
['L04', 'lifecycle.user_hidden', 'device-presentation-policy-lifecycle'],
|
||||
['L05', 'lifecycle.user_hidden_preview', 'device-presentation-policy-lifecycle'],
|
||||
['L06', 'lifecycle.orphaned_diagnostic', 'device-presentation-policy-lifecycle'],
|
||||
['S01', 'source.cover', 'presentation-source-decision-trace'],
|
||||
['S02', 'source.cover_capability_bypassed', 'presentation-source-decision-trace'],
|
||||
['S03', 'source.controls', 'controller-availability-follows-target'],
|
||||
['S04', 'availability.controller_available', 'controller-diagnostics-do-not-prove-online'],
|
||||
['S05', 'availability.controller_unavailable', 'controller-availability-follows-target'],
|
||||
['S06', 'source.virtual_controller', 'presentation-source-decision-trace'],
|
||||
['S07', 'source.filtered_saved_controls', 'wireless-controller-loses-filtered-target-role'],
|
||||
['S08', 'source.manual_virtual_light', 'presentation-source-decision-trace'],
|
||||
['S09', 'source.owned_light', 'presentation-source-decision-trace'],
|
||||
['S10', 'source.device_role', 'presentation-source-decision-trace'],
|
||||
['S11', 'source.primary_fallback', 'presentation-source-decision-trace'],
|
||||
['S12', 'source.none', 'presentation-source-decision-trace'],
|
||||
['S13', 'source.critical_sibling', 'device-presentation-policy-alarm'],
|
||||
['F01', 'status.alarm', 'device-presentation-policy-alarm'],
|
||||
['F02', 'status.unavailable', 'device-presentation-policy-unavailable'],
|
||||
['F03', 'source.device_role', 'presentation-source-decision-trace'],
|
||||
['F04', 'status.working', 'device-presentation-policy-status'],
|
||||
['F05', 'status.open', 'device-presentation-policy-status'],
|
||||
['F06', 'status.neutral', 'device-presentation-policy-status'],
|
||||
['F07', 'face.live_states_disabled', 'device-presentation-policy-live-gate'],
|
||||
['F08', 'face.static', 'device-presentation-policy-static'],
|
||||
['F09', 'content.value', 'device-presentation-policy-value'],
|
||||
['F10', 'content.value_no_state', 'device-presentation-policy-value'],
|
||||
['F11', 'content.value_ambiguous_sources', 'device-presentation-policy-value'],
|
||||
['F12', 'content.value_virtual', 'device-presentation-policy-value'],
|
||||
['F13', 'diagnostics.dynamic_icon', 'device-presentation-policy-diagnostics'],
|
||||
['F14', 'diagnostics.value_badge', 'device-presentation-policy-diagnostics'],
|
||||
['F15', 'diagnostics.metrics_enabled', 'device-presentation-policy-diagnostics'],
|
||||
['F16', 'diagnostics.lqi_low', 'device-marker-lqi-low-boundary-shifted'],
|
||||
['F17', 'diagnostics.vacuum_live', 'device-presentation-policy-diagnostics'],
|
||||
['A01', 'pulse.alarm_alarm', 'device-presentation-policy-alarm'],
|
||||
['A02', 'activity.pulse_suppressed', 'device-presentation-policy-pulse-gate'],
|
||||
['A03', 'pulse.short_event', 'device-presentation-policy-pulse-gate'],
|
||||
['A04', 'pulse.continuous_presence', 'device-presentation-policy-pulse-gate'],
|
||||
['A05', 'pulse.continuous_transition', 'device-presentation-policy-pulse-gate'],
|
||||
['A06', 'pulse.continuous_running', 'device-presentation-policy-pulse-gate'],
|
||||
['A07', 'pulse.none_none', 'device-presentation-policy-pulse-gate'],
|
||||
['A08', 'pulse.continuous_presence', 'device-presentation-policy-pulse-gate'],
|
||||
];
|
||||
|
||||
/**
|
||||
* The input scenario and observable projection are implemented by
|
||||
* runDecisionFixture() in device-presentation-policy.test.mjs. Keeping them on
|
||||
* the fixture row makes the document contract executable: a row cannot be
|
||||
* added as decision-ID metadata without also adding a production call and an
|
||||
* observable assertion.
|
||||
*/
|
||||
export const DEVICE_PRESENTATION_DECISIONS = ROWS.map(([id, expectedDecision, mutation]) => ({
|
||||
id,
|
||||
scenario: `row-${id.toLowerCase()}`,
|
||||
expectedDecision,
|
||||
mutation,
|
||||
}));
|
||||
Reference in New Issue
Block a user