fix: Match state trigger/condition semantics to HA (#2565)

* Closes #2530
This commit is contained in:
Dermot Duffy
2026-07-04 10:23:51 -07:00
committed by GitHub
parent 3494706225
commit 9f956fe89f
15 changed files with 825 additions and 69 deletions
@@ -143,6 +143,21 @@ describe('StateTrigger', () => {
expect(callback).toHaveBeenCalledTimes(2);
});
it('should match a transition to an empty-string state', () => {
// HA compares `state == ''`; the empty string is a real value to match.
const { trigger, stateManager, callback } = create({
trigger: 'state',
entity_id: ENTITY,
to: '',
});
trigger.subscribe(callback);
setHass(stateManager, { [ENTITY]: { state: 'on' } });
expect(callback).not.toHaveBeenCalled();
setHass(stateManager, { [ENTITY]: { state: '' } });
expect(callback).toHaveBeenCalledTimes(1);
});
it('should fan out independently over a list of entities', () => {
const { trigger, stateManager, callback } = create({
trigger: 'state',
@@ -329,6 +344,140 @@ describe('StateTrigger', () => {
expect(callback).not.toHaveBeenCalled();
});
describe('attribute matching by type', () => {
it('should match a numeric attribute against an unquoted number', () => {
const { trigger, stateManager, callback } = create({
trigger: 'state',
entity_id: ENTITY,
attribute: 'battery_level',
to: 50,
});
trigger.subscribe(callback);
setHass(stateManager, { [ENTITY]: { attributes: { battery_level: 20 } } });
expect(callback).not.toHaveBeenCalled();
setHass(stateManager, { [ENTITY]: { attributes: { battery_level: 50 } } });
expect(callback).toHaveBeenCalledTimes(1);
});
it('should not match a numeric attribute against a stringified number', () => {
// `50 == "50"` is false in Home Assistant.
const { trigger, stateManager, callback } = create({
trigger: 'state',
entity_id: ENTITY,
attribute: 'battery_level',
to: '50',
});
trigger.subscribe(callback);
setHass(stateManager, { [ENTITY]: { attributes: { battery_level: 20 } } });
setHass(stateManager, { [ENTITY]: { attributes: { battery_level: 50 } } });
expect(callback).not.toHaveBeenCalled();
});
it('should treat a boolean attribute as equal to its integer form', () => {
// Python's `bool` is a subtype of `int`, so `true == 1`.
const { trigger, stateManager, callback } = create({
trigger: 'state',
entity_id: ENTITY,
attribute: 'charging',
to: 1,
});
trigger.subscribe(callback);
setHass(stateManager, { [ENTITY]: { attributes: { charging: false } } });
expect(callback).not.toHaveBeenCalled();
setHass(stateManager, { [ENTITY]: { attributes: { charging: true } } });
expect(callback).toHaveBeenCalledTimes(1);
});
it('should match a list of numeric values by membership', () => {
const { trigger, stateManager, callback } = create({
trigger: 'state',
entity_id: ENTITY,
attribute: 'battery_level',
to: [50, 80],
});
trigger.subscribe(callback);
setHass(stateManager, { [ENTITY]: { attributes: { battery_level: 20 } } });
expect(callback).not.toHaveBeenCalled();
setHass(stateManager, { [ENTITY]: { attributes: { battery_level: 80 } } });
expect(callback).toHaveBeenCalledTimes(1);
});
it('should match a falsy attribute value of zero', () => {
// `0` is a real attribute value, not the "no value" sentinel, and must
// not be dropped (e.g. by `arrayify`).
const { trigger, stateManager, callback } = create({
trigger: 'state',
entity_id: ENTITY,
attribute: 'battery_level',
to: 0,
});
trigger.subscribe(callback);
setHass(stateManager, { [ENTITY]: { attributes: { battery_level: 5 } } });
expect(callback).not.toHaveBeenCalled();
setHass(stateManager, { [ENTITY]: { attributes: { battery_level: 0 } } });
expect(callback).toHaveBeenCalledTimes(1);
});
it('should not trigger when the raw attribute value is unchanged', () => {
const { trigger, stateManager, callback } = create({
trigger: 'state',
entity_id: ENTITY,
attribute: 'battery_level',
to: 50,
});
trigger.subscribe(callback);
setHass(stateManager, {
[ENTITY]: { state: 'on', attributes: { battery_level: 50 } },
});
expect(callback).toHaveBeenCalledTimes(1);
// The state changes but the watched numeric attribute does not -> ignored.
setHass(stateManager, {
[ENTITY]: { state: 'off', attributes: { battery_level: 50 } },
});
expect(callback).toHaveBeenCalledTimes(1);
});
it('should match a null attribute value via to: [null]', () => {
// HA collapses a missing/None attribute to `None`; `to: [null]` matches it.
const { trigger, stateManager, callback } = create({
trigger: 'state',
entity_id: ENTITY,
attribute: 'bar',
to: [null],
});
trigger.subscribe(callback);
setHass(stateManager, { [ENTITY]: { attributes: { bar: 'x' } } });
expect(callback).not.toHaveBeenCalled();
setHass(stateManager, { [ENTITY]: { attributes: { bar: null } } });
expect(callback).toHaveBeenCalledTimes(1);
});
it('should exclude a null attribute value via not_to: [null]', () => {
const { trigger, stateManager, callback } = create({
trigger: 'state',
entity_id: ENTITY,
attribute: 'bar',
not_to: [null],
});
trigger.subscribe(callback);
// To a real value -> fires.
setHass(stateManager, { [ENTITY]: { attributes: { bar: 'x' } } });
expect(callback).toHaveBeenCalledTimes(1);
// To null -> excluded.
setHass(stateManager, { [ENTITY]: { attributes: { bar: null } } });
expect(callback).toHaveBeenCalledTimes(1);
});
});
it('should trigger without a to_state when the entity is removed', () => {
const { trigger, stateManager, callback } = create({
trigger: 'state',