fix: Match state trigger/condition semantics to HA (#2565)
* Closes #2530
This commit is contained in:
@@ -295,6 +295,245 @@ describe('state condition', () => {
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
describe('attribute matching by type', () => {
|
||||
const evaluateBattery = (state: unknown, batteryLevel: unknown): boolean =>
|
||||
!!createConditionEvaluator(
|
||||
{
|
||||
condition: 'state' as const,
|
||||
entity_id: 'sensor.battery',
|
||||
attribute: 'battery_level',
|
||||
// `state` is compared raw against the attribute (Home Assistant's
|
||||
// `match_all` semantics), so it may be any type.
|
||||
state,
|
||||
},
|
||||
createEvaluatorContext(),
|
||||
).evaluate({
|
||||
hass: createHASS({
|
||||
'sensor.battery': createStateEntity({
|
||||
attributes: { battery_level: batteryLevel },
|
||||
}),
|
||||
}),
|
||||
}).result;
|
||||
|
||||
it('should match a numeric attribute against an unquoted number', () => {
|
||||
expect(evaluateBattery(50, 50)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not match a numeric attribute against a stringified number', () => {
|
||||
// `50 == "50"` is false in Home Assistant.
|
||||
expect(evaluateBattery('50', 50)).toBe(false);
|
||||
});
|
||||
|
||||
it('should treat a boolean attribute as equal to its integer form', () => {
|
||||
// Python's `bool` is a subtype of `int`, so `true == 1`.
|
||||
expect(evaluateBattery(1, true)).toBe(true);
|
||||
expect(evaluateBattery(true, 1)).toBe(true);
|
||||
expect(evaluateBattery(0, true)).toBe(false);
|
||||
});
|
||||
|
||||
it('should match a falsy attribute value of zero', () => {
|
||||
// `0` is a real attribute value, not the "no value" sentinel.
|
||||
expect(evaluateBattery(0, 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('should match a list of numeric values by membership', () => {
|
||||
expect(evaluateBattery([50, 80], 80)).toBe(true);
|
||||
expect(evaluateBattery([50, 80], 20)).toBe(false);
|
||||
});
|
||||
|
||||
it('should match a present null attribute against state: null', () => {
|
||||
const evaluator = createConditionEvaluator(
|
||||
{
|
||||
condition: 'state' as const,
|
||||
entity_id: 'sensor.foo',
|
||||
attribute: 'bar',
|
||||
state: null,
|
||||
},
|
||||
createEvaluatorContext(),
|
||||
);
|
||||
|
||||
// A present `null` value is a real value and matches.
|
||||
expect(
|
||||
evaluator.evaluate({
|
||||
hass: createHASS({
|
||||
'sensor.foo': createStateEntity({ attributes: { bar: null } }),
|
||||
}),
|
||||
}).result,
|
||||
).toBeTruthy();
|
||||
// A non-null value does not.
|
||||
expect(
|
||||
evaluator.evaluate({
|
||||
hass: createHASS({
|
||||
'sensor.foo': createStateEntity({ attributes: { bar: 'x' } }),
|
||||
}),
|
||||
}).result,
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should not match a missing attribute key even against state: null', () => {
|
||||
// HA distinguishes an absent key (no match) from a present `null` value.
|
||||
const evaluator = createConditionEvaluator(
|
||||
{
|
||||
condition: 'state' as const,
|
||||
entity_id: 'sensor.foo',
|
||||
attribute: 'bar',
|
||||
state: null,
|
||||
},
|
||||
createEvaluatorContext(),
|
||||
);
|
||||
|
||||
expect(
|
||||
evaluator.evaluate({
|
||||
hass: createHASS({ 'sensor.foo': createStateEntity({ attributes: {} }) }),
|
||||
}).result,
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should not treat an inherited property name as an attribute', () => {
|
||||
// `toString` etc. exist on the prototype but are not real attribute keys.
|
||||
const evaluator = createConditionEvaluator(
|
||||
{
|
||||
condition: 'state' as const,
|
||||
entity_id: 'sensor.foo',
|
||||
attribute: 'toString',
|
||||
state_not: 'x',
|
||||
},
|
||||
createEvaluatorContext(),
|
||||
);
|
||||
|
||||
// The attribute is absent, so the condition cannot match (not "not x").
|
||||
expect(
|
||||
evaluator.evaluate({
|
||||
hass: createHASS({ 'sensor.foo': createStateEntity({ attributes: {} }) }),
|
||||
}).result,
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should resolve an input helper expected value on the attribute path', () => {
|
||||
// HA resolves an `input_*` helper name to its state on the attribute path
|
||||
// too, and compares against the resolved state (not the literal name).
|
||||
const evaluator = createConditionEvaluator(
|
||||
{
|
||||
condition: 'state' as const,
|
||||
entity_id: 'sensor.foo',
|
||||
attribute: 'linked',
|
||||
state: 'input_text.expected',
|
||||
},
|
||||
createEvaluatorContext(),
|
||||
);
|
||||
|
||||
// The attribute value matches the helper's resolved state.
|
||||
expect(
|
||||
evaluator.evaluate({
|
||||
hass: createHASS({
|
||||
'sensor.foo': createStateEntity({ attributes: { linked: 'bar' } }),
|
||||
'input_text.expected': createStateEntity({ state: 'bar' }),
|
||||
}),
|
||||
}).result,
|
||||
).toBeTruthy();
|
||||
|
||||
// The literal helper name is not matched (only the resolved state is).
|
||||
expect(
|
||||
evaluator.evaluate({
|
||||
hass: createHASS({
|
||||
'sensor.foo': createStateEntity({
|
||||
attributes: { linked: 'input_text.expected' },
|
||||
}),
|
||||
'input_text.expected': createStateEntity({ state: 'bar' }),
|
||||
}),
|
||||
}).result,
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should not resolve a non-input entity name on the attribute path', () => {
|
||||
// Only `input_*` helpers are resolved; other entity names compare literally.
|
||||
const evaluator = createConditionEvaluator(
|
||||
{
|
||||
condition: 'state' as const,
|
||||
entity_id: 'sensor.foo',
|
||||
attribute: 'linked',
|
||||
state: 'sensor.other',
|
||||
},
|
||||
createEvaluatorContext(),
|
||||
);
|
||||
|
||||
expect(
|
||||
evaluator.evaluate({
|
||||
hass: createHASS({
|
||||
'sensor.foo': createStateEntity({ attributes: { linked: 'bar' } }),
|
||||
'sensor.other': createStateEntity({ state: 'bar' }),
|
||||
}),
|
||||
}).result,
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should not match when a named input helper is missing', () => {
|
||||
// HA errors when the referenced helper is unavailable; the card treats it
|
||||
// as no value, so it matches nothing.
|
||||
const evaluator = createConditionEvaluator(
|
||||
{
|
||||
condition: 'state' as const,
|
||||
entity_id: 'sensor.foo',
|
||||
attribute: 'linked',
|
||||
state: 'input_text.expected',
|
||||
},
|
||||
createEvaluatorContext(),
|
||||
);
|
||||
|
||||
expect(
|
||||
evaluator.evaluate({
|
||||
hass: createHASS({
|
||||
'sensor.foo': createStateEntity({ attributes: { linked: 'bar' } }),
|
||||
}),
|
||||
}).result,
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should match any raw change of the attribute when neither state nor state_not is set', () => {
|
||||
const evaluator = createConditionEvaluator(
|
||||
{
|
||||
condition: 'state' as const,
|
||||
entity_id: 'sensor.battery',
|
||||
attribute: 'battery_level',
|
||||
},
|
||||
createEvaluatorContext(),
|
||||
);
|
||||
|
||||
const evaluateChange = (to: unknown, from: unknown): boolean =>
|
||||
!!evaluator.evaluate(
|
||||
{
|
||||
hass: createHASS({
|
||||
'sensor.battery': createStateEntity({ attributes: { battery_level: to } }),
|
||||
}),
|
||||
},
|
||||
{
|
||||
hass: createHASS({
|
||||
'sensor.battery': createStateEntity({
|
||||
attributes: { battery_level: from },
|
||||
}),
|
||||
}),
|
||||
},
|
||||
).result;
|
||||
|
||||
// A real numeric change matches; a raw-equal value does not.
|
||||
expect(evaluateChange(60, 50)).toBe(true);
|
||||
expect(evaluateChange(50, 50)).toBe(false);
|
||||
});
|
||||
|
||||
it('should still match non-attribute state as strings', () => {
|
||||
// Regression guard: without `attribute`, matching stays string-vs-string.
|
||||
const evaluator = createConditionEvaluator(
|
||||
{ condition: 'state' as const, entity_id: 'sensor.battery', state: '50' },
|
||||
createEvaluatorContext(),
|
||||
);
|
||||
expect(
|
||||
evaluator.evaluate({
|
||||
hass: createHASS({ 'sensor.battery': createStateEntity({ state: '50' }) }),
|
||||
}).result,
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('for', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
@@ -327,7 +566,7 @@ describe('state condition', () => {
|
||||
}).result,
|
||||
).toBeFalsy();
|
||||
|
||||
// Held 8s (>= 5s) -> match.
|
||||
// Held 8s (> 5s) -> match.
|
||||
expect(
|
||||
evaluator.evaluate({
|
||||
hass: createHASS({
|
||||
@@ -340,6 +579,31 @@ describe('state condition', () => {
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should not match when held for exactly the duration', () => {
|
||||
// HA compares strictly (`>`): held == for is not yet a match.
|
||||
const evaluator = createConditionEvaluator(
|
||||
{
|
||||
condition: 'state' as const,
|
||||
entity_id: 'binary_sensor.foo',
|
||||
state: 'on',
|
||||
for: '00:00:05',
|
||||
},
|
||||
createEvaluatorContext(),
|
||||
);
|
||||
|
||||
// Held exactly 5s (now 22:56:56, last_changed 22:56:51).
|
||||
expect(
|
||||
evaluator.evaluate({
|
||||
hass: createHASS({
|
||||
'binary_sensor.foo': createStateEntity({
|
||||
state: 'on',
|
||||
last_changed: '2026-06-05T22:56:51Z',
|
||||
}),
|
||||
}),
|
||||
}).result,
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should render a templated "for" before comparing', async () => {
|
||||
// This case renders a real templated `for`, so load the lazily-imported
|
||||
// engine for the synchronous renderer.
|
||||
@@ -448,6 +712,98 @@ describe('state condition', () => {
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should compare an input helper by its state, not its literal name', () => {
|
||||
// HA replaces the helper name with its state, so the literal name never
|
||||
// matches (resolved-value-only).
|
||||
const evaluator = createConditionEvaluator(
|
||||
{
|
||||
condition: 'state' as const,
|
||||
entity_id: 'binary_sensor.foo',
|
||||
state: 'input_text.expected',
|
||||
},
|
||||
createEvaluatorContext(),
|
||||
);
|
||||
|
||||
expect(
|
||||
evaluator.evaluate({
|
||||
hass: createHASS({
|
||||
'binary_sensor.foo': createStateEntity({ state: 'input_text.expected' }),
|
||||
'input_text.expected': createStateEntity({ state: 'armed' }),
|
||||
}),
|
||||
}).result,
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should not resolve a non-input entity name', () => {
|
||||
// Only `input_*` helpers are resolved; other entity names compare literally.
|
||||
const evaluator = createConditionEvaluator(
|
||||
{
|
||||
condition: 'state' as const,
|
||||
entity_id: 'binary_sensor.foo',
|
||||
state: 'sensor.other',
|
||||
},
|
||||
createEvaluatorContext(),
|
||||
);
|
||||
|
||||
// Not resolved: the watched state does not match the helper's state.
|
||||
expect(
|
||||
evaluator.evaluate({
|
||||
hass: createHASS({
|
||||
'binary_sensor.foo': createStateEntity({ state: 'armed' }),
|
||||
'sensor.other': createStateEntity({ state: 'armed' }),
|
||||
}),
|
||||
}).result,
|
||||
).toBeFalsy();
|
||||
|
||||
// The literal name is compared as-is.
|
||||
expect(
|
||||
evaluator.evaluate({
|
||||
hass: createHASS({
|
||||
'binary_sensor.foo': createStateEntity({ state: 'sensor.other' }),
|
||||
'sensor.other': createStateEntity({ state: 'armed' }),
|
||||
}),
|
||||
}).result,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should fail when an unavailable input helper is reached before a match', () => {
|
||||
// HA scans in order and raises at the unavailable `input_*` helper before it
|
||||
// would reach (and match) the later `bar`.
|
||||
const evaluator = createConditionEvaluator(
|
||||
{
|
||||
condition: 'state' as const,
|
||||
entity_id: 'binary_sensor.foo',
|
||||
state: ['input_text.missing', 'bar'],
|
||||
},
|
||||
createEvaluatorContext(),
|
||||
);
|
||||
|
||||
expect(
|
||||
evaluator.evaluate({
|
||||
hass: createHASS({ 'binary_sensor.foo': createStateEntity({ state: 'bar' }) }),
|
||||
}).result,
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should match a value listed before an unavailable input helper', () => {
|
||||
// HA breaks on the first match, so an unavailable helper listed *after* the
|
||||
// matching value is never reached.
|
||||
const evaluator = createConditionEvaluator(
|
||||
{
|
||||
condition: 'state' as const,
|
||||
entity_id: 'binary_sensor.foo',
|
||||
state: ['bar', 'input_text.missing'],
|
||||
},
|
||||
createEvaluatorContext(),
|
||||
);
|
||||
|
||||
expect(
|
||||
evaluator.evaluate({
|
||||
hass: createHASS({ 'binary_sensor.foo': createStateEntity({ state: 'bar' }) }),
|
||||
}).result,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should match a state_not against an empty-string entity state', () => {
|
||||
const evaluator = createConditionEvaluator(
|
||||
{ condition: 'state' as const, entity_id: 'sensor.foo', state_not: 'on' },
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -31,4 +31,30 @@ describe('stateConditionSchema', () => {
|
||||
}),
|
||||
).toEqual({ condition: 'state', entity_id: 'binary_sensor.door', state_not: 'on' });
|
||||
});
|
||||
|
||||
it('should accept a non-string state value when matching an attribute', () => {
|
||||
expect(
|
||||
stateConditionSchema.parse({
|
||||
condition: 'state',
|
||||
entity_id: 'sensor.battery',
|
||||
attribute: 'battery_level',
|
||||
state: 50,
|
||||
}),
|
||||
).toEqual({
|
||||
condition: 'state',
|
||||
entity_id: 'sensor.battery',
|
||||
attribute: 'battery_level',
|
||||
state: 50,
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject a non-string state value without an attribute', () => {
|
||||
expect(() =>
|
||||
stateConditionSchema.parse({
|
||||
condition: 'state',
|
||||
entity_id: 'sensor.battery',
|
||||
state: 50,
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,4 +26,25 @@ describe('stateTriggerSchema', () => {
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('should accept a non-string to value when watching an attribute', () => {
|
||||
expect(
|
||||
stateTriggerSchema.parse({
|
||||
trigger: 'state',
|
||||
entity_id: 'sensor.battery',
|
||||
attribute: 'battery_level',
|
||||
to: 50,
|
||||
}),
|
||||
).toMatchObject({ attribute: 'battery_level', to: 50 });
|
||||
});
|
||||
|
||||
it('should reject a non-string to value without an attribute', () => {
|
||||
expect(() =>
|
||||
stateTriggerSchema.parse({
|
||||
trigger: 'state',
|
||||
entity_id: 'sensor.battery',
|
||||
to: 50,
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
allPromises,
|
||||
arefloatsApproximatelyEqual,
|
||||
arrayify,
|
||||
arrayifyWithFalsy,
|
||||
arrayMove,
|
||||
aspectRatioToStyle,
|
||||
contentsChanged,
|
||||
@@ -69,6 +70,22 @@ describe('arrayify', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('arrayifyWithFalsy', () => {
|
||||
it('should wrap a non-array in an array', () => {
|
||||
expect(arrayifyWithFalsy(1)).toEqual([1]);
|
||||
});
|
||||
it('should return an existing array unchanged', () => {
|
||||
const data = [1, 2, 3];
|
||||
expect(arrayifyWithFalsy(data)).toBe(data);
|
||||
});
|
||||
it('should preserve falsy values instead of dropping them', () => {
|
||||
expect(arrayifyWithFalsy(0)).toEqual([0]);
|
||||
expect(arrayifyWithFalsy(false)).toEqual([false]);
|
||||
expect(arrayifyWithFalsy('')).toEqual(['']);
|
||||
expect(arrayifyWithFalsy(null)).toEqual([null]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setify', () => {
|
||||
it('should convert non set to set', () => {
|
||||
expect(setify(1)).toEqual(new Set([1]));
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { forwardIssues } from '../../../src/utils/zod/forward-issues';
|
||||
|
||||
// Parse `value` through a schema whose `superRefine` delegates to
|
||||
// `forwardIssues`, returning the resulting error (or null on success).
|
||||
const runThrough = (
|
||||
value: unknown,
|
||||
target: z.ZodType,
|
||||
path?: PropertyKey[],
|
||||
): z.ZodError | null => {
|
||||
const result = z
|
||||
.unknown()
|
||||
.superRefine((v, ctx) => forwardIssues(ctx, v, target, path))
|
||||
.safeParse(value);
|
||||
return result.success ? null : result.error;
|
||||
};
|
||||
|
||||
describe('forwardIssues', () => {
|
||||
it('should add no issues when the value satisfies the schema', () => {
|
||||
expect(runThrough('on', z.string())).toBeNull();
|
||||
});
|
||||
|
||||
it('should forward issues with the given path prefix', () => {
|
||||
const error = runThrough(5, z.string(), ['field']);
|
||||
expect(error?.issues).toHaveLength(1);
|
||||
expect(error?.issues[0].path).toEqual(['field']);
|
||||
});
|
||||
|
||||
it('should forward at the issue path when no prefix is given', () => {
|
||||
const error = runThrough(5, z.string());
|
||||
expect(error?.issues[0].path).toEqual([]);
|
||||
});
|
||||
|
||||
it('should prepend the prefix to nested issue paths', () => {
|
||||
const error = runThrough({ inner: 5 }, z.object({ inner: z.string() }), ['outer']);
|
||||
expect(error?.issues[0].path).toEqual(['outer', 'inner']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user