mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 12:18:51 +00:00
Fix linked virtual light controller authority
Issue: #174 User-Visible: yes
This commit is contained in:
@@ -180,6 +180,47 @@ test('issue 107 manual virtual source owns its face despite saved outgoing contr
|
||||
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('passive sensor source keeps its normal scalar value and never probes marker ids', () => {
|
||||
const hits = [];
|
||||
const h = hass(new Proxy({
|
||||
|
||||
@@ -105,6 +105,121 @@ test('issue 107: exact manual virtual light wins over saved HA controls', () =>
|
||||
assert.equal(toggleOperation(resumed).kind, 'ha-service', 'saved controls resume outside the triple');
|
||||
});
|
||||
|
||||
test('issue 174: linked manual lamp redirects its toggle to the real controller driver', () => {
|
||||
const lamp = device({
|
||||
id: 'lamp', name: 'Dumb lamp',
|
||||
marker: { id: 'lamp', binding: 'virtual', is_light: true, tap_action: 'toggle', controls: [] },
|
||||
});
|
||||
const controller = device({
|
||||
id: 'wall', name: 'Wall relay', bindingKind: 'entity', bindingRef: 'switch.wall',
|
||||
primary: 'switch.wall', entities: ['switch.wall'],
|
||||
marker: {
|
||||
id: 'wall', binding: 'entity:switch.wall', tap_action: 'toggle', controls: ['marker:lamp'],
|
||||
},
|
||||
controls: [],
|
||||
});
|
||||
const devices = [controller, lamp];
|
||||
const h = hass({ 'switch.wall': state('switch.wall', 'on') });
|
||||
const manualOff = { rev: 5, configRev: 9, off: new Set(['lamp']) };
|
||||
|
||||
const on = resolveToggleIntent({ hass: h, devices, device: lamp, virtualLights: manualOff });
|
||||
assert.equal(toggleOperation(on).kind, 'ha-service');
|
||||
assert.deepEqual(toggleCommandEntityIds(on.command), ['switch.wall']);
|
||||
assert.equal(on.command.service, 'turn_off');
|
||||
assert.equal(on.targets[0].via, 'control-marker-driver');
|
||||
|
||||
h.states['switch.wall'].state = 'off';
|
||||
const off = resolveToggleIntent({ hass: h, devices, device: lamp, virtualLights: manualOff });
|
||||
assert.equal(off.command.service, 'turn_on');
|
||||
assert.equal(sameToggleOperationTargets(on, off), true, 'direction changes but the relay does not');
|
||||
|
||||
const unlinked = resolveToggleIntent({ hass: h, devices: [lamp], device: lamp, virtualLights: manualOff });
|
||||
assert.deepEqual(toggleOperation(unlinked), { kind: 'virtual-light', markerId: 'lamp' });
|
||||
assert.equal(unlinked.targets[0].state, 'off', 'unlink restores the stored manual state');
|
||||
assert.equal(sameToggleOperationTargets(on, unlinked), false, 'confirmation cannot cross modes');
|
||||
});
|
||||
|
||||
test('issue 174: source unions all drivers while each controller toggles only its own group', () => {
|
||||
const lamp = device({
|
||||
id: 'lamp', name: 'Dumb lamp',
|
||||
marker: { id: 'lamp', binding: 'virtual', is_light: true, tap_action: 'toggle', controls: [] },
|
||||
});
|
||||
const relayA = device({
|
||||
id: 'relay-a', primary: 'switch.a', entities: ['switch.a'],
|
||||
bindingKind: 'entity', bindingRef: 'switch.a', controls: [],
|
||||
marker: {
|
||||
id: 'relay-a', binding: 'entity:switch.a', tap_action: 'toggle', controls: ['marker:lamp'],
|
||||
},
|
||||
});
|
||||
const relayB = device({
|
||||
id: 'relay-b', bindingKind: 'virtual', bindingRef: 'relay-b',
|
||||
controls: ['switch.b'],
|
||||
marker: {
|
||||
id: 'relay-b', binding: 'virtual', tap_action: 'toggle',
|
||||
controls: ['switch.b', 'marker:lamp'],
|
||||
},
|
||||
});
|
||||
const devices = [relayA, relayB, lamp];
|
||||
const h = hass({
|
||||
'switch.a': state('switch.a', 'on'),
|
||||
'switch.b': state('switch.b', 'off'),
|
||||
});
|
||||
|
||||
const source = resolveToggleIntent({ hass: h, devices, device: lamp });
|
||||
assert.deepEqual(toggleCommandEntityIds(source.command), ['switch.a', 'switch.b']);
|
||||
assert.equal(source.command.service, 'turn_off', 'any-on applies to the union');
|
||||
|
||||
const first = resolveToggleIntent({ hass: h, devices, device: relayA });
|
||||
assert.deepEqual(toggleCommandEntityIds(first.command), ['switch.a']);
|
||||
const second = resolveToggleIntent({ hass: h, devices, device: relayB });
|
||||
assert.deepEqual(toggleCommandEntityIds(second.command), ['switch.b']);
|
||||
});
|
||||
|
||||
test('issue 174: linked source keeps partial availability and never falls back to manual state', () => {
|
||||
const lamp = device({
|
||||
id: 'lamp', name: 'Dumb lamp',
|
||||
marker: { id: 'lamp', binding: 'virtual', is_light: true, tap_action: 'toggle', controls: [] },
|
||||
});
|
||||
const partial = device({
|
||||
id: 'partial', bindingKind: 'virtual', bindingRef: 'partial',
|
||||
controls: ['switch.missing', 'switch.unavailable', 'switch.disabled', 'switch.ok'],
|
||||
marker: {
|
||||
id: 'partial', binding: 'virtual',
|
||||
controls: [
|
||||
'switch.missing', 'switch.unavailable', 'switch.disabled', 'switch.ok', 'marker:lamp',
|
||||
],
|
||||
},
|
||||
});
|
||||
const h = hass({
|
||||
'switch.unavailable': state('switch.unavailable', 'unavailable'),
|
||||
'switch.disabled': state('switch.disabled', 'off'),
|
||||
'switch.ok': state('switch.ok', 'off'),
|
||||
}, {
|
||||
'switch.disabled': { entity_id: 'switch.disabled', platform: 'test', disabled_by: 'user' },
|
||||
});
|
||||
const manualOff = { rev: 3, configRev: 4, off: new Set(['lamp']) };
|
||||
const intent = resolveToggleIntent({ hass: h, devices: [partial, lamp], device: lamp, virtualLights: manualOff });
|
||||
assert.deepEqual(toggleCommandEntityIds(intent.command), ['switch.ok']);
|
||||
assert.equal(intent.command.service, 'turn_on');
|
||||
assert.deepEqual(intent.skippedTargets.map((target) => [target.entityId, target.reason]), [
|
||||
['switch.missing', 'missing'],
|
||||
['switch.unavailable', 'unavailable'],
|
||||
['switch.disabled', 'ha-disabled'],
|
||||
]);
|
||||
|
||||
const dormant = device({
|
||||
id: 'dormant', bindingKind: 'virtual', bindingRef: 'dormant', controls: [],
|
||||
marker: { id: 'dormant', binding: 'virtual', controls: ['marker:lamp'] },
|
||||
});
|
||||
const none = resolveToggleIntent({
|
||||
hass: h, devices: [dormant, lamp], device: lamp, virtualLights: manualOff,
|
||||
});
|
||||
assert.equal(none.kind, 'group');
|
||||
assert.equal(none.command, null);
|
||||
assert.equal(none.noneReason, 'configured-targets-missing');
|
||||
assert.equal(toggleOperation(none), null, 'linked zero-driver state does not use virtual-light');
|
||||
});
|
||||
|
||||
test('exact entity binding never retargets to a controllable sibling', () => {
|
||||
const h = hass({
|
||||
'sensor.room': state('sensor.room', '21'),
|
||||
|
||||
+47
-3
@@ -9,6 +9,7 @@ import {
|
||||
hasOwnStatefulLightSource, ownControllableEntities, forcedLightEntityOf,
|
||||
removeMarkerControlReferences,
|
||||
rewriteMarkerControlReferences, markerControlWouldCycle, resolveDeviceLightSettings,
|
||||
incomingLightControls,
|
||||
} from '../test-build/devices.js';
|
||||
import { compileIconRules, iconFor } from '../test-build/rules.js';
|
||||
|
||||
@@ -1306,7 +1307,7 @@ test('issue 84: Always without an own entity is a constant passive source', () =
|
||||
assert.deepEqual(resolvedLightStats([source]), { on: 1, total: 1 });
|
||||
});
|
||||
|
||||
test('issue 107: manual virtual state is canonical and invalidates the light cache', () => {
|
||||
test('issue 174: linked manual virtual source follows controller state, not its stored fallback', () => {
|
||||
const target = {
|
||||
id: 'dumb', area: 'bedroom', entities: [],
|
||||
marker: {
|
||||
@@ -1327,13 +1328,56 @@ test('issue 107: manual virtual state is canonical and invalidates the light cac
|
||||
const first = resolvedLightSources(hass, devices, { id: 'bed', area: null }, onSnapshot);
|
||||
assert.equal(first[0].on, true);
|
||||
const second = resolvedLightSources(hass, devices, { id: 'bed', area: null }, offSnapshot);
|
||||
assert.equal(second[0].on, false, 'manual off overrides an incoming on controller');
|
||||
assert.equal(second[0].on, true, 'incoming controller overrides the dormant manual off fallback');
|
||||
assert.notEqual(second, first, 'operational revision participates in resolver caching');
|
||||
|
||||
hass.states['switch.wall'].state = 'off';
|
||||
const driverOff = resolvedLightSources(hass, devices, { id: 'bed', area: null }, offSnapshot);
|
||||
assert.equal(driverOff[0].on, false, 'external HA state is authoritative without a card action');
|
||||
const manualOnDriverOff = resolvedLightSources(
|
||||
hass, devices, { id: 'bed', area: null }, onSnapshot,
|
||||
);
|
||||
assert.equal(manualOnDriverOff[0].on, false, 'manual on cannot revive a linked driver-off lamp');
|
||||
|
||||
const unlinked = resolvedLightSources(hass, [target], { id: 'bed', area: null }, offSnapshot);
|
||||
assert.equal(unlinked[0].on, false, 'removing the final link restores the exact manual fallback');
|
||||
|
||||
const legacy = resolvedLightSources(hass, [{
|
||||
...target, marker: { ...target.marker, tap_action: 'info' },
|
||||
}, controller], { id: 'bed', area: null }, offSnapshot);
|
||||
assert.equal(legacy[0].on, true, 'leaving the exact triple restores issue #84 semantics');
|
||||
assert.equal(legacy[0].on, false, 'leaving the exact triple keeps issue #84 controller semantics');
|
||||
});
|
||||
|
||||
test('issue 174: one cached reverse graph owns linked state and all controller drivers', () => {
|
||||
const target = {
|
||||
id: 'dumb', area: 'bedroom', entities: [],
|
||||
marker: {
|
||||
id: 'dumb', binding: 'virtual', is_light: true, tap_action: 'toggle', room_id: 'bed',
|
||||
},
|
||||
};
|
||||
const controllerA = {
|
||||
id: 'a', area: 'hall', primary: 'switch.a', entities: ['switch.a'],
|
||||
marker: { id: 'a', binding: 'entity:switch.a', controls: ['marker:dumb'] },
|
||||
};
|
||||
const controllerB = {
|
||||
id: 'b', area: 'yard', entities: [], controls: ['light.external'],
|
||||
marker: { id: 'b', binding: 'virtual', controls: ['light.external', 'marker:dumb'] },
|
||||
};
|
||||
const devices = [controllerA, controllerB, target];
|
||||
const relation = incomingLightControls(devices).get('dumb');
|
||||
assert.deepEqual(relation.driverEids, ['switch.a', 'light.external']);
|
||||
assert.deepEqual(
|
||||
relation.controllers.map((entry) => [entry.device.id, entry.driverEids]),
|
||||
[['a', ['switch.a']], ['b', ['light.external']]],
|
||||
);
|
||||
|
||||
const hass = { states: {
|
||||
'switch.a': { state: 'off' }, 'light.external': { state: 'on' },
|
||||
} };
|
||||
const snapshot = { rev: 9, configRev: 3, off: new Set(['dumb']) };
|
||||
assert.equal(resolvedLightSources(hass, devices, null, snapshot)[0].on, true);
|
||||
hass.states['light.external'].state = 'off';
|
||||
assert.equal(resolvedLightSources(hass, devices, null, snapshot)[0].on, false);
|
||||
});
|
||||
|
||||
test('issue 84: passive target state is OR of controller drivers and remains target-owned', () => {
|
||||
|
||||
Reference in New Issue
Block a user